Commit 07a0a27edc47d5cd63a3244209fe1011085b056c

Authored by bboonnee
1 parent e3baa491

Give Planner/Coder/Validator real, machine-checkable acceptance criteria

Root cause traced (docs/implementation-gap-analysis.md): the Benchmark
document Validator relies on for its verdict was frequently a narrative
about the work session ("The planner workflow completed and produced a
Benchmark defining 18 scenarios...") instead of the actual scenarios --
found via a real skill-detail-page task's benchmark.md, then confirmed
present since the earliest real self-hosting runs. Validator's own
judgment was, in practice, resting on thin, sometimes-vacuous prose.

Planner's work graph (brd->prd->sdd->benchmark) extends to
benchmark->test_dataset->test_script: test_dataset/test_script are always
present as nodes, but the generating subagent judges per-goal whether a
real dataset/script applies (many goals, e.g. UI wiring, legitimately
don't have one -- replying 'N/A: <reason>' is a real, expected answer, not
a failure). test_script gets a genuinely mechanical verify_fn (real
py_compile/node --check, not LLM judgment) -- the one node type where an
objective check is actually possible. All four document prompts also gained
an explicit anti-narration instruction (only 'brd' had a weaker version of
this before).

Coder's implementation prompt now receives the real benchmark/dataset/
script and must make the Planner's real script pass for real, not just
whatever tests it invents on its own.

Validator gets its own 3-node Work Graph (validator_benchmark ->
validator_test_dataset -> validator_test_script), generated independently
from SDD/PRD -- deliberately NOT reusing Coder's or Planner's own copies,
same reasoning as a held-out eval set vs. the dev set a model was tuned
against. The resulting script, if real, is actually executed against the
current repository state; a non-zero real exit code force-overrides the
verdict to FAIL in code (user-confirmed hard gate: the script is not up
for LLM debate on a failure, only on a pass can subjective judgment add
INCONCLUSIVE/risk notes).

schema.py: artifact.artifact_type's CHECK constraint gains the 5 new
types, plus migrate_expand_artifact_types() -- SQLite has no ALTER-based
constraint migration, so this is the standard rebuild-copy-swap dance,
applied for real to the live ~/.graphlemon/graphlemon.db (42 rows,
verified byte-identical count before/after, integrity_check clean).

skills/qa-task-validation.md and skills/project-sdd-planning.md already
gained a standing principle from this same incident: verify cross-task
data dependencies against real system state, not seeded fixtures alone
(see prior commit d7035fc) -- this commit is the mechanism-level half of
the same fix, not just the prose-guidance half.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/e2e_pipeline.py
@@ -48,6 +48,7 @@ attempt, then `ConsistencyCheckFailedError` if it&#39;s still contradictory). @@ -48,6 +48,7 @@ attempt, then `ConsistencyCheckFailedError` if it&#39;s still contradictory).
48 import json 48 import json
49 import os 49 import os
50 import subprocess 50 import subprocess
  51 +import tempfile
51 import uuid 52 import uuid
52 53
53 from src import ( 54 from src import (
@@ -76,28 +77,63 @@ _PRD_CONSISTENCY_PROMPT = ( @@ -76,28 +77,63 @@ _PRD_CONSISTENCY_PROMPT = (
76 "contradiction, and list each one in issues.\n\nPRD:\n\n{prd_content}" 77 "contradiction, and list each one in issues.\n\nPRD:\n\n{prd_content}"
77 ) 78 )
78 79
  80 +_NO_NARRATION = (
  81 + "The `content` field must contain ONLY the raw document/code text itself -- nothing "
  82 + "else. Do not include any sentence describing what you did, what workflow ran, how "
  83 + "the process went, or that you completed a task. A machine parses `content` directly "
  84 + "into a file with no human review; any narrative text becomes permanently embedded in "
  85 + "a document that's supposed to look like a real, standalone deliverable. Start "
  86 + "directly with the content itself (e.g. a Markdown document starts with its own `#` "
  87 + "heading, not a sentence about producing it)."
  88 +)
  89 +
79 _DOC_PROMPTS = { 90 _DOC_PROMPTS = {
80 "brd": ( 91 "brd": (
81 "Write a BRD (Business Requirement Document) for this goal, following your own " 92 "Write a BRD (Business Requirement Document) for this goal, following your own "
82 "SOUL.md methodology: {goal_text}\n" 93 "SOUL.md methodology: {goal_text}\n"
83 - "Reply with the document content (Markdown) via the required schema -- no preamble, "  
84 - "no meta-commentary about what you're about to do." 94 + "Reply with the document content (Markdown) via the required schema. " + _NO_NARRATION
85 ), 95 ),
86 "prd": ( 96 "prd": (
87 "Here is the approved BRD for goal '{goal_text}':\n\n{brd_content}\n\n" 97 "Here is the approved BRD for goal '{goal_text}':\n\n{brd_content}\n\n"
88 "Write the PRD (Product Requirement Document) based on this BRD. " 98 "Write the PRD (Product Requirement Document) based on this BRD. "
89 - "Reply with the document content (Markdown) via the required schema." 99 + "Reply with the document content (Markdown) via the required schema. " + _NO_NARRATION
90 ), 100 ),
91 "sdd": ( 101 "sdd": (
92 "Here is the approved PRD for goal '{goal_text}':\n\n{prd_content}\n\n" 102 "Here is the approved PRD for goal '{goal_text}':\n\n{prd_content}\n\n"
93 "Write the SDD (System Design Document) based on this PRD, including a Development " 103 "Write the SDD (System Design Document) based on this PRD, including a Development "
94 - "Unit breakdown. Reply with the document content (Markdown) via the required schema." 104 + "Unit breakdown. Reply with the document content (Markdown) via the required schema. "
  105 + + _NO_NARRATION
95 ), 106 ),
96 "benchmark": ( 107 "benchmark": (
97 "Here is the approved SDD for goal '{goal_text}':\n\n{sdd_content}\n\n" 108 "Here is the approved SDD for goal '{goal_text}':\n\n{sdd_content}\n\n"
98 "Write the Benchmark/acceptance-test document based on this SDD: concrete test " 109 "Write the Benchmark/acceptance-test document based on this SDD: concrete test "
99 - "scenarios (happy/boundary/failure) with observable pass criteria. "  
100 - "Reply with the document content (Markdown) via the required schema." 110 + "scenarios (happy/boundary/failure) with observable pass criteria for each one -- not "
  111 + "just a one-line label per scenario, spell out the actual expected input/behavior/"
  112 + "output for each. Reply with the document content (Markdown) via the required schema. "
  113 + + _NO_NARRATION
  114 + ),
  115 + "test_dataset": (
  116 + "Here is the approved Benchmark for goal '{goal_text}':\n\n{benchmark_content}\n\n"
  117 + "Judge whether this goal has a natural, concrete test dataset (specific inputs with "
  118 + "known-correct outputs, fixture data, or QA pairs the Benchmark's scenarios can be "
  119 + "instantiated against) -- many goals (UI wiring, config/plumbing changes) don't have "
  120 + "one, and that's fine. If it does not apply, reply via the required schema with "
  121 + "content starting exactly with 'N/A: ' followed by a one-sentence reason. If it does "
  122 + "apply, reply with the real dataset itself (e.g. JSON/CSV/a table of concrete cases) "
  123 + "via the required schema. " + _NO_NARRATION
  124 + ),
  125 + "test_script": (
  126 + "Here is the approved Benchmark for goal '{goal_text}':\n\n{benchmark_content}\n\n"
  127 + "Here is the test dataset (may be 'N/A' if this goal has none):\n\n{test_dataset_content}\n\n"
  128 + "Write a real, runnable test script that mechanically checks the Benchmark's "
  129 + "scenarios (using the dataset above if it applies) -- a script a machine can execute "
  130 + "and get a real pass/fail exit code from, not prose describing what should be tested. "
  131 + "Use whatever language/framework fits this specific project's own conventions (don't "
  132 + "invent a new one). If genuinely no mechanical script can meaningfully check this goal "
  133 + "(e.g. a pure judgment call with no automatable check), reply via the required schema "
  134 + "with content starting exactly with 'N/A: ' followed by a one-sentence reason -- this "
  135 + "should be rare, prefer writing a real script whenever a real check is possible. "
  136 + "Reply with the real script content via the required schema. " + _NO_NARRATION
101 ), 137 ),
102 } 138 }
103 139
@@ -107,8 +143,21 @@ _CODE_OUTPUT_SCHEMA = {&quot;type&quot;: &quot;object&quot;, &quot;required&quot;: [&quot;response&quot;]} @@ -107,8 +143,21 @@ _CODE_OUTPUT_SCHEMA = {&quot;type&quot;: &quot;object&quot;, &quot;required&quot;: [&quot;response&quot;]}
107 _CODER_PROMPT = ( 143 _CODER_PROMPT = (
108 "Implement this goal per the approved SDD below. Write real code and real tests, run the " 144 "Implement this goal per the approved SDD below. Write real code and real tests, run the "
109 "tests yourself, and only stop once they pass (or once you've exhausted reasonable attempts " 145 "tests yourself, and only stop once they pass (or once you've exhausted reasonable attempts "
110 - "and can explain why not). Reply via the required schema with a `response` field describing "  
111 - "what you did and the real test commands/output. Goal: {goal_text}\n\nSDD:\n\n{sdd_content}" 146 + "and can explain why not).\n\n"
  147 + "The Planner also produced a Benchmark, a test dataset, and a test script for this goal "
  148 + "(test dataset/script may be 'N/A' if this goal has none -- that's expected, not an error). "
  149 + "If the test script is real (not N/A): materialize it as a real file under this project's "
  150 + "own test conventions (match how existing tests in this repo are named/located), and you "
  151 + "MUST make it pass for real before you're done -- it is not optional extra credit, it's "
  152 + "part of this goal's acceptance bar. Write your own additional tests too if the Planner's "
  153 + "script doesn't cover everything the SDD requires; the Planner's script is a floor, not a "
  154 + "ceiling.\n\n"
  155 + "Reply via the required schema with a `response` field describing what you did and the "
  156 + "real test commands/output (including the Planner's own test script's real output). "
  157 + "Goal: {goal_text}\n\nSDD:\n\n{sdd_content}\n\n"
  158 + "Benchmark:\n\n{benchmark_content}\n\n"
  159 + "Test dataset:\n\n{test_dataset_content}\n\n"
  160 + "Test script:\n\n{test_script_content}"
112 ) 161 )
113 162
114 _VALIDATOR_PROMPT = ( 163 _VALIDATOR_PROMPT = (
@@ -119,6 +168,77 @@ _VALIDATOR_PROMPT = ( @@ -119,6 +168,77 @@ _VALIDATOR_PROMPT = (
119 "Goal: {goal_text}\n\nBenchmark:\n\n{benchmark_content}" 168 "Goal: {goal_text}\n\nBenchmark:\n\n{benchmark_content}"
120 ) 169 )
121 170
  171 +# 2026-08-06: Validator gets its OWN benchmark/test_dataset/test_script,
  172 +# generated independently from SDD/PRD -- NOT reusing the Planner's/Coder's
  173 +# own copies. If the Coder could game the Planner's test_script (e.g.
  174 +# hard-code outputs for the specific dataset it developed against without
  175 +# solving the general problem), re-running that SAME script proves nothing
  176 +# -- this is the "held-out eval set vs. the dev/train set" distinction, not
  177 +# a redundant re-check of the same artifact.
  178 +_VALIDATOR_DOC_PROMPTS = {
  179 + "validator_benchmark": (
  180 + "You are independently defining YOUR OWN acceptance criteria for this goal, informed "
  181 + "by the SDD/PRD below -- do NOT copy or assume the Planner's own Benchmark, form your "
  182 + "own independent judgment of what 'done' means, from a different angle if you can. "
  183 + "Concrete test scenarios (happy/boundary/failure) with observable pass criteria for "
  184 + "each one -- spell out actual expected input/behavior/output, not one-line labels. "
  185 + "Reply with the document content (Markdown) via the required schema. " + _NO_NARRATION
  186 + + "\n\nGoal: {goal_text}\n\nSDD:\n\n{sdd_content}\n\nPRD:\n\n{prd_content}"
  187 + ),
  188 + "validator_test_dataset": (
  189 + "Here is your own independent Benchmark for goal '{goal_text}':"
  190 + "\n\n{validator_benchmark_content}\n\n"
  191 + "Judge whether this goal has a natural, concrete test dataset. If not, reply via the "
  192 + "required schema with content starting exactly with 'N/A: ' plus a one-sentence reason. "
  193 + "If so, produce your OWN independent dataset -- don't assume what the Coder actually "
  194 + "used, you have no visibility into that and shouldn't need it. Reply via the required "
  195 + "schema. " + _NO_NARRATION
  196 + ),
  197 + "validator_test_script": (
  198 + "Here is your own independent Benchmark:\n\n{validator_benchmark_content}\n\n"
  199 + "Here is your own test dataset (may be 'N/A'):\n\n{validator_test_dataset_content}\n\n"
  200 + "Write a real, runnable test script that mechanically checks your own Benchmark's "
  201 + "scenarios against the ACTUAL current implementation in this repository's working "
  202 + "directory -- run real commands, inspect real files/behavior, don't assume anything "
  203 + "about what was implemented. This script will be executed for real, and if it exits "
  204 + "non-zero the verdict is FAIL regardless of any other judgment -- make sure it actually "
  205 + "tests the real thing, not a strawman. If genuinely no mechanical script is possible for "
  206 + "this goal, reply via the required schema with content starting exactly with 'N/A: ' "
  207 + "plus a one-sentence reason -- this should be rare. " + _NO_NARRATION
  208 + ),
  209 +}
  210 +
  211 +_VALIDATOR_MANIFEST = {
  212 + "nodes": [
  213 + {"node_id": "validator_benchmark", "artifact_type": "validator_benchmark", "depends_on": []},
  214 + {
  215 + "node_id": "validator_test_dataset", "artifact_type": "validator_test_dataset",
  216 + "depends_on": ["validator_benchmark"],
  217 + },
  218 + {
  219 + # depends on BOTH -- its own prompt needs
  220 + # {validator_benchmark_content} directly, not just what
  221 + # survives into validator_test_dataset_content (same reasoning
  222 + # as the Planner's own test_script node, see
  223 + # work_graph_plan._normalize_graph_manifest).
  224 + "node_id": "validator_test_script", "artifact_type": "validator_test_script",
  225 + "depends_on": ["validator_test_dataset", "validator_benchmark"],
  226 + },
  227 + ],
  228 + "edges": [],
  229 +}
  230 +
  231 +_VALIDATOR_VERDICT_PROMPT = (
  232 + "Give your final verdict for this goal, informed by your own independent Benchmark below "
  233 + "and the REAL execution result of your own test script (if you wrote one -- 'N/A' means you "
  234 + "judged no mechanical script was possible, form your judgment from the Benchmark alone in "
  235 + "that case). Reply with a verdict line 'VERDICT: PASS' or 'VERDICT: FAIL' or "
  236 + "'VERDICT: INCONCLUSIVE' followed by your reasoning and, if FAIL, a root_cause_category "
  237 + "(planning|coder|skill_defect) with detail.\n\n"
  238 + "Goal: {goal_text}\n\nYour own Benchmark:\n\n{validator_benchmark_content}\n\n"
  239 + "Your own test script's REAL execution result:\n\n{script_execution_result}"
  240 +)
  241 +
122 242
123 def _planner_prompt_builder(goal_text, node_artifact_types): 243 def _planner_prompt_builder(goal_text, node_artifact_types):
124 """node_artifact_types: dict[node_id] -> artifact_type, read off the 244 """node_artifact_types: dict[node_id] -> artifact_type, read off the
@@ -137,6 +257,27 @@ def _planner_prompt_builder(goal_text, node_artifact_types): @@ -137,6 +257,27 @@ def _planner_prompt_builder(goal_text, node_artifact_types):
137 return build_prompt 257 return build_prompt
138 258
139 259
  260 +def _validator_prompt_builder(goal_text, sdd_content, prd_content, node_artifact_types):
  261 + """Same shape as _planner_prompt_builder, but the FIRST node
  262 + (validator_benchmark) needs sdd_content/prd_content from OUTSIDE
  263 + validator's own tiny graph -- those aren't a dependency within this
  264 + graph, they come from the Planner's already-completed one. Passed
  265 + unconditionally into every node's kwargs; .format() only consumes the
  266 + placeholders each template actually references, extra kwargs are
  267 + harmless."""
  268 + def build_prompt(node, outputs):
  269 + node_id = node["node_id"]
  270 + artifact_type = node_artifact_types.get(node_id, node_id)
  271 + content_kwargs = {"sdd_content": sdd_content, "prd_content": prd_content}
  272 + for dep_id in (node.get("depends_on") or []):
  273 + dep_output = outputs.get(dep_id)
  274 + if dep_output is not None:
  275 + dep_type = node_artifact_types.get(dep_id, dep_id)
  276 + content_kwargs[dep_type + "_content"] = dep_output["content"]
  277 + return _VALIDATOR_DOC_PROMPTS[artifact_type].format(goal_text=goal_text, **content_kwargs)
  278 + return build_prompt
  279 +
  280 +
140 def _coerce_bool(value): 281 def _coerce_bool(value):
141 """A real call (2026-08-02) returned `"consistent": "false"` as a JSON 282 """A real call (2026-08-02) returned `"consistent": "false"` as a JSON
142 STRING, not a boolean, even with `"type": "boolean"` in the schema -- 283 STRING, not a boolean, even with `"type": "boolean"` in the schema --
@@ -209,6 +350,89 @@ def _prd_consistency_verify_fn(node_artifact_types, target_folder_path): @@ -209,6 +350,89 @@ def _prd_consistency_verify_fn(node_artifact_types, target_folder_path):
209 return verify_fn 350 return verify_fn
210 351
211 352
  353 +def _is_na_content(content):
  354 + """test_dataset/test_script are allowed to legitimately not apply to a
  355 + given goal -- '_DOC_PROMPTS' asks for content starting with 'N/A: ' in
  356 + that case. That's a pass, not something to compile-check."""
  357 + return content.strip().upper().startswith("N/A")
  358 +
  359 +
  360 +def _detect_script_language(content):
  361 + """Tries Python first, then Node/JS (the two languages this project's
  362 + own tests are ever written in -- see CLAUDE.md's Testing constraints).
  363 + Returns (language_or_None, detail) -- language is 'python'/'javascript'
  364 + on success, None if neither compiles (detail then holds both compilers'
  365 + real error output). Shared by check_script_compiles (the verify_fn,
  366 + which only needs a yes/no) and run_test_script_for_real (which also
  367 + needs to know HOW to actually invoke the script)."""
  368 + with tempfile.TemporaryDirectory(prefix="graphlemon-script-check-") as tmpdir:
  369 + py_path = os.path.join(tmpdir, "candidate.py")
  370 + with open(py_path, "w", encoding="utf-8") as f:
  371 + f.write(content)
  372 + py_result = subprocess.run(
  373 + ["python3", "-m", "py_compile", py_path], capture_output=True, text=True
  374 + )
  375 + if py_result.returncode == 0:
  376 + return "python", None
  377 +
  378 + js_path = os.path.join(tmpdir, "candidate.js")
  379 + with open(js_path, "w", encoding="utf-8") as f:
  380 + f.write(content)
  381 + js_result = subprocess.run(["node", "--check", js_path], capture_output=True, text=True)
  382 + if js_result.returncode == 0:
  383 + return "javascript", None
  384 +
  385 + return None, (
  386 + "content does not compile as Python or Node/JS -- must be a real, runnable script, "
  387 + "not a narrative description.\npython3 -m py_compile:\n{}\nnode --check:\n{}".format(
  388 + py_result.stderr, js_result.stderr
  389 + )
  390 + )
  391 +
  392 +
  393 +def check_script_compiles(content):
  394 + """Mechanical (not LLM-judged) check that `content` is real, syntactically
  395 + valid code, not narrative prose masquerading as a script -- this is the
  396 + one node type where a genuinely objective check is possible (2026-08-06
  397 + design: see docs/implementation-gap-analysis.md's Benchmark-narration
  398 + incident). Returns (ok, detail)."""
  399 + if _is_na_content(content):
  400 + return True, None
  401 + language, detail = _detect_script_language(content)
  402 + return language is not None, detail
  403 +
  404 +
  405 +def _test_script_verify_fn(node_artifact_types, script_artifact_type="test_script"):
  406 + """work_graph_orchestrate.run_graph's verify_fn for a test_script node
  407 + -- mechanical compile check, not LLM judgment. On failure the same
  408 + reflecting/retry-once mechanism the PRD-consistency check already uses
  409 + kicks in (see run_graph's own docstring). `script_artifact_type` is
  410 + parameterized so this same function backs both the Planner's
  411 + 'test_script' node and the Validator's own 'validator_test_script' node."""
  412 + def verify_fn(node, output):
  413 + if node_artifact_types.get(node["node_id"]) != script_artifact_type:
  414 + return True, None
  415 + return check_script_compiles(output["content"])
  416 + return verify_fn
  417 +
  418 +
  419 +def _combined_planner_verify_fn(node_artifact_types, target_folder_path):
  420 + """Dispatches to whichever of the per-node-type verify_fns actually
  421 + applies -- run_graph only accepts one verify_fn, and each node type's
  422 + check is independent (a node is at most one artifact_type)."""
  423 + prd_check = _prd_consistency_verify_fn(node_artifact_types, target_folder_path)
  424 + script_check = _test_script_verify_fn(node_artifact_types)
  425 +
  426 + def verify_fn(node, output):
  427 + artifact_type = node_artifact_types.get(node["node_id"])
  428 + if artifact_type == "prd":
  429 + return prd_check(node, output)
  430 + if artifact_type == "test_script":
  431 + return script_check(node, output)
  432 + return True, None
  433 + return verify_fn
  434 +
  435 +
212 def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300): 436 def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300):
213 """Architecture §11 criterion 1: a real Planner Work Graph 437 """Architecture §11 criterion 1: a real Planner Work Graph
214 (c_work_graph_plan -> per-node c_work_graph_execute/c_work_node_run via 438 (c_work_graph_plan -> per-node c_work_graph_execute/c_work_node_run via
@@ -226,9 +450,16 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300): @@ -226,9 +450,16 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300):
226 outputs = work_graph_orchestrate.run_graph( 450 outputs = work_graph_orchestrate.run_graph(
227 conn, run_id, "planner", "planner-doc-writing", target_folder_path, graph_manifest, 451 conn, run_id, "planner", "planner-doc-writing", target_folder_path, graph_manifest,
228 _planner_prompt_builder(goal_text, node_artifact_types), _DOC_INPUT_SCHEMA, _DOC_OUTPUT_SCHEMA, 452 _planner_prompt_builder(goal_text, node_artifact_types), _DOC_INPUT_SCHEMA, _DOC_OUTPUT_SCHEMA,
229 - verify_fn=_prd_consistency_verify_fn(node_artifact_types, target_folder_path), 453 + verify_fn=_combined_planner_verify_fn(node_artifact_types, target_folder_path),
230 ) 454 )
231 455
  456 + # test_dataset/test_script aren't necessarily Markdown (a script is real
  457 + # code, a dataset could be JSON/CSV/anything) -- .txt is a deliberately
  458 + # generic container; Coder materializes it into a properly-named,
  459 + # properly-placed real file under the target project's own test
  460 + # conventions when it actually uses it, that's not this write step's job.
  461 + _ARTIFACT_FILE_EXT = {"test_dataset": "txt", "test_script": "txt"}
  462 +
232 artifact_ids = {} 463 artifact_ids = {}
233 for node in work_graph_orchestrate.topological_order(graph_manifest): 464 for node in work_graph_orchestrate.topological_order(graph_manifest):
234 node_id = node["node_id"] 465 node_id = node["node_id"]
@@ -238,16 +469,21 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300): @@ -238,16 +469,21 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300):
238 for dep_id in (node.get("depends_on") or []) 469 for dep_id in (node.get("depends_on") or [])
239 if node_artifact_types.get(dep_id) in artifact_ids 470 if node_artifact_types.get(dep_id) in artifact_ids
240 ] 471 ]
  472 + ext = _ARTIFACT_FILE_EXT.get(artifact_type, "md")
