stargate-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stargate/fanout.py ADDED
@@ -0,0 +1,1152 @@
1
+ """Fan-out runs: architect a DAG, execute ready nodes, then review integration."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from collections.abc import Callable
10
+ from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .agent import invoke_agent
16
+ from .commit import commit_run
17
+ from .config import blocking_severities, commit_enabled, prompt_dirs, render_prompt
18
+ from .core import RunContext, StargateError, git_quiet, print_output, short_name
19
+ from .run import (
20
+ budget_spent,
21
+ complete_stage,
22
+ create_worktree,
23
+ enter_stage,
24
+ load_run,
25
+ make_context,
26
+ save_state,
27
+ snapshot,
28
+ unique_branch,
29
+ warn_if_dirty,
30
+ worktree_fingerprint,
31
+ )
32
+ from .stages import finish, plan_tests, review_and_finish, run_tests
33
+
34
+ TASK_ID = re.compile(r"[a-z0-9][a-z0-9-]{0,47}\Z")
35
+ MAX_FANOUT_TASKS_DEFAULT = 8
36
+ MAX_PARALLEL_TASKS_DEFAULT = 2
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class FanoutTask:
41
+ id: str
42
+ task: str
43
+ depends_on: tuple[str, ...]
44
+ acceptance: tuple[str, ...]
45
+
46
+ def as_dict(self) -> dict[str, Any]:
47
+ return {
48
+ "id": self.id,
49
+ "task": self.task,
50
+ "depends_on": list(self.depends_on),
51
+ "acceptance": list(self.acceptance),
52
+ }
53
+
54
+
55
+ def _positive_setting(config: dict[str, Any], key: str, default: int) -> int:
56
+ value = config.get("settings", {}).get(key, default)
57
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
58
+ raise StargateError(f"settings.{key} must be a positive integer.")
59
+ return value
60
+
61
+
62
+ def fanout_limits(
63
+ config: dict[str, Any], parallel_override: int | None = None
64
+ ) -> tuple[int, int]:
65
+ max_tasks = _positive_setting(
66
+ config, "max_fanout_tasks", MAX_FANOUT_TASKS_DEFAULT
67
+ )
68
+ max_parallel = (
69
+ parallel_override
70
+ if parallel_override is not None
71
+ else _positive_setting(
72
+ config, "max_parallel_tasks", MAX_PARALLEL_TASKS_DEFAULT
73
+ )
74
+ )
75
+ if (
76
+ isinstance(max_parallel, bool)
77
+ or not isinstance(max_parallel, int)
78
+ or max_parallel < 1
79
+ ):
80
+ raise StargateError("--max-parallel-tasks must be a positive integer.")
81
+ # The configured graph ceiling is not a concurrency ceiling. The scheduler
82
+ # naturally limits active workers to the number of ready graph nodes.
83
+ return max_tasks, max_parallel
84
+
85
+
86
+ def parse_task_graph(
87
+ raw: str, max_tasks: int | None
88
+ ) -> tuple[str, list[FanoutTask]]:
89
+ """Validate the architect's JSON and return tasks in topological order."""
90
+ try:
91
+ data = json.loads(raw)
92
+ except json.JSONDecodeError as exc:
93
+ raise StargateError(
94
+ "Fan-out architect did not return valid JSON "
95
+ f"(line {exc.lineno}, column {exc.colno}): {exc.msg}"
96
+ ) from exc
97
+ if not isinstance(data, dict):
98
+ raise StargateError("Fan-out architect output must be one JSON object.")
99
+
100
+ name_value = data.get("name")
101
+ name = name_value.strip() if isinstance(name_value, str) else ""
102
+ if not short_name(name):
103
+ raise StargateError("Fan-out architect output needs a usable string 'name'.")
104
+
105
+ raw_tasks = data.get("tasks")
106
+ if not isinstance(raw_tasks, list) or not raw_tasks:
107
+ raise StargateError("Fan-out architect output needs a non-empty 'tasks' list.")
108
+ if max_tasks is not None and len(raw_tasks) > max_tasks:
109
+ extra = raw_tasks[max_tasks]
110
+ extra_id = extra.get("id") if isinstance(extra, dict) else None
111
+ where = f"tasks[{max_tasks}]"
112
+ if extra_id is not None:
113
+ where += f" ({extra_id!r})"
114
+ raise StargateError(
115
+ f"Fan-out graph contains {len(raw_tasks)} tasks; "
116
+ f"settings.max_fanout_tasks is {max_tasks}. "
117
+ f"First excess entry: {where}."
118
+ )
119
+
120
+ tasks: list[FanoutTask] = []
121
+ seen: set[str] = set()
122
+ for index, item in enumerate(raw_tasks, 1):
123
+ where = f"tasks[{index - 1}]"
124
+ if not isinstance(item, dict):
125
+ raise StargateError(f"{where} must be an object.")
126
+ task_id = item.get("id")
127
+ if not isinstance(task_id, str) or not TASK_ID.fullmatch(task_id):
128
+ raise StargateError(
129
+ f"{where}.id ({task_id!r}) must match "
130
+ "[a-z0-9][a-z0-9-]{0,47}."
131
+ )
132
+ if task_id in seen:
133
+ raise StargateError(
134
+ f"{where}.id duplicates fan-out task {task_id!r}."
135
+ )
136
+ seen.add(task_id)
137
+ task_where = f"Fan-out task {task_id!r} at {where}"
138
+
139
+ description = item.get("task")
140
+ if not isinstance(description, str) or not description.strip():
141
+ raise StargateError(f"{task_where}.task must be a non-empty string.")
142
+ dependencies = item.get("depends_on", [])
143
+ if not isinstance(dependencies, list) or not all(
144
+ isinstance(value, str) for value in dependencies
145
+ ):
146
+ raise StargateError(
147
+ f"{task_where}.depends_on must be a list of task IDs."
148
+ )
149
+ if len(set(dependencies)) != len(dependencies):
150
+ duplicate = next(
151
+ value
152
+ for offset, value in enumerate(dependencies)
153
+ if value in dependencies[:offset]
154
+ )
155
+ raise StargateError(
156
+ f"{task_where}.depends_on contains duplicate task ID "
157
+ f"{duplicate!r}."
158
+ )
159
+ acceptance = item.get("acceptance", [])
160
+ if (
161
+ not isinstance(acceptance, list)
162
+ or not all(
163
+ isinstance(value, str) and value.strip() for value in acceptance
164
+ )
165
+ ):
166
+ raise StargateError(
167
+ f"{task_where}.acceptance must be a list of non-empty strings."
168
+ )
169
+ tasks.append(
170
+ FanoutTask(
171
+ id=task_id,
172
+ task=description.strip(),
173
+ depends_on=tuple(dependencies),
174
+ acceptance=tuple(value.strip() for value in acceptance),
175
+ )
176
+ )
177
+
178
+ by_id = {task.id: task for task in tasks}
179
+ for task in tasks:
180
+ missing = [dep for dep in task.depends_on if dep not in by_id]
181
+ if missing:
182
+ raise StargateError(
183
+ f"Fan-out task {task.id!r} depends on unknown task(s): "
184
+ f"{', '.join(missing)}."
185
+ )
186
+ if task.id in task.depends_on:
187
+ raise StargateError(f"Fan-out task {task.id!r} depends on itself.")
188
+
189
+ position = {task.id: index for index, task in enumerate(tasks)}
190
+ indegree = {task.id: len(task.depends_on) for task in tasks}
191
+ children: dict[str, list[str]] = {task.id: [] for task in tasks}
192
+ for task in tasks:
193
+ for dependency in task.depends_on:
194
+ children[dependency].append(task.id)
195
+ ready = [task.id for task in tasks if indegree[task.id] == 0]
196
+ order: list[str] = []
197
+ while ready:
198
+ ready.sort(key=position.__getitem__)
199
+ task_id = ready.pop(0)
200
+ order.append(task_id)
201
+ for child in children[task_id]:
202
+ indegree[child] -= 1
203
+ if indegree[child] == 0:
204
+ ready.append(child)
205
+ if len(order) != len(tasks):
206
+ visiting: dict[str, int] = {}
207
+ visited: set[str] = set()
208
+ stack: list[str] = []
209
+
210
+ def find_cycle(task_id: str) -> list[str] | None:
211
+ visiting[task_id] = len(stack)
212
+ stack.append(task_id)
213
+ for dependency in by_id[task_id].depends_on:
214
+ if dependency in visiting:
215
+ return [*stack[visiting[dependency] :], dependency]
216
+ if dependency not in visited:
217
+ cycle = find_cycle(dependency)
218
+ if cycle is not None:
219
+ return cycle
220
+ stack.pop()
221
+ visiting.pop(task_id)
222
+ visited.add(task_id)
223
+ return None
224
+
225
+ cycle = next(
226
+ (
227
+ found
228
+ for task in tasks
229
+ if task.id not in visited
230
+ and (found := find_cycle(task.id)) is not None
231
+ ),
232
+ [],
233
+ )
234
+ raise StargateError(
235
+ "Fan-out task dependencies contain a cycle: "
236
+ f"{' -> '.join(cycle)}."
237
+ )
238
+ return name, [by_id[task_id] for task_id in order]
239
+
240
+
241
+ def normalized_graph(name: str, tasks: list[FanoutTask]) -> str:
242
+ graph_tasks: list[dict[str, Any]] = []
243
+ for task in tasks:
244
+ item = task.as_dict()
245
+ if not task.acceptance:
246
+ item.pop("acceptance")
247
+ graph_tasks.append(item)
248
+ return json.dumps(
249
+ {"name": name, "tasks": graph_tasks},
250
+ indent=2,
251
+ ) + "\n"
252
+
253
+
254
+ def _validate_frozen_graph(
255
+ ctx: RunContext, name: str, tasks: list[FanoutTask]
256
+ ) -> None:
257
+ if not ctx.fanout:
258
+ return
259
+ expected_order = ctx.fanout.get("order")
260
+ records = ctx.fanout.get("tasks")
261
+ if (
262
+ ctx.fanout.get("name") != name
263
+ or
264
+ expected_order != [task.id for task in tasks]
265
+ or not isinstance(records, dict)
266
+ ):
267
+ raise StargateError(
268
+ "tasks.json no longer matches the fan-out graph frozen in state.json."
269
+ )
270
+ for task in tasks:
271
+ record = records.get(task.id)
272
+ if not isinstance(record, dict) or any(
273
+ record.get(key) != value
274
+ for key, value in task.as_dict().items()
275
+ ):
276
+ raise StargateError(
277
+ "tasks.json no longer matches the fan-out graph frozen in state.json."
278
+ )
279
+
280
+
281
+ def _new_task_records(
282
+ ctx: RunContext, name: str, tasks: list[FanoutTask]
283
+ ) -> dict[str, Any]:
284
+ records: dict[str, dict[str, Any]] = {}
285
+ reserved_branches: set[str] = set()
286
+ for task in tasks:
287
+ branch_base = f"{ctx.branch}-{task.id}"
288
+ branch = unique_branch(ctx.repo, branch_base)
289
+ suffix = 2
290
+ while branch in reserved_branches:
291
+ branch = unique_branch(ctx.repo, f"{branch_base}-{suffix}")
292
+ suffix += 1
293
+ reserved_branches.add(branch)
294
+ records[task.id] = {
295
+ **task.as_dict(),
296
+ "status": "pending",
297
+ "stage": "pending",
298
+ "branch": branch,
299
+ "worktree": str(ctx.worktree.parent / f"{ctx.run_id}-{task.id}"),
300
+ "base_commit": "",
301
+ "commit": "",
302
+ "test_exit": None,
303
+ "tokens_used": 0,
304
+ "test_artifacts": [],
305
+ "error": None,
306
+ }
307
+ return {
308
+ "name": name,
309
+ "order": [task.id for task in tasks],
310
+ "tasks": records,
311
+ "integrated": [],
312
+ }
313
+
314
+
315
+ def _merge(worktree: Path, branch: str, purpose: str) -> None:
316
+ # A process can die after Git writes MERGE_HEAD but before our failure path
317
+ # gets to `merge --abort`. Generated worktrees have no legitimate reason to
318
+ # retain that state between scheduler invocations, so make an old merge
319
+ # recoverable before attempting the next one.
320
+ if _merge_residue(worktree):
321
+ print_output(f"Recovering an interrupted merge in {worktree}.")
322
+ _reset_worktree(worktree, "HEAD", clean_untracked=False)
323
+
324
+ tracked = git_quiet(
325
+ worktree, "status", "--porcelain", "--untracked-files=no"
326
+ ).strip()
327
+ if tracked:
328
+ raise StargateError(
329
+ f"Could not merge {branch!r} while {purpose}: {worktree} has "
330
+ "uncommitted tracked changes."
331
+ )
332
+
333
+ proc = subprocess.run(
334
+ ["git", "merge", "--no-edit", branch],
335
+ cwd=str(worktree),
336
+ text=True,
337
+ stdout=subprocess.PIPE,
338
+ stderr=subprocess.STDOUT,
339
+ )
340
+ if proc.stdout:
341
+ print_output(
342
+ proc.stdout, end="" if proc.stdout.endswith("\n") else "\n"
343
+ )
344
+ if proc.returncode == 0:
345
+ return
346
+ abort = subprocess.run(
347
+ ["git", "merge", "--abort"],
348
+ cwd=str(worktree),
349
+ text=True,
350
+ stdout=subprocess.PIPE,
351
+ stderr=subprocess.STDOUT,
352
+ )
353
+ cleanup_error = ""
354
+ tracked = git_quiet(
355
+ worktree, "status", "--porcelain", "--untracked-files=no"
356
+ ).strip()
357
+ if _merge_residue(worktree) or tracked:
358
+ try:
359
+ _reset_worktree(worktree, "HEAD", clean_untracked=False)
360
+ except StargateError as exc:
361
+ cleanup_error = str(exc)
362
+ residue = _merge_residue(worktree)
363
+ tracked = git_quiet(
364
+ worktree, "status", "--porcelain", "--untracked-files=no"
365
+ ).strip()
366
+ if residue or tracked:
367
+ abort_tail = "\n".join((abort.stdout or "").strip().splitlines()[-5:])
368
+ cleanup_error = cleanup_error or abort_tail or "Git left merge state behind."
369
+ tail = "\n".join((proc.stdout or "").strip().splitlines()[-20:])
370
+ raise StargateError(
371
+ f"Could not merge {branch!r} while {purpose}."
372
+ + (f"\nLast Git output:\n{tail}" if tail else "")
373
+ + (
374
+ f"\nCould not restore a clean worktree: {cleanup_error}"
375
+ if cleanup_error
376
+ else ""
377
+ )
378
+ )
379
+
380
+
381
+ def _merge_residue(worktree: Path) -> bool:
382
+ merge_head = subprocess.run(
383
+ ["git", "rev-parse", "-q", "--verify", "MERGE_HEAD"],
384
+ cwd=str(worktree),
385
+ stdout=subprocess.DEVNULL,
386
+ stderr=subprocess.DEVNULL,
387
+ )
388
+ unmerged = subprocess.run(
389
+ ["git", "ls-files", "-u"],
390
+ cwd=str(worktree),
391
+ text=True,
392
+ capture_output=True,
393
+ )
394
+ return merge_head.returncode == 0 or bool(unmerged.stdout.strip())
395
+
396
+
397
+ def _reset_worktree(
398
+ worktree: Path, revision: str, *, clean_untracked: bool
399
+ ) -> None:
400
+ commands = [["reset", "--hard", revision]]
401
+ if clean_untracked:
402
+ # Task worktrees are generated scratch space. An unfinished attempt must
403
+ # not silently influence the next developer, including via ignored
404
+ # build output or a nested repository.
405
+ commands.append(["clean", "-ffdx"])
406
+ for command in commands:
407
+ proc = subprocess.run(
408
+ ["git", *command],
409
+ cwd=str(worktree),
410
+ text=True,
411
+ stdout=subprocess.PIPE,
412
+ stderr=subprocess.STDOUT,
413
+ )
414
+ if proc.returncode:
415
+ detail = "\n".join((proc.stdout or "").strip().splitlines()[-10:])
416
+ raise StargateError(
417
+ f"Could not reset task worktree {worktree} with "
418
+ f"'git {' '.join(command)}'."
419
+ + (f"\nLast Git output:\n{detail}" if detail else "")
420
+ )
421
+
422
+
423
+ def _assert_branch(worktree: Path, expected: str, purpose: str) -> None:
424
+ proc = subprocess.run(
425
+ ["git", "symbolic-ref", "--short", "HEAD"],
426
+ cwd=str(worktree),
427
+ text=True,
428
+ capture_output=True,
429
+ )
430
+ checked_out = proc.stdout.strip() if proc.returncode == 0 else "(detached HEAD)"
431
+ if checked_out != expected:
432
+ raise StargateError(
433
+ f"{purpose} worktree {worktree} is on {checked_out!r}, "
434
+ f"not {expected!r}."
435
+ )
436
+
437
+
438
+ def _ref_commit(repo: Path, branch: str) -> str:
439
+ proc = subprocess.run(
440
+ [
441
+ "git",
442
+ "rev-parse",
443
+ "--verify",
444
+ "--end-of-options",
445
+ f"refs/heads/{branch}^{{commit}}",
446
+ ],
447
+ cwd=str(repo),
448
+ text=True,
449
+ stdout=subprocess.PIPE,
450
+ stderr=subprocess.DEVNULL,
451
+ )
452
+ return proc.stdout.strip() if proc.returncode == 0 else ""
453
+
454
+
455
+ def _is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool:
456
+ return subprocess.run(
457
+ ["git", "merge-base", "--is-ancestor", ancestor, descendant],
458
+ cwd=str(repo),
459
+ stdout=subprocess.DEVNULL,
460
+ stderr=subprocess.DEVNULL,
461
+ ).returncode == 0
462
+
463
+
464
+ def _task_context(ctx: RunContext, record: dict[str, Any]) -> RunContext:
465
+ task_artifacts = ctx.artifacts / "tasks" / record["id"]
466
+ task_artifacts.mkdir(parents=True, exist_ok=True)
467
+ task_ctx = RunContext(
468
+ repo=ctx.repo,
469
+ config=ctx.config,
470
+ run_id=ctx.run_id,
471
+ slug=record["id"],
472
+ branch=record["branch"],
473
+ base_ref=ctx.base_ref,
474
+ base_commit=record["base_commit"] or ctx.base_commit,
475
+ worktree=Path(record["worktree"]),
476
+ artifacts=task_artifacts,
477
+ task=record["task"],
478
+ tokens_used=int(record.get("tokens_used", 0)),
479
+ test_artifacts=set(record.get("test_artifacts", [])),
480
+ commit=str(record.get("commit") or ""),
481
+ mode="fanout-task",
482
+ )
483
+ task_ctx.test_command = ctx.test_command
484
+ task_ctx.test_source = ctx.test_source
485
+ task_ctx.detected = ctx.detected
486
+ return task_ctx
487
+
488
+
489
+ def _prepare_task(ctx: RunContext, record: dict[str, Any]) -> RunContext:
490
+ dependencies = [ctx.fanout["tasks"][item] for item in record["depends_on"]]
491
+ missing = [item["id"] for item in dependencies if not item.get("commit")]
492
+ if missing:
493
+ raise StargateError(
494
+ f"Task {record['id']!r} has uncommitted dependencies: {', '.join(missing)}."
495
+ )
496
+
497
+ initial_base = dependencies[0]["commit"] if dependencies else ctx.base_commit
498
+ recorded_base = str(record.get("base_commit") or "")
499
+ preparation_base = recorded_base or initial_base
500
+ task_ctx = _task_context(ctx, record)
501
+ task_ctx.base_commit = preparation_base
502
+ existed = task_ctx.worktree.exists() or bool(
503
+ _ref_commit(ctx.repo, task_ctx.branch)
504
+ )
505
+ create_worktree(
506
+ task_ctx,
507
+ recover_recorded_fanout_branch=bool(record.get("branch_created")),
508
+ )
509
+ if not record.get("branch_created"):
510
+ # Persist ownership only after Git creates the branch successfully. A
511
+ # competing process that takes the planned name must still be refused.
512
+ record["branch_created"] = True
513
+ save_state(ctx, "running")
514
+ _assert_branch(task_ctx.worktree, task_ctx.branch, "Task")
515
+
516
+ if existed:
517
+ print_output(
518
+ f"Clean retry for task {record['id']}: resetting {task_ctx.worktree} "
519
+ f"to {preparation_base[:12]} and discarding the unfinished "
520
+ "attempt's edits."
521
+ )
522
+ _reset_worktree(task_ctx.worktree, preparation_base, clean_untracked=True)
523
+
524
+ head = git_quiet(task_ctx.worktree, "rev-parse", "HEAD").strip()
525
+ if head != preparation_base:
526
+ raise StargateError(
527
+ f"Task {record['id']!r} branch {task_ctx.branch!r} starts at "
528
+ f"{head}, not its recorded base {preparation_base}."
529
+ )
530
+
531
+ if recorded_base:
532
+ missing_from_base = [
533
+ dependency["id"]
534
+ for dependency in dependencies
535
+ if not _is_ancestor(ctx.repo, dependency["commit"], recorded_base)
536
+ ]
537
+ if missing_from_base:
538
+ raise StargateError(
539
+ f"Task {record['id']!r} recorded base does not contain "
540
+ f"dependency task(s): {', '.join(missing_from_base)}."
541
+ )
542
+ else:
543
+ for dependency in dependencies:
544
+ branch_head = _ref_commit(ctx.repo, dependency["branch"])
545
+ if branch_head != dependency["commit"]:
546
+ raise StargateError(
547
+ f"Dependency task {dependency['id']!r} branch "
548
+ f"{dependency['branch']!r} is at {branch_head or '(missing)'}, "
549
+ f"not its recorded commit {dependency['commit']}."
550
+ )
551
+ _merge(
552
+ task_ctx.worktree,
553
+ dependency["commit"],
554
+ f"preparing task {record['id']!r} from dependency "
555
+ f"{dependency['id']!r}",
556
+ )
557
+ task_ctx.base_commit = git_quiet(
558
+ task_ctx.worktree, "rev-parse", "HEAD"
559
+ ).strip()
560
+ record["base_commit"] = task_ctx.base_commit
561
+
562
+ missing_from_head = [
563
+ dependency["id"]
564
+ for dependency in dependencies
565
+ if not _is_ancestor(ctx.repo, dependency["commit"], task_ctx.base_commit)
566
+ ]
567
+ if missing_from_head:
568
+ raise StargateError(
569
+ f"Task {record['id']!r} worktree does not contain dependency "
570
+ f"task(s): {', '.join(missing_from_head)}."
571
+ )
572
+ record["stage"] = "ready"
573
+ return task_ctx
574
+
575
+
576
+ def _task_prompt(overall_task: str, task: FanoutTask) -> str:
577
+ acceptance = "\n".join(f"- {item}" for item in task.acceptance) or "- Not specified"
578
+ dependencies = ", ".join(task.depends_on) or "none"
579
+ return (
580
+ f"OVERALL FAN-OUT GOAL:\n{overall_task}\n\n"
581
+ f"ASSIGNED TASK ID: {task.id}\n"
582
+ f"DEPENDENCIES ALREADY PRESENT: {dependencies}\n\n"
583
+ f"TASK:\n{task.task}\n\n"
584
+ f"ACCEPTANCE CRITERIA:\n{acceptance}"
585
+ )
586
+
587
+
588
+ def _execute_task(
589
+ outer: RunContext,
590
+ task_ctx: RunContext,
591
+ task: FanoutTask,
592
+ prompts: list[Path],
593
+ graph: str,
594
+ ) -> dict[str, Any]:
595
+ task_ctx.stage = "developer"
596
+ save_state(task_ctx, "running")
597
+ before = worktree_fingerprint(task_ctx)
598
+ prompt = render_prompt(
599
+ prompts,
600
+ "developer",
601
+ task=_task_prompt(outer.task, task),
602
+ base_ref=task_ctx.base_commit,
603
+ plan=graph,
604
+ )
605
+ print_output(f"\n=== TASK {task.id}: DEVELOPER ===")
606
+ invoke_agent(
607
+ task_ctx,
608
+ "developer",
609
+ prompt,
610
+ task_ctx.worktree,
611
+ task_ctx.artifacts / "developer.txt",
612
+ )
613
+ # Persist usage immediately after the billable phase. The outer scheduler
614
+ # reconciles this total into its own state once the future completes.
615
+ save_state(task_ctx, "running")
616
+ if worktree_fingerprint(task_ctx) == before:
617
+ raise StargateError(
618
+ f"Developer changed nothing for fan-out task {task.id!r} in "
619
+ f"{task_ctx.worktree}."
620
+ )
621
+ complete_stage(task_ctx, "developer")
622
+ test_exit, _ = run_tests(task_ctx, "task")
623
+ outcome = commit_run(task_ctx, "TASK_COMPLETE", test_exit)
624
+ if outcome == "failed":
625
+ raise StargateError(
626
+ task_ctx.commit_error or f"Could not commit fan-out task {task.id!r}."
627
+ )
628
+ if not task_ctx.commit:
629
+ raise StargateError(f"Fan-out task {task.id!r} produced no commit.")
630
+ result = {
631
+ "commit": task_ctx.commit,
632
+ "test_exit": test_exit,
633
+ "tokens_used": task_ctx.tokens_used,
634
+ "test_artifacts": sorted(task_ctx.test_artifacts),
635
+ }
636
+ (task_ctx.artifacts / "result.json").write_text(
637
+ json.dumps(result, indent=2) + "\n"
638
+ )
639
+ save_state(task_ctx, "task_complete")
640
+ return result
641
+
642
+
643
+ def _recover_task(ctx: RunContext, record: dict[str, Any]) -> bool:
644
+ state_path = ctx.artifacts / "tasks" / record["id"] / "state.json"
645
+ if not state_path.exists():
646
+ return False
647
+ try:
648
+ state = json.loads(state_path.read_text())
649
+ except (OSError, ValueError):
650
+ return False
651
+ if not isinstance(state, dict):
652
+ return False
653
+ branch_head = _ref_commit(ctx.repo, record["branch"])
654
+ if not branch_head:
655
+ return False
656
+ state_commit = str(state.get("commit") or "")
657
+ if state.get("status") == "task_complete" and state_commit:
658
+ if state_commit != branch_head:
659
+ return False
660
+ commit = branch_head
661
+ message_proc = subprocess.run(
662
+ ["git", "show", "-s", "--format=%B", commit],
663
+ cwd=str(ctx.repo),
664
+ text=True,
665
+ capture_output=True,
666
+ )
667
+ if message_proc.returncode:
668
+ return False
669
+ trailers = {
670
+ key: value
671
+ for line in message_proc.stdout.splitlines()
672
+ if ": " in line
673
+ for key, value in [line.split(": ", 1)]
674
+ }
675
+ base_commit = str(record.get("base_commit") or "")
676
+ if (
677
+ trailers.get("Stargate-Run-Id") != ctx.run_id
678
+ or trailers.get("Stargate-Verdict") != "TASK_COMPLETE"
679
+ or trailers.get("Stargate-Base-Commit") != base_commit
680
+ or not base_commit
681
+ or not _is_ancestor(ctx.repo, base_commit, commit)
682
+ ):
683
+ return False
684
+ result_path = state_path.parent / "result.json"
685
+ try:
686
+ result = json.loads(result_path.read_text())
687
+ except (OSError, ValueError):
688
+ result = {}
689
+ if not isinstance(result, dict) or (
690
+ result.get("commit") and result.get("commit") != commit
691
+ ):
692
+ result = {}
693
+ try:
694
+ tokens_used = max(
695
+ 0,
696
+ int(record.get("tokens_used", 0)),
697
+ int(state.get("tokens_used", 0)),
698
+ int(result.get("tokens_used", 0)),
699
+ )
700
+ except (TypeError, ValueError):
701
+ return False
702
+ test_exit = result.get("test_exit")
703
+ if test_exit is None and "Stargate-Tests-Exit" in trailers:
704
+ try:
705
+ test_exit = int(trailers["Stargate-Tests-Exit"])
706
+ except ValueError:
707
+ test_exit = None
708
+ record.update(
709
+ status="complete",
710
+ stage="complete",
711
+ commit=commit,
712
+ test_exit=test_exit,
713
+ tokens_used=tokens_used,
714
+ test_artifacts=list(
715
+ result.get("test_artifacts", state.get("test_artifacts", []))
716
+ ),
717
+ error=None,
718
+ )
719
+ print_output(
720
+ f"Recovered completed task {record['id']} at {commit[:12]} from its "
721
+ "task branch."
722
+ )
723
+ return True
724
+
725
+
726
+ def _run_scheduler(
727
+ ctx: RunContext,
728
+ tasks: list[FanoutTask],
729
+ prompts: list[Path],
730
+ graph: str,
731
+ max_parallel: int,
732
+ ) -> int | None:
733
+ enter_stage(ctx, "tasks")
734
+ by_id = {task.id: task for task in tasks}
735
+ records = ctx.fanout["tasks"]
736
+ for task in tasks:
737
+ record = records[task.id]
738
+ if record["status"] == "complete":
739
+ continue
740
+ previous_tokens = int(record.get("tokens_used", 0))
741
+ if _recover_task(ctx, record):
742
+ ctx.tokens_used += max(
743
+ 0, int(record.get("tokens_used", 0)) - previous_tokens
744
+ )
745
+ continue
746
+ record["status"] = "pending"
747
+ record["stage"] = "pending"
748
+ record["error"] = None
749
+ save_state(ctx, "running")
750
+
751
+ running: dict[Future[dict[str, Any]], tuple[str, RunContext]] = {}
752
+ budget_reached = False
753
+ interrupted: BaseException | None = None
754
+ executor = ThreadPoolExecutor(
755
+ max_workers=max_parallel, thread_name_prefix="stargate-task"
756
+ )
757
+ try:
758
+ while True:
759
+ if not budget_reached:
760
+ pending = [
761
+ task.id
762
+ for task in tasks
763
+ if records[task.id]["status"] == "pending"
764
+ ]
765
+ next_phase = f"fan-out task {pending[0]}" if pending else "integration"
766
+ if budget_spent(ctx, next_phase):
767
+ budget_reached = True
768
+ print_output(
769
+ "The cap is enforced between task agents. Up to "
770
+ f"{max_parallel} concurrently started task agent(s) may "
771
+ "finish above it; no more task agents will be scheduled.",
772
+ file=sys.stderr,
773
+ )
774
+
775
+ if not budget_reached:
776
+ for task in tasks:
777
+ if len(running) >= max_parallel:
778
+ break
779
+ record = records[task.id]
780
+ if record["status"] != "pending":
781
+ continue
782
+ if not all(
783
+ records[dependency]["status"] == "complete"
784
+ for dependency in task.depends_on
785
+ ):
786
+ continue
787
+ try:
788
+ task_ctx = _prepare_task(ctx, record)
789
+ except StargateError as exc:
790
+ record.update(
791
+ status="failed", stage="prepare", error=str(exc)
792
+ )
793
+ save_state(ctx, "running")
794
+ continue
795
+ record.update(status="running", stage="developer", error=None)
796
+ save_state(ctx, "running")
797
+ future = executor.submit(
798
+ _execute_task, ctx, task_ctx, by_id[task.id], prompts, graph
799
+ )
800
+ running[future] = (task.id, task_ctx)
801
+ print_output(
802
+ f"Scheduled {task.id} on {record['branch']} "
803
+ f"({len(running)}/{max_parallel} slots)"
804
+ )
805
+ if not running:
806
+ break
807
+
808
+ finished, _ = wait(running, return_when=FIRST_COMPLETED)
809
+ for future in finished:
810
+ task_id, task_ctx = running.pop(future)
811
+ record = records[task_id]
812
+ try:
813
+ result = future.result()
814
+ except BaseException as exc:
815
+ previous_tokens = int(record.get("tokens_used", 0))
816
+ record.update(
817
+ status="failed",
818
+ stage=task_ctx.stage,
819
+ tokens_used=task_ctx.tokens_used,
820
+ test_artifacts=sorted(task_ctx.test_artifacts),
821
+ error=f"{type(exc).__name__}: {exc}",
822
+ )
823
+ ctx.tokens_used += max(
824
+ 0,
825
+ task_ctx.tokens_used - previous_tokens,
826
+ )
827
+ print_output(
828
+ f"\nTask {task_id} failed: {exc}", file=sys.stderr
829
+ )
830
+ else:
831
+ previous_tokens = int(record.get("tokens_used", 0))
832
+ record.update(
833
+ status="complete",
834
+ stage="complete",
835
+ commit=result["commit"],
836
+ test_exit=result["test_exit"],
837
+ tokens_used=result["tokens_used"],
838
+ test_artifacts=result["test_artifacts"],
839
+ error=None,
840
+ )
841
+ ctx.tokens_used += max(
842
+ 0, int(result["tokens_used"]) - previous_tokens
843
+ )
844
+ print_output(
845
+ f"\nTask {task_id} complete at "
846
+ f"{result['commit'][:12]}"
847
+ )
848
+ save_state(ctx, "running")
849
+ except BaseException as exc:
850
+ interrupted = exc
851
+ finally:
852
+ executor.shutdown(wait=True, cancel_futures=True)
853
+
854
+ if interrupted is not None:
855
+ for future, (task_id, task_ctx) in running.items():
856
+ record = records[task_id]
857
+ previous_tokens = int(record.get("tokens_used", 0))
858
+ if future.cancelled():
859
+ record.update(
860
+ status="pending",
861
+ stage="interrupted",
862
+ error="Interrupted before the task agent started; safe to retry.",
863
+ )
864
+ continue
865
+ try:
866
+ result = future.result()
867
+ except BaseException:
868
+ record.update(
869
+ status="pending",
870
+ stage="interrupted",
871
+ tokens_used=task_ctx.tokens_used,
872
+ test_artifacts=sorted(task_ctx.test_artifacts),
873
+ error=(
874
+ "Interrupted before task completion; retry will use the "
875
+ "clean-retry policy."
876
+ ),
877
+ )
878
+ ctx.tokens_used += max(
879
+ 0, task_ctx.tokens_used - previous_tokens
880
+ )
881
+ else:
882
+ record.update(
883
+ status="complete",
884
+ stage="complete",
885
+ commit=result["commit"],
886
+ test_exit=result["test_exit"],
887
+ tokens_used=result["tokens_used"],
888
+ test_artifacts=result["test_artifacts"],
889
+ error=None,
890
+ )
891
+ ctx.tokens_used += max(
892
+ 0, int(result["tokens_used"]) - previous_tokens
893
+ )
894
+ for task in tasks:
895
+ record = records[task.id]
896
+ if record["status"] == "running":
897
+ record.update(
898
+ status="pending",
899
+ stage="interrupted",
900
+ error=(
901
+ "Interrupted while scheduling the task; retry will use "
902
+ "the clean-retry policy."
903
+ ),
904
+ )
905
+ save_state(
906
+ ctx,
907
+ "failed",
908
+ f"{type(interrupted).__name__}: {interrupted}",
909
+ )
910
+ raise interrupted
911
+
912
+ if budget_reached:
913
+ if all(records[task.id]["status"] == "complete" for task in tasks):
914
+ complete_stage(ctx, "tasks")
915
+ save_state(ctx, "budget_exceeded")
916
+ return 4
917
+
918
+ failed = [task.id for task in tasks if records[task.id]["status"] == "failed"]
919
+ for task in tasks:
920
+ record = records[task.id]
921
+ if record["status"] == "pending":
922
+ record.update(
923
+ status="blocked",
924
+ stage="dependencies",
925
+ error="A dependency did not complete.",
926
+ )
927
+ blocked = [task.id for task in tasks if records[task.id]["status"] == "blocked"]
928
+ if failed or blocked:
929
+ save_state(ctx, "failed", "One or more fan-out tasks did not complete.")
930
+ details = [*failed, *(f"{task_id} (blocked)" for task_id in blocked)]
931
+ raise StargateError("Fan-out tasks did not complete: " + ", ".join(details))
932
+ complete_stage(ctx, "tasks")
933
+ return None
934
+
935
+
936
+ def _integrate(ctx: RunContext, tasks: list[FanoutTask]) -> None:
937
+ enter_stage(ctx, "integration")
938
+ print("\n=== INTEGRATION ===")
939
+ create_worktree(ctx)
940
+ _assert_branch(ctx.worktree, ctx.branch, "Integration")
941
+ integrated = ctx.fanout.setdefault("integrated", [])
942
+ for task in tasks:
943
+ if task.id in integrated:
944
+ continue
945
+ record = ctx.fanout["tasks"][task.id]
946
+ branch_head = _ref_commit(ctx.repo, record["branch"])
947
+ if branch_head != record["commit"]:
948
+ raise StargateError(
949
+ f"Task {task.id!r} branch {record['branch']!r} is at "
950
+ f"{branch_head or '(missing)'}, not its recorded commit "
951
+ f"{record['commit']}."
952
+ )
953
+ _merge(
954
+ ctx.worktree,
955
+ record["commit"],
956
+ f"integrating task {task.id!r} from branch {record['branch']!r}",
957
+ )
958
+ integrated.append(task.id)
959
+ ctx.commit = git_quiet(ctx.worktree, "rev-parse", "HEAD").strip()
960
+ save_state(ctx, "running")
961
+ complete_stage(ctx, "integration")
962
+
963
+
964
+ def _load_or_create_graph(
965
+ ctx: RunContext,
966
+ prompts: list[Path],
967
+ max_tasks: int,
968
+ ) -> tuple[list[FanoutTask], str]:
969
+ """Load or create the frozen graph, enforcing current configured limits."""
970
+ graph_path = ctx.artifacts / "tasks.json"
971
+ architect_complete = "architect" in ctx.done
972
+ if architect_complete:
973
+ if not ctx.fanout:
974
+ raise StargateError(
975
+ "tasks.json no longer matches the fan-out graph frozen in state.json."
976
+ )
977
+ if graph_path.exists():
978
+ raw = graph_path.read_text()
979
+ print(f"\n=== ARCHITECT (skipped, reusing {graph_path}) ===")
980
+ else:
981
+ order = ctx.fanout.get("order")
982
+ records = ctx.fanout.get("tasks")
983
+ if not isinstance(order, list) or not isinstance(records, dict):
984
+ raise StargateError(
985
+ "tasks.json no longer matches the fan-out graph frozen in "
986
+ "state.json."
987
+ )
988
+ frozen_tasks: list[dict[str, Any]] = []
989
+ for task_id in order:
990
+ record = records.get(task_id)
991
+ if not isinstance(record, dict):
992
+ raise StargateError(
993
+ "tasks.json no longer matches the fan-out graph frozen in "
994
+ "state.json."
995
+ )
996
+ frozen_task = {
997
+ key: record.get(key)
998
+ for key in ("id", "task", "depends_on")
999
+ }
1000
+ if record.get("acceptance"):
1001
+ frozen_task["acceptance"] = record["acceptance"]
1002
+ frozen_tasks.append(frozen_task)
1003
+ raw = json.dumps(
1004
+ {"name": ctx.fanout.get("name"), "tasks": frozen_tasks}
1005
+ )
1006
+ print(f"\n=== ARCHITECT (skipped, restoring {graph_path}) ===")
1007
+ else:
1008
+ enter_stage(ctx, "architect")
1009
+ architect_prompt = render_prompt(
1010
+ prompts,
1011
+ "fanout",
1012
+ task=ctx.task,
1013
+ base_ref=ctx.base_ref,
1014
+ max_tasks=str(max_tasks),
1015
+ )
1016
+ print("\n=== ARCHITECT: FAN-OUT ===")
1017
+ raw = invoke_agent(
1018
+ ctx,
1019
+ "architect",
1020
+ architect_prompt,
1021
+ ctx.repo,
1022
+ ctx.artifacts / "architect-tasks.json",
1023
+ ).strip()
1024
+
1025
+ name, tasks = parse_task_graph(raw, max_tasks)
1026
+ graph = normalized_graph(name, tasks)
1027
+ _validate_frozen_graph(ctx, name, tasks)
1028
+ graph_path.write_text(graph)
1029
+ if not ctx.named_by_user and ctx.tag and not ctx.fanout:
1030
+ proposed = f"stargate/{short_name(name)}-{ctx.tag}"
1031
+ ctx.branch = unique_branch(ctx.repo, proposed)
1032
+ print(f"Branch: {ctx.branch} (named by the architect)")
1033
+ # Freeze the architect-selected parent before deriving task branches.
1034
+ save_state(ctx, "running")
1035
+ if not ctx.fanout:
1036
+ ctx.fanout = _new_task_records(ctx, name, tasks)
1037
+ if not architect_complete:
1038
+ complete_stage(ctx, "architect")
1039
+ return tasks, graph
1040
+
1041
+
1042
+ def orchestrate_fanout(
1043
+ args: argparse.Namespace,
1044
+ script_dir: Path,
1045
+ config: dict[str, Any],
1046
+ repo: Path,
1047
+ on_context: Callable[[RunContext], None] | None = None,
1048
+ ) -> int:
1049
+ """Run or resume the opt-in DAG workflow.
1050
+
1051
+ `on_context` receives the run being orchestrated once it is loaded or
1052
+ created and validated, so a caller handling a failure can act on that exact
1053
+ run instead of re-deriving it from the runs directory.
1054
+ """
1055
+ resuming = args.command == "resume"
1056
+ commit_error = (
1057
+ "Fan-out requires commits to move work between task branches; "
1058
+ "remove --no-commit and enable settings.commit."
1059
+ )
1060
+
1061
+ # Invocation errors must not reserve a new run or rewrite an existing run's
1062
+ # resumable status. A resume still validates the frozen/explicit config
1063
+ # after loading it below.
1064
+ if resuming and getattr(args, "redo", []):
1065
+ raise StargateError("--redo is not supported for fan-out runs.")
1066
+ if args.no_commit:
1067
+ raise StargateError(commit_error)
1068
+ if not resuming:
1069
+ blocking_severities(config)
1070
+ if not commit_enabled(config):
1071
+ raise StargateError(commit_error)
1072
+ max_tasks, max_parallel = fanout_limits(
1073
+ config, args.max_parallel_tasks
1074
+ )
1075
+
1076
+ if resuming:
1077
+ ctx = load_run(repo, args.run_id, config, use_frozen=args.config is None)
1078
+ blocking_severities(ctx.config)
1079
+ if not commit_enabled(ctx.config):
1080
+ raise StargateError(commit_error)
1081
+ max_tasks, max_parallel = fanout_limits(
1082
+ ctx.config, args.max_parallel_tasks
1083
+ )
1084
+ prompts = [ctx.artifacts / "prompts"]
1085
+ print(f"\nResuming fan-out {ctx.run_id}: {ctx.task}")
1086
+ else:
1087
+ warn_if_dirty(repo)
1088
+ ctx = make_context(
1089
+ repo, config, args.task, args.base_ref, args.name,
1090
+ task_source=getattr(args, "from_url", None) or "",
1091
+ )
1092
+ ctx.mode = "fanout"
1093
+ prompts = snapshot(ctx, prompt_dirs(config, script_dir))
1094
+ save_state(ctx, "running")
1095
+
1096
+ print(f"\nRun ID: {ctx.run_id}")
1097
+ print(f"Mode: fan-out ({max_parallel} parallel tasks)")
1098
+ print(f"Base: {ctx.base_ref} @ {ctx.base_commit[:12]}")
1099
+ print(f"Branch: {ctx.branch}")
1100
+ print(f"Worktree: {ctx.worktree} (integration)")
1101
+ print(f"Artifacts:{ctx.artifacts}")
1102
+
1103
+ # Announced only past invocation and config validation: an error raised
1104
+ # before any orchestration happened must leave an existing run's artifacts
1105
+ # exactly as its last completed attempt wrote them.
1106
+ if on_context is not None:
1107
+ on_context(ctx)
1108
+
1109
+ try:
1110
+ plan_tests(ctx)
1111
+ tasks, graph = _load_or_create_graph(ctx, prompts, max_tasks)
1112
+ scheduler_exit = _run_scheduler(
1113
+ ctx, tasks, prompts, graph, max_parallel
1114
+ )
1115
+ if scheduler_exit is not None:
1116
+ # `finish` gives this resumable terminal path the same summary and
1117
+ # RESULT block as every other budget stop. The integration worktree
1118
+ # may not exist yet, so create only its untouched base branch. Do
1119
+ # not put a terminal commit on that branch: a later resume must
1120
+ # integrate task work directly on the frozen base, without a
1121
+ # historical BUDGET_EXCEEDED marker beneath it.
1122
+ create_worktree(ctx)
1123
+ _assert_branch(ctx.worktree, ctx.branch, "Integration")
1124
+ exit_code = finish(
1125
+ ctx,
1126
+ ctx.task,
1127
+ "BUDGET_EXCEEDED",
1128
+ None,
1129
+ commit=False,
1130
+ publish=getattr(args, "pr", False),
1131
+ )
1132
+ print(
1133
+ f"\nResume with a larger token budget: stargate resume {ctx.run_id}",
1134
+ file=sys.stderr,
1135
+ )
1136
+ return exit_code
1137
+ _integrate(ctx, tasks)
1138
+ test_exit, test_report = run_tests(ctx, "integration")
1139
+ return review_and_finish(
1140
+ ctx,
1141
+ args,
1142
+ prompts,
1143
+ task=ctx.task,
1144
+ plan=graph,
1145
+ test_exit=test_exit,
1146
+ test_report=test_report,
1147
+ commit=True,
1148
+ )
1149
+ except (StargateError, KeyboardInterrupt) as exc:
1150
+ save_state(ctx, "failed", f"{type(exc).__name__}: {exc}")
1151
+ print(f"\nResume with: stargate resume {ctx.run_id}", file=sys.stderr)
1152
+ raise