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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- 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)
|