Commit b2908009610cfeb42c7b7d57f8ca8d349d78d878
1 parent
b39f4114
graphlemon: auto-commit uncommitted changes for 0123a9cc-01da-4ce6-9622-a210731a…
…de13-main-checkout-drift
Showing
2 changed files
with
191 additions
and
54 deletions
src/work_graph_orchestrate.py
| ... | ... | @@ -64,7 +64,9 @@ exception, re-raised unchanged after logging), `quota_limit` (the |
| 64 | 64 | actually happened (N attempts), not a bug. |
| 65 | 65 | """ |
| 66 | 66 | |
| 67 | +import concurrent.futures | |
| 67 | 68 | import json |
| 69 | +import threading | |
| 68 | 70 | import time |
| 69 | 71 | import uuid |
| 70 | 72 | |
| ... | ... | @@ -83,9 +85,61 @@ class ConsistencyCheckFailedError(Exception): |
| 83 | 85 | 'blocked' by the time this is raised.""" |
| 84 | 86 | |
| 85 | 87 | |
| 86 | -def topological_order(graph_manifest): | |
| 87 | - """Kahn's algorithm. Dependencies are read from BOTH `node['depends_on']` | |
| 88 | - and `graph_manifest['edges']` and merged -- `plan_work_graph()`'s own | |
| 88 | +class WorkGraphLayerError(Exception): | |
| 89 | + """One or more nodes in a concurrently-executed layer failed -- | |
| 90 | + `errors` is the full [(node_id, exception), ...] list (not just the | |
| 91 | + first failure), so a caller can tell whether every failure was the | |
| 92 | + same recoverable kind or something more serious.""" | |
| 93 | + | |
| 94 | + def __init__(self, errors): | |
| 95 | + self.errors = errors | |
| 96 | + summary = "; ".join("{}: {}".format(node_id, e) for node_id, e in errors) | |
| 97 | + super().__init__("{} node(s) failed in one layer: {}".format(len(errors), summary)) | |
| 98 | + | |
| 99 | + | |
| 100 | +class _LockedConn: | |
| 101 | + """sqlite3.Connection-compatible thread-safe proxy: execute/executemany/ | |
| 102 | + commit each take a shared lock for the duration of the call. SQLite only | |
| 103 | + ever allows one writer at a time regardless -- this doesn't create new | |
| 104 | + concurrency, it just makes the existing many-scattered-call-sites code | |
| 105 | + (`work_node_run.py`/`work_graph_orchestrate.py`/`execution_log.py`) safe | |
| 106 | + to call from multiple threads without rewriting every call site, since | |
| 107 | + the lock is held only per-statement, not around the (slow) `claude -p` | |
| 108 | + call itself. Mirrors `backend_server.py`'s existing shared-connection + | |
| 109 | + `threading.Lock()` pattern, generalized to per-statement granularity. | |
| 110 | + Attribute access not explicitly wrapped here (cursor()/rollback()/ | |
| 111 | + close()) passes through unlocked -- nothing on the concurrent path in | |
| 112 | + this module uses them.""" | |
| 113 | + | |
| 114 | + def __init__(self, conn, lock=None): | |
| 115 | + self._conn = conn | |
| 116 | + self._lock = lock or threading.Lock() | |
| 117 | + | |
| 118 | + def execute(self, *args, **kwargs): | |
| 119 | + with self._lock: | |
| 120 | + return self._conn.execute(*args, **kwargs) | |
| 121 | + | |
| 122 | + def executemany(self, *args, **kwargs): | |
| 123 | + with self._lock: | |
| 124 | + return self._conn.executemany(*args, **kwargs) | |
| 125 | + | |
| 126 | + def commit(self): | |
| 127 | + with self._lock: | |
| 128 | + return self._conn.commit() | |
| 129 | + | |
| 130 | + def __getattr__(self, name): | |
| 131 | + return getattr(self._conn, name) | |
| 132 | + | |
| 133 | + | |
| 134 | +def topological_layers(graph_manifest): | |
| 135 | + """Kahn's algorithm, layered: returns List[List[node]], where each inner | |
| 136 | + list is one round's batch of in-degree-zero nodes -- nodes within a | |
| 137 | + layer are, by construction, mutually independent (neither depends on | |
| 138 | + the other), so they're safe to run concurrently. Layers are ordered | |
| 139 | + (layer N+1 only becomes ready once layer N is fully accounted for); | |
| 140 | + each layer's nodes are sorted by node_id for deterministic test | |
| 141 | + assertions. Dependencies are read from BOTH `node['depends_on']` and | |
| 142 | + `graph_manifest['edges']` and merged -- `plan_work_graph()`'s own | |
| 89 | 143 | output isn't schema-validated (it can't be, `--json-schema` doesn't |
| 90 | 144 | work with `--agent`, see work_graph_plan.py), so a real LLM response |
| 91 | 145 | might populate one and not the other; trusting only one would silently |
| ... | ... | @@ -106,21 +160,30 @@ def topological_order(graph_manifest): |
| 106 | 160 | if dep in dependents: |
| 107 | 161 | dependents[dep].append(node_id) |
| 108 | 162 | |
| 109 | - ready = sorted(node_id for node_id, degree in in_degree.items() if degree == 0) | |
| 110 | - ordered = [] | |
| 111 | - while ready: | |
| 112 | - node_id = ready.pop(0) | |
| 113 | - ordered.append(nodes[node_id]) | |
| 114 | - for dependent in sorted(dependents[node_id]): | |
| 115 | - in_degree[dependent] -= 1 | |
| 116 | - if in_degree[dependent] == 0: | |
| 117 | - ready.append(dependent) | |
| 118 | - ready.sort() | |
| 119 | - if len(ordered) != len(nodes): | |
| 163 | + layers = [] | |
| 164 | + current = sorted(node_id for node_id, degree in in_degree.items() if degree == 0) | |
| 165 | + seen_count = 0 | |
| 166 | + while current: | |
| 167 | + layers.append([nodes[node_id] for node_id in current]) | |
| 168 | + seen_count += len(current) | |
| 169 | + next_layer = set() | |
| 170 | + for node_id in current: | |
| 171 | + for dependent in dependents[node_id]: | |
| 172 | + in_degree[dependent] -= 1 | |
| 173 | + if in_degree[dependent] == 0: | |
| 174 | + next_layer.add(dependent) | |
| 175 | + current = sorted(next_layer) | |
| 176 | + if seen_count != len(nodes): | |
| 120 | 177 | raise ValueError( |
| 121 | 178 | "graph_manifest has a dependency cycle or a depends_on/edge references an unknown node_id" |
| 122 | 179 | ) |
| 123 | - return ordered | |
| 180 | + return layers | |
| 181 | + | |
| 182 | + | |
| 183 | +def topological_order(graph_manifest): | |
| 184 | + """Flat view of topological_layers() -- kept for callers (e.g. | |
| 185 | + e2e_pipeline.py) that only need a total order, not the layer grouping.""" | |
| 186 | + return [node for layer in topological_layers(graph_manifest) for node in layer] | |
| 124 | 187 | |
| 125 | 188 | |
| 126 | 189 | def ensure_placeholder_skill(conn, skill_id, owner_org_node): |
| ... | ... | @@ -193,25 +256,37 @@ def _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, outpu |
| 193 | 256 | return run_fn |
| 194 | 257 | |
| 195 | 258 | |
| 259 | +_DEFAULT_MAX_WORKERS = 4 # see this module's own docstring section on G for rationale | |
| 260 | + | |
| 261 | + | |
| 196 | 262 | def run_graph( |
| 197 | 263 | conn, run_id, org_node, skill_id, target_folder_path, graph_manifest, |
| 198 | 264 | build_prompt, input_schema, output_schema, timeout=600, needs_worktree=False, |
| 199 | - verify_fn=None, sleep=time.sleep, now=time.time, | |
| 265 | + verify_fn=None, sleep=time.sleep, now=time.time, max_workers=_DEFAULT_MAX_WORKERS, | |
| 200 | 266 | ): |
| 201 | - """Runs every node in `graph_manifest` (topologically ordered) through | |
| 202 | - `run_work_node()`, each backed by a real one-node dynamic-workflow | |
| 203 | - `agent()` call using `org_node`'s migrated persona. | |
| 267 | + """Runs every node in `graph_manifest` through `run_work_node()`, each | |
| 268 | + backed by a real one-node dynamic-workflow `agent()` call using | |
| 269 | + `org_node`'s migrated persona. Nodes are grouped into topological | |
| 270 | + layers (`topological_layers()`) and run layer-by-layer with a barrier | |
| 271 | + in between; within a layer -- nodes that are mutually independent by | |
| 272 | + construction -- a layer with 2+ nodes runs them concurrently via a | |
| 273 | + `ThreadPoolExecutor` (bounded by `max_workers`), a layer with exactly 1 | |
| 274 | + node runs it synchronously (no thread-pool overhead for the common | |
| 275 | + case: every real graph_manifest observed so far is a single-node-per- | |
| 276 | + layer chain). | |
| 204 | 277 | |
| 205 | 278 | `build_prompt(node, outputs)` is called right before running that |
| 206 | - node -- `outputs` contains every already-completed node's | |
| 207 | - output_payload so far (topological order guarantees a node's | |
| 208 | - dependencies are always already present), so prompts needing upstream | |
| 209 | - content (e.g. "here is the approved BRD...") can be built just-in-time | |
| 210 | - instead of requiring the whole graph's prompts precomputed before | |
| 211 | - anything runs. This also makes resume-after-restart work correctly: | |
| 212 | - `run_work_node` short-circuits already-`done` nodes and returns their | |
| 213 | - persisted output, which lands in `outputs` the same as a freshly-run | |
| 214 | - one would. | |
| 279 | + node -- `outputs` contains every already-completed EARLIER layer's | |
| 280 | + output_payload (a layer never starts until every node in the previous | |
| 281 | + layer has finished, so this is always safe to read without locking), | |
| 282 | + so prompts needing upstream content (e.g. "here is the approved | |
| 283 | + BRD...") can be built just-in-time instead of requiring the whole | |
| 284 | + graph's prompts precomputed before anything runs. `build_prompt` never | |
| 285 | + sees a same-layer sibling's output -- by definition, same-layer nodes | |
| 286 | + don't depend on each other. This also makes resume-after-restart work | |
| 287 | + correctly: `run_work_node` short-circuits already-`done` nodes and | |
| 288 | + returns their persisted output, which lands in `outputs` the same as a | |
| 289 | + freshly-run one would. | |
| 215 | 290 | |
| 216 | 291 | `needs_worktree=True` (03-C §3.6: "并行写代码的节点必须先申请 |
| 217 | 292 | git_worktree_ref") isolates each node's actual file changes in its own |
| ... | ... | @@ -239,37 +314,89 @@ def run_graph( |
| 239 | 314 | passes: `work_graph_run.status` -> 'verifying' and the regenerated |
| 240 | 315 | output replaces the original in `outputs`. |
| 241 | 316 | |
| 317 | + **G (2026-08-06, Work Graph parallel execution)**: a multi-node layer's | |
| 318 | + failures are collected from every node in that layer (a sibling's | |
| 319 | + failure never cancels an already-running sibling -- they're | |
| 320 | + independent work, no reason to throw away completed work), then raised | |
| 321 | + together as `WorkGraphLayerError` once the whole layer has settled. | |
| 322 | + One real limitation, accepted deliberately (see | |
| 323 | + `docs/todo/work-graph-parallel-execution-design.md` §6): a node | |
| 324 | + waiting on a quota-backoff sleep inside `run_work_node` blocks the | |
| 325 | + thread pool worker running it, which in turn blocks the layer barrier | |
| 326 | + even after every sibling has finished -- Python threads have no clean | |
| 327 | + preemptible cancel, and building one is out of scope for this pass. | |
| 328 | + | |
| 242 | 329 | Returns {node_id: output_payload}. |
| 243 | 330 | """ |
| 244 | 331 | ensure_placeholder_skill(conn, skill_id, org_node) |
| 245 | 332 | effective_needs_worktree = needs_worktree or paths.is_self_target(target_folder_path) |
| 246 | - ordered = topological_order(graph_manifest) | |
| 333 | + layers = topological_layers(graph_manifest) | |
| 247 | 334 | outputs = {} |
| 248 | - for node in ordered: | |
| 249 | - node_id = node["node_id"] | |
| 250 | - node_exec_id = _ensure_node_row( | |
| 251 | - conn, run_id, node_id, org_node, skill_id, | |
| 252 | - "work_graph_node_input.schema.json", "work_graph_node_output.schema.json", | |
| 253 | - {"node_id": node_id}, | |
| 254 | - ) | |
| 255 | - prompt = build_prompt(node, outputs) | |
| 256 | - run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout) | |
| 257 | - output = work_node_run.run_work_node( | |
| 258 | - conn, node_exec_id, {"node_id": node_id}, input_schema, output_schema, run_fn, | |
| 259 | - needs_worktree=effective_needs_worktree, repo_path=target_folder_path, | |
| 260 | - sleep=sleep, now=now, | |
| 261 | - ) | |
| 262 | - | |
| 263 | - if verify_fn is not None: | |
| 264 | - output = _verify_and_maybe_regenerate( | |
| 265 | - conn, run_id, org_node, skill_id, target_folder_path, node, node_id, prompt, | |
| 266 | - output, input_schema, output_schema, timeout, effective_needs_worktree, verify_fn, sleep, now, | |
| 335 | + locked_conn = _LockedConn(conn) | |
| 336 | + | |
| 337 | + for layer in layers: | |
| 338 | + if len(layer) == 1: | |
| 339 | + node = layer[0] | |
| 340 | + outputs[node["node_id"]] = _run_one_node( | |
| 341 | + conn, run_id, org_node, skill_id, target_folder_path, node, outputs, | |
| 342 | + build_prompt, input_schema, output_schema, timeout, | |
| 343 | + effective_needs_worktree, verify_fn, sleep, now, | |
| 267 | 344 | ) |
| 345 | + continue | |
| 346 | + | |
| 347 | + worker_count = min(max_workers, len(layer)) | |
| 348 | + errors = [] | |
| 349 | + with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as pool: | |
| 350 | + futures = { | |
| 351 | + pool.submit( | |
| 352 | + _run_one_node, locked_conn, run_id, org_node, skill_id, target_folder_path, | |
| 353 | + node, outputs, build_prompt, input_schema, output_schema, timeout, | |
| 354 | + effective_needs_worktree, verify_fn, sleep, now, | |
| 355 | + ): node | |
| 356 | + for node in layer | |
| 357 | + } | |
| 358 | + done, _ = concurrent.futures.wait(futures, return_when=concurrent.futures.ALL_COMPLETED) | |
| 359 | + for future in done: | |
| 360 | + node = futures[future] | |
| 361 | + try: | |
| 362 | + outputs[node["node_id"]] = future.result() | |
| 363 | + except Exception as e: | |
| 364 | + errors.append((node["node_id"], e)) | |
| 365 | + | |
| 366 | + if errors: | |
| 367 | + raise WorkGraphLayerError(errors) | |
| 268 | 368 | |
| 269 | - outputs[node_id] = output | |
| 270 | 369 | return outputs |
| 271 | 370 | |
| 272 | 371 | |
| 372 | +def _run_one_node( | |
| 373 | + conn, run_id, org_node, skill_id, target_folder_path, node, outputs, | |
| 374 | + build_prompt, input_schema, output_schema, timeout, needs_worktree, verify_fn, sleep, now, | |
| 375 | +): | |
| 376 | + """One node's full lifecycle (row creation, prompt, run, optional | |
| 377 | + verify+regenerate) -- shared by both the synchronous single-node-layer | |
| 378 | + path and the ThreadPoolExecutor multi-node-layer path in run_graph(), | |
| 379 | + so there's exactly one implementation of "run a node" to maintain.""" | |
| 380 | + node_id = node["node_id"] | |
| 381 | + node_exec_id = _ensure_node_row( | |
| 382 | + conn, run_id, node_id, org_node, skill_id, | |
| 383 | + "work_graph_node_input.schema.json", "work_graph_node_output.schema.json", | |
| 384 | + {"node_id": node_id}, | |
| 385 | + ) | |
| 386 | + prompt = build_prompt(node, outputs) | |
| 387 | + run_fn = _make_run_fn(conn, target_folder_path, node_exec_id, org_node, prompt, output_schema, timeout) | |
| 388 | + output = work_node_run.run_work_node( | |
| 389 | + conn, node_exec_id, {"node_id": node_id}, input_schema, output_schema, run_fn, | |
| 390 | + needs_worktree=needs_worktree, repo_path=target_folder_path, sleep=sleep, now=now, | |
| 391 | + ) | |
| 392 | + if verify_fn is not None: | |
| 393 | + output = _verify_and_maybe_regenerate( | |
| 394 | + conn, run_id, org_node, skill_id, target_folder_path, node, node_id, prompt, | |
| 395 | + output, input_schema, output_schema, timeout, needs_worktree, verify_fn, sleep, now, | |
| 396 | + ) | |
| 397 | + return output | |
| 398 | + | |
| 399 | + | |
| 273 | 400 | def _verify_and_maybe_regenerate( |
| 274 | 401 | conn, run_id, org_node, skill_id, target_folder_path, node, node_id, prompt, |
| 275 | 402 | output, input_schema, output_schema, timeout, needs_worktree, verify_fn, sleep, now, | ... | ... |
src/work_node_run.py
| ... | ... | @@ -32,6 +32,7 @@ import json |
| 32 | 32 | import os |
| 33 | 33 | import re |
| 34 | 34 | import subprocess |
| 35 | +import threading | |
| 35 | 36 | import time |
| 36 | 37 | import uuid |
| 37 | 38 | from datetime import datetime, timezone |
| ... | ... | @@ -105,15 +106,24 @@ def _quota_limit_type(text): |
| 105 | 106 | return match.group(1).lower() if match else None |
| 106 | 107 | |
| 107 | 108 | |
| 109 | +_worktree_creation_lock = threading.Lock() | |
| 110 | + | |
| 111 | + | |
| 108 | 112 | def create_worktree(repo_path, node_exec_id): |
| 109 | 113 | """`git worktree add` for an isolated code-writing node (01-M |
| 110 | - work_node_execution.git_worktree_ref).""" | |
| 114 | + work_node_execution.git_worktree_ref). Serialized across threads via a | |
| 115 | + module-level lock -- `git worktree add` mutates shared `.git/worktrees/` | |
| 116 | + metadata, and concurrent Work Graph layers (G) may call this from | |
| 117 | + multiple threads for the same repo_path at once. The lock only wraps | |
| 118 | + this one git invocation (milliseconds), not the node's actual work, so | |
| 119 | + it doesn't meaningfully reduce the parallelism gained elsewhere.""" | |
| 111 | 120 | worktree_path = os.path.join(repo_path, ".graphlemon-worktrees", node_exec_id) |
| 112 | 121 | branch = "graphlemon/" + node_exec_id |
| 113 | - subprocess.run( | |
| 114 | - ["git", "worktree", "add", "-b", branch, worktree_path], | |
| 115 | - cwd=repo_path, check=True, capture_output=True, text=True, | |
| 116 | - ) | |
| 122 | + with _worktree_creation_lock: | |
| 123 | + subprocess.run( | |
| 124 | + ["git", "worktree", "add", "-b", branch, worktree_path], | |
| 125 | + cwd=repo_path, check=True, capture_output=True, text=True, | |
| 126 | + ) | |
| 117 | 127 | return worktree_path |
| 118 | 128 | |
| 119 | 129 | ... | ... |