241 result = artifact_write.write_artifact( 473 result = artifact_write.write_artifact(
242 conn, target_folder_path, artifact_type, task_id, run_id, 474 conn, target_folder_path, artifact_type, task_id, run_id,
243 - ".graphlemon/runs/{}/{}.md".format(task_id, artifact_type), outputs[node_id]["content"], 475 + ".graphlemon/runs/{}/{}.{}".format(task_id, artifact_type, ext), outputs[node_id]["content"],
244 depends_on_artifact_ids=deps, 476 depends_on_artifact_ids=deps,
245 ) 477 )
246 artifact_ids[artifact_type] = result["artifact_id"] 478 artifact_ids[artifact_type] = result["artifact_id"]
247 return {"run_id": run_id, "artifact_ids": artifact_ids} 479 return {"run_id": run_id, "artifact_ids": artifact_ids}
248 480
249 481
250 -def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, timeout=1200): 482 +def implement_code(
  483 + conn, task_id, sdd_content, goal_text, target_folder_path, timeout=1200,
  484 + benchmark_content="N/A: not provided", test_dataset_content="N/A: not provided",
  485 + test_script_content="N/A: not provided",
  486 +):
251 """Architecture §11 criterion 2: a real Coder call via c_work_node_run 487 """Architecture §11 criterion 2: a real Coder call via c_work_node_run
252 (work_graph_orchestrate.run_graph with a synthetic single-node 488 (work_graph_orchestrate.run_graph with a synthetic single-node
253 manifest -- see module docstring for why there's no real Coder graph 489 manifest -- see module docstring for why there's no real Coder graph
@@ -255,6 +491,12 @@ def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, ti @@ -255,6 +491,12 @@ def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, ti
255 code_diff (the diff itself) and test_report (the Coder's own response 491 code_diff (the diff itself) and test_report (the Coder's own response
256 text, which per its migrated SOUL.md is required to include real test 492 text, which per its migrated SOUL.md is required to include real test
257 commands + real output) artifacts. 493 commands + real output) artifacts.
  494 +
  495 + 2026-08-06: benchmark_content/test_dataset_content/test_script_content
  496 + (default 'N/A: not provided' for callers that predate this, e.g. a
  497 + resumed task from before this change) are threaded into _CODER_PROMPT
  498 + so the Coder must make the Planner's real test script pass for real,
  499 + not just write whatever tests it invents on its own.
258 """ 500 """
259 before_sha = _current_commit(target_folder_path) 501 before_sha = _current_commit(target_folder_path)
260 run_id = str(uuid.uuid4()) 502 run_id = str(uuid.uuid4())
@@ -268,7 +510,10 @@ def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, ti @@ -268,7 +510,10 @@ def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, ti
268 510
269 outputs = work_graph_orchestrate.run_graph( 511 outputs = work_graph_orchestrate.run_graph(
270 conn, run_id, "coder", "coder-implementation", target_folder_path, manifest, 512 conn, run_id, "coder", "coder-implementation", target_folder_path, manifest,
271 - lambda node, outputs: _CODER_PROMPT.format(goal_text=goal_text, sdd_content=sdd_content), 513 + lambda node, outputs: _CODER_PROMPT.format(
  514 + goal_text=goal_text, sdd_content=sdd_content, benchmark_content=benchmark_content,
  515 + test_dataset_content=test_dataset_content, test_script_content=test_script_content,
  516 + ),
272 _CODE_INPUT_SCHEMA, _CODE_OUTPUT_SCHEMA, timeout=timeout, 517 _CODE_INPUT_SCHEMA, _CODE_OUTPUT_SCHEMA, timeout=timeout,
273 ) 518 )
274 response = outputs["implement"]["response"] 519 response = outputs["implement"]["response"]
@@ -288,6 +533,19 @@ def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, ti @@ -288,6 +533,19 @@ def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, ti
288 return {"code_diff": diff_result["artifact_id"], "test_report": report_result["artifact_id"]} 533 return {"code_diff": diff_result["artifact_id"], "test_report": report_result["artifact_id"]}
289 534
290 535
  536 +def _parse_verdict_line(response):
  537 + verdict = "INCONCLUSIVE"
  538 + for line in response.splitlines():
  539 + stripped = line.strip().upper()
  540 + if stripped.startswith("VERDICT:"):
  541 + for candidate in ("PASS", "FAIL", "INCONCLUSIVE"):
  542 + if candidate in stripped:
  543 + verdict = candidate
  544 + break
  545 + break
  546 + return verdict
  547 +
  548 +
291 def validate_implementation(task_id, benchmark_content, goal_text, target_folder_path, timeout=1200): 549 def validate_implementation(task_id, benchmark_content, goal_text, target_folder_path, timeout=1200):
292 """Architecture §11 criterion 3: real Validator call, parses the 550 """Architecture §11 criterion 3: real Validator call, parses the
293 'VERDICT: PASS|FAIL|INCONCLUSIVE' line this Unit's prompt asks for. 551 'VERDICT: PASS|FAIL|INCONCLUSIVE' line this Unit's prompt asks for.
@@ -296,22 +554,161 @@ def validate_implementation(task_id, benchmark_content, goal_text, target_folder @@ -296,22 +554,161 @@ def validate_implementation(task_id, benchmark_content, goal_text, target_folder
296 controllers (c_bug_record_create/p_validator_root_cause_trace only), 554 controllers (c_bug_record_create/p_validator_root_cause_trace only),
297 so the simpler flat trigger_org_node call is already faithful to what 555 so the simpler flat trigger_org_node call is already faithful to what
298 this REQ actually requires. 556 this REQ actually requires.
  557 +
  558 + **2026-08-06**: kept as-is (still the simpler flat call, no Validator's
  559 + own Work Graph) for callers that only need this original REQ-04-scoped
  560 + behavior -- run_task_lifecycle now uses
  561 + validate_implementation_with_own_graph() instead, which wraps this same
  562 + prompt/parsing shape but adds the independent benchmark/dataset/script
  563 + generation and the real-script-execution hard gate. See that function's
  564 + own docstring and docs/implementation-gap-analysis.md for why (a
  565 + Benchmark that's only ever prose an LLM narrates over instead of
  566 + substantive content isn't a real acceptance anchor).
299 """ 567 """
300 response = org_node_trigger.trigger_org_node( 568 response = org_node_trigger.trigger_org_node(
301 "validator", 569 "validator",
302 _VALIDATOR_PROMPT.format(goal_text=goal_text, benchmark_content=benchmark_content), 570 _VALIDATOR_PROMPT.format(goal_text=goal_text, benchmark_content=benchmark_content),
303 target_folder_path, timeout=timeout, 571 target_folder_path, timeout=timeout,
304 ) 572 )
305 - verdict = "INCONCLUSIVE"  
306 - for line in response.splitlines():  
307 - stripped = line.strip().upper()  
308 - if stripped.startswith("VERDICT:"):  
309 - for candidate in ("PASS", "FAIL", "INCONCLUSIVE"):  
310 - if candidate in stripped:  
311 - verdict = candidate  
312 - break  
313 - break  
314 - return {"verdict": verdict, "raw_response": response} 573 + return {"verdict": _parse_verdict_line(response), "raw_response": response}
  574 +
  575 +
  576 +def _script_file_extension(language):
  577 + return {"python": "py", "javascript": "js"}.get(language, "txt")
  578 +
  579 +
  580 +def run_test_script_for_real(content, target_folder_path, task_id, node_label, timeout=120):
  581 + """Materializes `content` as a real file under .graphlemon/runs/<task_id>/
  582 + and actually executes it with cwd=target_folder_path, so it runs against
  583 + the REAL current state of the repository (whatever the Coder actually
  584 + did), not a sandboxed guess. Returns
  585 + {"ran": bool, "exit_code": int|None, "stdout": str, "stderr": str, "note": str}.
  586 +
  587 + `ran=False` covers both legitimate N/A content and the (should be rare,
  588 + since the generating verify_fn already gated this) case where the
  589 + content still doesn't compile -- both are "nothing to hard-gate on",
  590 + the caller decides what INCONCLUSIVE-vs-something-else means for each.
  591 + """
  592 + if _is_na_content(content):
  593 + return {"ran": False, "exit_code": None, "stdout": "", "stderr": "", "note": content}
  594 +
  595 + language, detail = _detect_script_language(content)
  596 + if language is None:
  597 + return {
  598 + "ran": False, "exit_code": None, "stdout": "", "stderr": "",
  599 + "note": "script does not compile, cannot execute: " + detail,
  600 + }
  601 +
  602 + script_path = os.path.join(
  603 + target_folder_path, ".graphlemon", "runs", task_id,
  604 + "{}.{}".format(node_label, _script_file_extension(language)),
  605 + )
  606 + os.makedirs(os.path.dirname(script_path), exist_ok=True)
  607 + with open(script_path, "w", encoding="utf-8") as f:
  608 + f.write(content)
  609 +
  610 + interpreter = "python3" if language == "python" else "node"
  611 + try:
  612 + result = subprocess.run(
  613 + [interpreter, script_path], cwd=target_folder_path, capture_output=True, text=True, timeout=timeout,
  614 + )
  615 + except subprocess.TimeoutExpired as e:
  616 + return {
  617 + "ran": True, "exit_code": None, "stdout": e.stdout or "", "stderr": e.stderr or "",
  618 + "note": "timed out after {}s".format(timeout),
  619 + }
  620 + return {
  621 + "ran": True, "exit_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr,
  622 + "note": "exit code {}".format(result.returncode),
  623 + }
  624 +
  625 +
  626 +def _format_script_execution_result(result):
  627 + if not result["ran"]:
  628 + return "Not executed ({}).".format(result["note"])
  629 + return (
  630 + "Executed for real. Exit code: {}\nstdout:\n{}\nstderr:\n{}"
  631 + ).format(result["exit_code"], result["stdout"][-4000:], result["stderr"][-4000:])
  632 +
  633 +
  634 +def validate_implementation_with_own_graph(
  635 + conn, task_id, sdd_content, prd_content, goal_text, target_folder_path, timeout=1200,
  636 +):
  637 + """2026-08-06: Validator's own Work Graph (validator_benchmark ->
  638 + validator_test_dataset -> validator_test_script), independently
  639 + generated from SDD/PRD -- NOT reusing the Planner's/Coder's own
  640 + benchmark/dataset/script (see _VALIDATOR_DOC_PROMPTS's module comment
  641 + for why: a held-out eval set, not a re-run of the dev set the Coder
  642 + already developed against). The resulting test_script, if real (not
  643 + N/A), is then actually EXECUTED against the current repository state --
  644 + a non-zero real exit code force-overrides the verdict to FAIL in code,
  645 + not just by asking the LLM nicely (user-confirmed 2026-08-06: script is
  646 + the hard gate, subjective judgment can only add INCONCLUSIVE/risk notes
  647 + on top of a passing script, never override a failing one to PASS).
  648 +
  649 + Returns {"verdict", "raw_response", "artifact_ids", "script_execution"}.
  650 + """
  651 + run_id = str(uuid.uuid4())
  652 + conn.execute(
  653 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  654 + "VALUES (?, ?, 'validator', ?, 'running')",
  655 + (run_id, task_id, json.dumps(_VALIDATOR_MANIFEST)),
  656 + )
  657 + conn.commit()
  658 +
  659 + node_artifact_types = {n["node_id"]: n["artifact_type"] for n in _VALIDATOR_MANIFEST["nodes"]}
  660 + outputs = work_graph_orchestrate.run_graph(
  661 + conn, run_id, "validator", "validator-independent-verification", target_folder_path, _VALIDATOR_MANIFEST,
  662 + _validator_prompt_builder(goal_text, sdd_content, prd_content, node_artifact_types),
  663 + _DOC_INPUT_SCHEMA, _DOC_OUTPUT_SCHEMA,
  664 + verify_fn=_test_script_verify_fn(node_artifact_types, script_artifact_type="validator_test_script"),
  665 + timeout=timeout,
  666 + )
  667 +
  668 + artifact_ids = {}
  669 + for node in work_graph_orchestrate.topological_order(_VALIDATOR_MANIFEST):
  670 + node_id = node["node_id"]
  671 + artifact_type = node_artifact_types[node_id]
  672 + deps = [
  673 + artifact_ids[node_artifact_types[dep_id]]
  674 + for dep_id in (node.get("depends_on") or [])
  675 + if node_artifact_types.get(dep_id) in artifact_ids
  676 + ]
  677 + ext = "txt" if artifact_type != "validator_benchmark" else "md"
  678 + result = artifact_write.write_artifact(
  679 + conn, target_folder_path, artifact_type, task_id, run_id,
  680 + ".graphlemon/runs/{}/{}.{}".format(task_id, artifact_type, ext), outputs[node_id]["content"],
  681 + depends_on_artifact_ids=deps,
  682 + )
  683 + artifact_ids[artifact_type] = result["artifact_id"]
  684 +
  685 + script_content = outputs["validator_test_script"]["content"]
  686 + script_execution = run_test_script_for_real(
  687 + script_content, target_folder_path, task_id, "validator_test_script", timeout=timeout,
  688 + )
  689 +
  690 + response = org_node_trigger.trigger_org_node(
  691 + "validator",
  692 + _VALIDATOR_VERDICT_PROMPT.format(
  693 + goal_text=goal_text,
  694 + validator_benchmark_content=outputs["validator_benchmark"]["content"],
  695 + script_execution_result=_format_script_execution_result(script_execution),
  696 + ),
  697 + target_folder_path, timeout=timeout,
  698 + )
  699 + verdict = _parse_verdict_line(response)
  700 +
  701 + # Hard gate (user-confirmed 2026-08-06): a real non-zero exit from
  702 + # validator's OWN test script is not up for LLM debate -- force FAIL
  703 + # regardless of what the verdict line said. A passing/not-run script
  704 + # leaves the LLM's own judgment (including INCONCLUSIVE) untouched.
  705 + if script_execution["ran"] and script_execution["exit_code"] not in (0, None):
  706 + verdict = "FAIL"
  707 +
  708 + return {
  709 + "verdict": verdict, "raw_response": response,
  710 + "artifact_ids": artifact_ids, "script_execution": script_execution,
  711 + }
315 712
316 713
317 def _current_commit(repo_path): 714 def _current_commit(repo_path):
@@ -334,13 +731,32 @@ def run_task_lifecycle(conn, task_id, target_folder_path, goal_text): @@ -334,13 +731,32 @@ def run_task_lifecycle(conn, task_id, target_folder_path, goal_text):
334 plan_result = plan_documents(conn, task_id, goal_text, target_folder_path) 731 plan_result = plan_documents(conn, task_id, goal_text, target_folder_path)
335 artifact_ids = plan_result["artifact_ids"] 732 artifact_ids = plan_result["artifact_ids"]
336 sdd_content = _read_artifact_content(conn, target_folder_path, artifact_ids["sdd"]) 733 sdd_content = _read_artifact_content(conn, target_folder_path, artifact_ids["sdd"])
  734 + prd_content = _read_artifact_content(conn, target_folder_path, artifact_ids["prd"])
337 benchmark_content = _read_artifact_content(conn, target_folder_path, artifact_ids["benchmark"]) 735 benchmark_content = _read_artifact_content(conn, target_folder_path, artifact_ids["benchmark"])
  736 + # test_dataset/test_script are new 2026-08-06 nodes -- guard with .get
  737 + # so a task resumed from an artifact set produced before this change
  738 + # (no test_dataset/test_script artifact_ids) still works, falling back
  739 + # to implement_code's own "not provided" defaults.
  740 + test_dataset_content = (
  741 + _read_artifact_content(conn, target_folder_path, artifact_ids["test_dataset"])
  742 + if "test_dataset" in artifact_ids else "N/A: not provided"
  743 + )
  744 + test_script_content = (
  745 + _read_artifact_content(conn, target_folder_path, artifact_ids["test_script"])
  746 + if "test_script" in artifact_ids else "N/A: not provided"
  747 + )
338 748
339 org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, target_folder_path, _READY_PROMPT) 749 org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, target_folder_path, _READY_PROMPT)
340 - code_artifact_ids = implement_code(conn, task_id, sdd_content, goal_text, target_folder_path) 750 + code_artifact_ids = implement_code(
  751 + conn, task_id, sdd_content, goal_text, target_folder_path,
  752 + benchmark_content=benchmark_content, test_dataset_content=test_dataset_content,
  753 + test_script_content=test_script_content,
  754 + )
341 755
342 org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, target_folder_path, _READY_PROMPT) 756 org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, target_folder_path, _READY_PROMPT)
343 - verdict = validate_implementation(task_id, benchmark_content, goal_text, target_folder_path) 757 + verdict = validate_implementation_with_own_graph(
  758 + conn, task_id, sdd_content, prd_content, goal_text, target_folder_path,
  759 + )
