autoforge-cli 0.1.0a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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