Commit 07a0a27edc47d5cd63a3244209fe1011085b056c
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>
Showing
8 changed files
with
947 additions
and
45 deletions
src/e2e_pipeline.py
| ... | ... | @@ -48,6 +48,7 @@ attempt, then `ConsistencyCheckFailedError` if it's still contradictory). |
| 48 | 48 | import json |
| 49 | 49 | import os |
| 50 | 50 | import subprocess |
| 51 | +import tempfile | |
| 51 | 52 | import uuid |
| 52 | 53 | |
| 53 | 54 | from src import ( |
| ... | ... | @@ -76,28 +77,63 @@ _PRD_CONSISTENCY_PROMPT = ( |
| 76 | 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 | 90 | _DOC_PROMPTS = { |
| 80 | 91 | "brd": ( |
| 81 | 92 | "Write a BRD (Business Requirement Document) for this goal, following your own " |
| 82 | 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 | 96 | "prd": ( |
| 87 | 97 | "Here is the approved BRD for goal '{goal_text}':\n\n{brd_content}\n\n" |
| 88 | 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 | 101 | "sdd": ( |
| 92 | 102 | "Here is the approved PRD for goal '{goal_text}':\n\n{prd_content}\n\n" |
| 93 | 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 | 107 | "benchmark": ( |
| 97 | 108 | "Here is the approved SDD for goal '{goal_text}':\n\n{sdd_content}\n\n" |
| 98 | 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 = {"type": "object", "required": ["response"]} |
| 107 | 143 | _CODER_PROMPT = ( |
| 108 | 144 | "Implement this goal per the approved SDD below. Write real code and real tests, run the " |
| 109 | 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 | 163 | _VALIDATOR_PROMPT = ( |
| ... | ... | @@ -119,6 +168,77 @@ _VALIDATOR_PROMPT = ( |
| 119 | 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 | 243 | def _planner_prompt_builder(goal_text, node_artifact_types): |
| 124 | 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 | 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 | 281 | def _coerce_bool(value): |
| 141 | 282 | """A real call (2026-08-02) returned `"consistent": "false"` as a JSON |
| 142 | 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 | 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 | 436 | def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300): |
| 213 | 437 | """Architecture §11 criterion 1: a real Planner Work Graph |
| 214 | 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 | 450 | outputs = work_graph_orchestrate.run_graph( |
| 227 | 451 | conn, run_id, "planner", "planner-doc-writing", target_folder_path, graph_manifest, |
| 228 | 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 | 463 | artifact_ids = {} |
| 233 | 464 | for node in work_graph_orchestrate.topological_order(graph_manifest): |
| 234 | 465 | node_id = node["node_id"] |
| ... | ... | @@ -238,16 +469,21 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300): |
| 238 | 469 | for dep_id in (node.get("depends_on") or []) |
| 239 | 470 | if node_artifact_types.get(dep_id) in artifact_ids |
| 240 | 471 | ] |
| 472 | + ext = _ARTIFACT_FILE_EXT.get(artifact_type, "md") | |
| 241 | 473 | result = artifact_write.write_artifact( |
| 242 | 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 | 476 | depends_on_artifact_ids=deps, |
| 245 | 477 | ) |
| 246 | 478 | artifact_ids[artifact_type] = result["artifact_id"] |
| 247 | 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 | 487 | """Architecture §11 criterion 2: a real Coder call via c_work_node_run |
| 252 | 488 | (work_graph_orchestrate.run_graph with a synthetic single-node |
| 253 | 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 | 491 | code_diff (the diff itself) and test_report (the Coder's own response |
| 256 | 492 | text, which per its migrated SOUL.md is required to include real test |
| 257 | 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 | 501 | before_sha = _current_commit(target_folder_path) |
| 260 | 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 | 510 | |
| 269 | 511 | outputs = work_graph_orchestrate.run_graph( |
| 270 | 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 | 517 | _CODE_INPUT_SCHEMA, _CODE_OUTPUT_SCHEMA, timeout=timeout, |
| 273 | 518 | ) |
| 274 | 519 | response = outputs["implement"]["response"] |
| ... | ... | @@ -288,6 +533,19 @@ def implement_code(conn, task_id, sdd_content, goal_text, target_folder_path, ti |
| 288 | 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 | 549 | def validate_implementation(task_id, benchmark_content, goal_text, target_folder_path, timeout=1200): |
| 292 | 550 | """Architecture §11 criterion 3: real Validator call, parses the |
| 293 | 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 | 554 | controllers (c_bug_record_create/p_validator_root_cause_trace only), |
| 297 | 555 | so the simpler flat trigger_org_node call is already faithful to what |
| 298 | 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 | 568 | response = org_node_trigger.trigger_org_node( |
| 301 | 569 | "validator", |
| 302 | 570 | _VALIDATOR_PROMPT.format(goal_text=goal_text, benchmark_content=benchmark_content), |
| 303 | 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 | 714 | def _current_commit(repo_path): |
| ... | ... | @@ -334,13 +731,32 @@ def run_task_lifecycle(conn, task_id, target_folder_path, goal_text): |
| 334 | 731 | plan_result = plan_documents(conn, task_id, goal_text, target_folder_path) |
| 335 | 732 | artifact_ids = plan_result["artifact_ids"] |
| 336 | 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 | 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 | 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 | 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 | 761 | return { |
| 346 | 762 | "task_id": task_id, | ... | ... |
src/schema.py
| ... | ... | @@ -161,7 +161,9 @@ CREATE TABLE work_node_execution ( |
| 161 | 161 | CREATE TABLE artifact ( |
| 162 | 162 | artifact_id TEXT PRIMARY KEY, |
| 163 | 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 | 168 | task_id TEXT NOT NULL REFERENCES task(task_id), |
| 167 | 169 | produced_by_run_id TEXT NOT NULL REFERENCES work_graph_run(run_id), |
| ... | ... | @@ -351,3 +353,48 @@ def create_schema(conn): |
| 351 | 353 | from U-034, PH5) + indexes on an open connection.""" |
| 352 | 354 | conn.executescript(SCHEMA_SQL) |
| 353 | 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 | 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 | 91 | def run_or_resume_task_lifecycle(conn, task_id, target_folder_path, goal_text): |
| 78 | 92 | """Same return shape as `e2e_pipeline.run_task_lifecycle` -- continues |
| 79 | 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 | 107 | |
| 94 | 108 | doc_artifact_ids = _latest_artifact_ids(conn, task_id, _DOC_ARTIFACT_TYPES) |
| 95 | 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 | 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 | 121 | if status == "planning_brd_prd_sdd": |
| 99 | 122 | _check_not_paused(conn, task_id) |
| 100 | 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 | 129 | else: # status == "coding" -- Coder already ran |
| 103 | 130 | code_artifact_ids = _latest_artifact_ids(conn, task_id, _CODE_ARTIFACT_TYPES) |
| 104 | 131 | |
| 105 | 132 | _check_not_paused(conn, task_id) |
| 106 | 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 | 138 | return { |
| 110 | 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 | 23 | """ |
| 24 | 24 | |
| 25 | 25 | import json |
| 26 | +import os | |
| 26 | 27 | import subprocess |
| 27 | 28 | import tempfile |
| 28 | 29 | import unittest |
| ... | ... | @@ -30,7 +31,11 @@ from unittest.mock import patch |
| 30 | 31 | |
| 31 | 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 | 40 | _FAKE_GRAPH_MANIFEST = { |
| 36 | 41 | "nodes": [ |
| ... | ... | @@ -38,11 +43,15 @@ _FAKE_GRAPH_MANIFEST = { |
| 38 | 43 | {"node_id": "prd", "artifact_type": "prd", "depends_on": ["brd"]}, |
| 39 | 44 | {"node_id": "sdd", "artifact_type": "sdd", "depends_on": ["prd"]}, |
| 40 | 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 | 49 | "edges": [ |
| 43 | 50 | {"from": "brd", "to": "prd", "edge_type": "needs_judgment"}, |
| 44 | 51 | {"from": "prd", "to": "sdd", "edge_type": "needs_judgment"}, |
| 45 | 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 | 80 | |
| 72 | 81 | |
| 73 | 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 | 84 | self.conn.execute( |
| 76 | 85 | "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " |
| 77 | 86 | "VALUES ('task-1', (SELECT project_id FROM project), '实现贪吃蛇游戏', 'goal', " |
| ... | ... | @@ -86,11 +95,12 @@ class PlanDocumentsTest(PipelineTestBase): |
| 86 | 95 | {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, |
| 87 | 96 | {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) |
| 88 | 97 | {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, |
| 98 | + {"content": _FAKE_DOCS["test_dataset"]}, {"content": _FAKE_DOCS["test_script"]}, | |
| 89 | 99 | ] |
| 90 | 100 | plan_result = e2e_pipeline.plan_documents(self.conn, "task-1", "实现贪吃蛇游戏", self.tmp.name) |
| 91 | 101 | |
| 92 | 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 | 104 | prd_deps = json.loads( |
| 95 | 105 | self.conn.execute( |
| 96 | 106 | "SELECT depends_on_artifact_ids FROM artifact WHERE artifact_id = ?", (artifact_ids["prd"],) |
| ... | ... | @@ -122,12 +132,21 @@ class RunEndToEndTest(PipelineTestBase): |
| 122 | 132 | {"content": _FAKE_DOCS["brd"]}, {"content": _FAKE_DOCS["prd"]}, |
| 123 | 133 | {"consistent": True, "issues": []}, # PRD consistency check (REQ-02 boundary) |
| 124 | 134 | {"content": _FAKE_DOCS["sdd"]}, {"content": _FAKE_DOCS["benchmark"]}, |
| 135 | + {"content": _FAKE_DOCS["test_dataset"]}, {"content": _FAKE_DOCS["test_script"]}, | |
| 125 | 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 | 144 | result = e2e_pipeline.run_end_to_end(self.conn, self.tmp.name, "实现贪吃蛇游戏") |
| 128 | 145 | |
| 129 | 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 | 150 | self.assertEqual(set(result["code_artifact_ids"]), {"code_diff", "test_report"}) |
| 132 | 151 | |
| 133 | 152 | task_row = self.conn.execute( |
| ... | ... | @@ -138,15 +157,19 @@ class RunEndToEndTest(PipelineTestBase): |
| 138 | 157 | all_artifacts = self.conn.execute( |
| 139 | 158 | "SELECT COUNT(*) FROM artifact WHERE task_id = ?", (result["task_id"],) |
| 140 | 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 | 167 | run_org_nodes = { |
| 145 | 168 | row[0] for row in self.conn.execute( |
| 146 | 169 | "SELECT org_node FROM work_graph_run WHERE task_id = ?", (result["task_id"],) |
| 147 | 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 | 175 | class PrdConsistencyVerifyFnTest(unittest.TestCase): |
| ... | ... | @@ -202,6 +225,92 @@ class PrdConsistencyVerifyFnTest(unittest.TestCase): |
| 202 | 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 | 314 | class PrdConsistencySelfTargetWorktreeTest(unittest.TestCase): |
| 206 | 315 | """2026-08-03 regression: a real self-hosting run showed _check_prd_ |
| 207 | 316 | consistency() calling execute_work_graph() directly against |
| ... | ... | @@ -280,5 +389,131 @@ class ValidateImplementationTest(unittest.TestCase): |
| 280 | 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 | 518 | if __name__ == "__main__": |
| 284 | 519 | unittest.main() | ... | ... |
src/test_schema.py
| ... | ... | @@ -206,6 +206,117 @@ class ArtifactUniqueConstraintTest(ConstraintTestBase): |
| 206 | 206 | with self.assertRaises(sqlite3.IntegrityError): |
| 207 | 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 | 321 | class ExecutionLogFileUniqueConstraintTest(ConstraintTestBase): |
| 211 | 322 | def setUp(self): | ... | ... |
src/test_task_execution.py
| ... | ... | @@ -76,18 +76,27 @@ class ResumeFromCodingTest(TaskExecutionTestBase): |
| 76 | 76 | |
| 77 | 77 | def test_skips_to_validator_only(self): |
| 78 | 78 | self._insert_task("task-1", status="coding", assigned_org_node="coder") |
| 79 | + self._write_artifact("task-1", "prd", "# PRD content") | |
| 79 | 80 | self._write_artifact("task-1", "sdd", "# SDD content") |
| 80 | 81 | self._write_artifact("task-1", "benchmark", "# Benchmark content") |
| 81 | 82 | self._write_artifact("task-1", "code_diff", "diff content") |
| 82 | 83 | self._write_artifact("task-1", "test_report", "report content") |
| 83 | 84 | |
| 84 | 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 | 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 | 95 | result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal") |
| 87 | 96 | |
| 88 | 97 | self.assertEqual(result["verdict"]["verdict"], "PASS") |
| 89 | 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 | 100 | mock_trigger.assert_called_once() |
| 92 | 101 | self.assertEqual(mock_trigger.call_args[0][0], "validator") |
| 93 | 102 | row = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone() |
| ... | ... | @@ -110,8 +119,15 @@ class ResumeFromPlanningTest(TaskExecutionTestBase): |
| 110 | 119 | |
| 111 | 120 | with patch.object(org_node_lifecycle, "trigger_org_node", side_effect=_fake_ready_trigger), \ |
| 112 | 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 | 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 | 131 | result = task_execution.run_or_resume_task_lifecycle(self.conn, "task-1", self.tmp.name, "a goal") |
| 116 | 132 | |
| 117 | 133 | self.assertEqual(result["verdict"]["verdict"], "PASS") |
| ... | ... | @@ -140,6 +156,7 @@ class PauseMidResumeTest(TaskExecutionTestBase): |
| 140 | 156 | |
| 141 | 157 | def test_pause_before_validator_stops_cleanly(self): |
| 142 | 158 | self._insert_task("task-1", status="coding", assigned_org_node="coder") |
| 159 | + self._write_artifact("task-1", "prd", "# PRD content") | |
| 143 | 160 | self._write_artifact("task-1", "sdd", "# SDD content") |
| 144 | 161 | self._write_artifact("task-1", "benchmark", "# Benchmark content") |
| 145 | 162 | self._write_artifact("task-1", "code_diff", "diff content") |
| ... | ... | @@ -259,13 +276,21 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): |
| 259 | 276 | conn.close() |
| 260 | 277 | |
| 261 | 278 | def test_real_thread_completes_and_closes_task(self): |
| 279 | + self._write_artifact("task-1", "prd", "# PRD") | |
| 262 | 280 | self._write_artifact("task-1", "sdd", "# SDD") |
| 263 | 281 | self._write_artifact("task-1", "benchmark", "# Benchmark") |
| 264 | 282 | self._write_artifact("task-1", "code_diff", "diff") |
| 265 | 283 | self._write_artifact("task-1", "test_report", "report") |
| 266 | 284 | |
| 267 | 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 | 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 | 294 | thread = task_execution.start_task_execution_in_background( |
| 270 | 295 | self.db_path, "task-1", self.repo.name, "a goal", |
| 271 | 296 | ) |
| ... | ... | @@ -277,13 +302,24 @@ class StartTaskExecutionInBackgroundTest(unittest.TestCase): |
| 277 | 302 | self.assertEqual(status, "done") |
| 278 | 303 | |
| 279 | 304 | def test_generic_failure_marks_blocked(self): |
| 305 | + self._write_artifact("task-1", "prd", "# PRD") | |
| 280 | 306 | self._write_artifact("task-1", "sdd", "# SDD") |
| 281 | 307 | self._write_artifact("task-1", "benchmark", "# Benchmark") |
| 282 | 308 | self._write_artifact("task-1", "code_diff", "diff") |
| 283 | 309 | self._write_artifact("task-1", "test_report", "report") |
| 284 | 310 | |
| 285 | 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 | 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 | 323 | thread = task_execution.start_task_execution_in_background( |
| 288 | 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 | 142 | {"node_id": "prd", "artifact_type": "prd"}, |
| 143 | 143 | {"node_id": "sdd", "artifact_type": "sdd"}, |
| 144 | 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 | 148 | "edges": [], |
| 147 | 149 | } |
| ... | ... | @@ -151,6 +153,11 @@ class NormalizeGraphManifestTest(unittest.TestCase): |
| 151 | 153 | self.assertEqual(by_type["prd"]["depends_on"], ["brd"]) |
| 152 | 154 | self.assertEqual(by_type["sdd"]["depends_on"], ["prd"]) |
| 153 | 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 | 162 | def test_canonical_chain_enforcement_uses_real_node_ids_not_artifact_types(self): |
| 156 | 163 | # node_id and artifact_type can differ (e.g. real LLM output naming |
| ... | ... | @@ -162,6 +169,8 @@ class NormalizeGraphManifestTest(unittest.TestCase): |
| 162 | 169 | {"node_id": "step-2", "artifact_type": "prd"}, |
| 163 | 170 | {"node_id": "step-3", "artifact_type": "sdd"}, |
| 164 | 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 | 175 | "edges": [], |
| 167 | 176 | } |
| ... | ... | @@ -170,6 +179,8 @@ class NormalizeGraphManifestTest(unittest.TestCase): |
| 170 | 179 | self.assertEqual(by_type["prd"]["depends_on"], ["step-1"]) |
| 171 | 180 | self.assertEqual(by_type["sdd"]["depends_on"], ["step-2"]) |
| 172 | 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 | 185 | def test_partial_canonical_set_does_not_trigger_enforcement(self): |
| 175 | 186 | # only brd/prd/sdd present, no benchmark -- not a recognized | ... | ... |
src/work_graph_plan.py
| ... | ... | @@ -41,7 +41,10 @@ GRAPH_MANIFEST_SCHEMA = { |
| 41 | 41 | "required": ["node_id", "artifact_type", "depends_on"], |
| 42 | 42 | "properties": { |
| 43 | 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 | 48 | "depends_on": {"type": "array", "items": {"type": "string"}}, |
| 46 | 49 | }, |
| 47 | 50 | }, |
| ... | ... | @@ -62,13 +65,14 @@ GRAPH_MANIFEST_SCHEMA = { |
| 62 | 65 | } |
| 63 | 66 | |
| 64 | 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 | 76 | "End your reply with the graph_manifest as a single fenced ```json code block " |
| 73 | 77 | "shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no extra wrapper key." |
| 74 | 78 | ) |
| ... | ... | @@ -102,7 +106,7 @@ def _extract_graph_manifest(text): |
| 102 | 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 | 112 | def _normalize_graph_manifest(manifest): |
| ... | ... | @@ -158,6 +162,19 @@ def _normalize_graph_manifest(manifest): |
| 158 | 162 | if prev_id not in cur_node["depends_on"]: |
| 159 | 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 | 178 | return manifest |
| 162 | 179 | |
| 163 | 180 | ... | ... |
-
mentioned in commit 43c3f0503b1bca667ee96f4912bcf7384ed13597