344 760
345 return { 761 return {
346 "task_id": task_id, 762 "task_id": task_id,
src/schema.py
@@ -161,7 +161,9 @@ CREATE TABLE work_node_execution ( @@ -161,7 +161,9 @@ CREATE TABLE work_node_execution (
161 CREATE TABLE artifact ( 161 CREATE TABLE artifact (
162 artifact_id TEXT PRIMARY KEY, 162 artifact_id TEXT PRIMARY KEY,
163 artifact_type TEXT NOT NULL CHECK (artifact_type IN ( 163 artifact_type TEXT NOT NULL CHECK (artifact_type IN (
164 - 'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report' 164 + 'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report',
  165 + 'test_dataset', 'test_script',
  166 + 'validator_benchmark', 'validator_test_dataset', 'validator_test_script'
165 )), 167 )),
166 task_id TEXT NOT NULL REFERENCES task(task_id), 168 task_id TEXT NOT NULL REFERENCES task(task_id),
167 produced_by_run_id TEXT NOT NULL REFERENCES work_graph_run(run_id), 169 produced_by_run_id TEXT NOT NULL REFERENCES work_graph_run(run_id),
@@ -351,3 +353,48 @@ def create_schema(conn): @@ -351,3 +353,48 @@ def create_schema(conn):
351 from U-034, PH5) + indexes on an open connection.""" 353 from U-034, PH5) + indexes on an open connection."""
352 conn.executescript(SCHEMA_SQL) 354 conn.executescript(SCHEMA_SQL)
353 conn.commit() 355 conn.commit()
  356 +
  357 +
  358 +def migrate_expand_artifact_types(conn):
  359 + """2026-08-06: adds test_dataset/test_script/validator_benchmark/
  360 + validator_test_dataset/validator_test_script to artifact.artifact_type's
  361 + CHECK enum on an ALREADY-POPULATED database -- SQLite has no
  362 + `ALTER TABLE ... MODIFY CONSTRAINT`, so this is the standard "create a
  363 + new table with the wanted constraint, copy every row, drop the old
  364 + table, rename" dance. No other table has a foreign key into `artifact`
  365 + (checked), so this doesn't need to touch anything beyond the table
  366 + itself and its one index.
  367 +
  368 + Idempotent and safe to call unconditionally (including against a
  369 + freshly create_schema()'d database, which already has the expanded
  370 + constraint) -- checks the live constraint text first and no-ops if it's
  371 + already expanded, so callers never need to special-case "is this a
  372 + fresh or an upgraded database".
  373 + """
  374 + row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='artifact'").fetchone()
  375 + if row is None or "'test_dataset'" in row[0]:
  376 + return
  377 + conn.executescript(
  378 + """
  379 + CREATE TABLE artifact_new (
  380 + artifact_id TEXT PRIMARY KEY,
  381 + artifact_type TEXT NOT NULL CHECK (artifact_type IN (
  382 + 'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report',
  383 + 'test_dataset', 'test_script',
  384 + 'validator_benchmark', 'validator_test_dataset', 'validator_test_script'
  385 + )),
  386 + task_id TEXT NOT NULL REFERENCES task(task_id),
  387 + produced_by_run_id TEXT NOT NULL REFERENCES work_graph_run(run_id),
  388 + file_path TEXT NOT NULL,
  389 + git_commit_sha TEXT NOT NULL,
  390 + depends_on_artifact_ids TEXT,
  391 + validation_status TEXT NOT NULL DEFAULT 'pending' CHECK (validation_status IN ('pending', 'passed', 'failed')),
  392 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  393 + );
  394 + INSERT INTO artifact_new SELECT * FROM artifact;
  395 + DROP TABLE artifact;
  396 + ALTER TABLE artifact_new RENAME TO artifact;
  397 + CREATE UNIQUE INDEX ux_artifact_task_type_commit ON artifact(task_id, artifact_type, git_commit_sha);
  398 + """
  399 + )
  400 + conn.commit()
src/task_execution.py
@@ -74,6 +74,20 @@ def _artifact_content(conn, target_folder_path, task_id, artifact_type): @@ -74,6 +74,20 @@ def _artifact_content(conn, target_folder_path, task_id, artifact_type):
74 return e2e_pipeline._read_artifact_content(conn, target_folder_path, row[0]) 74 return e2e_pipeline._read_artifact_content(conn, target_folder_path, row[0])
75 75
76 76
  77 +def _artifact_content_or_default(conn, target_folder_path, task_id, artifact_type, default):
  78 + """Like _artifact_content, but for the 2026-08-06 test_dataset/
  79 + test_script nodes -- a task resumed from before this feature existed
  80 + (or any other reason those two specific artifacts are missing) should
  81 + still be resumable, just without them, not hard-fail."""
  82 + row = conn.execute(
  83 + "SELECT artifact_id FROM artifact WHERE task_id = ? AND artifact_type = ? ORDER BY created_at DESC LIMIT 1",
  84 + (task_id, artifact_type),
  85 + ).fetchone()
  86 + if row is None:
  87 + return default
  88 + return e2e_pipeline._read_artifact_content(conn, target_folder_path, row[0])
  89 +
  90 +
77 def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text): 91 def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text):
78 """Same return shape as `e2e_pipeline.run_task_lifecycle` -- continues 92 """Same return shape as `e2e_pipeline.run_task_lifecycle` -- continues
79 from wherever `task.status` indicates real work last completed, instead 93 from wherever `task.status` indicates real work last completed, instead
@@ -93,18 +107,33 @@ def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text): @@ -93,18 +107,33 @@ def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text):
93 107
94 doc_artifact_ids = _latest_artifact_ids(conn, task_id, _DOC_ARTIFACT_TYPES) 108 doc_artifact_ids = _latest_artifact_ids(conn, task_id, _DOC_ARTIFACT_TYPES)
95 sdd_content = _artifact_content(conn, target_folder_path, task_id, "sdd") 109 sdd_content = _artifact_content(conn, target_folder_path, task_id, "sdd")
  110 + prd_content = _artifact_content(conn, target_folder_path, task_id, "prd")
96 benchmark_content = _artifact_content(conn, target_folder_path, task_id, "benchmark") 111 benchmark_content = _artifact_content(conn, target_folder_path, task_id, "benchmark")
  112 + # 2026-08-06 nodes -- see _artifact_content_or_default's own docstring
  113 + # for why these two specifically fall back instead of hard-failing.
  114 + test_dataset_content = _artifact_content_or_default(
  115 + conn, target_folder_path, task_id, "test_dataset", "N/A: not provided"
  116 + )
  117 + test_script_content = _artifact_content_or_default(
  118 + conn, target_folder_path, task_id, "test_script", "N/A: not provided"
  119 + )
97 120
98 if status == "planning_brd_prd_sdd": 121 if status == "planning_brd_prd_sdd":
99 _check_not_paused(conn, task_id) 122 _check_not_paused(conn, task_id)
100 org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, target_folder_path, e2e_pipeline._READY_PROMPT) 123 org_node_lifecycle.trigger_org_node_full(conn, "coder", task_id, target_folder_path, e2e_pipeline._READY_PROMPT)
101 - code_artifact_ids = e2e_pipeline.implement_code(conn, task_id, sdd_content, goal_text, target_folder_path) 124 + code_artifact_ids = e2e_pipeline.implement_code(
  125 + conn, task_id, sdd_content, goal_text, target_folder_path,
  126 + benchmark_content=benchmark_content, test_dataset_content=test_dataset_content,
  127 + test_script_content=test_script_content,
  128 + )
