Commit 5d6a9a68b79ddb8e3e60d0ec6e2dc9883b236ee6
1 parent
169d1bae
fix: node completion never permanently blocked from artifact promotion
Real incident (2026-08-13, task b243dfe8), diagnosed live with the user after
they correctly challenged the architecture: "every node should have its own
output, and node-to-node communication IS input/output flowing through
edges -- why doesn't a node have an artifact?"
They were right to push on this. work_node_execution.output_payload (the
real Work Graph data-flow layer) was never the problem -- it's populated the
instant any node completes, exactly as expected. The actual bug was one
level up: _write_completed_node_artifacts's OLD `only_node_ids` parameter
skipped any node not in "this specific append call's own newly-planned
nodes", assuming "not new to this call" means "an earlier stage already
promoted it". That assumption breaks the instant an earlier call's OWN
promotion attempt never actually ran (e.g. it crashed even earlier in that
same call, before reaching this function) -- which is exactly what happened
to b243dfe8 across multiple retries today: brd/prd/sdd/benchmark nodes kept
completing successfully ('done', real content), but zero artifact rows and
zero .graphlemon/runs/<task_id>/ files were EVER created, going back to the
task's original 2026-08-04 run. Every later append-mode retry kept treating
those nodes as "not new, must already be handled" and excluded them forever.
Renamed to `new_node_ids`, semantics inverted to check the real source of
truth: a node whose artifact_type already has a genuine artifact row for
this run_id is skipped UNLESS the node itself is newly-planned by this call
(which correctly allows a retry-loop's superseding content to still promote
a fresh artifact over a rejected one -- the scenario the previous attempt at
this fix broke, caught by the existing
test_rejected_first_attempt_replans_into_same_run_not_a_second_row test).
New tests cover both real scenarios directly, plus a fix to
test_orphaned_preexisting_run_with_no_artifacts_appends_not_collides's own
setup (the orphaned node needs to be seeded as already-done, matching the
real incident, not left for run_graph() to execute for real a second time).
822/822 tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
2 changed files
with
125 additions
and
27 deletions
src/e2e_pipeline.py
| @@ -552,7 +552,7 @@ def _resolve_node_artifact_type(node, node_artifact_types): | @@ -552,7 +552,7 @@ def _resolve_node_artifact_type(node, node_artifact_types): | ||
| 552 | 552 | ||
| 553 | def _write_completed_node_artifacts( | 553 | def _write_completed_node_artifacts( |
| 554 | conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, ext_for, | 554 | conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, ext_for, |
| 555 | - run_graph_error=None, only_node_ids=None, | 555 | + run_graph_error=None, new_node_ids=None, |
| 556 | ): | 556 | ): |
| 557 | """Writes an artifact for every node that's genuinely done AND | 557 | """Writes an artifact for every node that's genuinely done AND |
| 558 | verify_fn-accepted, sourced from `work_node_execution` in the DB -- | 558 | verify_fn-accepted, sourced from `work_node_execution` in the DB -- |
| @@ -580,12 +580,42 @@ def _write_completed_node_artifacts( | @@ -580,12 +580,42 @@ def _write_completed_node_artifacts( | ||
| 580 | 2026-08-12 (Phase 2 §2.3/§6): a node with no resolvable artifact_type | 580 | 2026-08-12 (Phase 2 §2.3/§6): a node with no resolvable artifact_type |
| 581 | (see `_resolve_node_artifact_type`) is silently skipped, not an | 581 | (see `_resolve_node_artifact_type`) is silently skipped, not an |
| 582 | error -- most Coder/Validator self-planned nodes are internal graph | 582 | error -- most Coder/Validator self-planned nodes are internal graph |
| 583 | - steps, not final deliverables. `only_node_ids` (default None = every | ||
| 584 | - node in `graph_manifest`, unchanged behavior) restricts processing to | ||
| 585 | - a subset -- needed once a caller works over a MERGED graph (Coder's | ||
| 586 | - `append_to_run_id`) and must avoid re-processing/re-promoting nodes an | ||
| 587 | - EARLIER stage already wrote artifacts for. | ||
| 588 | - """ | 583 | + steps, not final deliverables. |
| 584 | + | ||
| 585 | + 2026-08-13 (real incident, task b243dfe8 -- diagnosed live with the | ||
| 586 | + user, correcting an architectural misunderstanding they caught): the | ||
| 587 | + OLD `only_node_ids` param skipped any node not in "this call's own | ||
| 588 | + newly-planned nodes", on the assumption that "not new to this call" | ||
| 589 | + means "an earlier stage already promoted it". That assumption breaks | ||
| 590 | + the instant an earlier append's OWN promotion attempt never actually | ||
| 591 | + ran -- e.g. this exact function being reached only via a code path | ||
| 592 | + that crashed earlier in THAT call, before ever getting here. The | ||
| 593 | + node's real, valid, already-'done' content in work_node_execution. | ||
| 594 | + output_payload then becomes permanently unpromotable: every LATER | ||
| 595 | + retry (in append mode) kept excluding it as "not new", forever, even | ||
| 596 | + though zero artifact of that type had EVER actually been written -- | ||
| 597 | + exactly what happened to this task (multiple 'done' brd/prd/sdd/ | ||
| 598 | + benchmark nodes across retries, zero artifact rows, zero | ||
| 599 | + .graphlemon/runs/<task_id>/ files ever created, going back to the | ||
| 600 | + task's original 2026-08-04 run). | ||
| 601 | + | ||
| 602 | + Renamed to `new_node_ids` and its role inverted: a node whose type | ||
| 603 | + ALREADY has a real artifact row for this run_id is skipped UNLESS the | ||
| 604 | + node itself is in `new_node_ids` (default None -- treated as "nothing | ||
| 605 | + is new", the safe default for a caller with no such concept). This | ||
| 606 | + covers both real scenarios correctly, which a pure "already promoted | ||
| 607 | + -> always skip" rule (tried first, broke the retry-loop's own | ||
| 608 | + already-passing regression test -- a rejected attempt's superseding | ||
| 609 | + content MUST be able to promote a fresh artifact of the same type) | ||
| 610 | + can't distinguish on its own: | ||
| 611 | + - an OLD node (not new to this call) whose type was never actually | ||
| 612 | + promoted (this task's real bug): not in already_promoted_types -> | ||
| 613 | + promoted normally, regardless of new_node_ids. | ||
| 614 | + - an OLD node whose type genuinely was already promoted by an earlier, | ||
| 615 | + successful call: skipped -- no duplicate. | ||
| 616 | + - a NEW node (in new_node_ids) whose type happens to already have an | ||
| 617 | + artifact from a REJECTED earlier attempt (the retry-loop case): | ||
| 618 | + promoted anyway, correctly superseding the rejected one.""" | ||
| 589 | done_rows = conn.execute( | 619 | done_rows = conn.execute( |
| 590 | "SELECT node_id, output_payload FROM work_node_execution WHERE run_id = ? AND status = 'done'", | 620 | "SELECT node_id, output_payload FROM work_node_execution WHERE run_id = ? AND status = 'done'", |
| 591 | (run_id,), | 621 | (run_id,), |
| @@ -598,16 +628,23 @@ def _write_completed_node_artifacts( | @@ -598,16 +628,23 @@ def _write_completed_node_artifacts( | ||
| 598 | for excluded in _failed_node_ids(run_graph_error): | 628 | for excluded in _failed_node_ids(run_graph_error): |
| 599 | done_outputs.pop(excluded, None) | 629 | done_outputs.pop(excluded, None) |
| 600 | 630 | ||
| 631 | + already_promoted_types = { | ||
| 632 | + row[0] for row in conn.execute( | ||
| 633 | + "SELECT artifact_type FROM artifact WHERE task_id = ? AND produced_by_run_id = ?", (task_id, run_id) | ||
| 634 | + ).fetchall() | ||
| 635 | + } | ||
| 636 | + new_node_ids = new_node_ids if new_node_ids is not None else set() | ||
| 637 | + | ||
| 601 | artifact_ids = {} | 638 | artifact_ids = {} |
| 602 | for node in work_graph_orchestrate.topological_order(graph_manifest): | 639 | for node in work_graph_orchestrate.topological_order(graph_manifest): |
| 603 | node_id = node["node_id"] | 640 | node_id = node["node_id"] |
| 604 | if node_id not in done_outputs: | 641 | if node_id not in done_outputs: |
| 605 | continue | 642 | continue |
| 606 | - if only_node_ids is not None and node_id not in only_node_ids: | ||
| 607 | - continue | ||
| 608 | artifact_type = _resolve_node_artifact_type(node, node_artifact_types) | 643 | artifact_type = _resolve_node_artifact_type(node, node_artifact_types) |
| 609 | if artifact_type is None: | 644 | if artifact_type is None: |
| 610 | continue | 645 | continue |
| 646 | + if artifact_type in already_promoted_types and node_id not in new_node_ids: | ||
| 647 | + continue | ||
| 611 | deps = [ | 648 | deps = [ |
| 612 | artifact_ids[node_artifact_types[dep_id]] | 649 | artifact_ids[node_artifact_types[dep_id]] |
| 613 | for dep_id in (node.get("depends_on") or []) | 650 | for dep_id in (node.get("depends_on") or []) |
| @@ -619,6 +656,7 @@ def _write_completed_node_artifacts( | @@ -619,6 +656,7 @@ def _write_completed_node_artifacts( | ||
| 619 | done_outputs[node_id]["content"], depends_on_artifact_ids=deps, | 656 | done_outputs[node_id]["content"], depends_on_artifact_ids=deps, |
| 620 | ) | 657 | ) |
| 621 | artifact_ids[artifact_type] = result["artifact_id"] | 658 | artifact_ids[artifact_type] = result["artifact_id"] |
| 659 | + already_promoted_types.add(artifact_type) | ||
| 622 | return artifact_ids | 660 | return artifact_ids |
| 623 | 661 | ||
| 624 | 662 | ||
| @@ -680,10 +718,13 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap | @@ -680,10 +718,13 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap | ||
| 680 | `ux_work_graph_run_active` allows only one non-terminal row per | 718 | `ux_work_graph_run_active` allows only one non-terminal row per |
| 681 | task_id, and the earlier attempt's row is never finalized to | 719 | task_id, and the earlier attempt's row is never finalized to |
| 682 | 'done'/'failed' just because its content was rejected (Phase 2c: one | 720 | 'done'/'failed' just because its content was rejected (Phase 2c: one |
| 683 | - run spans a task's whole lifecycle). `only_node_ids` is threaded into | ||
| 684 | - `_write_completed_node_artifacts` the same way `implement_code()` | ||
| 685 | - already does, so a re-plan only (re-)promotes the nodes THIS call | ||
| 686 | - just planned, not the earlier attempt's already-processed ones again. | 721 | + run spans a task's whole lifecycle). `_write_completed_node_artifacts` |
| 722 | + itself now decides what to (re-)promote by checking real artifact rows | ||
| 723 | + for this run_id (2026-08-13 fix, see its own docstring) rather than by | ||
| 724 | + which call planned which node, so a re-plan naturally promotes | ||
| 725 | + whatever hasn't been genuinely promoted yet -- including an earlier | ||
| 726 | + attempt's own already-'done' nodes if THEIR promotion never actually | ||
| 727 | + ran. | ||
| 687 | """ | 728 | """ |
| 688 | plan_result = work_graph_plan.plan_work_graph( | 729 | plan_result = work_graph_plan.plan_work_graph( |
| 689 | conn, "planner", task_id, goal_text, target_folder_path, timeout=timeout, | 730 | conn, "planner", task_id, goal_text, target_folder_path, timeout=timeout, |
| @@ -692,7 +733,6 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap | @@ -692,7 +733,6 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap | ||
| 692 | run_id = plan_result["run_id"] | 733 | run_id = plan_result["run_id"] |
| 693 | graph_manifest = plan_result["graph_manifest"] | 734 | graph_manifest = plan_result["graph_manifest"] |
| 694 | node_artifact_types = {n["node_id"]: n.get("artifact_type", n["node_id"]) for n in graph_manifest["nodes"]} | 735 | node_artifact_types = {n["node_id"]: n.get("artifact_type", n["node_id"]) for n in graph_manifest["nodes"]} |
| 695 | - only_node_ids = set(plan_result["new_node_ids"]) if append_to_run_id else None | ||
| 696 | 736 | ||
| 697 | run_graph_error = None | 737 | run_graph_error = None |
| 698 | try: | 738 | try: |
| @@ -706,7 +746,7 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap | @@ -706,7 +746,7 @@ def plan_documents(conn, task_id, goal_text, target_folder_path, timeout=300, ap | ||
| 706 | 746 | ||
| 707 | artifact_ids = _write_completed_node_artifacts( | 747 | artifact_ids = _write_completed_node_artifacts( |
| 708 | conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, | 748 | conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, |
| 709 | - _planner_ext_for, run_graph_error, only_node_ids=only_node_ids, | 749 | + _planner_ext_for, run_graph_error, new_node_ids=set(plan_result["new_node_ids"]), |
| 710 | ) | 750 | ) |
| 711 | 751 | ||
| 712 | if run_graph_error is not None: | 752 | if run_graph_error is not None: |
| @@ -1060,7 +1100,7 @@ def implement_code( | @@ -1060,7 +1100,7 @@ def implement_code( | ||
| 1060 | 1100 | ||
| 1061 | artifact_ids = _write_completed_node_artifacts( | 1101 | artifact_ids = _write_completed_node_artifacts( |
| 1062 | conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, | 1102 | conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, |
| 1063 | - _planner_ext_for, run_graph_error, only_node_ids=new_node_ids, | 1103 | + _planner_ext_for, run_graph_error, new_node_ids=new_node_ids, |
| 1064 | ) | 1104 | ) |
| 1065 | 1105 | ||
| 1066 | if run_graph_error is not None: | 1106 | if run_graph_error is not None: |
| @@ -1362,7 +1402,7 @@ def validate_implementation_with_own_graph( | @@ -1362,7 +1402,7 @@ def validate_implementation_with_own_graph( | ||
| 1362 | 1402 | ||
| 1363 | artifact_ids = _write_completed_node_artifacts( | 1403 | artifact_ids = _write_completed_node_artifacts( |
| 1364 | conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, | 1404 | conn, target_folder_path, task_id, run_id, graph_manifest, node_artifact_types, |
| 1365 | - _planner_ext_for, run_graph_error, only_node_ids=new_node_ids, | 1405 | + _planner_ext_for, run_graph_error, new_node_ids=new_node_ids, |
| 1366 | ) | 1406 | ) |
| 1367 | 1407 | ||
| 1368 | if run_graph_error is not None: | 1408 | if run_graph_error is not None: |
src/test_e2e_pipeline.py
| @@ -209,9 +209,9 @@ class PipelineTestBase(unittest.TestCase): | @@ -209,9 +209,9 @@ class PipelineTestBase(unittest.TestCase): | ||
| 209 | 209 | ||
| 210 | class WriteCompletedNodeArtifactsTest(PipelineTestBase): | 210 | class WriteCompletedNodeArtifactsTest(PipelineTestBase): |
| 211 | """Phase 2 §2.3/§6 (2026-08-12) direct coverage: promotes_to_artifact | 211 | """Phase 2 §2.3/§6 (2026-08-12) direct coverage: promotes_to_artifact |
| 212 | - resolution and only_node_ids scoping, tested directly rather than only | ||
| 213 | - incidentally through plan_documents()/implement_code() integration | ||
| 214 | - tests.""" | 212 | + resolution and (2026-08-13) already-promoted-type dedup, tested |
| 213 | + directly rather than only incidentally through plan_documents()/ | ||
| 214 | + implement_code() integration tests.""" | ||
| 215 | 215 | ||
| 216 | def setUp(self): | 216 | def setUp(self): |
| 217 | super().setUp() | 217 | super().setUp() |
| @@ -270,11 +270,10 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase): | @@ -270,11 +270,10 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase): | ||
| 270 | with open(os.path.join(self.tmp.name, content)) as f: | 270 | with open(os.path.join(self.tmp.name, content)) as f: |
| 271 | self.assertEqual(f.read(), "# Real report content") | 271 | self.assertEqual(f.read(), "# Real report content") |
| 272 | 272 | ||
| 273 | - def test_only_node_ids_scopes_to_new_nodes_skipping_earlier_promotable_ones(self): | ||
| 274 | - # a merged graph (Planner's already-artifact'd nodes + Coder's just- | ||
| 275 | - # appended ones) must not re-promote/re-process nodes an EARLIER | ||
| 276 | - # stage already wrote artifacts for, even though they'd otherwise | ||
| 277 | - # be individually promotable. | 273 | + def test_node_with_type_already_promoted_this_run_is_not_duplicated(self): |
| 274 | + # a node whose artifact_type genuinely already has a real artifact | ||
| 275 | + # row for this run_id must not be re-promoted (no duplicate rows/ | ||
| 276 | + # git commits for unchanged content). | ||
| 278 | manifest = { | 277 | manifest = { |
| 279 | "nodes": [ | 278 | "nodes": [ |
| 280 | {"node_id": "sdd", "artifact_type": "sdd", "depends_on": []}, | 279 | {"node_id": "sdd", "artifact_type": "sdd", "depends_on": []}, |
| @@ -288,11 +287,54 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase): | @@ -288,11 +287,54 @@ class WriteCompletedNodeArtifactsTest(PipelineTestBase): | ||
| 288 | self._mark_done("sdd", "# SDD content") | 287 | self._mark_done("sdd", "# SDD content") |
| 289 | self._mark_done("impl", "# Report content") | 288 | self._mark_done("impl", "# Report content") |
| 290 | node_artifact_types = {"sdd": "sdd", "impl": "test_report"} | 289 | node_artifact_types = {"sdd": "sdd", "impl": "test_report"} |
| 290 | + e2e_pipeline._write_completed_node_artifacts( | ||
| 291 | + self.conn, self.tmp.name, "task-1", "run-1", manifest, node_artifact_types, lambda t: "md", | ||
| 292 | + ) | ||
| 293 | + count_after_first_call = self.conn.execute( | ||
| 294 | + "SELECT COUNT(*) FROM artifact WHERE task_id = 'task-1' AND artifact_type = 'sdd'" | ||
| 295 | + ).fetchone()[0] | ||
| 296 | + self.assertEqual(count_after_first_call, 1) | ||
| 297 | + | ||
| 298 | + # a second call (e.g. a later append-mode retry) over the SAME | ||
| 299 | + # manifest/run_id must not create a second "sdd" artifact. | ||
| 291 | artifact_ids = e2e_pipeline._write_completed_node_artifacts( | 300 | artifact_ids = e2e_pipeline._write_completed_node_artifacts( |
| 292 | self.conn, self.tmp.name, "task-1", "run-1", manifest, node_artifact_types, lambda t: "md", | 301 | self.conn, self.tmp.name, "task-1", "run-1", manifest, node_artifact_types, lambda t: "md", |
| 293 | - only_node_ids={"impl"}, | ||
| 294 | ) | 302 | ) |
| 295 | - self.assertEqual(set(artifact_ids), {"test_report"}) # NOT "sdd" -- outside only_node_ids | 303 | + self.assertEqual(artifact_ids, {}) |
| 304 | + count_after_second_call = self.conn.execute( | ||
| 305 | + "SELECT COUNT(*) FROM artifact WHERE task_id = 'task-1' AND artifact_type = 'sdd'" | ||
| 306 | + ).fetchone()[0] | ||
| 307 | + self.assertEqual(count_after_second_call, 1) | ||
| 308 | + | ||
| 309 | + def test_earlier_done_node_whose_promotion_never_ran_is_still_promotable_later(self): | ||
| 310 | + # Real incident (2026-08-13, task b243dfe8, diagnosed live with the | ||
| 311 | + # user): a node completed ('done', real output_payload) in an | ||
| 312 | + # EARLIER append-mode call, but that call's OWN | ||
| 313 | + # _write_completed_node_artifacts invocation was never reached | ||
| 314 | + # (e.g. it crashed even earlier in that same call, before getting | ||
| 315 | + # here) -- so no artifact was ever actually written for it. A LATER | ||
| 316 | + # call, over the same merged manifest/run_id, must still be able to | ||
| 317 | + # promote it: real content sitting in work_node_execution. | ||
| 318 | + # output_payload must not become permanently unpromotable just | ||
| 319 | + # because it wasn't part of THIS specific call's own newly-planned | ||
| 320 | + # nodes. | ||
| 321 | + manifest = { | ||
| 322 | + "nodes": [ | ||
| 323 | + {"node_id": "sdd", "artifact_type": "sdd", "depends_on": []}, | ||
| 324 | + {"node_id": "benchmark", "artifact_type": "benchmark", "depends_on": ["sdd"]}, | ||
| 325 | + ], | ||
| 326 | + "edges": [], | ||
| 327 | + } | ||
| 328 | + self._mark_done("sdd", "# SDD content") # done long ago; genuinely never promoted | ||
| 329 | + self._mark_done("benchmark", "# Benchmark content") # newly planned by THIS call | ||
| 330 | + node_artifact_types = {"sdd": "sdd", "benchmark": "benchmark"} | ||
| 331 | + | ||
| 332 | + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM artifact WHERE task_id = 'task-1'").fetchone()[0], 0) | ||
| 333 | + | ||
| 334 | + artifact_ids = e2e_pipeline._write_completed_node_artifacts( | ||
| 335 | + self.conn, self.tmp.name, "task-1", "run-1", manifest, node_artifact_types, lambda t: "md", | ||
| 336 | + ) | ||
| 337 | + self.assertEqual(set(artifact_ids), {"sdd", "benchmark"}) | ||
| 296 | 338 | ||
| 297 | 339 | ||
| 298 | class PlanDocumentsTest(PipelineTestBase): | 340 | class PlanDocumentsTest(PipelineTestBase): |
| @@ -714,12 +756,28 @@ class ReviewPlannerOutputWithRetriesTest(PipelineTestBase): | @@ -714,12 +756,28 @@ class ReviewPlannerOutputWithRetriesTest(PipelineTestBase): | ||
| 714 | with this leftover row via the exact same IntegrityError. Seeding | 756 | with this leftover row via the exact same IntegrityError. Seeding |
| 715 | prior_run_id from _existing_active_run_id() (queries | 757 | prior_run_id from _existing_active_run_id() (queries |
| 716 | work_graph_run directly) fixes this.""" | 758 | work_graph_run directly) fixes this.""" |
| 759 | + # orphan_sdd is marked 'done' up front (matching the REAL b243dfe8 | ||
| 760 | + # scenario: the node genuinely completed in an earlier, crashed | ||
| 761 | + # attempt) -- without this, run_graph() would treat it as never- | ||
| 762 | + # executed and run it for real via the same mocked | ||
| 763 | + # trigger_org_node_structured() as the "sdd" node below, producing | ||
| 764 | + # byte-identical content for two different node_ids of the same | ||
| 765 | + # artifact_type and hitting a real (but different, not what this | ||
| 766 | + # test is about) "nothing to commit" GitCommitFailedError. | ||
| 717 | orphaned_manifest = {"nodes": [{"node_id": "orphan_sdd", "artifact_type": "sdd", "depends_on": []}], "edges": []} | 767 | orphaned_manifest = {"nodes": [{"node_id": "orphan_sdd", "artifact_type": "sdd", "depends_on": []}], "edges": []} |
| 718 | self.conn.execute( | 768 | self.conn.execute( |
| 719 | "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) " | 769 | "INSERT INTO work_graph_run (run_id, task_id, graph_manifest, status) " |
| 720 | "VALUES ('run-orphan', ?, ?, 'planning')", | 770 | "VALUES ('run-orphan', ?, ?, 'planning')", |
| 721 | (self.task_id, json.dumps(orphaned_manifest)), | 771 | (self.task_id, json.dumps(orphaned_manifest)), |
| 722 | ) | 772 | ) |
| 773 | + work_graph_orchestrate.ensure_placeholder_skill(self.conn, "planner-doc-writing", "planner") | ||
| 774 | + self.conn.execute( | ||
| 775 | + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, " | ||
| 776 | + "input_schema_ref, output_schema_ref, input_payload, output_payload, status) " | ||
| 777 | + "VALUES ('node-exec-orphan-sdd', 'run-orphan', 'orphan_sdd', 'planner', 'planner-doc-writing', " | ||
| 778 | + "'in.json', 'out.json', '{}', ?, 'done')", | ||
| 779 | + (json.dumps({"content": "# SDD -- orphaned, never promoted"}),), | ||
| 780 | + ) | ||
| 723 | self.conn.commit() | 781 | self.conn.commit() |
| 724 | 782 | ||
| 725 | def fake_plan_subprocess_run(*args, **kwargs): | 783 | def fake_plan_subprocess_run(*args, **kwargs): |