diff --git a/src/task_query.py b/src/task_query.py index c3b5459..9723ef7 100644 --- a/src/task_query.py +++ b/src/task_query.py @@ -206,9 +206,10 @@ def get_task(conn, task_id): task["work_graph_runs"] = [] for w in runs: nodes = _work_nodes_for_run(conn, w[0]) + graph_manifest = json.loads(w[1]) if w[1] else None task["work_graph_runs"].append({ "run_id": w[0], - "graph_manifest": json.loads(w[1]) if w[1] else None, + "graph_manifest": _display_graph_manifest(graph_manifest, nodes), "status": w[2], "started_at": w[3], "completed_at": w[4], # D (2026-08-06): both None for a normal run; both set when this # run was forked from a historical node (src/work_graph_fork.py). diff --git a/src/work_graph_plan.py b/src/work_graph_plan.py index a11b8de..b3ea722 100644 --- a/src/work_graph_plan.py +++ b/src/work_graph_plan.py @@ -29,6 +29,13 @@ import json import subprocess import uuid +from src import execution_log, paths + +# node_id reserved for the synthetic work_node_execution _record_planning_node +# creates below -- task_query.py matches on this exact string to recognize +# "this run has a real planning node to display", so keep the two in sync. +PLANNING_NODE_ID = "__planning__" + GRAPH_MANIFEST_SCHEMA = { "type": "object", "required": ["nodes", "edges"], @@ -571,6 +578,71 @@ def _normalize_graph_manifest(manifest, additional_valid_loop_back_target_ids=() return manifest +def _record_planning_node(conn, run_id, task_id, org_node, prompt, raw_response, graph_manifest): + """Persists the planning call itself (the `claude --agent -p + ` subprocess call in plan_work_graph() below, NOT any node it + planned) as a real, clickable work_node_execution -- previously this + call's own raw output was discarded the moment _extract_graph_manifest + pulled the JSON back out of it, and the call itself left no trace + anywhere the console renders (v_task_detail's to-do tree/DAG only ever + show the nodes a planning call PRODUCED, never the act of planning + itself). Deliberately does NOT touch work_graph_run.graph_manifest -- + that column stays the pure, LLM-authored manifest run_graph()/fork/ + append-mode all still execute and merge against unchanged; task_query.py + synthesizes a display-only node from THIS row (matched by node_id == + PLANNING_NODE_ID) when building the console's API response instead, so + execution/fork/append logic never has to know this row exists. + + Only called from the fresh (non-append) branch of plan_work_graph() -- + one real "how was this run planned" entry per run is enough to answer + the question; Coder/Validator's own append_to_run_id planning calls + aren't given one (YAGNI until someone actually needs to inspect those + too).""" + project_id = conn.execute("SELECT project_id FROM task WHERE task_id = ?", (task_id,)).fetchone()[0] + + # work_node_execution.skill_ref is a required FK to a real skill row. + # design-dynamic-work-graph is the actual Skill documenting this + # self-planning mechanism (skills/design-dynamic-work-graph/SKILL.md) -- + # reused here rather than inventing a placeholder skill_id nothing else + # would ever reference. + skill_id = "design-dynamic-work-graph" + if conn.execute("SELECT 1 FROM skill WHERE skill_id = ?", (skill_id,)).fetchone() is None: + conn.execute( + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " + "VALUES (?, ?, ?, 'unknown', 'new', 'active')", + (skill_id, org_node, "skills/" + skill_id + "/"), + ) + + node_exec_id = str(uuid.uuid4()) + conn.execute( + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, " + "input_schema_ref, output_schema_ref, input_payload, output_payload, status) " + "VALUES (?, ?, ?, ?, ?, 'work_graph_plan.goal_text', 'work_graph_plan.graph_manifest', ?, ?, 'done')", + ( + node_exec_id, run_id, PLANNING_NODE_ID, org_node, skill_id, + json.dumps({"prompt": prompt}), json.dumps({"graph_manifest": graph_manifest}), + ), + ) + + log_file_id = str(uuid.uuid4()) + log_path = paths.log_file_path(project_id, task_id, run_id, node_exec_id) + conn.execute( + "INSERT INTO execution_log_file (log_file_id, node_exec_id, project_id, file_path, format, status) " + "VALUES (?, ?, ?, ?, 'jsonl', 'closed')", + (log_file_id, node_exec_id, project_id, log_path), + ) + # execution_log.append_log does its own conn.commit() -- covers the two + # INSERTs above too, same connection/transaction. + execution_log.append_log( + conn, node_exec_id, + json.dumps({"event": "node_start", "org_node": org_node, "prompt": prompt}, ensure_ascii=False) + "\n" + + json.dumps( + {"event": "node_completed", "raw_response": raw_response, "graph_manifest": graph_manifest}, + ensure_ascii=False, + ) + "\n", + ) + + def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, timeout=300, append_to_run_id=None): """Returns {"run_id", "graph_manifest", "new_node_ids"}. Creates one `work_graph_run` row (status='planning') -- moving it to 'running'/ @@ -672,6 +744,7 @@ def plan_work_graph(conn, org_node, task_id, goal_text, target_folder_path, time (run_id, task_id, json.dumps(new_manifest)), ) conn.commit() + _record_planning_node(conn, run_id, task_id, org_node, prompt, payload.get("result", ""), new_manifest) return {"run_id": run_id, "graph_manifest": new_manifest, "new_node_ids": []} new_node_ids = [n["node_id"] for n in new_manifest["nodes"]]