autoforge-cli 0.1.0a0__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.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1486 @@
1
+ """What the ``autoforge epic`` commands do besides running the scheduler (ADR 0008; #183).
2
+
3
+ ``epic run`` and ``epic resume`` run :mod:`autoforge.scheduler`; the CLI
4
+ wires them. This module holds the rest, so the CLI only parses and prints:
5
+
6
+ * **``epic status``** (:func:`collect_status`): lock-free and read-only. It
7
+ never probes L0, takes no lock and creates nothing. GitHub gives the
8
+ snapshot (#179's four reads); each child's namespace is read strictly
9
+ without a lock (:func:`~autoforge.scheduler_state.read_namespace`),
10
+ classified for the EPIC (:func:`~autoforge.scheduler_state.classify_for_epic`)
11
+ and probed (:func:`~autoforge.locking.issue_run_lock_live`, D8.3). Each
12
+ child gets exactly one :class:`Category` (:func:`categorize`, pure) from
13
+ the snapshot, the claim, the state and the probe, never from a log, a
14
+ timestamp or a PID. A claim's worker PID and process start time are
15
+ shown as diagnostics only: nothing is ever signalled.
16
+ * **``epic run --dry-run``** (:func:`dry_run_plan`, D9.8): the snapshot,
17
+ the ordered verdicts and the admission plan, from the same lock-free
18
+ reads; no lock, namespace, claim, worktree or worker.
19
+ * **``epic stop``** (:func:`stop_scheduler`, D16.4): #182's
20
+ :func:`~autoforge.scheduler_stop.request_stop`, reported.
21
+ * **``epic archive``** (:func:`archive_issue`, D17.4): under the caller's L0
22
+ and its own L2, the claim closed first and the state moved aside second;
23
+ the logs, the worktree, the binding and the claim history are kept.
24
+ * **``epic run``'s refusal and ``--force``** (:func:`epic_run_guard`), run by
25
+ the scheduler holding L0 before it writes anything, and the end-of-run
26
+ check that no claim was left open (:func:`claims_left_open`).
27
+ * **The checkout's EPIC** (:func:`checkout_epic`): every ``epic`` command
28
+ first refuses an EPIC of another repository than this checkout's
29
+ ``origin``, before anything is read or written here. ``epic stop`` and
30
+ ``epic archive`` also refuse a ``scheduler.json`` or a binding recorded
31
+ for another repository, by its numeric id read from GitHub, so a rename
32
+ is no refusal and a name taken by another repository is one
33
+ (:func:`check_recorded_repository`); ``epic status`` reports either as a
34
+ problem.
35
+
36
+ Nothing here names a provider, writes GitHub, records an effect or parses an
37
+ agent's result.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import shlex
43
+ from collections.abc import Callable, Iterable
44
+ from dataclasses import dataclass
45
+ from enum import Enum
46
+ from pathlib import Path
47
+
48
+ from .epic_graph import (
49
+ ChildVerdict,
50
+ EpicSnapshot,
51
+ GraphReader,
52
+ LocalFact,
53
+ Shape,
54
+ VerdictKind,
55
+ build_epic_snapshot,
56
+ evaluate,
57
+ ready_set,
58
+ )
59
+ from .errors import (
60
+ ConfigurationError,
61
+ ExecutionError,
62
+ GitHubError,
63
+ IssueBusy,
64
+ LockError,
65
+ NamespaceMismatch,
66
+ OpenSchedulerClaimsError,
67
+ SchedulerStateError,
68
+ SnapshotRefused,
69
+ StateError,
70
+ )
71
+ from .executor import ExecutionRequest, ExecutionResult, execute
72
+ from .git_transport import local_git_request
73
+ from .locking import IssueRunLock, issue_run_lock_live, repository_lock_path
74
+ from .ownership import StandaloneHold, standalone_hold
75
+ from .redaction import redact, redact_dict
76
+ from .run_namespace import (
77
+ EpicRef,
78
+ IssueNamespace,
79
+ NamespaceLookup,
80
+ RejectedEntry,
81
+ RunBinding,
82
+ StateStatus,
83
+ archive_issue_state,
84
+ issue_namespace,
85
+ list_issue_namespaces,
86
+ load_issue_namespace,
87
+ )
88
+ from .scheduler import SPAWN_FAILURE_DETAILS
89
+ from .scheduler_state import (
90
+ ArchiveAction,
91
+ Claim,
92
+ ClaimArchive,
93
+ ClaimClassification,
94
+ ClassificationKind,
95
+ ClosedClaim,
96
+ EpicClassification,
97
+ ExpectedIdentity,
98
+ NamespaceRead,
99
+ OpenClaim,
100
+ SchedulerState,
101
+ archive_claim,
102
+ classify_for_epic,
103
+ load_scheduler_state,
104
+ open_claims_for_epic,
105
+ quarantine_scheduler_state,
106
+ read_namespace,
107
+ )
108
+ from .scheduler_stop import StopMode, StopRead, read_stop_request, request_stop
109
+ from .state import AutoForgeState
110
+ from .transitions import Phase, Settlement
111
+ from .validation import GitHubIssueRef, parse_issue_url, parse_remote_repository
112
+
113
+ STATUS_SCHEMA = "autoforge.epic-status/1"
114
+ # ``epic run`` and ``epic resume`` end with claims of the EPIC still open: an
115
+ # uncertain namespace's, or one whose run lock a process the scheduler did not
116
+ # start held to the end. Exit 1 is the scheduler's global uncertainty
117
+ # (``degraded``); 2 a held lock or a refusal; 130 a stop or an interrupt.
118
+ EXIT_OPEN_CLAIMS = 3
119
+ # A BLOCKED state's reason, as status quotes it, and a whole row's detail.
120
+ MAX_BLOCK_REASON_CHARS = 500
121
+ MAX_DETAIL_CHARS = 2000
122
+ # What the operator's ``unblock --reason`` is shown as in a printed command.
123
+ REASON_PLACEHOLDER = "<why>"
124
+
125
+ DRAIN_HELP = (
126
+ "a drain cannot be escalated by another 'epic stop': press Ctrl-C in the scheduler's "
127
+ "terminal to interrupt its workers"
128
+ )
129
+
130
+ Runner = Callable[[ExecutionRequest], ExecutionResult]
131
+
132
+
133
+ class Category(Enum):
134
+ """One child's ``epic status`` category: exactly one per child (#183)."""
135
+
136
+ #: Open, with a blocker not closed as completed.
137
+ BLOCKED_BY_DEPENDENCY = "blocked-by-dependency"
138
+ #: Open, unblocked, and nothing local holds it: the next admission takes it.
139
+ READY = "ready"
140
+ #: A claim is open and no live process holds the run lock: ``epic resume``.
141
+ CLAIMED = "claimed"
142
+ #: A live process holds the issue's run lock.
143
+ RUNNING = "running"
144
+ #: The state is at ``READY_FOR_MERGE``: a human merges.
145
+ READY_FOR_MERGE = "ready-for-merge"
146
+ #: The state is ``BLOCKED``: ``unblock`` on the namespace, then ``epic resume``.
147
+ BLOCKED = "blocked"
148
+ #: An in-progress state, no claim open: the next admission resumes it.
149
+ RESUMABLE = "resumable"
150
+ #: The namespace cannot be trusted: ``epic archive`` or a fix by hand.
151
+ UNCERTAIN = "uncertain"
152
+ #: ``DONE``, ``FAILED``, a hand-off phase or a pause: no more automatic work.
153
+ SETTLED = "settled"
154
+ #: Not admitted for a reason of its own (:class:`HoldReason`).
155
+ HELD = "held"
156
+ #: A child the scheduler never admits (another repository, a nested EPIC).
157
+ UNSUPPORTED = "unsupported"
158
+ #: Closed on GitHub.
159
+ CLOSED = "closed"
160
+
161
+
162
+ class HoldReason(Enum):
163
+ STANDALONE_STATE = "standalone-state"
164
+ OTHER_EPIC = "other-epic"
165
+ SPAWN_FAILED = "spawn-failed"
166
+
167
+
168
+ def command(flags: Iterable[str], *words: str) -> str:
169
+ """An ``autoforge`` command line as the operator types it, ``--config`` included."""
170
+ return shlex.join(["autoforge", *flags, *words])
171
+
172
+
173
+ def _bounded(text: str, limit: int) -> str:
174
+ text = redact(text)
175
+ if len(text) <= limit:
176
+ return text
177
+ marker = " [... truncated]"
178
+ return text[: limit - len(marker)] + marker
179
+
180
+
181
+ def coordination_root_of(repository: str | Path) -> Path:
182
+ """The git common dir L0 is keyed by: the scheduler's coordination root (D5.3)."""
183
+ return repository_lock_path(repository).parent.parent
184
+
185
+
186
+ def checkout_repository(workdir: str | Path, runner: Runner = execute) -> str:
187
+ """``owner/repo`` of the checkout containing ``workdir``: its ``origin`` remote.
188
+
189
+ Read locally with the controller's hardened git, as ``doctor`` reads it,
190
+ so ``epic stop`` and ``epic archive`` check it too without a GitHub
191
+ read. No ``origin``, or one that is not a github.com repository, raises
192
+ :class:`~autoforge.errors.ConfigurationError`: there is then nothing to
193
+ bind an EPIC to.
194
+ """
195
+ try:
196
+ res = runner(local_git_request(["remote", "get-url", "origin"], cwd=str(workdir)))
197
+ except ExecutionError as exc:
198
+ raise ConfigurationError(
199
+ f"cannot read this checkout's 'origin' remote: git could not be run ({exc})"
200
+ ) from exc
201
+ if res.timed_out or res.truncated or res.exit_code != 0:
202
+ detail = (res.stderr or res.stdout).strip().splitlines()
203
+ shown = detail[0] if detail else f"git remote get-url exited {res.exit_code}"
204
+ raise ConfigurationError(
205
+ "an 'epic' command runs on an EPIC of this checkout's own repository, and its "
206
+ f"'origin' remote cannot be read ({_bounded(shown, 500)})"
207
+ )
208
+ try:
209
+ return parse_remote_repository(res.stdout)
210
+ except ConfigurationError as exc:
211
+ raise ConfigurationError(
212
+ "an 'epic' command runs on an EPIC of this checkout's own repository, and its "
213
+ f"'origin' remote does not name one: {_bounded(str(exc), 500)}"
214
+ ) from exc
215
+
216
+
217
+ def checkout_epic(workdir: str | Path, epic_url: str, runner: Runner = execute) -> GitHubIssueRef:
218
+ """The EPIC, refused unless it belongs to this checkout's repository (#183).
219
+
220
+ Every ``epic`` command calls it first, before it takes a lock or reads
221
+ the coordination root or GitHub. That root is this checkout's, and
222
+ ``scheduler.json``, the stop request and every namespace are found by
223
+ EPIC and issue number: another repository's EPIC would otherwise write
224
+ its bindings and claims here (global uncertainty for every later
225
+ scheduler), or stop or archive this checkout's EPIC of the same number.
226
+ The names are compared case-insensitively, as GitHub does.
227
+ """
228
+ epic = parse_issue_url(epic_url)
229
+ home = checkout_repository(workdir, runner)
230
+ if home.lower() != epic.repository.lower():
231
+ raise ConfigurationError(
232
+ f"{epic.canonical} is an EPIC of {epic.repository}, and this checkout's 'origin' "
233
+ f"is {home}: an 'epic' command runs only on an EPIC of the checkout's own "
234
+ "repository; nothing was written"
235
+ )
236
+ return epic
237
+
238
+
239
+ def check_recorded_repository(
240
+ subject: str,
241
+ recorded: str,
242
+ recorded_id: int,
243
+ epic: GitHubIssueRef,
244
+ repository_id: Callable[[str], int],
245
+ *,
246
+ refused: str,
247
+ ) -> None:
248
+ """Refuse a file recorded for another repository than the EPIC's (D5.1).
249
+
250
+ ``recorded`` is the file's ``owner/name``, display only: a rename leaves
251
+ the old name in ``scheduler.json`` until the next scheduler start, and
252
+ in a binding for good, while a repository transferred or deleted leaves
253
+ its name free for another one. So a name proves nothing either way, and
254
+ only the numeric repository id tells the same repository from another:
255
+ ``repository_id`` reads it from GitHub for the EPIC's repository, every
256
+ time, and the recorded id must be it. Another id, or no answer, raises
257
+ :class:`~autoforge.errors.SchedulerStateError`; the caller then writes
258
+ nothing.
259
+ """
260
+ if recorded.lower() == epic.repository.lower():
261
+ shown = recorded
262
+ what = "another repository that took the name since"
263
+ else:
264
+ shown = f"{recorded}, not {epic.repository},"
265
+ what = "a rename from another repository"
266
+ try:
267
+ live = repository_id(epic.repository)
268
+ except (GitHubError, ConfigurationError, ExecutionError) as exc:
269
+ raise SchedulerStateError(
270
+ f"{subject} {shown} and the repository id that tells {what} cannot be read from "
271
+ f"GitHub ({_bounded(str(exc), 500)}): {refused}"
272
+ ) from exc
273
+ if live != recorded_id:
274
+ raise SchedulerStateError(
275
+ f"{subject} {recorded} (repository id {recorded_id}), and {epic.repository} is "
276
+ f"repository id {live}: {refused}"
277
+ )
278
+
279
+
280
+ # -- one child's category (pure) --------------------------------------------------------
281
+
282
+
283
+ @dataclass(frozen=True)
284
+ class IssueFacts:
285
+ """What status read about one issue; :func:`categorize` decides from these alone.
286
+
287
+ ``verdict`` is the graph's own verdict (no local facts), ``None`` for a
288
+ namespace the snapshot does not list or when no snapshot was taken.
289
+ ``read`` is the namespace read lock-free, ``None`` when there is none;
290
+ ``found`` its classification for the EPIC. ``probe_problem`` is set when
291
+ the run lock could not be probed. ``standalone`` is the configured
292
+ standalone state when it holds this issue; ``spawn_failed`` this
293
+ invocation's claim of the issue closed ``abandoned`` because its worker
294
+ never started. ``unidentified`` says why the namespace could not be
295
+ classified (neither GitHub nor ``scheduler.json`` gave the identities).
296
+ """
297
+
298
+ number: int
299
+ verdict: ChildVerdict | None = None
300
+ read: NamespaceRead | None = None
301
+ found: EpicClassification | None = None
302
+ live: bool = False
303
+ probe_problem: str = ""
304
+ standalone: StandaloneHold | None = None
305
+ spawn_failed: ClosedClaim | None = None
306
+ unidentified: str = ""
307
+
308
+ @property
309
+ def lookup(self) -> NamespaceLookup | None:
310
+ read = self.read
311
+ return (
312
+ read.lookup if read is not None and isinstance(read.lookup, NamespaceLookup) else None
313
+ )
314
+
315
+ @property
316
+ def state(self) -> AutoForgeState | None:
317
+ lookup = self.lookup
318
+ if lookup is None or lookup.state.status is not StateStatus.LOADED:
319
+ return None
320
+ return lookup.state.state
321
+
322
+ @property
323
+ def open_claim(self) -> Claim | None:
324
+ """The open claim on disk, as the classification read it, else the claim file's."""
325
+ found = self.found
326
+ if found is not None and found.classification is not None:
327
+ if found.classification.open_claim is not None:
328
+ return found.classification.open_claim
329
+ return None if self.read is None else self.read.claim.open_claim
330
+
331
+
332
+ @dataclass(frozen=True)
333
+ class IssueStatus:
334
+ """One row of ``epic status``.
335
+
336
+ ``category`` is ``None`` only for a namespace outside the snapshot that
337
+ holds nothing (no claim, no state, no live owner). ``next`` are the
338
+ commands to run, in order. ``problem`` is set for an ``uncertain`` row
339
+ only (a namespace that cannot be read, classified or probed): it is one
340
+ of the report's problems, never a key of the row's document.
341
+ """
342
+
343
+ number: int
344
+ category: Category | None
345
+ detail: str
346
+ hold_reason: HoldReason | None = None
347
+ verdict: str | None = None
348
+ blockers: tuple[str, ...] = ()
349
+ phase: str | None = None
350
+ settlement: str | None = None
351
+ classification: str | None = None
352
+ pr_url: str | None = None
353
+ state_dir: str | None = None
354
+ live: bool = False
355
+ orphaned: bool = False
356
+ claim: Claim | None = None
357
+ next: tuple[str, ...] = ()
358
+ problem: str = ""
359
+
360
+ def to_dict(self) -> dict:
361
+ claim = self.claim
362
+ return {
363
+ "number": self.number,
364
+ "category": None if self.category is None else self.category.value,
365
+ "hold_reason": None if self.hold_reason is None else self.hold_reason.value,
366
+ "detail": self.detail,
367
+ "verdict": self.verdict,
368
+ "blockers": list(self.blockers),
369
+ "phase": self.phase,
370
+ "settlement": self.settlement,
371
+ "classification": self.classification,
372
+ "pr_url": self.pr_url,
373
+ "state_dir": self.state_dir,
374
+ "live": self.live,
375
+ "orphaned": self.orphaned,
376
+ "claim": None if claim is None else _claim_dict(claim),
377
+ "next": list(self.next),
378
+ }
379
+
380
+
381
+ def _claim_dict(claim: Claim) -> dict:
382
+ worker = claim.worker
383
+ return {
384
+ "claim_id": claim.claim_id,
385
+ "generation": claim.generation,
386
+ "kind": claim.kind.value,
387
+ "invocation_id": claim.invocation_id,
388
+ "created_at": claim.created_at,
389
+ "worker": None if worker is None else worker.to_dict(),
390
+ }
391
+
392
+
393
+ def _claim_line(claim: Claim) -> str:
394
+ return (
395
+ f"claim generation {claim.generation} ({claim.kind.value}) of invocation "
396
+ f"{claim.invocation_id}, opened {claim.created_at}"
397
+ )
398
+
399
+
400
+ def worker_diagnostics(claim: Claim) -> str:
401
+ """A claim's worker as an operator recognises it: diagnostics, never authority (D7.3)."""
402
+ worker = claim.worker
403
+ if worker is None:
404
+ return "no worker recorded"
405
+ started = "unknown" if worker.process_start_time is None else str(worker.process_start_time)
406
+ return (
407
+ f"worker diagnostics (never signalled; a PID may be reused): pid {worker.pid}, "
408
+ f"process start time {started}, spawned {worker.spawned_at}, log {worker.log}"
409
+ )
410
+
411
+
412
+ def categorize(
413
+ facts: IssueFacts, *, epic_url: str, flags: tuple[str, ...] = (), orphaned: bool = False
414
+ ) -> IssueStatus:
415
+ """The one :class:`Category` of an issue and what to do about it; pure.
416
+
417
+ First match wins: a probe that failed (uncertain); a live run lock
418
+ (running); an uncertain namespace; a namespace of another EPIC (held);
419
+ an open claim (claimed); ``DONE``, ``FAILED``, a hand-off phase or a
420
+ pause (settled); closed and unsupported on GitHub; ``READY_FOR_MERGE``;
421
+ ``BLOCKED``; an unsatisfied blocker (blocked by dependency); the
422
+ configured standalone state's issue and a worker that never started
423
+ this invocation (held); an in-progress state (resumable); ready.
424
+ """
425
+ found = facts.found
426
+ classification = found.classification if found is not None else None
427
+ state = facts.state
428
+ namespace = None if facts.read is None else facts.read.namespace
429
+ verdict = facts.verdict
430
+ claim = facts.open_claim
431
+ resume = command(flags, "epic", "resume", epic_url)
432
+ archive = command(flags, "epic", "archive", epic_url, str(facts.number))
433
+
434
+ def row(
435
+ category: Category | None,
436
+ detail: str,
437
+ *,
438
+ hold_reason: HoldReason | None = None,
439
+ blockers: tuple[str, ...] = (),
440
+ steps: tuple[str, ...] = (),
441
+ problem: str = "",
442
+ ) -> IssueStatus:
443
+ return IssueStatus(
444
+ number=facts.number,
445
+ category=category,
446
+ detail=_bounded(detail, MAX_DETAIL_CHARS),
447
+ hold_reason=hold_reason,
448
+ verdict=None if verdict is None else verdict.reason,
449
+ blockers=blockers,
450
+ phase=None if state is None else state.phase.value,
451
+ settlement=(
452
+ None
453
+ if classification is None or classification.settlement is None
454
+ else classification.settlement.value
455
+ ),
456
+ classification=None if classification is None else str(classification),
457
+ pr_url=(state.current_pr_url or None) if state is not None else None,
458
+ state_dir=None if namespace is None else str(namespace.state_dir),
459
+ live=facts.live,
460
+ orphaned=orphaned,
461
+ claim=claim,
462
+ next=steps,
463
+ problem=_bounded(problem, MAX_DETAIL_CHARS),
464
+ )
465
+
466
+ if facts.probe_problem:
467
+ return row(
468
+ Category.UNCERTAIN,
469
+ f"its run lock cannot be probed ({facts.probe_problem}); repair the namespace by "
470
+ "hand or archive it",
471
+ steps=(archive, resume),
472
+ problem=f"its run lock cannot be probed ({facts.probe_problem})",
473
+ )
474
+ if facts.live:
475
+ if claim is not None:
476
+ detail = f"a live process holds its run lock; {_claim_line(claim)}"
477
+ else:
478
+ detail = (
479
+ "a live process holds its run lock and no claim is open (an admission in "
480
+ "progress, or a process the scheduler did not start)"
481
+ )
482
+ return row(Category.RUNNING, detail)
483
+ if facts.unidentified:
484
+ return row(Category.UNCERTAIN, facts.unidentified, problem=facts.unidentified)
485
+ if classification is not None and classification.kind is ClassificationKind.UNCERTAIN:
486
+ return row(
487
+ Category.UNCERTAIN,
488
+ f"{classification}: {classification.detail}; 'epic archive' moves the claim "
489
+ "and the state aside (keeping the logs, the worktree and the binding), or repair "
490
+ "the files by hand",
491
+ steps=(archive, resume),
492
+ problem=f"{classification}: {classification.detail}",
493
+ )
494
+ if found is not None and found.fact is LocalFact.HELD_OTHER_EPIC:
495
+ return row(
496
+ Category.HELD,
497
+ f"{found.detail}: that EPIC's scheduler runs it",
498
+ hold_reason=HoldReason.OTHER_EPIC,
499
+ )
500
+ if claim is not None:
501
+ phase = "no state yet" if state is None else f"the state is at {state.phase.value}"
502
+ return row(
503
+ Category.CLAIMED,
504
+ f"{_claim_line(claim)} is open and no live process holds its run lock ({phase}): "
505
+ "its scheduler ended without closing it; 'epic resume' classifies it",
506
+ steps=(resume,),
507
+ )
508
+ settlement = None if classification is None else classification.settlement
509
+ closed = verdict is not None and verdict.kind is VerdictKind.CLOSED
510
+ if state is not None and settlement is not None and _settled(state.phase, settlement):
511
+ if closed:
512
+ return row(
513
+ Category.SETTLED,
514
+ f"the state is {settlement.value} (phase {state.phase.value}); the issue is closed",
515
+ )
516
+ return row(
517
+ Category.SETTLED,
518
+ f"the state is {settlement.value} (phase {state.phase.value}) and the issue is "
519
+ "open: the scheduler launches nothing more for it until 'epic archive' releases "
520
+ "it for a fresh run",
521
+ steps=(archive, resume),
522
+ )
523
+ if verdict is not None and verdict.kind is VerdictKind.CLOSED:
524
+ return row(Category.CLOSED, verdict.explain())
525
+ if verdict is not None and verdict.kind is VerdictKind.UNSUPPORTED:
526
+ return row(Category.UNSUPPORTED, verdict.explain())
527
+ if state is not None and state.phase is Phase.READY_FOR_MERGE:
528
+ pr = state.current_pr_url or "its PR"
529
+ return row(
530
+ Category.READY_FOR_MERGE,
531
+ f"{pr} is at READY_FOR_MERGE: a human merges it (scheduler workers never "
532
+ "merge); its dependents become ready once the issue is closed as completed",
533
+ )
534
+ if state is not None and state.phase is Phase.BLOCKED and namespace is not None:
535
+ reason = _bounded(state.block_reason or "no reason recorded", MAX_BLOCK_REASON_CHARS)
536
+ unblock = command(
537
+ flags,
538
+ "--state-dir",
539
+ str(namespace.state_dir),
540
+ "unblock",
541
+ "--reason",
542
+ REASON_PLACEHOLDER,
543
+ )
544
+ status = command(flags, "--state-dir", str(namespace.state_dir), "status")
545
+ return row(
546
+ Category.BLOCKED,
547
+ f"BLOCKED: {reason}. 'unblock' on the namespace, while no scheduler runs (it "
548
+ "takes the controller lock), re-inspects GitHub and re-enters a phase (an "
549
+ f"acceptance action a FIX plan awaits is listed by '{status}' and decided with "
550
+ "'unblock --approve-action' or '--decline-action'; an operator action, listed "
551
+ "there too, is posted by the operator ('status --action X-...') or bound with "
552
+ "'unblock --bind-evidence X-... <comment URL>', or declined with "
553
+ "'--decline-action X-...'); 'epic resume' then continues it",
554
+ steps=(unblock, resume),
555
+ )
556
+ if verdict is not None and verdict.kind is VerdictKind.BLOCKED:
557
+ home = verdict.repository
558
+ return row(
559
+ Category.BLOCKED_BY_DEPENDENCY,
560
+ verdict.explain(),
561
+ blockers=tuple(status.describe(home) for status in verdict.blockers),
562
+ )
563
+ if facts.standalone is not None:
564
+ hold = facts.standalone
565
+ return row(
566
+ Category.HELD,
567
+ f"the standalone state {hold.state_file} runs it (phase {hold.phase}): finish it "
568
+ "with the standalone commands; the scheduler admits the issue once that run is DONE",
569
+ hold_reason=HoldReason.STANDALONE_STATE,
570
+ )
571
+ if facts.spawn_failed is not None:
572
+ failed = facts.spawn_failed
573
+ return row(
574
+ Category.HELD,
575
+ f"{failed.detail}: held for the rest of invocation {failed.claim.invocation_id}; "
576
+ "the next 'epic run' or 'epic resume' tries again",
577
+ hold_reason=HoldReason.SPAWN_FAILED,
578
+ )
579
+ if classification is not None and classification.kind is ClassificationKind.RESUMABLE:
580
+ return row(
581
+ Category.RESUMABLE,
582
+ f"{classification.detail}: the next admission resumes it",
583
+ steps=(resume,),
584
+ )
585
+ if verdict is not None and verdict.kind is VerdictKind.READY:
586
+ return row(Category.READY, verdict.explain())
587
+ return row(None, "a namespace holding no claim and no state: nothing to do")
588
+
589
+
590
+ def _settled(phase: Phase, settlement: Settlement) -> bool:
591
+ """``DONE``, ``FAILED``, a hand-off phase or a pause: not ``READY_FOR_MERGE``, ``BLOCKED``."""
592
+ if settlement in (Settlement.HANDOFF, Settlement.AWAITING_INPUT):
593
+ return True
594
+ return settlement is Settlement.TERMINAL and phase is not Phase.BLOCKED
595
+
596
+
597
+ # -- reading (lock-free) ----------------------------------------------------------------
598
+
599
+
600
+ @dataclass(frozen=True)
601
+ class Identity:
602
+ """Who the EPIC and its repository are: from GitHub, else from ``scheduler.json``."""
603
+
604
+ epic: EpicRef
605
+ repository_id: int
606
+
607
+
608
+ def _probe(namespace: IssueNamespace) -> tuple[bool, str]:
609
+ try:
610
+ return issue_run_lock_live(namespace), ""
611
+ except (LockError, StateError, OSError) as exc:
612
+ return False, _bounded(str(exc), 500)
613
+
614
+
615
+ def _same_repository(url: str, repository: str) -> bool:
616
+ try:
617
+ return parse_issue_url(url).repository.lower() == repository.lower()
618
+ except ConfigurationError:
619
+ return False
620
+
621
+
622
+ def _spawn_failed(
623
+ lookup_claims: tuple[ClosedClaim, ...], scheduler: SchedulerState | None
624
+ ) -> ClosedClaim | None:
625
+ """This invocation's last claim closed ``abandoned`` because its worker never started."""
626
+ if scheduler is None or scheduler.invocation.ended_at is not None or not lookup_claims:
627
+ return None
628
+ last = lookup_claims[-1]
629
+ if (
630
+ last.classification is ClaimClassification.ABANDONED
631
+ and last.claim.invocation_id == scheduler.invocation.invocation_id
632
+ and last.detail.startswith(SPAWN_FAILURE_DETAILS)
633
+ ):
634
+ return last
635
+ return None
636
+
637
+
638
+ def read_issue(
639
+ root: Path,
640
+ number: int,
641
+ *,
642
+ verdict: ChildVerdict | None,
643
+ identity: Identity | None,
644
+ expected: ExpectedIdentity | None,
645
+ scheduler: SchedulerState | None,
646
+ standalone: StandaloneHold | None,
647
+ unidentified: str = "",
648
+ ) -> IssueFacts:
649
+ """One issue's facts, read lock-free; nothing is created (D8.3)."""
650
+ if verdict is not None and verdict.shape is Shape.FOREIGN_REPOSITORY:
651
+ return IssueFacts(number, verdict) # another repository's number: no namespace here
652
+ namespace = issue_namespace(root, number)
653
+ read = read_namespace(namespace)
654
+ live, problem = _probe(namespace)
655
+ if read.absent:
656
+ return IssueFacts(number, verdict, live=live, probe_problem=problem, standalone=standalone)
657
+ found: EpicClassification | None = None
658
+ if identity is not None and expected is not None:
659
+ found = classify_for_epic(read, identity.epic, expected=expected)
660
+ claim_file = read.claim.claim_file
661
+ return IssueFacts(
662
+ number,
663
+ verdict,
664
+ read,
665
+ found,
666
+ live=live,
667
+ probe_problem=problem,
668
+ standalone=standalone,
669
+ spawn_failed=_spawn_failed(() if claim_file is None else claim_file.history, scheduler),
670
+ unidentified=unidentified,
671
+ )
672
+
673
+
674
+ def _bound_elsewhere(
675
+ lookup: NamespaceLookup | None, identity: Identity | None, epic_number: int
676
+ ) -> bool:
677
+ """A namespace of this repository bound to another EPIC: not this EPIC's business."""
678
+ if lookup is None or lookup.binding is None:
679
+ return False
680
+ binding = lookup.binding
681
+ if identity is not None and binding.run_key.repository_id != identity.repository_id:
682
+ return False # another repository's binding: global uncertainty, shown
683
+ return binding.epic.number != epic_number
684
+
685
+
686
+ @dataclass(frozen=True)
687
+ class StatusReport:
688
+ """Everything ``epic status`` shows (schema :data:`STATUS_SCHEMA`)."""
689
+
690
+ repository: str
691
+ epic_url: str
692
+ epic_number: int
693
+ epic_issue_id: int | None
694
+ flags: tuple[str, ...]
695
+ scheduler: SchedulerState | None = None
696
+ scheduler_problem: str = ""
697
+ stop: StopRead | None = None
698
+ stop_problem: str = ""
699
+ snapshot: EpicSnapshot | None = None
700
+ snapshot_problem: str = ""
701
+ children: tuple[IssueStatus, ...] = ()
702
+ other_namespaces: tuple[IssueStatus, ...] = ()
703
+ rejected: tuple[RejectedEntry, ...] = ()
704
+ problems: tuple[str, ...] = ()
705
+
706
+ @property
707
+ def any_live(self) -> bool:
708
+ return any(row.live for row in (*self.children, *self.other_namespaces))
709
+
710
+ @property
711
+ def invocation_hint(self) -> str:
712
+ """The not-ended hint: an invocation ``scheduler.json`` shows running while none is."""
713
+ state = self.scheduler
714
+ if state is None or state.invocation.ended_at is not None or self.any_live:
715
+ return ""
716
+ resume = command(self.flags, "epic", "resume", self.epic_url)
717
+ return (
718
+ "not ended: if no 'autoforge epic' process is running, it ended without "
719
+ f"finalizing (crash or kill); run '{resume}'"
720
+ )
721
+
722
+ @property
723
+ def exit_code(self) -> int:
724
+ """0, or 1 when GitHub, ``scheduler.json`` or the namespaces could not all be read.
725
+
726
+ An ``uncertain`` namespace (one that cannot be read, classified or
727
+ probed), a child's or an orphaned one, a binding or a
728
+ ``scheduler.json`` of another repository, and an entry under
729
+ ``issues/`` that is not a namespace (:attr:`rejected`) are among
730
+ :attr:`problems`.
731
+ """
732
+ return 1 if (self.snapshot is None or self.scheduler_problem or self.problems) else 0
733
+
734
+ def to_dict(self) -> dict:
735
+ state = self.scheduler
736
+ stop = self.stop
737
+ request = None if stop is None else stop.request
738
+ stop_problem = self.stop_problem or (stop.problem if stop is not None else "")
739
+ invocation = None
740
+ if state is not None:
741
+ invocation = {
742
+ **state.invocation.to_dict(),
743
+ "ended": state.invocation.ended_at is not None,
744
+ "hint": self.invocation_hint or None,
745
+ }
746
+ if state is not None:
747
+ scheduler_state = "readable"
748
+ elif self.scheduler_problem:
749
+ scheduler_state = "unreadable"
750
+ else:
751
+ scheduler_state = "absent"
752
+ snapshot = self.snapshot
753
+ document = {
754
+ "schema": STATUS_SCHEMA,
755
+ "repository": self.repository,
756
+ "epic": {
757
+ "number": self.epic_number,
758
+ "url": self.epic_url,
759
+ "issue_id": self.epic_issue_id,
760
+ },
761
+ "scheduler": {
762
+ "state": scheduler_state,
763
+ "problem": self.scheduler_problem or None,
764
+ "invocation": invocation,
765
+ "admission": None if state is None else state.admission.to_dict(),
766
+ "max_parallel": None if state is None else state.config.max_parallel,
767
+ "last_snapshot": (
768
+ None
769
+ if state is None or state.last_snapshot is None
770
+ else state.last_snapshot.to_dict()
771
+ ),
772
+ },
773
+ "stop_request": {
774
+ "present": bool(stop is not None and stop.present),
775
+ "mode": None if stop is None or stop.mode is None else stop.mode.value,
776
+ "invocation_id": None if request is None else request.invocation_id,
777
+ "requested_at": None if request is None else request.requested_at,
778
+ "applies": bool(
779
+ stop is not None
780
+ and state is not None
781
+ and stop.applies_to(state.invocation.invocation_id)
782
+ ),
783
+ "problem": stop_problem or None,
784
+ },
785
+ "snapshot": {
786
+ "taken": snapshot is not None,
787
+ "digest": None if snapshot is None else snapshot.digest,
788
+ "observed_at": None if snapshot is None else snapshot.observed_at,
789
+ "problem": self.snapshot_problem or None,
790
+ },
791
+ "problems": list(self.problems),
792
+ "children": [row.to_dict() for row in self.children],
793
+ "other_namespaces": [row.to_dict() for row in self.other_namespaces],
794
+ "rejected": [{"name": entry.name, "reason": entry.reason} for entry in self.rejected],
795
+ }
796
+ return redact_dict(document)
797
+
798
+ def lines(self) -> list[str]:
799
+ """The human rendering, redacted."""
800
+ out = [f"EPIC #{self.epic_number} of {self.repository} ({self.epic_url})"]
801
+ state = self.scheduler
802
+ if state is not None:
803
+ inv = state.invocation
804
+ ended = (
805
+ f"ended {inv.ended_at} ({inv.end_reason.value})"
806
+ if inv.ended_at is not None and inv.end_reason is not None
807
+ else self.invocation_hint or "not ended: a worker of it is running"
808
+ )
809
+ out.append(
810
+ f"last scheduler invocation: {inv.invocation_id}, started {inv.started_at}; {ended}"
811
+ )
812
+ admission = state.admission
813
+ reason = f" ({admission.reason.value})" if admission.reason is not None else ""
814
+ out.append(
815
+ f" admission {admission.status.value}{reason}, max_parallel "
816
+ f"{state.config.max_parallel}"
817
+ )
818
+ last = state.last_snapshot
819
+ if last is not None:
820
+ counts = ", ".join(f"{k} {v}" for k, v in last.to_dict()["counts"].items())
821
+ out.append(f" last snapshot {last.digest[:12]} at {last.observed_at}: {counts}")
822
+ elif self.scheduler_problem:
823
+ out.append(
824
+ f"scheduler.json cannot be read: {self.scheduler_problem} ('epic run --force' "
825
+ "moves it aside)"
826
+ )
827
+ else:
828
+ out.append("no scheduler has run for this EPIC here (no scheduler.json)")
829
+ stop = self.stop
830
+ if self.stop_problem:
831
+ out.append(f"stop request: cannot be read ({self.stop_problem})")
832
+ elif stop is not None and stop.present:
833
+ mode = stop.mode.value if stop.mode is not None else "drain"
834
+ if stop.request is None:
835
+ out.append(f"stop request: does not parse, read as a drain ({stop.problem})")
836
+ else:
837
+ applies = (
838
+ "for the last invocation"
839
+ if state is not None and stop.applies_to(state.invocation.invocation_id)
840
+ else "of an earlier invocation (stale; the next scheduler removes it)"
841
+ )
842
+ out.append(f"stop request: {mode}, {applies}, at {stop.request.requested_at}")
843
+ if self.snapshot is not None:
844
+ snapshot = self.snapshot
845
+ out.append(
846
+ f"snapshot {snapshot.digest[:12]} at {snapshot.observed_at}: "
847
+ f"{len(snapshot.children)} children"
848
+ )
849
+ else:
850
+ out.append(f"no snapshot: {self.snapshot_problem}")
851
+ for problem in self.problems:
852
+ out.append(f"problem: {problem}")
853
+ if self.children:
854
+ out.append("children, in admission order:")
855
+ out.extend(_row_lines(row) for row in self.children)
856
+ if self.other_namespaces:
857
+ out.append(
858
+ "namespaces of this EPIC the snapshot does not list:"
859
+ if self.snapshot is not None
860
+ else "namespaces of this EPIC:"
861
+ )
862
+ out.extend(_row_lines(row) for row in self.other_namespaces)
863
+ return [redact(line) for line in out]
864
+
865
+
866
+ def _row_lines(row: IssueStatus) -> str:
867
+ category = "-" if row.category is None else row.category.value
868
+ if row.hold_reason is not None:
869
+ category += f":{row.hold_reason.value}"
870
+ lines = [f" #{row.number} {category}: {row.detail}"]
871
+ if row.claim is not None:
872
+ lines.append(f" {worker_diagnostics(row.claim)}")
873
+ lines.extend(f" next: {step}" for step in row.next)
874
+ return "\n".join(lines)
875
+
876
+
877
+ def _read_scheduler(root: Path, epic_number: int) -> tuple[SchedulerState | None, str]:
878
+ try:
879
+ return load_scheduler_state(root, epic_number), ""
880
+ except (StateError, OSError) as exc:
881
+ return None, _bounded(str(exc), 1000)
882
+
883
+
884
+ def _identity(
885
+ graph: GraphReader, epic: GitHubIssueRef, scheduler: SchedulerState | None
886
+ ) -> tuple[Identity | None, str]:
887
+ """The EPIC's identity from GitHub, else the last invocation's ``scheduler.json``."""
888
+ try:
889
+ repository_id = graph.get_repository_id(epic.repository)
890
+ node = graph.get_issue_node(epic.canonical)
891
+ return Identity(EpicRef(epic.number, node.id), repository_id), ""
892
+ except GitHubError as exc:
893
+ problem = f"cannot read the repository or the EPIC on GitHub: {_bounded(str(exc), 500)}"
894
+ if scheduler is not None:
895
+ return Identity(scheduler.epic, scheduler.repository_id), problem
896
+ return None, problem
897
+
898
+
899
+ def _snapshot(
900
+ graph: GraphReader, epic: GitHubIssueRef, identity: Identity | None, problem: str
901
+ ) -> tuple[EpicSnapshot | None, str]:
902
+ if problem:
903
+ return None, problem
904
+ assert identity is not None
905
+ try:
906
+ return build_epic_snapshot(graph, epic.canonical, repository_id=identity.repository_id), ""
907
+ except (SnapshotRefused, GitHubError) as exc:
908
+ return None, f"the snapshot is refused: {_bounded(str(exc), 1000)}"
909
+
910
+
911
+ def _expected(
912
+ identity: Identity | None, snapshot: EpicSnapshot | None, repository: str
913
+ ) -> ExpectedIdentity | None:
914
+ if identity is None:
915
+ return None
916
+ issue_ids: dict[int, int] = {}
917
+ if snapshot is not None:
918
+ for child in snapshot.children:
919
+ if child.repository.lower() == repository.lower():
920
+ issue_ids[child.number] = child.id
921
+ return ExpectedIdentity(identity.repository_id, issue_ids, repository=repository)
922
+
923
+
924
+ def _standalone(state_dir: str | Path, repository: str) -> tuple[StandaloneHold | None, str]:
925
+ hold = standalone_hold(state_dir)
926
+ if hold.problem:
927
+ return None, f"the standalone state {hold.state_file} cannot be read: {hold.problem}"
928
+ if hold.holds and _same_repository(hold.issue_url, repository):
929
+ return hold, ""
930
+ return None, ""
931
+
932
+
933
+ def collect_status(
934
+ graph: GraphReader,
935
+ *,
936
+ repository_root: str | Path,
937
+ epic_url: str,
938
+ standalone_state_dir: str | Path,
939
+ flags: tuple[str, ...] = (),
940
+ ) -> StatusReport:
941
+ """``epic status``: every child's category, lock-free and read-only (#183).
942
+
943
+ GitHub is read for the identities and the snapshot only; when it cannot
944
+ be, the local namespaces are still shown, identified by
945
+ ``scheduler.json``, and when it can, a ``scheduler.json`` of another
946
+ repository id is a problem. Nothing is created, locked, written or
947
+ signalled.
948
+ """
949
+ epic = parse_issue_url(epic_url)
950
+ repository = epic.repository
951
+ root = coordination_root_of(repository_root)
952
+ scheduler, scheduler_problem = _read_scheduler(root, epic.number)
953
+ stop: StopRead | None = None
954
+ stop_problem = ""
955
+ try:
956
+ stop = read_stop_request(root, epic.number)
957
+ except (StateError, OSError) as exc:
958
+ stop_problem = _bounded(str(exc), 500)
959
+ identity, github_problem = _identity(graph, epic, scheduler)
960
+ snapshot, snapshot_problem = _snapshot(graph, epic, identity, github_problem)
961
+ expected = _expected(identity, snapshot, repository)
962
+ unidentified = (
963
+ ""
964
+ if identity is not None
965
+ else "not classified: neither GitHub nor scheduler.json identifies the EPIC and its "
966
+ "repository"
967
+ )
968
+ hold, hold_problem = _standalone(standalone_state_dir, repository)
969
+ problems: list[str] = [hold_problem] if hold_problem else []
970
+ if not github_problem:
971
+ problems.extend(_foreign_scheduler(scheduler, identity, repository))
972
+ try:
973
+ listing = list_issue_namespaces(root)
974
+ listed = sorted((*listing.bound, *listing.unbound), key=lambda ns: ns.issue_number)
975
+ rejected = listing.rejected
976
+ except (StateError, OSError) as exc:
977
+ problems.append(f"the scheduler's namespaces cannot be listed: {_bounded(str(exc), 500)}")
978
+ listed, rejected = [], ()
979
+ # An entry no reader follows: what it holds is never shown or classified,
980
+ # so it is a problem (exit 1), as an unreadable namespace is.
981
+ problems.extend(
982
+ f"issues/{entry.name} is not a namespace ({entry.reason}) and is never read as one: "
983
+ "move it out of the scheduler's issues/ directory by hand"
984
+ for entry in rejected
985
+ )
986
+
987
+ def facts_of(number: int, verdict: ChildVerdict | None) -> IssueFacts:
988
+ standalone = hold if hold is not None and hold.issue_number == number else None
989
+ return read_issue(
990
+ root,
991
+ number,
992
+ verdict=verdict,
993
+ identity=identity,
994
+ expected=expected,
995
+ scheduler=scheduler,
996
+ standalone=standalone,
997
+ unidentified=unidentified,
998
+ )
999
+
1000
+ children: list[IssueStatus] = []
1001
+ shown: set[int] = set()
1002
+ if snapshot is not None:
1003
+ for verdict in evaluate(snapshot):
1004
+ facts = facts_of(verdict.number, verdict)
1005
+ if verdict.shape is not Shape.FOREIGN_REPOSITORY:
1006
+ shown.add(verdict.number)
1007
+ children.append(categorize(facts, epic_url=epic.canonical, flags=flags))
1008
+ _note_problem(facts, children[-1], problems)
1009
+ others: list[IssueStatus] = []
1010
+ for namespace in listed:
1011
+ number = namespace.issue_number
1012
+ if number in shown:
1013
+ continue
1014
+ facts = facts_of(number, None)
1015
+ if facts.read is None and not facts.live and not facts.probe_problem:
1016
+ continue # nothing there but a free run lock or logs
1017
+ if _bound_elsewhere(facts.lookup, identity, epic.number):
1018
+ continue
1019
+ others.append(
1020
+ categorize(facts, epic_url=epic.canonical, flags=flags, orphaned=snapshot is not None)
1021
+ )
1022
+ _note_problem(facts, others[-1], problems)
1023
+ return StatusReport(
1024
+ repository=repository,
1025
+ epic_url=epic.canonical,
1026
+ epic_number=epic.number,
1027
+ epic_issue_id=None if identity is None else identity.epic.issue_id,
1028
+ flags=flags,
1029
+ scheduler=scheduler,
1030
+ scheduler_problem=scheduler_problem,
1031
+ stop=stop,
1032
+ stop_problem=stop_problem,
1033
+ snapshot=snapshot,
1034
+ snapshot_problem=snapshot_problem,
1035
+ children=tuple(children),
1036
+ other_namespaces=tuple(others),
1037
+ rejected=tuple(rejected),
1038
+ problems=tuple(problems),
1039
+ )
1040
+
1041
+
1042
+ def _foreign_scheduler(
1043
+ scheduler: SchedulerState | None, identity: Identity | None, repository: str
1044
+ ) -> tuple[str, ...]:
1045
+ """``scheduler.json`` of another repository than GitHub's: global uncertainty (D14.3).
1046
+
1047
+ Compared by the numeric id GitHub gave (D5.1): a rename keeps it, while
1048
+ ``origin`` pointed at another repository, or a name another repository
1049
+ took, does not, whatever the recorded name.
1050
+ """
1051
+ if scheduler is None or identity is None:
1052
+ return ()
1053
+ if scheduler.repository_id == identity.repository_id:
1054
+ return ()
1055
+ recorded = f"{scheduler.repository_owner}/{scheduler.repository_name}"
1056
+ return (
1057
+ f"scheduler.json of EPIC #{scheduler.epic.number} here was written for {recorded} "
1058
+ f"(repository id {scheduler.repository_id}), and {repository} is repository id "
1059
+ f"{identity.repository_id} (global uncertainty: no scheduler admits anything until it "
1060
+ "is resolved)",
1061
+ )
1062
+
1063
+
1064
+ def _note_problem(facts: IssueFacts, row: IssueStatus, problems: list[str]) -> None:
1065
+ """An issue status cannot vouch for is a problem (exit 1), named once.
1066
+
1067
+ A binding of another repository is global uncertainty; otherwise an
1068
+ ``uncertain`` row's reason: a namespace that cannot be read, classified
1069
+ or probed.
1070
+ """
1071
+ found = facts.found
1072
+ if found is not None and found.repository_mismatch and found.classification is not None:
1073
+ problems.append(
1074
+ f"issue #{facts.number}: {found.classification.detail} (global uncertainty: no "
1075
+ "scheduler admits anything until it is resolved)"
1076
+ )
1077
+ elif row.problem:
1078
+ problems.append(f"issue #{facts.number}: {row.problem}")
1079
+
1080
+
1081
+ # -- epic run --dry-run (D9.8) ----------------------------------------------------------
1082
+
1083
+
1084
+ @dataclass(frozen=True)
1085
+ class DryRunPlan:
1086
+ """What ``epic run`` would do now: the verdicts in order and who is admitted."""
1087
+
1088
+ lines: tuple[str, ...]
1089
+ exit_code: int
1090
+
1091
+
1092
+ def dry_run_plan(
1093
+ graph: GraphReader,
1094
+ *,
1095
+ repository_root: str | Path,
1096
+ epic_url: str,
1097
+ max_parallel: int,
1098
+ standalone_state_dir: str | Path,
1099
+ force: bool = False,
1100
+ flags: tuple[str, ...] = (),
1101
+ ) -> DryRunPlan:
1102
+ """``epic run --dry-run``: the snapshot, the ordered verdicts and the plan (D9.8).
1103
+
1104
+ The local facts are the ones the scheduler's startup would find, read
1105
+ lock-free: a live run lock is ``owned``; a settled, uncertain or other
1106
+ EPIC's namespace is held; the configured standalone state's issue is
1107
+ held. No lock is taken, and no namespace, claim, worktree or worker is
1108
+ created.
1109
+ """
1110
+ epic = parse_issue_url(epic_url)
1111
+ root = coordination_root_of(repository_root)
1112
+ out = ["dry run: nothing is locked, written or started"]
1113
+ scheduler, scheduler_problem = _read_scheduler(root, epic.number)
1114
+ identity, github_problem = _identity(graph, epic, None)
1115
+ snapshot, snapshot_problem = _snapshot(graph, epic, identity, github_problem)
1116
+ if snapshot is None or identity is None:
1117
+ out.append(f"no snapshot, so nothing would be admitted: {snapshot_problem}")
1118
+ return DryRunPlan(tuple(redact(line) for line in out), 1)
1119
+ expected = _expected(identity, snapshot, epic.repository)
1120
+ assert expected is not None
1121
+ blocked: list[str] = []
1122
+ if scheduler_problem:
1123
+ if force:
1124
+ out.append(
1125
+ f"scheduler.json cannot be read ({scheduler_problem}): --force would move it "
1126
+ "aside (a dry run moves nothing)"
1127
+ )
1128
+ else:
1129
+ blocked.append(
1130
+ f"scheduler.json cannot be read ({scheduler_problem}); 'epic run --force' "
1131
+ "moves it aside"
1132
+ )
1133
+ elif scheduler is not None and scheduler.repository_id != identity.repository_id:
1134
+ blocked.append(
1135
+ f"scheduler.json names repository id {scheduler.repository_id}, and the live "
1136
+ f"repository is {identity.repository_id}"
1137
+ )
1138
+ try:
1139
+ open_claims = open_claims_for_epic(root, identity.epic)
1140
+ except (StateError, OSError) as exc:
1141
+ blocked.append(f"the open claims cannot be listed: {_bounded(str(exc), 500)}")
1142
+ open_claims = ()
1143
+ if open_claims:
1144
+ blocked.append(open_claims_message(open_claims, flags, epic.canonical))
1145
+ facts: dict[int, LocalFact] = {}
1146
+ resumable: set[int] = set()
1147
+ try:
1148
+ listing = list_issue_namespaces(root)
1149
+ listed = sorted((*listing.bound, *listing.unbound), key=lambda ns: ns.issue_number)
1150
+ except (StateError, OSError) as exc:
1151
+ blocked.append(f"the scheduler's namespaces cannot be listed: {_bounded(str(exc), 500)}")
1152
+ listed = []
1153
+ for namespace in listed:
1154
+ number = namespace.issue_number
1155
+ live, problem = _probe(namespace)
1156
+ if live:
1157
+ facts[number] = LocalFact.OWNED
1158
+ continue
1159
+ if problem:
1160
+ facts[number] = LocalFact.HELD_UNCERTAIN
1161
+ continue
1162
+ found = classify_for_epic(read_namespace(namespace), identity.epic, expected=expected)
1163
+ if found is None:
1164
+ continue
1165
+ if found.repository_mismatch and found.classification is not None:
1166
+ blocked.append(f"issue #{number}: {found.classification.detail}")
1167
+ if found.fact is not None:
1168
+ facts[number] = found.fact
1169
+ if (
1170
+ found.classification is not None
1171
+ and found.classification.kind is ClassificationKind.RESUMABLE
1172
+ ):
1173
+ resumable.add(number)
1174
+ hold, hold_problem = _standalone(standalone_state_dir, epic.repository)
1175
+ if hold_problem:
1176
+ out.append(hold_problem)
1177
+ if hold is not None and hold.issue_number is not None:
1178
+ facts.setdefault(hold.issue_number, LocalFact.HELD_STANDALONE_STATE)
1179
+ verdicts = evaluate(snapshot, facts)
1180
+ out.append(
1181
+ f"EPIC #{epic.number} of {epic.repository}: snapshot {snapshot.digest[:12]} at "
1182
+ f"{snapshot.observed_at}, {len(snapshot.children)} children"
1183
+ )
1184
+ out.append("verdicts, in admission order:")
1185
+ out.extend(f" {verdict.explain()}" for verdict in verdicts)
1186
+ if blocked:
1187
+ out.append("'epic run' would admit nothing:")
1188
+ out.extend(f" {line}" for line in blocked)
1189
+ return DryRunPlan(tuple(redact(line) for line in out), 0)
1190
+ ready = ready_set(verdicts)
1191
+ now, queued = ready[:max_parallel], ready[max_parallel:]
1192
+ out.append(f"admission plan (at most {max_parallel} worker(s)):")
1193
+ if not ready:
1194
+ out.append(" nothing is ready")
1195
+ else:
1196
+ admitted = ", ".join(
1197
+ f"#{v.number} ({'resume' if v.number in resumable else 'start'})" for v in now
1198
+ )
1199
+ out.append(f" admitted now: {admitted}")
1200
+ if queued:
1201
+ out.append(" queued for a free slot: " + ", ".join(f"#{v.number}" for v in queued))
1202
+ return DryRunPlan(tuple(redact(line) for line in out), 0)
1203
+
1204
+
1205
+ # -- epic run's refusal and --force (D14.4, D6.3) ---------------------------------------
1206
+
1207
+
1208
+ def open_claims_message(claims: Iterable[OpenClaim], flags: tuple[str, ...], epic_url: str) -> str:
1209
+ listed = []
1210
+ for item in claims:
1211
+ if item.claim is None:
1212
+ listed.append(f"#{item.issue_number} (its claim file cannot be read: {item.problem})")
1213
+ else:
1214
+ listed.append(
1215
+ f"#{item.issue_number} (generation {item.claim.generation} of invocation "
1216
+ f"{item.claim.invocation_id})"
1217
+ )
1218
+ resume = command(flags, "epic", "resume", epic_url)
1219
+ return (
1220
+ f"claims of this EPIC are still open: {', '.join(listed)}; an earlier scheduler "
1221
+ f"ended without closing them. '{resume}' classifies them and continues"
1222
+ )
1223
+
1224
+
1225
+ def epic_run_guard(
1226
+ *, force: bool, flags: tuple[str, ...], epic_url: str, say: Callable[[str], None]
1227
+ ) -> Callable[[Path, EpicRef], None]:
1228
+ """``epic run``'s ``before_start``: ``--force``'s quarantine, then the open-claim refusal.
1229
+
1230
+ Called by the scheduler holding L0, before ``scheduler.json`` is read:
1231
+ ``--force`` moves a corrupt ``scheduler.json`` aside and nothing else
1232
+ (D6.3); any open claim of the EPIC refuses the run
1233
+ (:class:`~autoforge.errors.OpenSchedulerClaimsError`) with nothing
1234
+ written (D14.4).
1235
+ """
1236
+
1237
+ def guard(root: Path, epic: EpicRef) -> None:
1238
+ if force:
1239
+ try:
1240
+ state = load_scheduler_state(root, epic.number)
1241
+ except SchedulerStateError:
1242
+ moved = quarantine_scheduler_state(root, epic.number)
1243
+ say(f"--force: the unreadable scheduler.json was moved aside to {moved}")
1244
+ else:
1245
+ found = "is readable" if state is not None else "does not exist"
1246
+ say(f"--force: scheduler.json {found}; nothing was moved aside")
1247
+ claims = open_claims_for_epic(root, epic)
1248
+ if claims:
1249
+ raise OpenSchedulerClaimsError(
1250
+ f"'epic run' refuses: {open_claims_message(claims, flags, epic_url)}"
1251
+ )
1252
+
1253
+ return guard
1254
+
1255
+
1256
+ def claims_left_open(root: Path, epic: EpicRef | None) -> tuple[OpenClaim, ...]:
1257
+ """The EPIC's claims still open after a scheduler ended: an exit-3 condition."""
1258
+ if epic is None:
1259
+ return ()
1260
+ try:
1261
+ return open_claims_for_epic(root, epic)
1262
+ except (StateError, OSError) as exc:
1263
+ return (OpenClaim(0, None, _bounded(str(exc), 500)),)
1264
+
1265
+
1266
+ def left_open_lines(
1267
+ claims: Iterable[OpenClaim], flags: tuple[str, ...], epic_url: str
1268
+ ) -> list[str]:
1269
+ resume = command(flags, "epic", "resume", epic_url)
1270
+ out = []
1271
+ for item in claims:
1272
+ if item.claim is None:
1273
+ where = f"issue #{item.issue_number}" if item.issue_number else "the namespaces"
1274
+ out.append(f"{where}: the claims cannot be read ({item.problem})")
1275
+ else:
1276
+ out.append(f"issue #{item.issue_number}: {_claim_line(item.claim)} is still open")
1277
+ out.append(
1278
+ f"claims of this EPIC were left open; 'autoforge epic status' says why, and '{resume}' "
1279
+ "classifies them at its start"
1280
+ )
1281
+ return [redact(line) for line in out]
1282
+
1283
+
1284
+ # -- epic stop (D16.4) ------------------------------------------------------------------
1285
+
1286
+
1287
+ @dataclass(frozen=True)
1288
+ class StopOutcome:
1289
+ lines: tuple[str, ...]
1290
+ exit_code: int
1291
+
1292
+
1293
+ def live_namespaces(root: Path, epic_number: int) -> tuple[int, ...]:
1294
+ """The issues of this EPIC whose run lock a live process holds (the D8.3 probe)."""
1295
+ try:
1296
+ listing = list_issue_namespaces(root)
1297
+ except (StateError, OSError):
1298
+ return ()
1299
+ live: list[int] = []
1300
+ for namespace in listing.bound:
1301
+ try:
1302
+ lookup = load_issue_namespace(root, namespace.issue_number)
1303
+ except (NamespaceMismatch, StateError, OSError):
1304
+ continue
1305
+ if lookup.binding is None or lookup.binding.epic.number != epic_number:
1306
+ continue
1307
+ held, _ = _probe(namespace)
1308
+ if held:
1309
+ live.append(namespace.issue_number)
1310
+ return tuple(live)
1311
+
1312
+
1313
+ def stop_scheduler(
1314
+ root: Path, epic: GitHubIssueRef, mode: StopMode, repository_id: Callable[[str], int]
1315
+ ) -> StopOutcome:
1316
+ """``epic stop``: publish a stop request (#182's ``request_stop``) and say what happened.
1317
+
1318
+ Exit 0 when a request was delivered to the invocation ``scheduler.json``
1319
+ names; 1 when nothing was delivered (a request was already there, or
1320
+ the scheduler changed after the write). No ``scheduler.json`` (or one
1321
+ that does not load), or one of another repository than the EPIC's
1322
+ (:func:`check_recorded_repository`, which reads ``repository_id``),
1323
+ raises :class:`~autoforge.errors.SchedulerStateError`, with nothing
1324
+ created.
1325
+
1326
+ The workers holding their run locks are always listed. An invocation
1327
+ that is not ended either still runs or died without finalizing (a
1328
+ SIGKILL leaves it so), and nothing here can tell which without trusting
1329
+ a PID: its workers get the survivors' caveat conditionally. Nothing is
1330
+ ever removed or signalled.
1331
+ """
1332
+ epic_number = epic.number
1333
+ state = load_scheduler_state(root, epic_number) # absent: request_stop refuses
1334
+ if state is not None:
1335
+ check_recorded_repository(
1336
+ f"scheduler.json of EPIC #{epic_number} here was written for",
1337
+ f"{state.repository_owner}/{state.repository_name}",
1338
+ state.repository_id,
1339
+ epic,
1340
+ repository_id,
1341
+ refused="no stop request was written",
1342
+ )
1343
+ delivery = request_stop(root, epic_number, mode)
1344
+ out = [delivery.message]
1345
+ code = 0 if delivery.written and not delivery.scheduler_changed else 1
1346
+ if delivery.existing is not None:
1347
+ existing = delivery.existing
1348
+ if existing.mode is StopMode.DRAIN and mode is StopMode.NOW:
1349
+ out.append(DRAIN_HELP)
1350
+ elif delivery.written and mode is StopMode.DRAIN and not delivery.scheduler_ended:
1351
+ out.append(
1352
+ "admission is closed at the scheduler's next tick and each worker returns at its "
1353
+ f"next step boundary; {DRAIN_HELP}"
1354
+ )
1355
+ live = live_namespaces(root, epic_number)
1356
+ listed = ", ".join(f"#{n}" for n in live)
1357
+ if not live:
1358
+ out.append("no worker of this EPIC holds a run lock")
1359
+ elif delivery.scheduler_ended:
1360
+ out.append(
1361
+ f"workers still running (their run locks are held): {listed}; they drain at "
1362
+ "their next step boundary, and no process is ever signalled by a recorded PID"
1363
+ )
1364
+ elif delivery.scheduler_changed:
1365
+ out.append(f"workers holding their run locks: {listed}")
1366
+ else:
1367
+ out.append(
1368
+ f"workers holding their run locks: {listed}. If no 'autoforge epic' process of this "
1369
+ "EPIC is running, its invocation ended without finalizing (a crash or a kill) and "
1370
+ "these workers survived it: a stop request reaches each only at its next step "
1371
+ "boundary, '--now' cannot signal them, and no process is ever signalled by a "
1372
+ "recorded PID"
1373
+ )
1374
+ return StopOutcome(tuple(redact(line) for line in out), code)
1375
+
1376
+
1377
+ # -- epic archive (D17.4) ---------------------------------------------------------------
1378
+
1379
+
1380
+ @dataclass(frozen=True)
1381
+ class ArchiveReport:
1382
+ """What ``epic archive`` did to one namespace, and what it kept."""
1383
+
1384
+ namespace: IssueNamespace
1385
+ claim: ClaimArchive | None = None
1386
+ state_moved_to: Path | None = None
1387
+ binding: RunBinding | None = None
1388
+ binding_problem: str = ""
1389
+
1390
+ @property
1391
+ def nothing_left(self) -> bool:
1392
+ claim_done = self.claim is not None and self.claim.action is not ArchiveAction.NOTHING
1393
+ return not claim_done and self.state_moved_to is None
1394
+
1395
+ def lines(self) -> list[str]:
1396
+ ns = self.namespace
1397
+ number = ns.issue_number
1398
+ out: list[str] = []
1399
+ if self.binding_problem:
1400
+ out.append(f"issue #{number}: the binding cannot be read ({self.binding_problem})")
1401
+ claim = self.claim
1402
+ if claim is not None and claim.action is ArchiveAction.CLOSED and claim.closed:
1403
+ out.append(f"issue #{number}: {_claim_line(claim.closed.claim)} closed as archived")
1404
+ elif claim is not None and claim.action is ArchiveAction.MOVED_ASIDE:
1405
+ out.append(
1406
+ f"issue #{number}: the claim file could not be used ({claim.reason}); moved "
1407
+ f"aside to {claim.moved_to}"
1408
+ )
1409
+ if self.state_moved_to is not None:
1410
+ out.append(f"issue #{number}: the state was moved aside to {self.state_moved_to}")
1411
+ if self.nothing_left:
1412
+ out.append(f"issue #{number}: nothing left to archive (no open claim and no state)")
1413
+ return [redact(line) for line in out]
1414
+ worktree = self.binding.worktree_path if self.binding is not None else "(no binding)"
1415
+ out.append(
1416
+ f"kept: the run logs ({ns.state_dir / 'logs'}), the worker logs ({ns.workers_dir}), "
1417
+ f"the worktree ({worktree}), the binding ({ns.binding_file}) and the claim history "
1418
+ f"({ns.claim_file})"
1419
+ )
1420
+ if self.state_moved_to is not None:
1421
+ out.append(
1422
+ "the run's loop-bound evidence (review rounds, fix attempts, the replan count) "
1423
+ f"and its validation records now live only in {self.state_moved_to}: the next "
1424
+ "admission starts the issue fresh and adopts its marked PR"
1425
+ )
1426
+ out.append("nothing is archived automatically; this was the operator's explicit retry")
1427
+ return [redact(line) for line in out]
1428
+
1429
+
1430
+ def archive_issue(
1431
+ root: Path, epic_url: str, issue: int, repository_id: Callable[[str], int]
1432
+ ) -> ArchiveReport:
1433
+ """``epic archive``: close the claim, then move the state aside (D17.4).
1434
+
1435
+ The caller holds L0. A namespace that does not exist is left alone:
1436
+ nothing is created. One bound under another repository (by its numeric
1437
+ id, :func:`check_recorded_repository`, which reads ``repository_id``
1438
+ whatever name the binding keeps from its first admission) or under
1439
+ another EPIC is refused. L2 is taken non-blocking, so a live owner
1440
+ refuses the archive (:class:`~autoforge.errors.IssueBusy`). Each step is
1441
+ idempotent: a re-run after a crash completes the archive, and one with
1442
+ nothing left says so. No other GitHub read; ``scheduler.json`` is not
1443
+ written.
1444
+ """
1445
+ epic = parse_issue_url(epic_url)
1446
+ namespace = issue_namespace(root, issue)
1447
+ binding: RunBinding | None = None
1448
+ problem = ""
1449
+ try:
1450
+ lookup = load_issue_namespace(root, issue)
1451
+ except (NamespaceMismatch, StateError, OSError) as exc:
1452
+ problem = _bounded(str(exc), 500)
1453
+ else:
1454
+ if not lookup.claim_present and lookup.binding is None:
1455
+ if lookup.state.status is StateStatus.ABSENT:
1456
+ return ArchiveReport(namespace)
1457
+ binding = lookup.binding
1458
+ if binding is not None:
1459
+ check_recorded_repository(
1460
+ f"issue #{issue}'s namespace is bound under",
1461
+ binding.repository,
1462
+ binding.run_key.repository_id,
1463
+ epic,
1464
+ repository_id,
1465
+ refused="nothing was archived",
1466
+ )
1467
+ if binding is not None and binding.epic.number != epic.number:
1468
+ # The same repository (checked above), under its current name.
1469
+ other = f"https://github.com/{epic.repository}/issues/{binding.epic.number}"
1470
+ raise SchedulerStateError(
1471
+ f"issue #{issue}'s namespace is bound under EPIC #{binding.epic.number}, not "
1472
+ f"#{epic.number}: 'autoforge epic archive {other} {issue}' archives it"
1473
+ )
1474
+ lock = IssueRunLock(namespace)
1475
+ try:
1476
+ lock.acquire()
1477
+ except IssueBusy as exc:
1478
+ raise IssueBusy(
1479
+ f"issue #{issue} has a live owner ({exc}); 'epic archive' refuses while it runs"
1480
+ ) from exc
1481
+ try:
1482
+ claim = archive_claim(namespace, lock)
1483
+ moved = archive_issue_state(namespace)
1484
+ finally:
1485
+ lock.release()
1486
+ return ArchiveReport(namespace, claim, moved, binding, problem)