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/run.py ADDED
@@ -0,0 +1,847 @@
1
+ """The lifecycle of one run: picking its base commit, reserving its id and
2
+ branch, its worktree, the state file that makes it resumable, and cleanup."""
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import datetime as dt
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import textwrap
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import yaml
20
+
21
+ from .config import PROMPTS, find_prompt, token_cap
22
+ from .core import (
23
+ RunContext,
24
+ StargateError,
25
+ git,
26
+ git_quiet,
27
+ short_name,
28
+ slugify,
29
+ )
30
+ from .source import branch_hint
31
+
32
+
33
+ def resolve_base_ref(repo: Path, requested: str | None) -> tuple[str, str]:
34
+ if requested:
35
+ ref = requested
36
+ else:
37
+ proc = git(repo, "rev-parse", "--abbrev-ref", "HEAD", capture=True)
38
+ ref = proc.stdout.strip()
39
+ if ref == "HEAD":
40
+ ref = git(repo, "rev-parse", "HEAD").stdout.strip()
41
+
42
+ commit = git(
43
+ repo, "rev-parse", "--verify", "--end-of-options",
44
+ f"{ref}^{{commit}}", capture=True,
45
+ ).stdout.strip()
46
+
47
+ symbolic = subprocess.run(
48
+ ["git", "rev-parse", "--symbolic-full-name", "--verify",
49
+ "--end-of-options", ref],
50
+ cwd=str(repo), text=True, capture_output=True,
51
+ )
52
+ local_branch = symbolic.stdout.strip()
53
+ if symbolic.returncode == 0 and local_branch.startswith("refs/heads/"):
54
+ branch = local_branch.removeprefix("refs/heads/")
55
+ remote = subprocess.run(
56
+ ["git", "config", "--get", f"branch.{branch}.remote"],
57
+ cwd=str(repo), text=True, capture_output=True,
58
+ ).stdout.strip()
59
+ merge_ref = subprocess.run(
60
+ ["git", "config", "--get", f"branch.{branch}.merge"],
61
+ cwd=str(repo), text=True, capture_output=True,
62
+ ).stdout.strip()
63
+ if remote and merge_ref:
64
+ upstream = (
65
+ merge_ref if remote == "."
66
+ else f"{remote}/{merge_ref.removeprefix('refs/heads/')}"
67
+ )
68
+ if remote == ".":
69
+ upstream_proc = subprocess.run(
70
+ ["git", "rev-parse", "--verify", "--end-of-options",
71
+ f"{merge_ref}^{{commit}}"],
72
+ cwd=str(repo), text=True, capture_output=True,
73
+ )
74
+ else:
75
+ try:
76
+ upstream_proc = subprocess.run(
77
+ ["git", "ls-remote", "--exit-code", "--refs",
78
+ remote, merge_ref],
79
+ cwd=str(repo), text=True, capture_output=True,
80
+ stdin=subprocess.DEVNULL, timeout=60,
81
+ env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
82
+ )
83
+ except subprocess.TimeoutExpired as exc:
84
+ raise StargateError(
85
+ f"Timed out validating upstream {upstream!r}."
86
+ ) from exc
87
+ if upstream_proc.returncode != 0 or not upstream_proc.stdout.strip():
88
+ detail = upstream_proc.stderr.strip() or "ref not found"
89
+ raise StargateError(
90
+ f"Could not validate upstream {upstream!r}: {detail}"
91
+ )
92
+ upstream_commit = upstream_proc.stdout.split()[0]
93
+ if upstream_commit != commit:
94
+ contains_upstream = subprocess.run(
95
+ ["git", "merge-base", "--is-ancestor", upstream_commit,
96
+ commit],
97
+ cwd=str(repo), stdout=subprocess.DEVNULL,
98
+ stderr=subprocess.DEVNULL,
99
+ ).returncode == 0
100
+ if not contains_upstream:
101
+ raise StargateError(
102
+ f"Base branch {ref!r} is behind or diverged from "
103
+ f"upstream {upstream!r}. Update it before running "
104
+ "Stargate."
105
+ )
106
+ return ref, commit
107
+
108
+
109
+ def warn_if_dirty(repo: Path) -> None:
110
+ status = git(repo, "status", "--porcelain").stdout.strip()
111
+ if status:
112
+ print(
113
+ "\nWarning: the source repository has local changes. "
114
+ "The agent worktree is created from the selected base ref, "
115
+ "so those uncommitted source-tree changes are NOT copied.",
116
+ file=sys.stderr,
117
+ )
118
+
119
+
120
+ def default_worktree_root(repo: Path) -> Path:
121
+ return repo.parent / ".stargate-worktrees" / repo.name
122
+
123
+
124
+ def branch_exists(repo: Path, branch: str) -> bool:
125
+ proc = subprocess.run(
126
+ ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
127
+ cwd=str(repo), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
128
+ )
129
+ return proc.returncode == 0
130
+
131
+
132
+ def unique_branch(repo: Path, branch: str) -> str:
133
+ for attempt in range(1, 100):
134
+ candidate = branch if attempt == 1 else f"{branch}-{attempt}"
135
+ if not branch_exists(repo, candidate):
136
+ return candidate
137
+ raise StargateError(f"Could not find an unused branch name based on {branch!r}.")
138
+
139
+
140
+ def reserve_run(repo: Path, now: str, slug: str) -> tuple[str, str, Path]:
141
+ """Reserve one discriminator for both artifacts and the initial branch.
142
+
143
+ A second process in the same second would otherwise share the artifacts
144
+ directory while Git also refuses to attach its branch to another worktree.
145
+ Creating the directory is the atomic claim; old first-attempt names retain
146
+ their exact shape, so `runs` and `resume` need no format migration.
147
+ """
148
+ for attempt in range(1, 100):
149
+ suffix = "" if attempt == 1 else f"-{attempt}"
150
+ tag = f"{now}{suffix}"
151
+ run_id = f"{now}-{slug}{suffix}"
152
+ branch = f"stargate/{slug}-{tag}"
153
+ if branch_exists(repo, branch):
154
+ continue
155
+ artifacts = repo / ".stargate" / "runs" / run_id
156
+ try:
157
+ artifacts.mkdir(parents=True, exist_ok=False)
158
+ except FileExistsError:
159
+ continue
160
+ return run_id, branch, artifacts
161
+ raise StargateError(
162
+ f"Could not reserve a unique run name for {slug!r} at {now}."
163
+ )
164
+
165
+
166
+ def make_context(
167
+ repo: Path,
168
+ config: dict[str, Any],
169
+ task: str,
170
+ base_ref: str | None,
171
+ name: str | None = None,
172
+ *,
173
+ task_source: str = "",
174
+ ) -> RunContext:
175
+ now = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
176
+ named = short_name(name or "") if name is not None else ""
177
+ if name is not None and not named:
178
+ print(
179
+ "Warning: --name produced no usable slug; using the task text.",
180
+ file=sys.stderr,
181
+ )
182
+ slug = named or slugify(f"{branch_hint(task_source)} {task}")
183
+ base, base_commit = resolve_base_ref(repo, base_ref)
184
+ run_id, branch, artifacts = reserve_run(repo, now, slug)
185
+ tag = branch.removeprefix(f"stargate/{slug}-")
186
+
187
+ configured = str(config.get("settings", {}).get("worktree_root", "") or "").strip()
188
+ worktree_root = (
189
+ Path(os.path.expanduser(configured)).resolve()
190
+ if configured else default_worktree_root(repo)
191
+ )
192
+ worktree = worktree_root / run_id
193
+ # Keep run artifacts out of the target repo's git status without touching
194
+ # the user's own .gitignore.
195
+ (repo / ".stargate" / ".gitignore").write_text("*\n")
196
+ worktree.parent.mkdir(parents=True, exist_ok=True)
197
+
198
+ return RunContext(
199
+ repo=repo,
200
+ config=config,
201
+ run_id=run_id,
202
+ slug=slug,
203
+ branch=branch,
204
+ base_ref=base,
205
+ base_commit=base_commit,
206
+ worktree=worktree,
207
+ artifacts=artifacts,
208
+ task=task,
209
+ task_source=task_source,
210
+ tag=tag,
211
+ named_by_user=bool(named),
212
+ )
213
+
214
+
215
+ def budget_spent(ctx: RunContext, next_phase: str) -> bool:
216
+ """Whether the cap is reached. Checked BETWEEN phases: nothing here can stop
217
+ an agent already running, so a single runaway invocation still overshoots."""
218
+ cap = token_cap(ctx.config)
219
+ if not cap or ctx.tokens_used < cap:
220
+ return False
221
+ print(
222
+ f"\nToken budget reached: {ctx.tokens_used:,} of {cap:,} used. "
223
+ f"Stopping before {next_phase}.",
224
+ file=sys.stderr,
225
+ )
226
+ return True
227
+
228
+
229
+ STAGES = ("architect", "worktree", "developer", "review")
230
+
231
+
232
+ # These are the completed records that skip work. The worktree is reused
233
+ # regardless, and the review loop already restarts from its first attempt.
234
+ REDOABLE_STAGES = ("architect", "developer")
235
+
236
+
237
+ def save_state(ctx: RunContext, status: str, error: str | None = None) -> None:
238
+ """Record where the run got to, so a failed stage can be resumed instead of
239
+ restarting the whole flow and leaving a second plan, branch and worktree."""
240
+ path = ctx.artifacts / "state.json"
241
+ started = json.loads(path.read_text()).get("started_at") if path.exists() else None
242
+ contents = json.dumps({
243
+ "run_id": ctx.run_id,
244
+ "task": ctx.task,
245
+ "task_source": ctx.task_source or None,
246
+ "repo": str(ctx.repo),
247
+ "base_ref": ctx.base_ref,
248
+ "base_commit": ctx.base_commit,
249
+ "branch": ctx.branch,
250
+ "worktree": str(ctx.worktree),
251
+ "stage": ctx.stage,
252
+ "status": status,
253
+ "error": error,
254
+ "completed": sorted(ctx.done),
255
+ "tokens_used": ctx.tokens_used,
256
+ "named_by_user": ctx.named_by_user,
257
+ "test_artifacts": sorted(ctx.test_artifacts),
258
+ "commit": ctx.commit or None,
259
+ "commit_error": ctx.commit_error or None,
260
+ "mode": ctx.mode,
261
+ "fanout": ctx.fanout or None,
262
+ "review": ctx.review or None,
263
+ "findings": ctx.findings or None,
264
+ "started_at": started or dt.datetime.now().isoformat(timespec="seconds"),
265
+ "updated_at": dt.datetime.now().isoformat(timespec="seconds"),
266
+ }, indent=2) + "\n"
267
+ temporary: Path | None = None
268
+ try:
269
+ with tempfile.NamedTemporaryFile(
270
+ "w",
271
+ encoding="utf-8",
272
+ dir=path.parent,
273
+ prefix=f".{path.name}.",
274
+ suffix=".tmp",
275
+ delete=False,
276
+ ) as handle:
277
+ temporary = Path(handle.name)
278
+ handle.write(contents)
279
+ handle.flush()
280
+ os.fsync(handle.fileno())
281
+ os.replace(temporary, path)
282
+ finally:
283
+ if temporary is not None:
284
+ with contextlib.suppress(FileNotFoundError):
285
+ temporary.unlink()
286
+
287
+
288
+ def enter_stage(ctx: RunContext, stage: str) -> None:
289
+ ctx.stage = stage
290
+ save_state(ctx, "running")
291
+
292
+
293
+ def complete_stage(ctx: RunContext, stage: str) -> None:
294
+ ctx.done.add(stage)
295
+ save_state(ctx, "running")
296
+
297
+
298
+ def load_run(repo: Path, run_id: str, config: dict[str, Any], use_frozen: bool) -> RunContext:
299
+ artifacts = repo / ".stargate" / "runs" / run_id
300
+ state_path = artifacts / "state.json"
301
+ if not state_path.exists():
302
+ raise StargateError(f"No run state at {state_path}")
303
+ state = json.loads(state_path.read_text())
304
+ if not isinstance(state, dict):
305
+ raise StargateError(f"Cannot load {run_id}: state.json is not a JSON object.")
306
+ # Runs created before mode was persisted predate fan-out and are linear.
307
+ # An explicitly stored invalid value is still corruption, not a migration.
308
+ mode = state["mode"] if "mode" in state else "linear"
309
+ if mode not in ("linear", "fanout"):
310
+ raise StargateError(
311
+ f"Cannot load {run_id}: state.json has no valid mode "
312
+ "(expected 'linear' or 'fanout')."
313
+ )
314
+ frozen = artifacts / "config.yaml"
315
+ if use_frozen and frozen.exists():
316
+ # Default to the run's own frozen config so resuming does not silently
317
+ # change the agents the earlier stages ran under. An explicit --config
318
+ # overrides it, which is how you resume past a bad agent definition.
319
+ config = yaml.safe_load(frozen.read_text()) or config
320
+ return RunContext(
321
+ repo=repo,
322
+ config=config,
323
+ run_id=state["run_id"],
324
+ slug=slugify(state["task"]),
325
+ branch=state["branch"],
326
+ base_ref=state["base_ref"],
327
+ base_commit=str(state.get("base_commit") or state["base_ref"]),
328
+ worktree=Path(state["worktree"]),
329
+ artifacts=artifacts,
330
+ task=state["task"],
331
+ task_source=str(state.get("task_source") or ""),
332
+ stage=state.get("stage", "init"),
333
+ done=set(state.get("completed", [])),
334
+ tokens_used=int(state.get("tokens_used", 0)),
335
+ test_artifacts=set(state.get("test_artifacts", [])),
336
+ commit=str(state.get("commit") or ""),
337
+ commit_error=str(state.get("commit_error") or ""),
338
+ mode=mode,
339
+ fanout=dict(state.get("fanout") or {}),
340
+ review=dict(state.get("review") or {}),
341
+ findings=list(state.get("findings") or []),
342
+ tag=(
343
+ match.group(1)
344
+ if (match := re.search(
345
+ r"-(\d{8}-\d{6}(?:-\d+)?)$", str(state["branch"])
346
+ )) else ""
347
+ ),
348
+ named_by_user=bool(state.get("named_by_user", False)),
349
+ )
350
+
351
+
352
+ RESUMABLE_STATUSES = ("running", "failed", "budget_exceeded")
353
+
354
+
355
+ def read_run(path: Path) -> dict[str, Any]:
356
+ """Build one listing row, including runs whose state cannot be read."""
357
+ state_path = path / "state.json"
358
+ row: dict[str, Any] = {
359
+ "run_id": path.name,
360
+ "status": "unknown",
361
+ "stage": "-",
362
+ "branch": "(unknown)",
363
+ "worktree": "(unknown)",
364
+ "worktree_missing": False,
365
+ "task": "-",
366
+ "updated": "-",
367
+ "resumable": False,
368
+ "error": "",
369
+ }
370
+ try:
371
+ state = json.loads(state_path.read_text())
372
+ if not isinstance(state, dict):
373
+ raise ValueError("state.json is not an object")
374
+ except (OSError, ValueError) as exc:
375
+ row["error"] = f"unreadable state.json ({exc})"
376
+ return row
377
+
378
+ status = " ".join(str(state.get("status") or "unknown").split())
379
+ worktree = str(state.get("worktree") or "")
380
+ missing = False
381
+ if worktree:
382
+ try:
383
+ missing = not Path(worktree).exists()
384
+ except (OSError, ValueError):
385
+ missing = True
386
+ row.update(
387
+ run_id=" ".join(str(state.get("run_id") or path.name).split()),
388
+ status=status,
389
+ stage=" ".join(str(state.get("stage") or "-").split()),
390
+ branch=" ".join(str(state.get("branch") or "(unknown)").split()),
391
+ worktree=" ".join(worktree.split()) or "(unknown)",
392
+ worktree_missing=missing,
393
+ updated=" ".join(str(state.get("updated_at") or "-").split()),
394
+ task=(" ".join(str(state.get("task") or "").split()) or "-"),
395
+ # A run that reached a verdict but could not commit -- a signing
396
+ # prompt that timed out, a hook that rejected the tree -- is exactly
397
+ # the run a resume can finish, whatever terminal status it recorded.
398
+ resumable=(
399
+ status.lower() in RESUMABLE_STATUSES
400
+ or bool(state.get("commit_error"))
401
+ ),
402
+ error="",
403
+ )
404
+ return row
405
+
406
+
407
+ def inherited_findings(repo: Path, base_ref: str) -> tuple[str, list[Any]]:
408
+ """Find the base branch's recorded run and its last completed findings.
409
+
410
+ Match the stored branch exactly: run ids and branches use different name
411
+ orders, and an architect rename or collision makes inversion unreliable.
412
+ Missing or unreadable bookkeeping must never prevent a new run, and neither
413
+ does a run whose branch never received its commit.
414
+ """
415
+ if not base_ref.startswith("stargate/"):
416
+ return "", []
417
+ root = repo / ".stargate" / "runs"
418
+ try:
419
+ paths = sorted(
420
+ (path for path in root.iterdir() if path.is_dir()),
421
+ key=lambda path: path.name,
422
+ reverse=True,
423
+ )
424
+ except OSError:
425
+ return "", []
426
+ for path in paths:
427
+ try:
428
+ state = json.loads((path / "state.json").read_text())
429
+ except (OSError, ValueError):
430
+ continue
431
+ if not isinstance(state, dict) or state.get("branch") != base_ref:
432
+ continue
433
+ if not state.get("commit"):
434
+ # The run left no terminal commit -- settings.commit was false, or
435
+ # the commit failed -- so its branch does not carry the work those
436
+ # findings describe. Inheriting them would hand the architect a
437
+ # review of a tree this run cannot see.
438
+ return "", []
439
+ findings = state.get("findings")
440
+ if not isinstance(findings, list) or not findings:
441
+ return "", []
442
+ return str(state.get("run_id") or path.name), findings
443
+ return "", []
444
+
445
+
446
+ def list_runs(repo: Path) -> int:
447
+ root = repo / ".stargate" / "runs"
448
+ # Listing a repository that has never run stargate must not create the
449
+ # bookkeeping directory it is meant only to inspect.
450
+ if not root.is_dir():
451
+ print(f"No recorded runs in {root}")
452
+ return 0
453
+
454
+ try:
455
+ paths = sorted(
456
+ (path for path in root.iterdir() if path.is_dir()),
457
+ key=lambda path: path.name,
458
+ reverse=True,
459
+ )
460
+ except OSError as exc:
461
+ raise StargateError(
462
+ f"Could not read recorded runs in {root}: {exc}"
463
+ ) from exc
464
+ if not paths:
465
+ print(f"No recorded runs in {root}")
466
+ return 0
467
+
468
+ rows = [read_run(path) for path in paths]
469
+ width = max(20, shutil.get_terminal_size().columns)
470
+ print(f"Runs in {repo} (newest first):\n")
471
+ for index, row in enumerate(rows):
472
+ if index:
473
+ print()
474
+ marker = "*" if row["resumable"] else " "
475
+ print(f"{marker} {row['run_id']} [{row['status']}]")
476
+ updated = row["updated"].replace("T", " ", 1)
477
+ print(f" updated {updated} | stage {row['stage']}")
478
+ print(
479
+ textwrap.fill(
480
+ row["task"], width=width,
481
+ initial_indent=" task ", subsequent_indent=" ",
482
+ max_lines=3, placeholder=" ...",
483
+ )
484
+ )
485
+ print(f" branch {row['branch']}")
486
+ missing = " (MISSING)" if row["worktree_missing"] else ""
487
+ print(f" worktree {row['worktree']}{missing}")
488
+ if row["error"]:
489
+ print(f" {row['error']}")
490
+
491
+ newest = next((row for row in rows if row["resumable"]), None)
492
+ if newest:
493
+ print(
494
+ "\n* resumable. Resume the newest with: "
495
+ f"stargate resume {newest['run_id']}"
496
+ )
497
+ else:
498
+ print("\nNo runs are marked resumable.")
499
+ return 0
500
+
501
+
502
+ def snapshot(ctx: RunContext, dirs: list[Path]) -> list[Path]:
503
+ """Copy the effective config and all four prompts into the run's artifacts,
504
+ and use those copies for the rest of the run.
505
+
506
+ A run outlives its own installation: upgrading or reinstalling the package
507
+ mid-run otherwise deletes the prompts out from under the next role. It also
508
+ makes the run reproducible -- the artifacts say exactly what was used.
509
+ """
510
+ (ctx.artifacts / "config.yaml").write_text(yaml.safe_dump(ctx.config, sort_keys=False))
511
+ frozen = ctx.artifacts / "prompts"
512
+ frozen.mkdir(exist_ok=True)
513
+ for name in PROMPTS:
514
+ (frozen / f"{name}.md").write_text(find_prompt(dirs, name).read_text())
515
+ return [frozen]
516
+
517
+
518
+ def _registered_worktrees(
519
+ repo: Path,
520
+ ) -> tuple[dict[Path, str | None], set[Path]]:
521
+ """Return registered worktree branches and paths locked against removal."""
522
+ raw = git_quiet(repo, "worktree", "list", "--porcelain", "-z")
523
+ registrations: dict[Path, str | None] = {}
524
+ locked: set[Path] = set()
525
+ for record in raw.split("\0\0"):
526
+ fields = [field for field in record.split("\0") if field]
527
+ path_value = next(
528
+ (field.removeprefix("worktree ") for field in fields
529
+ if field.startswith("worktree ")),
530
+ None,
531
+ )
532
+ if path_value is None:
533
+ continue
534
+ branch = next(
535
+ (field.removeprefix("branch refs/heads/") for field in fields
536
+ if field.startswith("branch refs/heads/")),
537
+ None,
538
+ )
539
+ path = Path(path_value).resolve()
540
+ registrations[path] = branch
541
+ if any(field == "locked" or field.startswith("locked ") for field in fields):
542
+ locked.add(path)
543
+ return registrations, locked
544
+
545
+
546
+ def create_worktree(
547
+ ctx: RunContext,
548
+ *,
549
+ recover_recorded_fanout_branch: bool = False,
550
+ ) -> None:
551
+ worktree = ctx.worktree.resolve()
552
+ registrations, _ = _registered_worktrees(ctx.repo)
553
+ registered = worktree in registrations
554
+ registered_branch = registrations.get(worktree)
555
+ if ctx.worktree.exists():
556
+ if not registered:
557
+ raise StargateError(
558
+ f"Worktree path {ctx.worktree} exists but is not a registered "
559
+ f"Git worktree for {ctx.repo}; refusing to reuse it."
560
+ )
561
+ if registered_branch != ctx.branch:
562
+ raise StargateError(
563
+ f"Worktree path {ctx.worktree} is registered on branch "
564
+ f"{registered_branch!r}, not expected branch {ctx.branch!r}; "
565
+ "refusing to reuse it."
566
+ )
567
+ print(f"Reusing existing worktree: {ctx.worktree}")
568
+ return
569
+ if registered:
570
+ raise StargateError(
571
+ f"Worktree path {ctx.worktree} is registered on branch "
572
+ f"{registered_branch!r}, but the path is missing; refusing to recreate it."
573
+ )
574
+ branch_worktree = next(
575
+ (path for path, branch in registrations.items() if branch == ctx.branch),
576
+ None,
577
+ )
578
+ if branch_worktree is not None:
579
+ raise StargateError(
580
+ f"Branch {ctx.branch!r} is already registered at {branch_worktree}, "
581
+ f"not the expected worktree {ctx.worktree}."
582
+ )
583
+ if ctx.mode.startswith("fanout"):
584
+ exists = branch_exists(ctx.repo, ctx.branch)
585
+ if exists:
586
+ state_path = ctx.artifacts / "state.json"
587
+ try:
588
+ recorded = json.loads(state_path.read_text())
589
+ except (OSError, ValueError):
590
+ recorded = None
591
+ recorded_worktree = (
592
+ recorded.get("worktree") if isinstance(recorded, dict) else None
593
+ )
594
+ # The run's own state.json is the ownership record: a branch this
595
+ # run registered on this path stays adoptable after `git worktree
596
+ # prune` reclaims the directory, for the integration worktree as
597
+ # much as for a task's.
598
+ saved_run_matches = isinstance(recorded, dict) and (
599
+ recorded.get("mode") == ctx.mode
600
+ and recorded.get("run_id") == ctx.run_id
601
+ and recorded.get("branch") == ctx.branch
602
+ and isinstance(recorded_worktree, str)
603
+ and Path(recorded_worktree).resolve() == worktree
604
+ )
605
+ branch_is_recorded = saved_run_matches or (
606
+ ctx.mode == "fanout-task" and recover_recorded_fanout_branch
607
+ )
608
+ if not branch_is_recorded:
609
+ raise StargateError(
610
+ f"Branch {ctx.branch!r} already exists without the expected registered "
611
+ "worktree; refusing to adopt it."
612
+ )
613
+ else:
614
+ exists = (
615
+ git(ctx.repo, "rev-parse", "--verify", ctx.branch, check=False).returncode == 0
616
+ )
617
+ args = ["worktree", "add"] + (
618
+ [str(ctx.worktree), ctx.branch]
619
+ if exists
620
+ else ["-b", ctx.branch, str(ctx.worktree), ctx.base_commit]
621
+ )
622
+ git(ctx.repo, *args, capture=True)
623
+
624
+
625
+ def clean_run(repo: Path, run_id: str, *, validate_only: bool = False) -> None:
626
+ root = repo / ".stargate" / "runs"
627
+ if not run_id or Path(run_id).name != run_id or run_id in (".", ".."):
628
+ raise StargateError(f"Invalid run ID: {run_id!r}")
629
+ artifacts = root / run_id
630
+ if artifacts.is_symlink() or not artifacts.is_dir():
631
+ raise StargateError(f"No recorded run {run_id!r} in {root}")
632
+ try:
633
+ state = json.loads((artifacts / "state.json").read_text())
634
+ except (OSError, ValueError) as exc:
635
+ raise StargateError(f"Cannot clean {run_id}: unreadable state.json ({exc})") from exc
636
+ if not isinstance(state, dict) or state.get("run_id") != run_id:
637
+ raise StargateError(f"Cannot clean {run_id}: state.json has a different run ID")
638
+ recorded_repo = state.get("repo")
639
+ if not isinstance(recorded_repo, str) or Path(recorded_repo).resolve() != repo:
640
+ raise StargateError(f"Cannot clean {run_id}: state.json belongs to another repository")
641
+ branch = state.get("branch")
642
+ worktree_value = state.get("worktree")
643
+ if not isinstance(branch, str) or not branch.startswith("stargate/"):
644
+ raise StargateError(f"Cannot clean {run_id}: invalid Stargate branch")
645
+ if not isinstance(worktree_value, str) or not worktree_value:
646
+ raise StargateError(f"Cannot clean {run_id}: invalid worktree path")
647
+ worktree = Path(worktree_value).resolve()
648
+ targets = [(branch, worktree)]
649
+ fanout = state.get("fanout") if state.get("mode") == "fanout" else None
650
+ if fanout:
651
+ records = fanout.get("tasks") if isinstance(fanout, dict) else None
652
+ if not isinstance(records, dict):
653
+ raise StargateError(f"Cannot clean {run_id}: invalid fan-out task state")
654
+ for task_id, record in records.items():
655
+ task_branch = record.get("branch") if isinstance(record, dict) else None
656
+ task_worktree = record.get("worktree") if isinstance(record, dict) else None
657
+ expected_branch = f"{branch}-{task_id}"
658
+ if (
659
+ not isinstance(task_id, str)
660
+ or not isinstance(task_branch, str)
661
+ or not isinstance(task_worktree, str)
662
+ or not task_worktree
663
+ ):
664
+ raise StargateError(
665
+ f"Cannot clean {run_id}: invalid fan-out task target {task_id!r}"
666
+ )
667
+ task_path = Path(task_worktree).resolve()
668
+ branch_matches = task_branch == expected_branch or re.fullmatch(
669
+ rf"{re.escape(expected_branch)}-(?:[2-9]|[1-9][0-9]+)",
670
+ task_branch,
671
+ )
672
+ expected_path = (worktree.parent / f"{run_id}-{task_id}").resolve()
673
+ if not branch_matches or task_path != expected_path:
674
+ raise StargateError(
675
+ f"Cannot clean {run_id}: fan-out task {task_id!r} does not match "
676
+ "its recorded branch/worktree naming"
677
+ )
678
+ targets.append((task_branch, task_path))
679
+
680
+ registrations, locked = _registered_worktrees(repo)
681
+ present = [
682
+ (target_branch, target_worktree, branch_exists(repo, target_branch),
683
+ target_worktree.exists(), target_worktree in registrations)
684
+ for target_branch, target_worktree in targets
685
+ ]
686
+ if not any(branch_present or worktree_present or registered
687
+ for _, _, branch_present, worktree_present, registered in present):
688
+ if validate_only:
689
+ return
690
+ shutil.rmtree(artifacts)
691
+ print(
692
+ f"Cleaned {run_id}: artifacts removed; worktrees and branches were absent."
693
+ )
694
+ return
695
+
696
+ # Validate every target before removing the first one. A dirty sibling must
697
+ # not turn a fan-out cleanup into a partially destructive operation.
698
+ for (
699
+ target_branch,
700
+ target_worktree,
701
+ branch_present,
702
+ worktree_present,
703
+ registered,
704
+ ) in present:
705
+ registered_branch = registrations.get(target_worktree)
706
+ if worktree_present and not registered:
707
+ raise StargateError(
708
+ f"Cannot clean {run_id}: {target_worktree} is not a registered "
709
+ "Git worktree"
710
+ )
711
+ if registered and registered_branch != target_branch:
712
+ raise StargateError(
713
+ f"Cannot clean {run_id}: {target_worktree} is registered on branch "
714
+ f"{registered_branch!r}, not {target_branch!r}"
715
+ )
716
+ if registered and target_worktree in locked:
717
+ raise StargateError(
718
+ f"Cannot clean {run_id}: worktree {target_worktree} is locked"
719
+ )
720
+ other_path = next(
721
+ (
722
+ path for path, checked_out in registrations.items()
723
+ if checked_out == target_branch and path != target_worktree
724
+ ),
725
+ None,
726
+ )
727
+ if other_path is not None:
728
+ raise StargateError(
729
+ f"Cannot clean {run_id}: branch {target_branch!r} is registered "
730
+ f"at {other_path}, not {target_worktree}"
731
+ )
732
+ if worktree_present:
733
+ if git_quiet(target_worktree, "status", "--porcelain"):
734
+ raise StargateError(
735
+ f"Cannot clean {run_id}: worktree {target_worktree} is dirty"
736
+ )
737
+ if not branch_present:
738
+ continue
739
+ merged = subprocess.run(
740
+ [
741
+ "git", "merge-base", "--is-ancestor",
742
+ f"refs/heads/{target_branch}", "HEAD",
743
+ ],
744
+ cwd=str(repo), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
745
+ )
746
+ if merged.returncode == 1:
747
+ raise StargateError(
748
+ f"Cannot clean {run_id}: branch {target_branch!r} is not merged into HEAD"
749
+ )
750
+ if merged.returncode != 0:
751
+ raise StargateError(
752
+ f"Cannot determine whether branch {target_branch!r} is merged"
753
+ )
754
+
755
+ if validate_only:
756
+ return
757
+ for _, target_worktree, _, worktree_present, registered in reversed(present):
758
+ if worktree_present:
759
+ git(repo, "worktree", "remove", str(target_worktree))
760
+ elif registered:
761
+ # Remove only this stale registration. A global prune could also
762
+ # discard an unrelated user's temporarily missing worktree.
763
+ git(repo, "worktree", "remove", "--force", str(target_worktree))
764
+ for target_branch, _, branch_present, _, _ in reversed(present):
765
+ if branch_present:
766
+ git(repo, "branch", "-d", "--", target_branch)
767
+ shutil.rmtree(artifacts)
768
+ print(f"Cleaned {run_id}: worktrees, branches and artifacts removed.")
769
+
770
+
771
+ def clean_runs(repo: Path, run_id: str | None, all_runs: bool) -> int:
772
+ if all_runs == (run_id is not None):
773
+ raise StargateError("Use either 'stargate clean <run-id>' or 'stargate clean --all'.")
774
+ if run_id is not None:
775
+ clean_run(repo, run_id)
776
+ return 0
777
+
778
+ root = repo / ".stargate" / "runs"
779
+ if not root.is_dir():
780
+ print(f"No recorded runs in {root}")
781
+ return 0
782
+ run_ids = sorted(
783
+ (path.name for path in root.iterdir() if path.is_dir()), reverse=True
784
+ )
785
+ if not run_ids:
786
+ print(f"No recorded runs in {root}")
787
+ return 0
788
+ # Atomicity is per run: a run is never partially destroyed, but one run
789
+ # that fails its checks -- an in-progress run is the common case -- must
790
+ # not hold every unrelated finished run hostage.
791
+ failures: list[tuple[str, str]] = []
792
+ cleanable: list[str] = []
793
+ for candidate in run_ids:
794
+ try:
795
+ clean_run(repo, candidate, validate_only=True)
796
+ except StargateError as exc:
797
+ failures.append((candidate, str(exc)))
798
+ else:
799
+ cleanable.append(candidate)
800
+ for candidate in cleanable:
801
+ try:
802
+ clean_run(repo, candidate)
803
+ except StargateError as exc:
804
+ # Re-checked at removal time, so state that changed since the
805
+ # preflight is reported like any other skipped run.
806
+ failures.append((candidate, str(exc)))
807
+ if failures:
808
+ for candidate, error in sorted(failures):
809
+ print(f" {candidate}: {error}", file=sys.stderr)
810
+ print(
811
+ f"Skipped {len(failures)} of {len(run_ids)} runs that failed "
812
+ "safety checks; the remaining runs were cleaned.",
813
+ file=sys.stderr,
814
+ )
815
+ return 1
816
+ return 0
817
+
818
+
819
+ def worktree_fingerprint(ctx: RunContext) -> str:
820
+ """Digest the tracked and untracked state that an agent can change."""
821
+ parts = [git_quiet(ctx.worktree, "diff", ctx.base_commit)]
822
+ untracked = git_quiet(
823
+ ctx.worktree, "ls-files", "--others", "--exclude-standard", "-z"
824
+ )
825
+ for name in untracked.split("\0"):
826
+ if not name:
827
+ continue
828
+ # New files are implementation work too. Metadata errs toward allowing
829
+ # a review rather than hashing an arbitrarily large untracked artifact.
830
+ with contextlib.suppress(OSError):
831
+ info = (ctx.worktree / name).lstat()
832
+ parts.append(f"{name}\t{info.st_size}\t{info.st_mtime_ns}")
833
+ return hashlib.sha256("\n".join(parts).encode()).hexdigest()
834
+
835
+
836
+ def untracked_entries(worktree: Path) -> set[str]:
837
+ """Return untracked paths at Git's directory-grouped granularity."""
838
+ out = git_quiet(
839
+ worktree,
840
+ "ls-files",
841
+ "--others",
842
+ "--exclude-standard",
843
+ "--directory",
844
+ "--no-empty-directory",
845
+ "-z",
846
+ )
847
+ return {name for name in out.split("\0") if name}