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,839 @@
|
|
|
1
|
+
"""Per-issue run identities and their on-disk namespaces (ADR 0008 D5, D17.3; #178).
|
|
2
|
+
|
|
3
|
+
A standalone controller keeps one run's state in one state directory and
|
|
4
|
+
reuses it for the next issue (``reset_for_new_issue``). The parallel EPIC
|
|
5
|
+
scheduler instead gives every issue a stable identity and a directory of its
|
|
6
|
+
own, found again from the repository alone after a crash:
|
|
7
|
+
|
|
8
|
+
* :class:`RunKey` is ``(repository_id, issue_number)``: GitHub's numeric
|
|
9
|
+
repository id and the issue number. Titles, URLs and the owner/name pair
|
|
10
|
+
are never part of it, so a renamed repository keeps its namespaces.
|
|
11
|
+
* :func:`issue_namespace` is the layout, a pure function of the coordination
|
|
12
|
+
root (the git common dir, the key the repository lock already uses) and
|
|
13
|
+
the issue number::
|
|
14
|
+
|
|
15
|
+
<git common dir>/autoforge/scheduler/issues/<n>/
|
|
16
|
+
run.lock L2, reserved: #180 creates it, nothing here does
|
|
17
|
+
binding.json autoforge.run-binding/1 (this module)
|
|
18
|
+
claim.json autoforge.scheduler-claim/1: scheduler_state.py (#181), never here
|
|
19
|
+
state/ an ordinary state directory (state.json, logs/, read-context/)
|
|
20
|
+
workers/ worker console logs (#182)
|
|
21
|
+
|
|
22
|
+
The common dir is the anchor: every component below it is AutoForge's own
|
|
23
|
+
and is opened descriptor-relative with ``O_NOFOLLOW`` (``safefs``), as a
|
|
24
|
+
LOCAL run's state is (:class:`autoforge.state.StatePaths`). The issue's
|
|
25
|
+
worktree is not in the namespace: it keeps the engine's derivation
|
|
26
|
+
(:meth:`autoforge.engine.ControllerEngine.issue_worktree_path`), so a
|
|
27
|
+
worker reuses the worktree a standalone run made for the same issue.
|
|
28
|
+
* ``binding.json`` (:class:`RunBinding`) records whose namespace it is. It is
|
|
29
|
+
strict, written once with create-exclusive semantics and never rewritten;
|
|
30
|
+
:func:`check_binding` turns a difference from the caller's expectation
|
|
31
|
+
into a typed :class:`~autoforge.errors.NamespaceMismatch`.
|
|
32
|
+
* :func:`load_issue_namespace`, :func:`load_binding` (the binding alone, a
|
|
33
|
+
worker's first read) and :func:`list_issue_namespaces` only read:
|
|
34
|
+
they never create a directory, quarantine or write. A corrupt state is
|
|
35
|
+
reported for its own namespace and no other.
|
|
36
|
+
* :func:`archive_issue_state` moves ``state/state.json`` aside with the
|
|
37
|
+
link-then-unlink primitive of ``run --force``'s quarantine.
|
|
38
|
+
* :func:`check_standalone_state_dir` is the standalone commands' refusal of
|
|
39
|
+
a state directory inside the scheduler root (D17.3), and
|
|
40
|
+
:func:`namespace_issue_of_state_dir` names the namespace whose own state
|
|
41
|
+
directory ``unblock`` acts on (the forward guard exempts it, D17.2).
|
|
42
|
+
|
|
43
|
+
Nothing here takes a lock, creates a claim or reads GitHub: the caller
|
|
44
|
+
supplies the ids (#179), holds the issue's L2 for a write (#180), and decides
|
|
45
|
+
what a lookup means (#181). Every name below the anchor is a constant or a
|
|
46
|
+
validated decimal issue number, never a value read from a file.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
import re
|
|
54
|
+
import stat
|
|
55
|
+
from collections.abc import Iterable
|
|
56
|
+
from dataclasses import dataclass, field
|
|
57
|
+
from datetime import datetime
|
|
58
|
+
from enum import Enum
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
from typing import NoReturn
|
|
61
|
+
|
|
62
|
+
from .errors import NamespaceError, NamespaceMismatch, SchedulerStateDirError, StateError
|
|
63
|
+
from .redaction import redact
|
|
64
|
+
from .safefs import ReadLimitExceeded, SafeRoot, WalkBudgetExceeded, entry_kind
|
|
65
|
+
from .state import (
|
|
66
|
+
STATE_FILENAME,
|
|
67
|
+
AutoForgeState,
|
|
68
|
+
StatePaths,
|
|
69
|
+
archive_state_file,
|
|
70
|
+
load_state,
|
|
71
|
+
utcnow_iso,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
AUTOFORGE_DIRNAME = "autoforge" # inside the git common dir, beside controller.lock
|
|
75
|
+
SCHEDULER_DIRNAME = "scheduler"
|
|
76
|
+
ISSUES_DIRNAME = "issues"
|
|
77
|
+
BINDING_FILENAME = "binding.json"
|
|
78
|
+
# Reserved in every issue namespace for the children that own them: the
|
|
79
|
+
# per-issue run lock (L2, #180) and the scheduler claim (#181). This module
|
|
80
|
+
# reads whether a claim exists and nothing else about either.
|
|
81
|
+
RUN_LOCK_FILENAME = "run.lock"
|
|
82
|
+
CLAIM_FILENAME = "claim.json"
|
|
83
|
+
STATE_DIRNAME = "state"
|
|
84
|
+
WORKERS_DIRNAME = "workers"
|
|
85
|
+
|
|
86
|
+
BINDING_SCHEMA = "autoforge.run-binding/1"
|
|
87
|
+
|
|
88
|
+
# More entries than this under ``issues/`` refuses the listing (D18.2): no
|
|
89
|
+
# EPIC this controller can drive has that many children, and the bound is
|
|
90
|
+
# applied while the directory is read, before anything is sorted or opened.
|
|
91
|
+
MAX_ISSUE_NAMESPACES = 10_000
|
|
92
|
+
# A binding is a few hundred bytes; a larger file is refused unread.
|
|
93
|
+
MAX_BINDING_BYTES = 16 * 1024
|
|
94
|
+
# GitHub's ids are 64-bit signed integers.
|
|
95
|
+
MAX_ID = 2**63 - 1
|
|
96
|
+
MAX_OWNER_CHARS = 39 # GitHub's limit on a user or organization name
|
|
97
|
+
MAX_REPOSITORY_NAME_CHARS = 100 # GitHub's limit on a repository name
|
|
98
|
+
MAX_WORKTREE_PATH_CHARS = 4096 # PATH_MAX
|
|
99
|
+
MAX_TIMESTAMP_CHARS = 32 # utcnow_iso(): 2026-01-01T00:00:00.000000+00:00
|
|
100
|
+
# The reason a namespace's state could not be loaded, as reported: redacted,
|
|
101
|
+
# then bounded, because it quotes the state loader's message.
|
|
102
|
+
MAX_STATE_REASON_CHARS = 2000
|
|
103
|
+
# A binding's refusal quotes the file only this far: at most this many
|
|
104
|
+
# unknown field names (each cut to 80 characters), and an out-of-range id
|
|
105
|
+
# only while its decimal rendering is this short (otherwise its length).
|
|
106
|
+
MAX_QUOTED_NAMES = 5
|
|
107
|
+
MAX_QUOTED_INT_CHARS = 40
|
|
108
|
+
|
|
109
|
+
_BINDING_KEYS = (
|
|
110
|
+
"schema",
|
|
111
|
+
"run_key",
|
|
112
|
+
"issue_id",
|
|
113
|
+
"repository",
|
|
114
|
+
"epic",
|
|
115
|
+
"worktree_path",
|
|
116
|
+
"created_at",
|
|
117
|
+
)
|
|
118
|
+
_NAME_RE = re.compile(r"[A-Za-z0-9_.-]+")
|
|
119
|
+
# An issue directory's name: the canonical decimal rendering of a positive
|
|
120
|
+
# issue number, so ``011`` can never alias ``11``. Nineteen digits reach
|
|
121
|
+
# past MAX_ID; the listing checks the value as well.
|
|
122
|
+
_ISSUE_DIR_RE = re.compile(r"[1-9][0-9]{0,18}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# -- field checks -----------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _fail(what: str, message: str) -> NoReturn:
|
|
129
|
+
raise NamespaceError(f"{what} {message}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _exact(raw: object, keys: Iterable[str], what: str) -> dict:
|
|
133
|
+
if not isinstance(raw, dict):
|
|
134
|
+
_fail(what, f"must be an object, got {type(raw).__name__}")
|
|
135
|
+
wanted = tuple(keys)
|
|
136
|
+
unknown = sorted(str(k) for k in raw if k not in wanted)
|
|
137
|
+
if unknown:
|
|
138
|
+
_fail(what, f"has unknown field(s) {_quoted_names(unknown)}")
|
|
139
|
+
missing = [k for k in wanted if k not in raw]
|
|
140
|
+
if missing:
|
|
141
|
+
_fail(what, f"is missing field(s) {missing}")
|
|
142
|
+
return raw
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _check_id(value: object, what: str) -> int:
|
|
146
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
147
|
+
_fail(what, f"must be an integer, got {type(value).__name__}")
|
|
148
|
+
if value < 1 or value > MAX_ID:
|
|
149
|
+
digits = str(abs(value))
|
|
150
|
+
got = str(value) if len(digits) <= MAX_QUOTED_INT_CHARS else f"{len(digits)} digits"
|
|
151
|
+
_fail(what, f"must be a positive integer of at most {MAX_ID}, got {got}")
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _quoted_names(names: list[str]) -> str:
|
|
156
|
+
shown = ", ".join(f"{name!r:.80}" for name in names[:MAX_QUOTED_NAMES])
|
|
157
|
+
more = len(names) - MAX_QUOTED_NAMES
|
|
158
|
+
return f"[{shown}]" if more <= 0 else f"[{shown}] and {more} more"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _check_str(value: object, what: str, max_chars: int) -> str:
|
|
162
|
+
if not isinstance(value, str):
|
|
163
|
+
_fail(what, f"must be a string, got {type(value).__name__}")
|
|
164
|
+
if not value:
|
|
165
|
+
_fail(what, "must not be empty")
|
|
166
|
+
if len(value) > max_chars:
|
|
167
|
+
_fail(what, f"is {len(value)} characters, over its bound of {max_chars}")
|
|
168
|
+
return value
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _check_name(value: object, what: str, max_chars: int) -> str:
|
|
172
|
+
text = _check_str(value, what, max_chars)
|
|
173
|
+
if not _NAME_RE.fullmatch(text) or text in (".", ".."):
|
|
174
|
+
_fail(what, f"is not a GitHub owner or repository name: {text!r}")
|
|
175
|
+
return text
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _check_path(value: object, what: str) -> str:
|
|
179
|
+
text = _check_str(value, what, MAX_WORKTREE_PATH_CHARS)
|
|
180
|
+
if not os.path.isabs(text) or any(ord(c) < 0x20 or ord(c) == 0x7F for c in text):
|
|
181
|
+
_fail(what, "must be an absolute path without control characters")
|
|
182
|
+
return text
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _check_timestamp(value: object, what: str) -> str:
|
|
186
|
+
text = _check_str(value, what, MAX_TIMESTAMP_CHARS)
|
|
187
|
+
try:
|
|
188
|
+
moment = datetime.fromisoformat(text)
|
|
189
|
+
except ValueError:
|
|
190
|
+
_fail(what, f"is not an ISO 8601 time: {text!r}")
|
|
191
|
+
offset = moment.utcoffset()
|
|
192
|
+
if offset is None or offset.total_seconds() != 0 or "T" not in text:
|
|
193
|
+
_fail(what, f"must be an ISO 8601 UTC time as utcnow_iso writes it, got {text!r}")
|
|
194
|
+
return text
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# -- identity and layout ----------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class RunKey:
|
|
202
|
+
"""The identity of one issue's run and of its namespace (ADR 0008 D5.1).
|
|
203
|
+
|
|
204
|
+
``repository_id`` is GitHub's numeric repository id and ``issue_number``
|
|
205
|
+
the issue's number, both positive. ``str(key)`` is the stable
|
|
206
|
+
``<repository_id>/<issue_number>`` diagnostics print.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
repository_id: int
|
|
210
|
+
issue_number: int
|
|
211
|
+
|
|
212
|
+
def __post_init__(self) -> None:
|
|
213
|
+
_check_id(self.repository_id, "run key repository_id")
|
|
214
|
+
_check_id(self.issue_number, "run key issue_number")
|
|
215
|
+
|
|
216
|
+
def __str__(self) -> str:
|
|
217
|
+
return f"{self.repository_id}/{self.issue_number}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def scheduler_root(coordination_root: str | Path) -> Path:
|
|
221
|
+
"""``<coordination root>/autoforge/scheduler``: the scheduler's private tree."""
|
|
222
|
+
return Path(coordination_root) / AUTOFORGE_DIRNAME / SCHEDULER_DIRNAME
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True)
|
|
226
|
+
class IssueNamespace:
|
|
227
|
+
"""Where one issue's scheduler run lives (ADR 0008 D5.3); paths only, no I/O.
|
|
228
|
+
|
|
229
|
+
Build it with :func:`issue_namespace`. ``coordination_root`` is the git
|
|
230
|
+
common dir and the anchor of every path here; :attr:`relative` is the
|
|
231
|
+
chain below it that is walked with ``O_NOFOLLOW``.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
coordination_root: Path
|
|
235
|
+
issue_number: int
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def relative(self) -> tuple[str, ...]:
|
|
239
|
+
"""The namespace directory's components below the coordination root."""
|
|
240
|
+
return (AUTOFORGE_DIRNAME, SCHEDULER_DIRNAME, ISSUES_DIRNAME, str(self.issue_number))
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def directory(self) -> Path:
|
|
244
|
+
return self.coordination_root.joinpath(*self.relative)
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def binding_file(self) -> Path:
|
|
248
|
+
return self.directory / BINDING_FILENAME
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def run_lock_file(self) -> Path:
|
|
252
|
+
"""L2's path (#180); reserved, never created here."""
|
|
253
|
+
return self.directory / RUN_LOCK_FILENAME
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def claim_file(self) -> Path:
|
|
257
|
+
"""The scheduler claim's path (#181); reserved, never created here."""
|
|
258
|
+
return self.directory / CLAIM_FILENAME
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def state_dir(self) -> Path:
|
|
262
|
+
return self.directory / STATE_DIRNAME
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def state_file(self) -> Path:
|
|
266
|
+
return self.state_dir / STATE_FILENAME
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def workers_dir(self) -> Path:
|
|
270
|
+
return self.directory / WORKERS_DIRNAME
|
|
271
|
+
|
|
272
|
+
def state_paths(self) -> StatePaths:
|
|
273
|
+
"""The namespace's state directory, anchored at the coordination root.
|
|
274
|
+
|
|
275
|
+
What a worker's engine runs on (:meth:`ControllerEngine.bind_issue_namespace
|
|
276
|
+
<autoforge.engine.ControllerEngine.bind_issue_namespace>`): every
|
|
277
|
+
component from ``autoforge`` down to ``state`` is opened ``O_NOFOLLOW``
|
|
278
|
+
from the common dir's descriptor, never resolved from the working
|
|
279
|
+
directory, ``--state-dir`` or ``config.state_dir``.
|
|
280
|
+
"""
|
|
281
|
+
return StatePaths.from_state_dir(self.state_dir, anchor=self.coordination_root)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def issue_namespace(coordination_root: str | Path, issue_number: int) -> IssueNamespace:
|
|
285
|
+
"""The namespace of ``issue_number`` under ``coordination_root``; pure and deterministic.
|
|
286
|
+
|
|
287
|
+
``coordination_root`` is the git common dir as an absolute path (the
|
|
288
|
+
caller resolves it, as :func:`autoforge.locking.repository_lock_path`
|
|
289
|
+
does); it is normalised lexically and never looked up, so two calls with
|
|
290
|
+
the same arguments give the same value whatever the filesystem holds.
|
|
291
|
+
"""
|
|
292
|
+
_check_id(issue_number, "issue number")
|
|
293
|
+
return IssueNamespace(_coordination_root(coordination_root), issue_number)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _coordination_root(coordination_root: str | Path) -> Path:
|
|
297
|
+
root = str(coordination_root)
|
|
298
|
+
if not os.path.isabs(root):
|
|
299
|
+
raise NamespaceError(
|
|
300
|
+
f"coordination root {root!r} must be an absolute path (the resolved git common dir)"
|
|
301
|
+
)
|
|
302
|
+
return Path(os.path.normpath(root))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# -- binding.json -----------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@dataclass(frozen=True)
|
|
309
|
+
class EpicRef:
|
|
310
|
+
"""The EPIC a namespace was first bound under: its number and GitHub issue id."""
|
|
311
|
+
|
|
312
|
+
number: int
|
|
313
|
+
issue_id: int
|
|
314
|
+
|
|
315
|
+
def __post_init__(self) -> None:
|
|
316
|
+
_check_id(self.number, "epic number")
|
|
317
|
+
_check_id(self.issue_id, "epic issue_id")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@dataclass(frozen=True)
|
|
321
|
+
class RunBinding:
|
|
322
|
+
"""``binding.json``, schema ``autoforge.run-binding/1`` (ADR 0008 D5.2).
|
|
323
|
+
|
|
324
|
+
``run_key``, ``issue_id`` and ``epic`` are identity, compared by
|
|
325
|
+
:func:`check_binding`; ``repository_owner``/``repository_name`` are for
|
|
326
|
+
display and ``worktree_path`` for diagnostics only. Every field is
|
|
327
|
+
validated on construction, so a value that exists is one the file may
|
|
328
|
+
hold.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
run_key: RunKey
|
|
332
|
+
issue_id: int
|
|
333
|
+
repository_owner: str
|
|
334
|
+
repository_name: str
|
|
335
|
+
epic: EpicRef
|
|
336
|
+
worktree_path: str
|
|
337
|
+
created_at: str = field(default_factory=utcnow_iso)
|
|
338
|
+
|
|
339
|
+
def __post_init__(self) -> None:
|
|
340
|
+
if not isinstance(self.run_key, RunKey):
|
|
341
|
+
_fail("binding run_key", f"must be a RunKey, got {type(self.run_key).__name__}")
|
|
342
|
+
_check_id(self.issue_id, "binding issue_id")
|
|
343
|
+
_check_name(self.repository_owner, "binding repository owner", MAX_OWNER_CHARS)
|
|
344
|
+
_check_name(self.repository_name, "binding repository name", MAX_REPOSITORY_NAME_CHARS)
|
|
345
|
+
if not isinstance(self.epic, EpicRef):
|
|
346
|
+
_fail("binding epic", f"must be an EpicRef, got {type(self.epic).__name__}")
|
|
347
|
+
_check_path(self.worktree_path, "binding worktree_path")
|
|
348
|
+
_check_timestamp(self.created_at, "binding created_at")
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def repository(self) -> str:
|
|
352
|
+
"""``owner/name``, for display."""
|
|
353
|
+
return f"{self.repository_owner}/{self.repository_name}"
|
|
354
|
+
|
|
355
|
+
def to_dict(self) -> dict:
|
|
356
|
+
return {
|
|
357
|
+
"schema": BINDING_SCHEMA,
|
|
358
|
+
"run_key": {
|
|
359
|
+
"repository_id": self.run_key.repository_id,
|
|
360
|
+
"issue_number": self.run_key.issue_number,
|
|
361
|
+
},
|
|
362
|
+
"issue_id": self.issue_id,
|
|
363
|
+
"repository": {"owner": self.repository_owner, "name": self.repository_name},
|
|
364
|
+
"epic": {"number": self.epic.number, "issue_id": self.epic.issue_id},
|
|
365
|
+
"worktree_path": self.worktree_path,
|
|
366
|
+
"created_at": self.created_at,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
@classmethod
|
|
370
|
+
def from_dict(cls, raw: object, *, where: str = BINDING_FILENAME) -> RunBinding:
|
|
371
|
+
"""Strictly parse a binding: an unknown version or field is refused, never skipped.
|
|
372
|
+
|
|
373
|
+
``where`` names the file in every refusal (:class:`NamespaceError`).
|
|
374
|
+
"""
|
|
375
|
+
try:
|
|
376
|
+
if not isinstance(raw, dict):
|
|
377
|
+
_fail("binding", f"must be a JSON object, got {type(raw).__name__}")
|
|
378
|
+
# The version decides what the other fields mean, so it is read
|
|
379
|
+
# first: a later schema is refused as such, not as unknown fields.
|
|
380
|
+
schema = raw.get("schema")
|
|
381
|
+
if schema != BINDING_SCHEMA:
|
|
382
|
+
_fail(
|
|
383
|
+
"binding",
|
|
384
|
+
f"has schema {schema!r:.80}; this controller reads only {BINDING_SCHEMA}",
|
|
385
|
+
)
|
|
386
|
+
data = _exact(raw, _BINDING_KEYS, "binding")
|
|
387
|
+
key = _exact(data["run_key"], ("repository_id", "issue_number"), "binding run_key")
|
|
388
|
+
repo = _exact(data["repository"], ("owner", "name"), "binding repository")
|
|
389
|
+
epic = _exact(data["epic"], ("number", "issue_id"), "binding epic")
|
|
390
|
+
return cls(
|
|
391
|
+
run_key=RunKey(key["repository_id"], key["issue_number"]),
|
|
392
|
+
issue_id=data["issue_id"],
|
|
393
|
+
repository_owner=repo["owner"],
|
|
394
|
+
repository_name=repo["name"],
|
|
395
|
+
epic=EpicRef(epic["number"], epic["issue_id"]),
|
|
396
|
+
worktree_path=data["worktree_path"],
|
|
397
|
+
created_at=data["created_at"],
|
|
398
|
+
)
|
|
399
|
+
except NamespaceError as exc:
|
|
400
|
+
raise NamespaceError(f"invalid {BINDING_SCHEMA} document {where}: {exc}") from exc
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def check_binding(
|
|
404
|
+
binding: RunBinding, *, run_key: RunKey, issue_id: int, epic: EpicRef | None = None
|
|
405
|
+
) -> None:
|
|
406
|
+
"""Refuse a binding that names another run than the caller expects.
|
|
407
|
+
|
|
408
|
+
Raises :class:`~autoforge.errors.NamespaceMismatch` with ``field`` set to
|
|
409
|
+
the first identity that differs, in the order ``repository_id`` (another
|
|
410
|
+
repository behind this common dir: the scheduler's global uncertainty,
|
|
411
|
+
D14.3), ``issue_number``, ``issue_id`` (the issue was deleted and its
|
|
412
|
+
number reused) and ``epic``. ``epic=None`` skips the EPIC: a namespace
|
|
413
|
+
first bound under another EPIC is the caller's ``held:other-epic``, not
|
|
414
|
+
a broken binding (D5.5).
|
|
415
|
+
"""
|
|
416
|
+
checks: list[tuple[str, object, object]] = [
|
|
417
|
+
("repository_id", binding.run_key.repository_id, run_key.repository_id),
|
|
418
|
+
("issue_number", binding.run_key.issue_number, run_key.issue_number),
|
|
419
|
+
("issue_id", binding.issue_id, issue_id),
|
|
420
|
+
]
|
|
421
|
+
if epic is not None:
|
|
422
|
+
checks.append(
|
|
423
|
+
(
|
|
424
|
+
"epic",
|
|
425
|
+
(binding.epic.number, binding.epic.issue_id),
|
|
426
|
+
(epic.number, epic.issue_id),
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
for name, recorded, expected in checks:
|
|
430
|
+
if recorded != expected:
|
|
431
|
+
raise NamespaceMismatch(
|
|
432
|
+
f"the namespace of run {binding.run_key} is bound with {name} {recorded}, "
|
|
433
|
+
f"not the expected {expected}; a binding is never rewritten to agree",
|
|
434
|
+
field=name,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def write_binding(namespace: IssueNamespace, binding: RunBinding) -> Path:
|
|
439
|
+
"""Create the namespace's ``binding.json``, exactly once; returns its path.
|
|
440
|
+
|
|
441
|
+
The bytes are written and fsynced into a temporary and *linked* to the
|
|
442
|
+
final name (:meth:`SafeRoot.create_exclusive`), so a crash leaves either
|
|
443
|
+
no binding or a complete one, and an existing entry of that name (a
|
|
444
|
+
binding, a link, anything) refuses the write with
|
|
445
|
+
:class:`NamespaceError`. The directories from the coordination root down
|
|
446
|
+
are created ``O_NOFOLLOW``, so a link planted at any of them refuses the
|
|
447
|
+
write instead of redirecting it. The caller (#182's admission) holds the
|
|
448
|
+
issue's L2 and decides when to call this.
|
|
449
|
+
"""
|
|
450
|
+
if binding.run_key.issue_number != namespace.issue_number:
|
|
451
|
+
raise NamespaceMismatch(
|
|
452
|
+
f"binding for run {binding.run_key} cannot be written into the namespace of "
|
|
453
|
+
f"issue {namespace.issue_number}",
|
|
454
|
+
field="issue_number",
|
|
455
|
+
)
|
|
456
|
+
payload = json.dumps(binding.to_dict(), indent=2, sort_keys=True) + "\n"
|
|
457
|
+
with _open_anchor(namespace.coordination_root) as anchor:
|
|
458
|
+
try:
|
|
459
|
+
anchor.create_exclusive(
|
|
460
|
+
"/".join((*namespace.relative, BINDING_FILENAME)), payload.encode("utf-8")
|
|
461
|
+
)
|
|
462
|
+
except FileExistsError:
|
|
463
|
+
raise NamespaceError(
|
|
464
|
+
f"the namespace of issue {namespace.issue_number} is already bound "
|
|
465
|
+
f"({namespace.binding_file} exists); binding.json is written once and never "
|
|
466
|
+
"rewritten"
|
|
467
|
+
) from None
|
|
468
|
+
return namespace.binding_file
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# -- lookup -----------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class NamespaceKind(Enum):
|
|
475
|
+
"""What :func:`load_issue_namespace` found (ADR 0008 D14.2 classifies it)."""
|
|
476
|
+
|
|
477
|
+
#: No binding, no claim file and no ``state/state.json``. A directory
|
|
478
|
+
#: holding only ``run.lock`` (a crash between taking L2 and writing the
|
|
479
|
+
#: binding), logs or worker logs is absent too.
|
|
480
|
+
ABSENT = "absent"
|
|
481
|
+
#: A claim file or a state exists, and ``binding.json`` does not.
|
|
482
|
+
UNBOUND = "unbound"
|
|
483
|
+
#: ``binding.json`` exists and is a valid binding of this issue.
|
|
484
|
+
BOUND = "bound"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class StateStatus(Enum):
|
|
488
|
+
ABSENT = "absent"
|
|
489
|
+
LOADED = "loaded"
|
|
490
|
+
CORRUPT = "corrupt"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@dataclass(frozen=True)
|
|
494
|
+
class NamespaceState:
|
|
495
|
+
"""The namespace's ``state/state.json``, read strictly and read-only.
|
|
496
|
+
|
|
497
|
+
``state`` is set when :attr:`status` is ``LOADED``; ``reason`` (redacted
|
|
498
|
+
and bounded) when it is ``CORRUPT``: unreadable, unparseable, of an
|
|
499
|
+
unknown protocol, not a regular file, or reached through a link.
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
status: StateStatus
|
|
503
|
+
state: AutoForgeState | None = None
|
|
504
|
+
reason: str = ""
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@dataclass(frozen=True)
|
|
508
|
+
class NamespaceLookup:
|
|
509
|
+
"""One namespace as it is on disk; nothing was created, moved or written to get it."""
|
|
510
|
+
|
|
511
|
+
namespace: IssueNamespace
|
|
512
|
+
kind: NamespaceKind
|
|
513
|
+
binding: RunBinding | None
|
|
514
|
+
state: NamespaceState
|
|
515
|
+
#: Whether ``claim.json`` exists (as any kind of entry); parsing it is #181's.
|
|
516
|
+
claim_present: bool
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def load_issue_namespace(coordination_root: str | Path, issue_number: int) -> NamespaceLookup:
|
|
520
|
+
"""Read issue ``issue_number``'s namespace, strictly and without writing anything.
|
|
521
|
+
|
|
522
|
+
A binding is parsed strictly: a corrupt one (including one past the JSON
|
|
523
|
+
decoder's integer or nesting limit), or one that names another
|
|
524
|
+
issue than its directory, raises (:class:`NamespaceError`,
|
|
525
|
+
:class:`~autoforge.errors.NamespaceMismatch`), and the caller reports
|
|
526
|
+
that namespace as uncertain. A symbolic link or another kind of entry at
|
|
527
|
+
any directory of the namespace's path raises
|
|
528
|
+
:class:`~autoforge.safefs.UnsafePathError` without following it. The
|
|
529
|
+
state is loaded with :func:`autoforge.state.load_state` through a root
|
|
530
|
+
opened ``O_NOFOLLOW`` from the coordination root, never created and
|
|
531
|
+
never quarantined; a state that does not load is reported as
|
|
532
|
+
``CORRUPT`` for this namespace only.
|
|
533
|
+
"""
|
|
534
|
+
namespace = issue_namespace(coordination_root, issue_number)
|
|
535
|
+
with _open_anchor(namespace.coordination_root) as anchor:
|
|
536
|
+
try:
|
|
537
|
+
issue_root = anchor.subroot("/".join(namespace.relative))
|
|
538
|
+
except FileNotFoundError:
|
|
539
|
+
return NamespaceLookup(
|
|
540
|
+
namespace, NamespaceKind.ABSENT, None, NamespaceState(StateStatus.ABSENT), False
|
|
541
|
+
)
|
|
542
|
+
with issue_root:
|
|
543
|
+
binding = _read_binding(issue_root, namespace)
|
|
544
|
+
claim_present = issue_root.lstat(CLAIM_FILENAME) is not None
|
|
545
|
+
state = _read_state(issue_root, namespace)
|
|
546
|
+
if binding is not None:
|
|
547
|
+
kind = NamespaceKind.BOUND
|
|
548
|
+
elif claim_present or state.status is not StateStatus.ABSENT:
|
|
549
|
+
kind = NamespaceKind.UNBOUND
|
|
550
|
+
else:
|
|
551
|
+
kind = NamespaceKind.ABSENT
|
|
552
|
+
return NamespaceLookup(namespace, kind, binding, state, claim_present)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def load_binding(namespace: IssueNamespace) -> RunBinding | None:
|
|
556
|
+
"""The namespace's ``binding.json`` alone, strictly; ``None`` when there is none.
|
|
557
|
+
|
|
558
|
+
What a worker checks before it reads anything else of its namespace
|
|
559
|
+
(#182): :func:`load_issue_namespace`'s strict binding read, without the
|
|
560
|
+
state. Read-only; a corrupt binding, one of another issue and a link at
|
|
561
|
+
any directory of the path raise as they do there.
|
|
562
|
+
"""
|
|
563
|
+
with _open_anchor(namespace.coordination_root) as anchor:
|
|
564
|
+
try:
|
|
565
|
+
issue_root = anchor.subroot("/".join(namespace.relative))
|
|
566
|
+
except FileNotFoundError:
|
|
567
|
+
return None
|
|
568
|
+
with issue_root:
|
|
569
|
+
return _read_binding(issue_root, namespace)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _read_binding(issue_root: SafeRoot, namespace: IssueNamespace) -> RunBinding | None:
|
|
573
|
+
where = str(namespace.binding_file)
|
|
574
|
+
st = issue_root.lstat(BINDING_FILENAME)
|
|
575
|
+
if st is None:
|
|
576
|
+
return None
|
|
577
|
+
kind = entry_kind(st.st_mode)
|
|
578
|
+
if kind is not None:
|
|
579
|
+
raise NamespaceError(f"{where} is a {kind}, not a regular file; it is never followed")
|
|
580
|
+
try:
|
|
581
|
+
raw = issue_root.read_bytes(BINDING_FILENAME, limit=MAX_BINDING_BYTES)
|
|
582
|
+
except ReadLimitExceeded as exc:
|
|
583
|
+
raise NamespaceError(
|
|
584
|
+
f"{where} is larger than {MAX_BINDING_BYTES} bytes, which no binding can be"
|
|
585
|
+
) from exc
|
|
586
|
+
if raw is None:
|
|
587
|
+
# Removed between the lstat and the open: no binding is what is there now.
|
|
588
|
+
return None
|
|
589
|
+
try:
|
|
590
|
+
data = json.loads(
|
|
591
|
+
raw.decode("utf-8"),
|
|
592
|
+
object_pairs_hook=_unique_keys,
|
|
593
|
+
parse_constant=_no_constant,
|
|
594
|
+
)
|
|
595
|
+
except UnicodeDecodeError as exc:
|
|
596
|
+
raise NamespaceError(f"{where} is not valid UTF-8 ({exc})") from exc
|
|
597
|
+
except json.JSONDecodeError as exc:
|
|
598
|
+
raise NamespaceError(f"{where} is not valid JSON ({exc})") from exc
|
|
599
|
+
except NamespaceError as exc: # a duplicated key or a non-JSON number
|
|
600
|
+
raise NamespaceError(f"{where}: {exc}") from exc
|
|
601
|
+
except (ValueError, RecursionError) as exc:
|
|
602
|
+
# The decoder's own limits, which are not JSONDecodeError: an integer
|
|
603
|
+
# past ``sys.get_int_max_str_digits()`` and nesting past the recursion
|
|
604
|
+
# limit or the C stack, both inside MAX_BINDING_BYTES. Only the type
|
|
605
|
+
# is reported, never the input; an interrupt still propagates.
|
|
606
|
+
raise NamespaceError(
|
|
607
|
+
f"{where} exceeds the JSON decoder's integer or nesting limit "
|
|
608
|
+
f"({type(exc).__name__}); no binding comes near it"
|
|
609
|
+
) from exc
|
|
610
|
+
binding = RunBinding.from_dict(data, where=where)
|
|
611
|
+
if binding.run_key.issue_number != namespace.issue_number:
|
|
612
|
+
raise NamespaceMismatch(
|
|
613
|
+
f"{where} binds run {binding.run_key}, not issue {namespace.issue_number}",
|
|
614
|
+
field="issue_number",
|
|
615
|
+
)
|
|
616
|
+
return binding
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _unique_keys(pairs: list[tuple[str, object]]) -> dict:
|
|
620
|
+
data: dict = {}
|
|
621
|
+
for key, value in pairs:
|
|
622
|
+
if key in data:
|
|
623
|
+
raise NamespaceError(f"binding has the field {key!r:.80} more than once")
|
|
624
|
+
data[key] = value
|
|
625
|
+
return data
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _no_constant(name: str) -> NoReturn:
|
|
629
|
+
raise NamespaceError(f"binding holds the non-JSON number {name}")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _read_state(issue_root: SafeRoot, namespace: IssueNamespace) -> NamespaceState:
|
|
633
|
+
relpath = f"{STATE_DIRNAME}/{STATE_FILENAME}"
|
|
634
|
+
try:
|
|
635
|
+
if issue_root.lstat(relpath) is None:
|
|
636
|
+
return NamespaceState(StateStatus.ABSENT)
|
|
637
|
+
with issue_root.subroot(STATE_DIRNAME) as state_root:
|
|
638
|
+
state = load_state(namespace.state_file, root=state_root)
|
|
639
|
+
except FileNotFoundError:
|
|
640
|
+
# The state directory went away between the lstat and the open.
|
|
641
|
+
return NamespaceState(StateStatus.ABSENT)
|
|
642
|
+
except StateError as exc:
|
|
643
|
+
return NamespaceState(StateStatus.CORRUPT, reason=_bounded_reason(str(exc)))
|
|
644
|
+
return NamespaceState(StateStatus.LOADED, state=state)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _bounded_reason(text: str) -> str:
|
|
648
|
+
text = redact(text)
|
|
649
|
+
if len(text) <= MAX_STATE_REASON_CHARS:
|
|
650
|
+
return text
|
|
651
|
+
marker = " [... reason truncated]"
|
|
652
|
+
return text[: MAX_STATE_REASON_CHARS - len(marker)] + marker
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# -- enumeration ------------------------------------------------------------------------
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@dataclass(frozen=True)
|
|
659
|
+
class RejectedEntry:
|
|
660
|
+
"""An entry of ``issues/`` that is not an issue directory: reported, never followed."""
|
|
661
|
+
|
|
662
|
+
name: str
|
|
663
|
+
reason: str
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@dataclass(frozen=True)
|
|
667
|
+
class NamespaceListing:
|
|
668
|
+
"""Every entry of ``<common>/autoforge/scheduler/issues``, each in one place.
|
|
669
|
+
|
|
670
|
+
``bound``: issue directories holding a ``binding.json`` entry, in
|
|
671
|
+
ascending issue number (load each with :func:`load_issue_namespace`);
|
|
672
|
+
``unbound``: issue directories without one, also ascending;
|
|
673
|
+
``rejected``: everything else, by name.
|
|
674
|
+
"""
|
|
675
|
+
|
|
676
|
+
bound: tuple[IssueNamespace, ...]
|
|
677
|
+
unbound: tuple[IssueNamespace, ...]
|
|
678
|
+
rejected: tuple[RejectedEntry, ...]
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def list_issue_namespaces(coordination_root: str | Path) -> NamespaceListing:
|
|
682
|
+
"""List the namespaces under ``coordination_root``, read-only and bounded.
|
|
683
|
+
|
|
684
|
+
More than :data:`MAX_ISSUE_NAMESPACES` entries refuses the listing
|
|
685
|
+
(:class:`NamespaceError`) as soon as the directory read passes the
|
|
686
|
+
bound. An entry whose name is not a canonical decimal issue number (or
|
|
687
|
+
is one past :data:`MAX_ID`), or that is a symbolic link, a regular file
|
|
688
|
+
or any other non-directory, is reported in
|
|
689
|
+
:attr:`NamespaceListing.rejected` and never opened; it never hides the
|
|
690
|
+
other entries. A link in place of ``autoforge``, ``scheduler`` or
|
|
691
|
+
``issues`` itself raises :class:`~autoforge.safefs.UnsafePathError`. No
|
|
692
|
+
directory is created: a repository without a scheduler tree lists
|
|
693
|
+
nothing.
|
|
694
|
+
"""
|
|
695
|
+
root = _coordination_root(coordination_root)
|
|
696
|
+
issues_relpath = "/".join((AUTOFORGE_DIRNAME, SCHEDULER_DIRNAME, ISSUES_DIRNAME))
|
|
697
|
+
with _open_anchor(root) as anchor:
|
|
698
|
+
try:
|
|
699
|
+
issues = anchor.subroot(issues_relpath)
|
|
700
|
+
except FileNotFoundError:
|
|
701
|
+
return NamespaceListing((), (), ())
|
|
702
|
+
bound: list[IssueNamespace] = []
|
|
703
|
+
unbound: list[IssueNamespace] = []
|
|
704
|
+
rejected: list[RejectedEntry] = []
|
|
705
|
+
with issues:
|
|
706
|
+
entries: list[tuple[str, int]] = []
|
|
707
|
+
try:
|
|
708
|
+
for entry in issues.walk(max_entries=MAX_ISSUE_NAMESPACES):
|
|
709
|
+
entry.skip = True # one level: an issue directory's contents are its own
|
|
710
|
+
entries.append((entry.name, entry.st.st_mode))
|
|
711
|
+
except WalkBudgetExceeded as exc:
|
|
712
|
+
raise NamespaceError(
|
|
713
|
+
f"{root / issues_relpath} holds more than {MAX_ISSUE_NAMESPACES} entries; "
|
|
714
|
+
"refusing to list it"
|
|
715
|
+
) from exc
|
|
716
|
+
for name, mode in entries:
|
|
717
|
+
if not _ISSUE_DIR_RE.fullmatch(name):
|
|
718
|
+
rejected.append(RejectedEntry(name, "not a decimal issue number"))
|
|
719
|
+
continue
|
|
720
|
+
if int(name) > MAX_ID:
|
|
721
|
+
rejected.append(RejectedEntry(name, f"past the largest issue number {MAX_ID}"))
|
|
722
|
+
continue
|
|
723
|
+
if not stat.S_ISDIR(mode):
|
|
724
|
+
kind = entry_kind(mode) or "regular file"
|
|
725
|
+
rejected.append(RejectedEntry(name, f"a {kind}, not a directory"))
|
|
726
|
+
continue
|
|
727
|
+
namespace = issue_namespace(root, int(name))
|
|
728
|
+
try:
|
|
729
|
+
has_binding = issues.lstat(f"{name}/{BINDING_FILENAME}") is not None
|
|
730
|
+
except StateError as exc:
|
|
731
|
+
# Replaced by a link or made unreadable since it was listed.
|
|
732
|
+
rejected.append(RejectedEntry(name, _bounded_reason(str(exc))))
|
|
733
|
+
continue
|
|
734
|
+
(bound if has_binding else unbound).append(namespace)
|
|
735
|
+
return NamespaceListing(
|
|
736
|
+
bound=tuple(sorted(bound, key=lambda ns: ns.issue_number)),
|
|
737
|
+
unbound=tuple(sorted(unbound, key=lambda ns: ns.issue_number)),
|
|
738
|
+
rejected=tuple(sorted(rejected, key=lambda r: r.name)),
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# -- archive ----------------------------------------------------------------------------
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def archive_issue_state(namespace: IssueNamespace) -> Path | None:
|
|
746
|
+
"""Move the namespace's ``state/state.json`` aside; ``None`` when there is none.
|
|
747
|
+
|
|
748
|
+
The state is renamed to ``state.json.archived-<UTC timestamp>`` by
|
|
749
|
+
:func:`autoforge.state.archive_state_file`, through a root on the
|
|
750
|
+
namespace's ``state/`` reached ``O_NOFOLLOW`` from the coordination root:
|
|
751
|
+
the archived name is reserved by ``link(2)`` before ``state.json`` is
|
|
752
|
+
unlinked, so a crash between the two leaves both names and calling this
|
|
753
|
+
again completes the move. With no ``state.json`` (or no state directory)
|
|
754
|
+
nothing is changed and ``None`` is returned. The logs, the worker logs,
|
|
755
|
+
the binding, the claim and the worktree are never touched.
|
|
756
|
+
|
|
757
|
+
The caller must hold the issue's L2 (#180) so that no worker writes the
|
|
758
|
+
state while it is moved; ``epic archive`` (#183) closes the claim first
|
|
759
|
+
(ADR 0008 D17.4).
|
|
760
|
+
"""
|
|
761
|
+
with _open_anchor(namespace.coordination_root) as anchor:
|
|
762
|
+
try:
|
|
763
|
+
state_root = anchor.subroot("/".join((*namespace.relative, STATE_DIRNAME)))
|
|
764
|
+
except FileNotFoundError:
|
|
765
|
+
return None
|
|
766
|
+
with state_root:
|
|
767
|
+
if state_root.lstat(STATE_FILENAME) is None:
|
|
768
|
+
return None
|
|
769
|
+
return archive_state_file(namespace.state_file, root=state_root)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
# -- the standalone commands' refusal ---------------------------------------------------
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def inside_scheduler_root(path: str | Path, coordination_root: str | Path) -> bool:
|
|
776
|
+
"""Whether ``path`` resolves to the scheduler root or anything below it.
|
|
777
|
+
|
|
778
|
+
Decided on resolved paths, so a relative path and one reached through a
|
|
779
|
+
symbolic link are judged by where they lead. Reads nothing but the two
|
|
780
|
+
paths' resolution.
|
|
781
|
+
"""
|
|
782
|
+
resolved = Path(os.path.realpath(path))
|
|
783
|
+
root = Path(os.path.realpath(scheduler_root(coordination_root)))
|
|
784
|
+
return resolved == root or root in resolved.parents
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def namespace_issue_of_state_dir(
|
|
788
|
+
state_dir: str | Path, coordination_root: str | Path
|
|
789
|
+
) -> int | None:
|
|
790
|
+
"""The issue whose namespace ``state_dir`` is the state directory of, else ``None``.
|
|
791
|
+
|
|
792
|
+
Decided on resolved paths, like :func:`inside_scheduler_root`: only
|
|
793
|
+
``<scheduler root>/issues/<n>/state`` itself, with ``<n>`` a canonical
|
|
794
|
+
decimal issue number, names a namespace. ``unblock`` acting on that
|
|
795
|
+
directory exempts issue ``<n>`` from the forward guard (ADR 0008 D17.2).
|
|
796
|
+
"""
|
|
797
|
+
resolved = Path(os.path.realpath(state_dir))
|
|
798
|
+
issues = Path(os.path.realpath(scheduler_root(coordination_root))) / ISSUES_DIRNAME
|
|
799
|
+
if resolved.name != STATE_DIRNAME or resolved.parent.parent != issues:
|
|
800
|
+
return None
|
|
801
|
+
name = resolved.parent.name
|
|
802
|
+
if not _ISSUE_DIR_RE.fullmatch(name) or int(name) > MAX_ID:
|
|
803
|
+
return None
|
|
804
|
+
return int(name)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def check_standalone_state_dir(
|
|
808
|
+
state_dir: str | Path, coordination_root: str | Path, command: str
|
|
809
|
+
) -> None:
|
|
810
|
+
"""Refuse a standalone command's state directory inside the scheduler root (D17.3).
|
|
811
|
+
|
|
812
|
+
A relative ``--state-dir``, one reached through a symbolic link and the
|
|
813
|
+
scheduler root itself are all refused (:func:`inside_scheduler_root`)
|
|
814
|
+
with :class:`~autoforge.errors.SchedulerStateDirError`, whose message
|
|
815
|
+
names the scheduler commands instead.
|
|
816
|
+
"""
|
|
817
|
+
if not inside_scheduler_root(state_dir, coordination_root):
|
|
818
|
+
return
|
|
819
|
+
resolved = Path(os.path.realpath(state_dir))
|
|
820
|
+
root = Path(os.path.realpath(scheduler_root(coordination_root)))
|
|
821
|
+
raise SchedulerStateDirError(
|
|
822
|
+
f"'{command}' refuses state directory {state_dir}: it resolves to {resolved}, inside "
|
|
823
|
+
f"the parallel EPIC scheduler's root {root}, whose per-issue namespaces only the "
|
|
824
|
+
"scheduler runs. Continue a scheduler-owned issue with 'autoforge epic resume' and "
|
|
825
|
+
"inspect it with 'autoforge epic status'; 'autoforge epic archive <epic> <issue>' "
|
|
826
|
+
"releases it to a standalone run. 'status' reads this state directory and 'unblock' "
|
|
827
|
+
"acts on it."
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# -- shared -----------------------------------------------------------------------------
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _open_anchor(coordination_root: Path) -> SafeRoot:
|
|
835
|
+
"""A root on the coordination root; it is never created (it is the git common dir)."""
|
|
836
|
+
try:
|
|
837
|
+
return SafeRoot.open(coordination_root)
|
|
838
|
+
except FileNotFoundError:
|
|
839
|
+
raise NamespaceError(f"coordination root {coordination_root} does not exist") from None
|