102 else: # status == "coding" -- Coder already ran 129 else: # status == "coding" -- Coder already ran
103 code_artifact_ids = _latest_artifact_ids(conn, task_id, _CODE_ARTIFACT_TYPES) 130 code_artifact_ids = _latest_artifact_ids(conn, task_id, _CODE_ARTIFACT_TYPES)
104 131
105 _check_not_paused(conn, task_id) 132 _check_not_paused(conn, task_id)
106 org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, target_folder_path, e2e_pipeline._READY_PROMPT) 133 org_node_lifecycle.trigger_org_node_full(conn, "validator", task_id, target_folder_path, e2e_pipeline._READY_PROMPT)
107 - verdict = e2e_pipeline.validate_implementation(task_id, benchmark_content, goal_text, target_folder_path) 134 + verdict = e2e_pipeline.validate_implementation_with_own_graph(
  135 + conn, task_id, sdd_content, prd_content, goal_text, target_folder_path,
  136 + )
108 137
109 return { 138 return {
110 "task_id": task_id, "doc_artifact_ids": doc_artifact_ids, 139 "task_id": task_id, "doc_artifact_ids": doc_artifact_ids,
src/test_e2e_pipeline.py
@@ -23,6 +23,7 @@ Run: python3 -m unittest src.test_e2e_pipeline -v @@ -23,6 +23,7 @@ Run: python3 -m unittest src.test_e2e_pipeline -v
23 """ 23 """
24 24
25 import json 25 import json
  26 +import os
26 import subprocess 27 import subprocess
27 import tempfile 28 import tempfile
28 import unittest 29 import unittest
@@ -30,7 +31,11 @@ from unittest.mock import patch @@ -30,7 +31,11 @@ from unittest.mock import patch
30 31
31 from src import e2e_pipeline, org_node_trigger, project_scaffold, schema, work_graph_execute 32 from src import e2e_pipeline, org_node_trigger, project_scaffold, schema, work_graph_execute
32 33
33 -_FAKE_DOCS = {"brd": "# BRD\nfake", "prd": "# PRD\nfake", "sdd": "# SDD\nfake", "benchmark": "# Benchmark\nfake"} 34 +_FAKE_DOCS = {
  35 + "brd": "# BRD\nfake", "prd": "# PRD\nfake", "sdd": "# SDD\nfake", "benchmark": "# Benchmark\nfake",
  36 + "test_dataset": "N/A: no natural dataset for this fake goal",
  37 + "test_script": "assert True # fake but real, compilable script",
  38 +}
34 39
35 _FAKE_GRAPH_MANIFEST = { 40 _FAKE_GRAPH_MANIFEST = {
36 "nodes": [ 41 "nodes": [
@@ -38,11 +43,15 @@ _FAKE_GRAPH_MANIFEST = { @@ -38,11 +43,15 @@ _FAKE_GRAPH_MANIFEST = {
38 {"node_id": "prd", "artifact_type": "prd", "depends_on": ["brd"]}, 43 {"node_id": "prd", "artifact_type": "prd", "depends_on": ["brd"]},
39 {"node_id": "sdd", "artifact_type": "sdd", "depends_on": ["prd"]}, 44 {"node_id": "sdd", "artifact_type": "sdd", "depends_on": ["prd"]},
40 {"node_id": "benchmark", "artifact_type": "benchmark", "depends_on": ["sdd"]}, 45 {"node_id": "benchmark", "artifact_type": "benchmark", "depends_on": ["sdd"]},
  46 + {"node_id": "test_dataset", "artifact_type": "test_dataset", "depends_on": ["benchmark"]},
  47 + {"node_id": "test_script", "artifact_type": "test_script", "depends_on": ["test_dataset"]},
41 ], 48 ],
42 "edges": [ 49 "edges": [
43 {"from": "brd", "to": "prd", "edge_type": "needs_judgment"}, 50 {"from": "brd", "to": "prd", "edge_type": "needs_judgment"},
44 {"from": "prd", "to": "sdd", "edge_type": "needs_judgment"}, 51 {"from": "prd", "to": "sdd", "edge_type": "needs_judgment"},
45 {"from": "sdd", "to": "benchmark", "edge_type": "needs_judgment"}, 52 {"from": "sdd", "to": "benchmark", "edge_type": "needs_judgment"},
  53 + {"from": "benchmark", "to": "test_dataset", "edge_type": "needs_judgment"},
  54 + {"from": "test_dataset", "to": "test_script", "edge_type": "needs_judgment"},
46 ], 55 ],
47 } 56 }
48 57
@@ -71,7 +80,7 @@ class PipelineTestBase(unittest.TestCase): @@ -71,7 +80,7 @@ class PipelineTestBase(unittest.TestCase):
71 80
72 81
73 class PlanDocumentsTest(PipelineTestBase): 82 class PlanDocumentsTest(PipelineTestBase):
74 - def test_produces_four_artifacts_with_correct_lineage(self): 83 + def test_produces_six_artifacts_with_correct_lineage(self):
75 self.conn.execute( 84 self.conn.execute(
76 "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " 85 "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
77 "VALUES ('task-1', (SELECT project_id FROM project), '实现贪吃蛇游戏', 'goal', " 86 "VALUES ('task-1', (SELECT project_id FROM project), '实现贪吃蛇游戏', 'goal', "
@@ -86,11 +95,12 @@ class PlanDocumentsTest(PipelineTestBase): @@ -86,11 +95,12 @@ class PlanDocumentsTest(PipelineTestBase):
86 {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, 95 {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]},
87 {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) 96 {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary)
88 {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, 97 {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]},
  98 + {"content": _FAKE_DOCS["test_dataset"]}, {"content": _FAKE_DOCS["test_script"]},
89 ] 99 ]
90 plan_result = e2e_pipeline.plan_documents(self.conn, "task-1", "实现贪吃蛇游戏", self.tmp.name) 100 plan_result = e2e_pipeline.plan_documents(self.conn, "task-1", "实现贪吃蛇游戏", self.tmp.name)
91 101
92 artifact_ids = plan_result["artifact_ids"] 102 artifact_ids = plan_result["artifact_ids"]
93 - self.assertEqual(set(artifact_ids), {"brd", "prd", "sdd", "benchmark"}) 103 + self.assertEqual(set(artifact_ids), {"brd", "prd", "sdd", "benchmark", "test_dataset", "test_script"})
94 prd_deps = json.loads( 104 prd_deps = json.loads(
95 self.conn.execute( 105 self.conn.execute(
96 "SELECT depends_on_artifact_ids FROM artifact WHERE artifact_id = ?", (artifact_ids["prd"],) 106 "SELECT depends_on_artifact_ids FROM artifact WHERE artifact_id = ?", (artifact_ids["prd"],)
@@ -122,12 +132,21 @@ class RunEndToEndTest(PipelineTestBase): @@ -122,12 +132,21 @@ class RunEndToEndTest(PipelineTestBase):
122 {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, 132 {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]},
123 {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) 133 {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary)
124 {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, 134 {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]},
  135 + {"content": _FAKE_DOCS["test_dataset"]}, {"content": _FAKE_DOCS["test_script"]},
125 {"response": "Implemented. Ran `pytest`: 3 passed."}, 136 {"response": "Implemented. Ran `pytest`: 3 passed."},
  137 + # Validator's OWN independent benchmark/dataset/script (2026-08-06) --
  138 + # deliberately different content from the Planner's, to also prove
  139 + # nothing here accidentally reuses the Planner's copies.
  140 + {"content": "# Validator's own Benchmark\nfake"},
  141 + {"content": "N/A: validator judged no dataset needed either"},
  142 + {"content": "assert True # validator's own real, compilable script"},
126 ] 143 ]
127 result = e2e_pipeline.run_end_to_end(self.conn, self.tmp.name, "实现贪吃蛇游戏") 144 result = e2e_pipeline.run_end_to_end(self.conn, self.tmp.name, "实现贪吃蛇游戏")
128 145
129 self.assertEqual(result["verdict"]["verdict"], "PASS") 146 self.assertEqual(result["verdict"]["verdict"], "PASS")
130 - self.assertEqual(set(result["doc_artifact_ids"]), {"brd", "prd", "sdd", "benchmark"}) 147 + self.assertEqual(
  148 + set(result["doc_artifact_ids"]), {"brd", "prd", "sdd", "benchmark", "test_dataset", "test_script"}
  149 + )
131 self.assertEqual(set(result["code_artifact_ids"]), {"code_diff", "test_report"}) 150 self.assertEqual(set(result["code_artifact_ids"]), {"code_diff", "test_report"})
132 151
133 task_row = self.conn.execute( 152 task_row = self.conn.execute(
@@ -138,15 +157,19 @@ class RunEndToEndTest(PipelineTestBase): @@ -138,15 +157,19 @@ class RunEndToEndTest(PipelineTestBase):
138 all_artifacts = self.conn.execute( 157 all_artifacts = self.conn.execute(
139 "SELECT COUNT(*) FROM artifact WHERE task_id = ?", (result["task_id"],) 158 "SELECT COUNT(*) FROM artifact WHERE task_id = ?", (result["task_id"],)
140 ).fetchone()[0] 159 ).fetchone()[0]
141 - self.assertEqual(all_artifacts, 6) # brd, prd, sdd, benchmark, code_diff, test_report 160 + # brd, prd, sdd, benchmark, test_dataset, test_script, code_diff, test_report,
  161 + # validator_benchmark, validator_test_dataset, validator_test_script
  162 + self.assertEqual(all_artifacts, 11)
142 163
143 - # planner and coder each got their own work_graph_run (not sharing one) 164 + # planner, coder, AND validator (2026-08-06: validator now has its own
  165 + # real work_graph_run too, not a flat direct call) each got their own
  166 + # work_graph_run (not sharing one)
144 run_org_nodes = { 167 run_org_nodes = {
145 row[0] for row in self.conn.execute( 168 row[0] for row in self.conn.execute(
146 "SELECT org_node FROM work_graph_run WHERE task_id = ?", (result["task_id"],) 169 "SELECT org_node FROM work_graph_run WHERE task_id = ?", (result["task_id"],)
147 ).fetchall() 170 ).fetchall()
148 } 171 }
149 - self.assertEqual(run_org_nodes, {"planner", "coder"}) 172 + self.assertEqual(run_org_nodes, {"planner", "coder", "validator"})
150 173
151 174
152 class PrdConsistencyVerifyFnTest(unittest.TestCase): 175 class PrdConsistencyVerifyFnTest(unittest.TestCase):
@@ -202,6 +225,92 @@ class PrdConsistencyVerifyFnTest(unittest.TestCase): @@ -202,6 +225,92 @@ class PrdConsistencyVerifyFnTest(unittest.TestCase):
202 self.assertEqual(args[0], "/tmp/proj-1") 225 self.assertEqual(args[0], "/tmp/proj-1")
203 226
204 227
  228 +class CheckScriptCompilesTest(unittest.TestCase):
  229 + """2026-08-06: the one mechanical (not LLM-judged) verify_fn -- real,
  230 + unmocked subprocess calls to py_compile/node --check, no fixtures to
  231 + fake since this is testing an actual compiler's real opinion."""
  232 +
  233 + def test_na_content_passes_without_compiling(self):
  234 + ok, detail = e2e_pipeline.check_script_compiles("N/A: this goal has no automatable check")
  235 + self.assertTrue(ok)
  236 + self.assertIsNone(detail)
  237 +
  238 + def test_valid_python_passes(self):
  239 + ok, detail = e2e_pipeline.check_script_compiles("def test_thing():\n assert 1 + 1 == 2\n")
  240 + self.assertTrue(ok)
  241 +
  242 + def test_valid_javascript_passes(self):
  243 + ok, detail = e2e_pipeline.check_script_compiles("function testThing() { return 1 + 1; }\n")
  244 + self.assertTrue(ok)
  245 +
  246 + def test_narrative_prose_fails_both(self):
  247 + # this is the exact failure mode that motivated this check -- a
  248 + # narrative sentence about a script, not a real script.
  249 + ok, detail = e2e_pipeline.check_script_compiles(
  250 + "The graphlemon planner Work Node workflow completed successfully and produced "
  251 + "a test script covering 18 scenarios across four areas."
  252 + )
  253 + self.assertFalse(ok)
  254 + self.assertIn("py_compile", detail)
  255 + self.assertIn("node --check", detail)
  256 +
  257 + def test_syntactically_broken_python_and_js_fails(self):
  258 + ok, detail = e2e_pipeline.check_script_compiles("def broken(:\n this is not valid anything\n")
  259 + self.assertFalse(ok)
  260 +
  261 +
  262 +class TestScriptVerifyFnTest(unittest.TestCase):
  263 + def test_non_test_script_node_passes_through_without_compiling(self):
  264 + node_artifact_types = {"brd": "brd"}
  265 + verify_fn = e2e_pipeline._test_script_verify_fn(node_artifact_types)
  266 + ok, detail = verify_fn({"node_id": "brd"}, {"content": "not even close to code {{{"})
  267 + self.assertTrue(ok)
  268 + self.assertIsNone(detail)
  269 +
  270 + def test_test_script_node_with_broken_content_fails(self):
  271 + node_artifact_types = {"script": "test_script"}
  272 + verify_fn = e2e_pipeline._test_script_verify_fn(node_artifact_types)
  273 + ok, detail = verify_fn({"node_id": "script"}, {"content": "this is narrative, not code, no way"})
  274 + self.assertFalse(ok)
  275 +
  276 + def test_test_script_node_with_real_code_passes(self):
  277 + node_artifact_types = {"script": "test_script"}
  278 + verify_fn = e2e_pipeline._test_script_verify_fn(node_artifact_types)
  279 + ok, detail = verify_fn({"node_id": "script"}, {"content": "assert True\n"})
  280 + self.assertTrue(ok)
  281 +
  282 +
  283 +class CombinedPlannerVerifyFnTest(unittest.TestCase):
  284 + """Dispatches to the right per-node-type check -- both real checks
  285 + (PRD consistency and script-compiles) are exercised elsewhere in
  286 + isolation; this only tests the dispatch itself."""
  287 +
  288 + def test_prd_node_dispatches_to_prd_check(self):
  289 + node_artifact_types = {"n1": "prd"}
  290 + verify_fn = e2e_pipeline._combined_planner_verify_fn(node_artifact_types, "/tmp/proj-1")
  291 + with patch.object(work_graph_execute, "write_workflow_script"), \
  292 + patch.object(
  293 + work_graph_execute, "execute_work_graph",
  294 + return_value={"consistent": True, "issues": []},
  295 + ) as mock_execute:
  296 + ok, detail = verify_fn({"node_id": "n1"}, {"content": "a prd"})
  297 + self.assertTrue(ok)
  298 + mock_execute.assert_called_once()
  299 +
  300 + def test_test_script_node_dispatches_to_compile_check(self):
  301 + node_artifact_types = {"n1": "test_script"}
  302 + verify_fn = e2e_pipeline._combined_planner_verify_fn(node_artifact_types, "/tmp/proj-1")
  303 + ok, detail = verify_fn({"node_id": "n1"}, {"content": "not code at all, just words"})
  304 + self.assertFalse(ok)
  305 +
  306 + def test_other_node_types_pass_through(self):
  307 + node_artifact_types = {"n1": "test_dataset"}
  308 + verify_fn = e2e_pipeline._combined_planner_verify_fn(node_artifact_types, "/tmp/proj-1")
  309 + ok, detail = verify_fn({"node_id": "n1"}, {"content": "anything at all"})
  310 + self.assertTrue(ok)
  311 + self.assertIsNone(detail)
  312 +
  313 +
205 class PrdConsistencySelfTargetWorktreeTest(unittest.TestCase): 314 class PrdConsistencySelfTargetWorktreeTest(unittest.TestCase):
206 """2026-08-03 regression: a real self-hosting run showed _check_prd_ 315 """2026-08-03 regression: a real self-hosting run showed _check_prd_
207 consistency() calling execute_work_graph() directly against 316 consistency() calling execute_work_graph() directly against
@@ -280,5 +389,131 @@ class ValidateImplementationTest(unittest.TestCase): @@ -280,5 +389,131 @@ class ValidateImplementationTest(unittest.TestCase):
280 self.assertEqual(result["verdict"], "INCONCLUSIVE") 389 self.assertEqual(result["verdict"], "INCONCLUSIVE")
281 390
282 391
  392 +class RunTestScriptForRealTest(unittest.TestCase):
  393 + """2026-08-06: real, unmocked execution -- these are testing what an
  394 + actual interpreter actually does, same reasoning as CheckScriptCompilesTest."""
  395 +
  396 + def setUp(self):
  397 + self.tmp = tempfile.TemporaryDirectory()
  398 + self.addCleanup(self.tmp.cleanup)
  399 +
  400 + def test_na_content_is_not_executed(self):
  401 + result = e2e_pipeline.run_test_script_for_real(
  402 + "N/A: no automatable check for this goal", self.tmp.name, "task-1", "validator_test_script"
  403 + )
  404 + self.assertFalse(result["ran"])
  405 + self.assertIsNone(result["exit_code"])
  406 +
  407 + def test_real_passing_python_script(self):
  408 + result = e2e_pipeline.run_test_script_for_real(
  409 + "import sys\nsys.exit(0)\n", self.tmp.name, "task-1", "validator_test_script"
  410 + )
  411 + self.assertTrue(result["ran"])
  412 + self.assertEqual(result["exit_code"], 0)
  413 +
  414 + def test_real_failing_python_script(self):
  415 + result = e2e_pipeline.run_test_script_for_real(
  416 + "import sys\nsys.exit(1)\n", self.tmp.name, "task-1", "validator_test_script"
  417 + )
  418 + self.assertTrue(result["ran"])
  419 + self.assertEqual(result["exit_code"], 1)
  420 +
  421 + def test_script_runs_with_real_cwd_so_it_sees_the_real_repo(self):
  422 + with open(os.path.join(self.tmp.name, "marker.txt"), "w") as f:
  423 + f.write("real file")
  424 + script = (
  425 + "import os, sys\n"
  426 + "sys.exit(0 if os.path.exists('marker.txt') else 1)\n"
  427 + )
  428 + result = e2e_pipeline.run_test_script_for_real(script, self.tmp.name, "task-1", "validator_test_script")
  429 + self.assertEqual(result["exit_code"], 0)
  430 +
  431 + def test_materializes_a_real_file_under_graphlemon_runs(self):
  432 + e2e_pipeline.run_test_script_for_real(
  433 + "import sys\nsys.exit(0)\n", self.tmp.name, "task-42", "validator_test_script"
  434 + )
  435 + expected = os.path.join(self.tmp.name, ".graphlemon", "runs", "task-42", "validator_test_script.py")
  436 + self.assertTrue(os.path.exists(expected))
  437 +
  438 +
  439 +class ValidateImplementationWithOwnGraphTest(unittest.TestCase):
  440 + """2026-08-06: the safety-critical piece -- a real non-zero exit from
  441 + Validator's own script must force FAIL no matter what the LLM's verdict
  442 + line says (user-confirmed hard gate)."""
  443 +
  444 + def setUp(self):
  445 + self.tmp = tempfile.TemporaryDirectory()
  446 + self.addCleanup(self.tmp.cleanup)
  447 + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True)
  448 + self.conn = schema.connect(":memory:")
  449 + schema.create_schema(self.conn)
  450 + project_scaffold.create_project(self.conn, self.tmp.name, init_git_if_missing=False)
  451 + self.conn.execute(
  452 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  453 + "VALUES ('task-1', (SELECT project_id FROM project), 'goal', 'goal', 'validating', 'validator')"
  454 + )
  455 + self.conn.commit()
  456 +
  457 + def tearDown(self):
  458 + self.conn.close()
  459 +
  460 + def _run(self, script_content, verdict_line):
  461 + with patch.object(work_graph_execute, "write_workflow_script"), \
  462 + patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \
  463 + patch.object(org_node_trigger, "trigger_org_node", return_value=verdict_line):
  464 + mock_execute.side_effect = [
  465 + {"content": "# Validator's own Benchmark"},
  466 + {"content": "N/A: no dataset needed"},
  467 + {"content": script_content},
  468 + ]
  469 + return e2e_pipeline.validate_implementation_with_own_graph(
  470 + self.conn, "task-1", "sdd content", "prd content", "goal text", self.tmp.name,
  471 + )
  472 +
  473 + def test_llm_says_pass_and_script_really_passes_stays_pass(self):
  474 + result = self._run("import sys\nsys.exit(0)\n", "VERDICT: PASS\nlooks good")
  475 + self.assertEqual(result["verdict"], "PASS")
  476 + self.assertTrue(result["script_execution"]["ran"])
  477 + self.assertEqual(result["script_execution"]["exit_code"], 0)
  478 +
  479 + def test_llm_says_pass_but_script_really_fails_is_force_overridden_to_fail(self):
  480 + # the exact case the user's hard-gate rule exists for: a script
  481 + # that objectively fails must not be argued away by the LLM's own
  482 + # verdict line.
  483 + result = self._run("import sys\nsys.exit(1)\n", "VERDICT: PASS\nlooks good to me")
  484 + self.assertEqual(result["verdict"], "FAIL")
  485 +
  486 + def test_llm_says_fail_and_script_fails_stays_fail(self):
  487 + result = self._run("import sys\nsys.exit(1)\n", "VERDICT: FAIL\nreal bug found")
  488 + self.assertEqual(result["verdict"], "FAIL")
  489 +
  490 + def test_na_script_leaves_llm_judgment_untouched(self):
  491 + result = self._run("N/A: no mechanical check possible", "VERDICT: INCONCLUSIVE\nneeds human review")
  492 + self.assertEqual(result["verdict"], "INCONCLUSIVE")
  493 + self.assertFalse(result["script_execution"]["ran"])
  494 +
  495 + def test_writes_three_validator_artifacts_with_lineage(self):
  496 + result = self._run("import sys\nsys.exit(0)\n", "VERDICT: PASS\nlooks good")
  497 + self.assertEqual(
  498 + set(result["artifact_ids"]),
  499 + {"validator_benchmark", "validator_test_dataset", "validator_test_script"},
  500 + )
  501 + script_deps = json.loads(
  502 + self.conn.execute(
  503 + "SELECT depends_on_artifact_ids FROM artifact WHERE artifact_id = ?",
  504 + (result["artifact_ids"]["validator_test_script"],),
  505 + ).fetchone()[0]
  506 + )
  507 + self.assertIn(result["artifact_ids"]["validator_benchmark"], script_deps)
  508 + self.assertIn(result["artifact_ids"]["validator_test_dataset"], script_deps)
  509 +
  510 + def test_creates_its_own_work_graph_run_row(self):
  511 + result = self._run("import sys\nsys.exit(0)\n", "VERDICT: PASS\nlooks good")
  512 + run_row = self.conn.execute(
  513 + "SELECT org_node FROM work_graph_run WHERE task_id = 'task-1'"
  514 + ).fetchone()
  515 + self.assertEqual(run_row[0], "validator")
  516 +
  517 +
283 if __name__ == "__main__": 518 if __name__ == "__main__":
284 unittest.main() 519 unittest.main()
src/test_schema.py
@@ -206,6 +206,117 @@ class ArtifactUniqueConstraintTest(ConstraintTestBase): @@ -206,6 +206,117 @@ class ArtifactUniqueConstraintTest(ConstraintTestBase):
206 with self.assertRaises(sqlite3.IntegrityError): 206 with self.assertRaises(sqlite3.IntegrityError):
207 self._insert_artifact("artifact-2") 207 self._insert_artifact("artifact-2")
208 208
  209 + def test_new_test_artifact_types_accepted(self):
  210 + for artifact_type in (
  211 + "test_dataset", "test_script",
  212 + "validator_benchmark", "validator_test_dataset", "validator_test_script",
  213 + ):
  214 + self.conn.execute(
  215 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  216 + "file_path, git_commit_sha) VALUES (?, ?, 'task-1', 'run-1', 'x.md', 'sha-1')",
  217 + ("artifact-" + artifact_type, artifact_type),
  218 + )
  219 + self.conn.commit()
  220 +
  221 +
  222 +class MigrateExpandArtifactTypesTest(unittest.TestCase):
  223 + """2026-08-06: real upgrade path for an already-populated database whose
  224 + artifact table still has the pre-expansion CHECK constraint."""
  225 +
  226 + def setUp(self):
  227 + self.conn = schema.connect(":memory:")
  228 + # Simulate a pre-migration database: create the OLD schema by hand
  229 + # (not schema.create_schema(), which already has the new constraint).
  230 + old_sql = schema.SCHEMA_SQL.replace(
  231 + "'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report',\n"
  232 + " 'test_dataset', 'test_script',\n"
  233 + " 'validator_benchmark', 'validator_test_dataset', 'validator_test_script'",
  234 + "'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report'",
  235 + )
  236 + self.conn.executescript(old_sql)
  237 + self.conn.commit()
  238 + self.conn.execute(
  239 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  240 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  241 + )
  242 + self.conn.execute(
  243 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  244 + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')"
  245 + )
  246 + self.conn.execute(
  247 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  248 + "VALUES ('run-1', 'task-1', 'planner', '{}', 'planning')"
  249 + )
  250 + self.conn.execute(
  251 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  252 + "file_path, git_commit_sha) VALUES ('art-1', 'brd', 'task-1', 'run-1', 'brd.md', 'sha-1')"
  253 + )
  254 + self.conn.commit()
  255 +
  256 + def tearDown(self):
  257 + self.conn.close()
  258 +
  259 + def test_old_check_constraint_rejects_new_type_before_migration(self):
  260 + with self.assertRaises(sqlite3.IntegrityError):
  261 + self.conn.execute(
  262 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  263 + "file_path, git_commit_sha) VALUES ('art-2', 'test_script', 'task-1', 'run-1', 'x', 'sha-2')"
  264 + )
  265 +
  266 + def test_migration_preserves_existing_rows_and_accepts_new_types(self):
  267 + schema.migrate_expand_artifact_types(self.conn)
  268 +
  269 + row = self.conn.execute("SELECT artifact_type FROM artifact WHERE artifact_id = 'art-1'").fetchone()
  270 + self.assertEqual(row[0], "brd")
  271 +
  272 + self.conn.execute(
  273 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  274 + "file_path, git_commit_sha) VALUES ('art-2', 'test_script', 'task-1', 'run-1', 'x', 'sha-2')"
  275 + )
  276 + self.conn.commit()
  277 + count = self.conn.execute("SELECT COUNT(*) FROM artifact").fetchone()[0]
  278 + self.assertEqual(count, 2)
  279 +
  280 + def test_unique_index_still_enforced_after_migration(self):
  281 + schema.migrate_expand_artifact_types(self.conn)
  282 + self.conn.commit()
  283 + with self.assertRaises(sqlite3.IntegrityError):
  284 + self.conn.execute(
  285 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  286 + "file_path, git_commit_sha) VALUES ('art-dup', 'brd', 'task-1', 'run-1', 'brd.md', 'sha-1')"
  287 + )
  288 +
  289 + def test_idempotent_on_a_database_already_migrated(self):
  290 + schema.migrate_expand_artifact_types(self.conn)
  291 + schema.migrate_expand_artifact_types(self.conn) # must not raise / must not duplicate
  292 + count = self.conn.execute("SELECT COUNT(*) FROM artifact").fetchone()[0]
  293 + self.assertEqual(count, 1)
  294 +
  295 + def test_noop_on_a_freshly_created_already_expanded_database(self):
  296 + fresh = schema.connect(":memory:")
  297 + try:
  298 + schema.create_schema(fresh)
  299 + schema.migrate_expand_artifact_types(fresh) # must not raise
  300 + fresh.execute(
  301 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  302 + "VALUES ('p', 'P', '/tmp/p', 1, 'active')"
  303 + )
  304 + fresh.execute(
  305 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  306 + "VALUES ('t', 'p', 'g', 'goal', 'queued', 'planner')"
  307 + )
  308 + fresh.execute(
  309 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  310 + "VALUES ('r', 't', 'planner', '{}', 'planning')"
  311 + )
  312 + fresh.execute(
  313 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  314 + "file_path, git_commit_sha) VALUES ('a', 'test_script', 't', 'r', 'x', 'sha')"
  315 + )
  316 + fresh.commit()
  317 + finally:
  318 + fresh.close()
  319 +
209 320
210 class ExecutionLogFileUniqueConstraintTest(ConstraintTestBase): 321 class ExecutionLogFileUniqueConstraintTest(ConstraintTestBase):
211 def setUp(self): 322 def setUp(self):
src/test_task_execution.py
@@ -76,18 +76,27 @@ class ResumeFromCodingTest(TaskExecutionTestBase): @@ -76,18 +76,27 @@ class ResumeFromCodingTest(TaskExecutionTestBase):
76 76
77 def test_skips_to_validator_only(self): 77 def test_skips_to_validator_only(self):
78 self._insert_task("task-1", status="coding", assigned_org_node="coder") 78 self._insert_task("task-1", status="coding", assigned_org_node="coder")
  79 + self._write_artifact("task-1", "prd", "# PRD content")
79 self._write_artifact("task-1", "sdd", "# SDD content") 80 self._write_artifact("task-1", "sdd", "# SDD content")
80 self._write_artifact("task-1", "benchmark", "# Benchmark content") 81 self._write_artifact("task-1", "benchmark", "# Benchmark content")
81 self._write_artifact("task-1", "code_diff", "diff content") 82 self._write_artifact("task-1", "code_diff", "diff content")
82 self._write_artifact("task-1", "test_report", "report content") 83 self._write_artifact("task-1", "test_report", "report content")
83 84
84 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ 85 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
  86 + patch.object(work_graph_execute, "write_workflow_script"), \
  87 + patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \
85 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger: 88 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger:
  89 + # validator's OWN benchmark/test_dataset/test_script graph (2026-08-06)
  90 + mock_execute.side_effect = [
  91 + {"content": "# Validator's own Benchmark"},
  92 + {"content": "N/A: no dataset needed"},
  93 + {"content": "import sys\nsys.exit(0)\n"},
  94 + ]
86 result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal") 95 result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
87 96
88 self.assertEqual(result["verdict"]["verdict"], "PASS") 97 self.assertEqual(result["verdict"]["verdict"], "PASS")
89 self.assertEqual(result["code_artifact_ids"]["code_diff"], "art-code_diff") 98 self.assertEqual(result["code_artifact_ids"]["code_diff"], "art-code_diff")
90 - # only the validator's real call happened -- no coder call 99 + # only the validator's final-verdict real call happened -- no coder call
91 mock_trigger.assert_called_once() 100 mock_trigger.assert_called_once()
92 self.assertEqual(mock_trigger.call_args[0][0], "validator") 101 self.assertEqual(mock_trigger.call_args[0][0], "validator")
93 row = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone() 102 row = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()
@@ -110,8 +119,15 @@ class ResumeFromPlanningTest(TaskExecutionTestBase): @@ -110,8 +119,15 @@ class ResumeFromPlanningTest(TaskExecutionTestBase):
110 119
111 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ 120 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
112 patch.object(work_graph_execute, "write_workflow_script"), \ 121 patch.object(work_graph_execute, "write_workflow_script"), \
113 - patch.object(work_graph_execute, "execute_work_graph", return_value={"response": "done, tests pass"}), \ 122 + patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \
114 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger: 123 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS") as mock_trigger:
  124 + mock_execute.side_effect = [
  125 + {"response": "done, tests pass"}, # coder's implement node
  126 + # validator's OWN benchmark/test_dataset/test_script graph (2026-08-06)
  127 + {"content": "# Validator's own Benchmark"},
  128 + {"content": "N/A: no dataset needed"},
  129 + {"content": "import sys\nsys.exit(0)\n"},
  130 + ]
115 result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal") 131 result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal")
116 132
117 self.assertEqual(result["verdict"]["verdict"], "PASS") 133 self.assertEqual(result["verdict"]["verdict"], "PASS")
@@ -140,6 +156,7 @@ class PauseMidResumeTest(TaskExecutionTestBase): @@ -140,6 +156,7 @@ class PauseMidResumeTest(TaskExecutionTestBase):
140 156
141 def test_pause_before_validator_stops_cleanly(self): 157 def test_pause_before_validator_stops_cleanly(self):
142 self._insert_task("task-1", status="coding", assigned_org_node="coder") 158 self._insert_task("task-1", status="coding", assigned_org_node="coder")
  159 + self._write_artifact("task-1", "prd", "# PRD content")
143 self._write_artifact("task-1", "sdd", "# SDD content") 160 self._write_artifact("task-1", "sdd", "# SDD content")
144 self._write_artifact("task-1", "benchmark", "# Benchmark content") 161 self._write_artifact("task-1", "benchmark", "# Benchmark content")
145 self._write_artifact("task-1", "code_diff", "diff content") 162 self._write_artifact("task-1", "code_diff", "diff content")
@@ -259,13 +276,21 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): @@ -259,13 +276,21 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase):
259 conn.close() 276 conn.close()
260 277
261 def test_real_thread_completes_and_closes_task(self): 278 def test_real_thread_completes_and_closes_task(self):
  279 + self._write_artifact("task-1", "prd", "# PRD")
262 self._write_artifact("task-1", "sdd", "# SDD") 280 self._write_artifact("task-1", "sdd", "# SDD")
263 self._write_artifact("task-1", "benchmark", "# Benchmark") 281 self._write_artifact("task-1", "benchmark", "# Benchmark")
264 self._write_artifact("task-1", "code_diff", "diff") 282 self._write_artifact("task-1", "code_diff", "diff")
265 self._write_artifact("task-1", "test_report", "report") 283 self._write_artifact("task-1", "test_report", "report")
266 284
267 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ 285 with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \
  286 + patch.object(work_graph_execute, "write_workflow_script"), \
  287 + patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \
268 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS"): 288 patch.object(org_node_trigger, "trigger_org_node", return_value="VERDICT: PASS"):
  289 + mock_execute.side_effect = [
  290 + {"content": "# Validator's own Benchmark"},
  291 + {"content": "N/A: no dataset needed"},
  292 + {"content": "import sys\nsys.exit(0)\n"},
  293 + ]
269 thread = task_execution.start_task_execution_in_background( 294 thread = task_execution.start_task_execution_in_background(
270 self.db_path, "task-1", self.repo.name, "a goal", 295 self.db_path, "task-1", self.repo.name, "a goal",
271 ) 296 )
@@ -277,13 +302,24 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): @@ -277,13 +302,24 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase):
277 self.assertEqual(status, "done") 302 self.assertEqual(status, "done")
278 303
279 def test_generic_failure_marks_blocked(self): 304 def test_generic_failure_marks_blocked(self):
  305 + self._write_artifact("task-1", "prd", "# PRD")
280 self._write_artifact("task-1", "sdd", "# SDD") 306 self._write_artifact("task-1", "sdd", "# SDD")
281 self._write_artifact("task-1", "benchmark", "# Benchmark") 307 self._write_artifact("task-1", "benchmark", "# Benchmark")
282 self._write_artifact("task-1", "code_diff", "diff") 308 self._write_artifact("task-1", "code_diff", "diff")
283 self._write_artifact("task-1", "test_report", "report") 309 self._write_artifact("task-1", "test_report", "report")
284 310
285 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), \
  312 + patch.object(work_graph_execute, "write_workflow_script"), \
  313 + patch.object(work_graph_execute, "execute_work_graph") as mock_execute, \
286 patch.object(org_node_trigger, "trigger_org_node", side_effect=RuntimeError("boom")): 314 patch.object(org_node_trigger, "trigger_org_node", side_effect=RuntimeError("boom")):
  315 + # validator's own benchmark/dataset/script graph completes fine --
  316 + # the failure this test cares about is the FINAL verdict call
  317 + # (org_node_trigger.trigger_org_node), which happens after.
  318 + mock_execute.side_effect = [
  319 + {"content": "# Validator's own Benchmark"},
  320 + {"content": "N/A: no dataset needed"},
  321 + {"content": "import sys\nsys.exit(0)\n"},
  322 + ]
287 thread = task_execution.start_task_execution_in_background( 323 thread = task_execution.start_task_execution_in_background(
288 self.db_path, "task-1", self.repo.name, "a goal", 324 self.db_path, "task-1", self.repo.name, "a goal",
289 ) 325 )
src/test_work_graph_plan.py
@@ -142,6 +142,8 @@ class NormalizeGraphManifestTest(unittest.TestCase): @@ -142,6 +142,8 @@ class NormalizeGraphManifestTest(unittest.TestCase):
142 {"node_id": "prd", "artifact_type": "prd"}, 142 {"node_id": "prd", "artifact_type": "prd"},
143 {"node_id": "sdd", "artifact_type": "sdd"}, 143 {"node_id": "sdd", "artifact_type": "sdd"},
144 {"node_id": "benchmark", "artifact_type": "benchmark"}, 144 {"node_id": "benchmark", "artifact_type": "benchmark"},
  145 + {"node_id": "test_dataset", "artifact_type": "test_dataset"},
  146 + {"node_id": "test_script", "artifact_type": "test_script"},
145 ], 147 ],
146 "edges": [], 148 "edges": [],
147 } 149 }
@@ -151,6 +153,11 @@ class NormalizeGraphManifestTest(unittest.TestCase): @@ -151,6 +153,11 @@ class NormalizeGraphManifestTest(unittest.TestCase):
151 self.assertEqual(by_type["prd"]["depends_on"], ["brd"]) 153 self.assertEqual(by_type["prd"]["depends_on"], ["brd"])
152 self.assertEqual(by_type["sdd"]["depends_on"], ["prd"]) 154 self.assertEqual(by_type["sdd"]["depends_on"], ["prd"])
153 self.assertEqual(by_type["benchmark"]["depends_on"], ["sdd"]) 155 self.assertEqual(by_type["benchmark"]["depends_on"], ["sdd"])
  156 + self.assertEqual(by_type["test_dataset"]["depends_on"], ["benchmark"])
  157 + # test_script needs both -- its own prompt references
  158 + # {benchmark_content} directly, not just what survives into
  159 + # test_dataset_content (see work_graph_plan._normalize_graph_manifest).
  160 + self.assertEqual(by_type["test_script"]["depends_on"], ["test_dataset", "benchmark"])
