work_graph_fork.py
12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
"""D (2026-08-06): Work Graph time-travel debugging -- fork a new
work_graph_run from an already-'done' work_node_execution, substituting a
human-provided output for that node and re-executing everything strictly
downstream of it for real, while every other node reuses the original
run's persisted output_payload untouched. See
docs/done/work-graph-time-travel-debugging-design.md for the full design.
**Output substitution, not re-prompting** (design doc §2, resolving the
requirements doc's FR-1 ambiguity): the fork point's own output_payload is
replaced directly with what the human supplies -- it is NOT re-executed
via a new claude -p call. This sidesteps having to reconcile the different
build_prompt/input_payload conventions different callers use (Planner's
fixed artifact_type lookup, E's generic task_prompt field, F's jury) --
this module only needs the persisted graph_manifest and each node's
output_payload, nothing caller-specific, for the SUBSTITUTED node. The
nodes that DO get genuinely re-executed (the strict downstream subgraph)
still need a real build_prompt, which is reconstructed generically -- see
_reconstruct_build_prompt.
**v1 restricts forking to a parent run that has already reached a
terminal status** ('done'/'failed'/'blocked') -- design doc §1: this keeps
the new fork completely outside ux_work_graph_run_active's partial unique
index (that index only applies to non-terminal statuses), so no schema/
constraint change is needed to avoid a collision. Forking from a
still-in-flight parent run is left for a future iteration.
**Zero changes to the execution engine** (design doc §3): pre-seeding
every non-downstream node as a 'done' work_node_execution row before
calling run_graph() on the UNCHANGED original graph_manifest is enough --
run_work_node()'s existing REQ-06 breakpoint-recovery short-circuit
(already-'done' nodes return their persisted output without calling
run_fn) does all the "reuse without re-executing" work for free.
"""
import json
import threading
import time
import uuid
from src import e2e_pipeline, schema, work_graph_orchestrate, work_graph_plan
_FORK_INPUT_SCHEMA = {"type": "object", "required": ["node_id"]}
_FORK_OUTPUT_SCHEMA = {"type": "object", "required": ["content"]}
_TERMINAL_RUN_STATUSES = ("done", "failed", "blocked")
class ForkSourceNotDoneError(Exception):
"""FR-1: can only fork from a work_node_execution with status='done' --
its output_payload has to exist to be a valid substitution point."""
class ForkParentRunNotTerminalError(Exception):
"""v1 scope cut (design doc §1): forking from a node inside a
still-in-flight parent run would collide with ux_work_graph_run_active
(same task_id+org_node, both non-terminal) -- not supported yet."""
class ForkGraphShapeUnsupportedError(Exception):
"""The original graph_manifest doesn't carry enough self-contained
information to reconstruct a build_prompt generically for the
re-executed downstream nodes -- e.g. F's jury nodes or Coder's single
ad hoc node, which rely on caller-side closures over state that isn't
persisted in graph_manifest itself (design doc §2)."""
def _downstream_node_ids(graph_manifest, start_node_id):
"""Every node_id reachable from start_node_id via depends_on/edges,
INCLUDING start_node_id itself. Same dependents-graph construction
topological_layers() uses, kept local here rather than imported since
it's a small, self-contained piece not worth threading a shared helper
for across modules."""
nodes = {n["node_id"]: n for n in graph_manifest["nodes"]}
dependents = {node_id: [] for node_id in nodes}
for node in graph_manifest["nodes"]:
for dep in (node.get("depends_on") or []):
if dep in dependents:
dependents[dep].append(node["node_id"])
for edge in graph_manifest.get("edges", []):
from_id, to_id = edge.get("from"), edge.get("to")
if from_id in dependents and to_id in nodes:
dependents[from_id].append(to_id)
seen = set()
frontier = [start_node_id]
while frontier:
node_id = frontier.pop()
if node_id in seen:
continue
seen.add(node_id)
frontier.extend(dependents[node_id])
return seen
def _reconstruct_build_prompt(conn, task_id, org_node, graph_manifest):
"""Rebuilds a build_prompt(node, outputs) usable for the re-executed
downstream subgraph, purely from persisted state -- no access to the
original call site's Python closures. Two supported shapes (design doc
§2): Planner's canonical doc chain (every node has 'artifact_type',
prompt reconstructed via e2e_pipeline's own builder + task.goal_text)
and E's generic dynamic path (every node carries its own
self-contained 'task_prompt'). Anything else raises rather than
silently building broken prompts."""
nodes = graph_manifest["nodes"]
if org_node == "planner" and nodes and all("artifact_type" in n for n in nodes):
goal_text = conn.execute("SELECT goal_text FROM task WHERE task_id = ?", (task_id,)).fetchone()[0]
node_artifact_types = {n["node_id"]: n["artifact_type"] for n in nodes}
return e2e_pipeline._planner_prompt_builder(goal_text, node_artifact_types)
if nodes and all("task_prompt" in n for n in nodes):
return work_graph_plan.generic_node_prompt_builder
raise ForkGraphShapeUnsupportedError(
"graph_manifest nodes must either all have 'artifact_type' (Planner's canonical chain) "
"or all have 'task_prompt' (the generic dynamic-planning path) to reconstruct prompts for a fork"
)
def create_fork(conn, source_node_exec_id, new_output_payload):
"""Synchronous, DB-only half of a fork: validates the source node and
parent run, pre-seeds every non-downstream node as 'done' under a
brand-new work_graph_run, and returns everything execute_fork() needs
to actually run the downstream subgraph. Split from execute_fork() so
an HTTP caller can hand the (fast) run_id back immediately and let the
(slow, real claude -p calls) execution happen in the background --
same split task_execution.start_task_execution_in_background already
uses for the manual Start button."""
row = conn.execute(
"SELECT wne.run_id, wne.node_id, wne.status, wne.subagent_type, wne.skill_ref, "
"wgr.task_id, wgr.org_node, wgr.graph_manifest, wgr.status "
"FROM work_node_execution wne JOIN work_graph_run wgr ON wgr.run_id = wne.run_id "
"WHERE wne.node_exec_id = ?",
(source_node_exec_id,),
).fetchone()
if row is None:
raise ValueError("no such work_node_execution: " + source_node_exec_id)
(
source_run_id, source_node_id, source_status, subagent_type, skill_ref,
task_id, org_node, graph_manifest_json, parent_run_status,
) = row
if source_status != "done":
raise ForkSourceNotDoneError(
"work_node_execution {} is status={}, must be 'done' to fork from".format(
source_node_exec_id, source_status
)
)
if parent_run_status not in _TERMINAL_RUN_STATUSES:
raise ForkParentRunNotTerminalError(
"parent work_graph_run {} is status={}, must be terminal ({}) to fork from -- forking from "
"a still-in-flight run isn't supported yet".format(
source_run_id, parent_run_status, _TERMINAL_RUN_STATUSES
)
)
graph_manifest = json.loads(graph_manifest_json)
build_prompt = _reconstruct_build_prompt(conn, task_id, org_node, graph_manifest)
downstream = _downstream_node_ids(graph_manifest, source_node_id) - {source_node_id}
original_outputs = {
r[0]: r[1] for r in conn.execute(
"SELECT node_id, output_payload FROM work_node_execution WHERE run_id = ? AND status = 'done'",
(source_run_id,),
).fetchall()
}
new_run_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status, started_at, "
"parent_run_id, fork_of_node_exec_id) VALUES (?, ?, ?, ?, 'running', CURRENT_TIMESTAMP, ?, ?)",
(new_run_id, task_id, org_node, graph_manifest_json, source_run_id, source_node_exec_id),
)
conn.commit()
for node in graph_manifest["nodes"]:
node_id = node["node_id"]
if node_id in downstream:
continue
payload = new_output_payload if node_id == source_node_id else json.loads(original_outputs[node_id])
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, 'done')",
(
str(uuid.uuid4()), new_run_id, node_id, subagent_type, skill_ref,
"work_graph_node_input.schema.json", "work_graph_node_output.schema.json",
json.dumps({"node_id": node_id}), json.dumps(payload),
),
)
conn.commit()
return {
"run_id": new_run_id, "org_node": org_node, "skill_ref": skill_ref,
"graph_manifest": graph_manifest, "build_prompt": build_prompt,
}
def execute_fork(conn, fork_info, target_folder_path, timeout=600, sleep=time.sleep, now=time.time):
"""The real (slow) half: runs the downstream subgraph via the
unmodified run_graph() -- every pre-seeded node short-circuits, every
downstream node executes for real (see module docstring). Sets the new
run's status to 'done'/'failed' on completion -- unlike every other
real graph_manifest run in this codebase, which never transitions
work_graph_run.status past its initial value (a pre-existing gap, out
of D's scope to fix generically -- see design doc §3's own note); the
forked run gets this narrowly because FR-4's console visibility needs
SOME real completion signal to show, not because this module is fixing
the wider gap."""
run_id = fork_info["run_id"]
try:
outputs = work_graph_orchestrate.run_graph(
conn, run_id, fork_info["org_node"], fork_info["skill_ref"], target_folder_path,
fork_info["graph_manifest"], fork_info["build_prompt"], _FORK_INPUT_SCHEMA, _FORK_OUTPUT_SCHEMA,
timeout=timeout, sleep=sleep, now=now,
)
except Exception:
conn.execute(
"UPDATE work_graph_run SET status = 'failed', completed_at = CURRENT_TIMESTAMP WHERE run_id = ?",
(run_id,),
)
conn.commit()
raise
conn.execute(
"UPDATE work_graph_run SET status = 'done', completed_at = CURRENT_TIMESTAMP WHERE run_id = ?", (run_id,)
)
conn.commit()
return outputs
def fork_work_graph_run(conn, source_node_exec_id, new_output_payload, target_folder_path, timeout=600):
"""create_fork() + execute_fork() in one synchronous call -- what
tests and any fully-synchronous caller should use. HTTP handlers use
the two halves separately (see start_fork_in_background)."""
fork_info = create_fork(conn, source_node_exec_id, new_output_payload)
outputs = execute_fork(conn, fork_info, target_folder_path, timeout=timeout)
return {"run_id": fork_info["run_id"], "outputs": outputs}
def start_fork_in_background(db_path, source_node_exec_id, new_output_payload, target_folder_path, timeout=600):
"""HTTP entrypoint: create_fork() runs synchronously (fast, DB-only)
so the caller gets run_id back immediately and can navigate the
console straight to it; execute_fork()'s real claude -p calls then run
in a background daemon thread, own connection (check_same_thread=False
-- same reasoning as scheduler.run_goal_to_completion's own conn, see
that module's comment: this can reach run_graph()'s multi-node
concurrent layers). Returns (run_id, thread) -- thread is already
started, callers/tests can .join() it if needed (mirrors
task_execution.start_task_execution_in_background's own Thread
return)."""
conn = schema.connect(db_path, check_same_thread=False)
fork_info = create_fork(conn, source_node_exec_id, new_output_payload)
def run():
try:
execute_fork(conn, fork_info, target_folder_path, timeout=timeout)
except Exception:
pass # already reflected in work_graph_run/work_node_execution status
finally:
conn.close()
thread = threading.Thread(target=run, daemon=True)
thread.start()
return fork_info["run_id"], thread