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/__init__.py +0 -0
- stargate/__main__.py +3 -0
- stargate/agent.py +194 -0
- stargate/agents.yaml +148 -0
- stargate/cli.py +286 -0
- stargate/commit.py +253 -0
- stargate/config.py +422 -0
- stargate/core.py +341 -0
- stargate/detect.py +140 -0
- stargate/doctor.py +407 -0
- stargate/fanout.py +1152 -0
- stargate/prompts/architect.md +28 -0
- stargate/prompts/developer.md +27 -0
- stargate/prompts/fanout.md +44 -0
- stargate/prompts/fixer.md +35 -0
- stargate/prompts/reviewer.md +86 -0
- stargate/publish.py +105 -0
- stargate/run.py +847 -0
- stargate/source.py +66 -0
- stargate/stages.py +1131 -0
- stargate_cli-1.0.0.dist-info/METADATA +1427 -0
- stargate_cli-1.0.0.dist-info/RECORD +26 -0
- stargate_cli-1.0.0.dist-info/WHEEL +5 -0
- stargate_cli-1.0.0.dist-info/entry_points.txt +2 -0
- stargate_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- stargate_cli-1.0.0.dist-info/top_level.txt +1 -0
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
|