154 161
155 def test_canonical_chain_enforcement_uses_real_node_ids_not_artifact_types(self): 162 def test_canonical_chain_enforcement_uses_real_node_ids_not_artifact_types(self):
156 # node_id and artifact_type can differ (e.g. real LLM output naming 163 # node_id and artifact_type can differ (e.g. real LLM output naming
@@ -162,6 +169,8 @@ class NormalizeGraphManifestTest(unittest.TestCase): @@ -162,6 +169,8 @@ class NormalizeGraphManifestTest(unittest.TestCase):
162 {"node_id": "step-2", "artifact_type": "prd"}, 169 {"node_id": "step-2", "artifact_type": "prd"},
163 {"node_id": "step-3", "artifact_type": "sdd"}, 170 {"node_id": "step-3", "artifact_type": "sdd"},
164 {"node_id": "step-4", "artifact_type": "benchmark"}, 171 {"node_id": "step-4", "artifact_type": "benchmark"},
  172 + {"node_id": "step-5", "artifact_type": "test_dataset"},
  173 + {"node_id": "step-6", "artifact_type": "test_script"},
165 ], 174 ],
166 "edges": [], 175 "edges": [],
167 } 176 }
@@ -170,6 +179,8 @@ class NormalizeGraphManifestTest(unittest.TestCase): @@ -170,6 +179,8 @@ class NormalizeGraphManifestTest(unittest.TestCase):
170 self.assertEqual(by_type["prd"]["depends_on"], ["step-1"]) 179 self.assertEqual(by_type["prd"]["depends_on"], ["step-1"])
171 self.assertEqual(by_type["sdd"]["depends_on"], ["step-2"]) 180 self.assertEqual(by_type["sdd"]["depends_on"], ["step-2"])
172 self.assertEqual(by_type["benchmark"]["depends_on"], ["step-3"]) 181 self.assertEqual(by_type["benchmark"]["depends_on"], ["step-3"])
  182 + self.assertEqual(by_type["test_dataset"]["depends_on"], ["step-4"])
  183 + self.assertEqual(by_type["test_script"]["depends_on"], ["step-5", "step-4"])
173 184
174 def test_partial_canonical_set_does_not_trigger_enforcement(self): 185 def test_partial_canonical_set_does_not_trigger_enforcement(self):
175 # only brd/prd/sdd present, no benchmark -- not a recognized 186 # only brd/prd/sdd present, no benchmark -- not a recognized
src/work_graph_plan.py
@@ -41,7 +41,10 @@ GRAPH_MANIFEST_SCHEMA = { @@ -41,7 +41,10 @@ GRAPH_MANIFEST_SCHEMA = {
41 "required": ["node_id", "artifact_type", "depends_on"], 41 "required": ["node_id", "artifact_type", "depends_on"],
42 "properties": { 42 "properties": {
43 "node_id": {"type": "string"}, 43 "node_id": {"type": "string"},
44 - "artifact_type": {"type": "string", "enum": ["brd", "prd", "sdd", "benchmark"]}, 44 + "artifact_type": {
  45 + "type": "string",
  46 + "enum": ["brd", "prd", "sdd", "benchmark", "test_dataset", "test_script"],
  47 + },
45 "depends_on": {"type": "array", "items": {"type": "string"}}, 48 "depends_on": {"type": "array", "items": {"type": "string"}},
46 }, 49 },
47 }, 50 },
@@ -62,13 +65,14 @@ GRAPH_MANIFEST_SCHEMA = { @@ -62,13 +65,14 @@ GRAPH_MANIFEST_SCHEMA = {
62 } 65 }
63 66
64 _PROMPT_TEMPLATE = ( 67 _PROMPT_TEMPLATE = (
65 - "Plan (do not execute) the Work Graph needed to produce BRD, PRD, SDD, and "  
66 - "Benchmark artifacts for this goal: {goal_text}\n"  
67 - "Output a graph_manifest with one node per artifact_type (brd, prd, sdd, "  
68 - "benchmark) and edges expressing their dependency chain (sdd depends on prd "  
69 - "depends on brd, benchmark depends on sdd). Every edge needs edge_type: "  
70 - "'zero_token' for pure aggregation/reduce steps, 'needs_judgment' for anything "  
71 - "requiring the agent to actually reason about content.\n" 68 + "Plan (do not execute) the Work Graph needed to produce BRD, PRD, SDD, Benchmark, "
  69 + "Test Dataset, and Test Script artifacts for this goal: {goal_text}\n"
  70 + "Output a graph_manifest with one node per artifact_type (brd, prd, sdd, benchmark, "
  71 + "test_dataset, test_script) and edges expressing their dependency chain (sdd depends "
  72 + "on prd depends on brd, benchmark depends on sdd, test_dataset depends on benchmark, "
  73 + "test_script depends on test_dataset). Every edge needs edge_type: 'zero_token' for "
  74 + "pure aggregation/reduce steps, 'needs_judgment' for anything requiring the agent to "
  75 + "actually reason about content.\n"
72 "End your reply with the graph_manifest as a single fenced ```json code block " 76 "End your reply with the graph_manifest as a single fenced ```json code block "
73 "shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no extra wrapper key." 77 "shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no extra wrapper key."
74 ) 78 )
@@ -102,7 +106,7 @@ def _extract_graph_manifest(text): @@ -102,7 +106,7 @@ def _extract_graph_manifest(text):
102 return None 106 return None
103 107
104 108
105 -_CANONICAL_DOC_CHAIN = ("brd", "prd", "sdd", "benchmark") 109 +_CANONICAL_DOC_CHAIN = ("brd", "prd", "sdd", "benchmark", "test_dataset", "test_script")
106 110
107 111
108 def _normalize_graph_manifest(manifest): 112 def _normalize_graph_manifest(manifest):
@@ -158,6 +162,19 @@ def _normalize_graph_manifest(manifest): @@ -158,6 +162,19 @@ def _normalize_graph_manifest(manifest):
158 if prev_id not in cur_node["depends_on"]: 162 if prev_id not in cur_node["depends_on"]:
159 cur_node["depends_on"].append(prev_id) 163 cur_node["depends_on"].append(prev_id)
160 164
  165 + # 2026-08-06: test_script's own prompt needs {benchmark_content}
  166 + # directly (not just what survives into test_dataset_content, which
  167 + # can be a terse 'N/A: ...' with none of the original scenario
  168 + # detail) -- the plain linear chain above only gives it
  169 + # test_dataset as an immediate dependency, so add benchmark as a
  170 + # second one. e2e_pipeline._planner_prompt_builder already threads
  171 + # every *immediate* dependency's content through generically, this
  172 + # is the one canonical node that genuinely needs two.
  173 + benchmark_id = by_artifact_type["benchmark"]["node_id"]
  174 + script_node = by_artifact_type["test_script"]
  175 + if benchmark_id not in script_node["depends_on"]:
  176 + script_node["depends_on"].append(benchmark_id)
  177 +
161 return manifest 178 return manifest
162 179
163 180