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,421 @@
1
+ """The scheduler's stop request: ``epics/<n>/stop-request`` (ADR 0008 D16; #182).
2
+
3
+ ``autoforge.scheduler-stop/1`` holds ``invocation_id`` (the scheduler
4
+ invocation the request is for), ``mode`` (``drain``: admit nothing more and
5
+ let every worker return at its next step boundary; ``now``: as a Ctrl-C of
6
+ the scheduler) and ``requested_at``. It lives beside ``scheduler.json`` in
7
+ ``<common>/autoforge/scheduler/epics/<n>/``.
8
+
9
+ It is the one scheduler file with a second writer that holds no lock, so
10
+ the protocol is publication by ``link(2)``:
11
+
12
+ * :func:`request_stop` (the library function behind ``epic stop``, #183)
13
+ refuses when ``scheduler.json`` is absent and then creates no directory.
14
+ It reads the invocation id from ``scheduler.json``, writes and fsyncs a
15
+ uniquely named temporary in the same directory, links it to
16
+ ``stop-request`` and unlinks the temporary. The file is never replaced,
17
+ so no reader ever sees a partial request; when one exists (``EEXIST``) it
18
+ reports the existing request and writes nothing. After the link it reads
19
+ ``scheduler.json`` again and reports "scheduler changed; stop not
20
+ delivered" when the invocation id is another one.
21
+ * Every reader (:func:`read_stop_request`) is lock-free and strict, and
22
+ treats a request that does not parse as a drain, never as a crash; a
23
+ leftover temporary is not a request.
24
+ * Only the L0 holder removes anything (:func:`remove_stale_stop_requests`):
25
+ a request bound to another invocation at its start and on every tick, a
26
+ request that does not parse and every leftover temporary at its next
27
+ start.
28
+
29
+ Nothing here signals a process: the scheduler reads the request and acts.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import errno
35
+ import json
36
+ import os
37
+ import re
38
+ import secrets
39
+ from dataclasses import dataclass
40
+ from datetime import datetime
41
+ from enum import Enum
42
+ from pathlib import Path
43
+ from typing import NoReturn
44
+
45
+ from .errors import SchedulerStateError, StateError
46
+ from .locking import assert_shared_git_lock_free
47
+ from .run_namespace import AUTOFORGE_DIRNAME, SCHEDULER_DIRNAME
48
+ from .safefs import ReadLimitExceeded, SafeRoot, entry_kind
49
+ from .scheduler_state import EPICS_DIRNAME, load_scheduler_state
50
+ from .state import utcnow_iso
51
+
52
+ STOP_SCHEMA = "autoforge.scheduler-stop/1"
53
+ STOP_FILENAME = "stop-request"
54
+ # A request is a few hundred bytes; anything past this is not one.
55
+ MAX_STOP_BYTES = 4096
56
+ MAX_TIMESTAMP_CHARS = 32
57
+ # A refusal quotes at most this many characters of what it read.
58
+ MAX_PROBLEM_CHARS = 500
59
+
60
+ _TMP_PREFIX = ".stop-request."
61
+ _TMP_SUFFIX = ".tmp"
62
+ _TMP_RE = re.compile(r"\.stop-request\.[0-9a-f]{16}\.tmp")
63
+ _HEX32 = re.compile(r"[0-9a-f]{32}")
64
+ _KEYS = ("schema", "invocation_id", "mode", "requested_at")
65
+ _TMP_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC
66
+
67
+ SCHEDULER_CHANGED = "scheduler changed; stop not delivered"
68
+
69
+
70
+ class StopMode(Enum):
71
+ DRAIN = "drain"
72
+ NOW = "now"
73
+
74
+
75
+ def _fail(message: str) -> NoReturn:
76
+ raise SchedulerStateError(f"stop request {message}")
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class StopRequest:
81
+ """One parsed ``stop-request``; validated on construction."""
82
+
83
+ invocation_id: str
84
+ mode: StopMode
85
+ requested_at: str
86
+
87
+ def __post_init__(self) -> None:
88
+ if not isinstance(self.invocation_id, str) or not _HEX32.fullmatch(self.invocation_id):
89
+ _fail(f"invocation_id is not an invocation id: {self.invocation_id!r:.80}")
90
+ if not isinstance(self.mode, StopMode):
91
+ _fail(f"mode must be a StopMode, got {type(self.mode).__name__}")
92
+ text = self.requested_at
93
+ if not isinstance(text, str) or len(text) > MAX_TIMESTAMP_CHARS:
94
+ _fail(f"requested_at is not a timestamp: {text!r:.80}")
95
+ try:
96
+ parsed = datetime.fromisoformat(text)
97
+ except ValueError:
98
+ _fail(f"requested_at is not a timestamp: {text!r:.80}")
99
+ if parsed.tzinfo is None:
100
+ _fail(f"requested_at has no time zone: {text!r:.80}")
101
+
102
+ def to_dict(self) -> dict:
103
+ return {
104
+ "schema": STOP_SCHEMA,
105
+ "invocation_id": self.invocation_id,
106
+ "mode": self.mode.value,
107
+ "requested_at": self.requested_at,
108
+ }
109
+
110
+ @classmethod
111
+ def from_dict(cls, raw: object) -> StopRequest:
112
+ """Strictly parse a request: an unknown schema or field is refused, never skipped."""
113
+ if not isinstance(raw, dict):
114
+ _fail(f"must be a JSON object, got {type(raw).__name__}")
115
+ if raw.get("schema") != STOP_SCHEMA:
116
+ _fail(f"has schema {raw.get('schema')!r:.80}; this controller reads {STOP_SCHEMA}")
117
+ if sorted(raw) != sorted(_KEYS):
118
+ unknown = sorted(str(key)[:80] for key in raw if key not in _KEYS)[:5]
119
+ missing = [key for key in _KEYS if key not in raw]
120
+ _fail(f"has the fields {unknown} it may not have and lacks {missing}")
121
+ mode = raw["mode"]
122
+ # A string first: ``[]`` or ``{}`` is unhashable, and set membership would raise.
123
+ if not isinstance(mode, str) or mode not in {m.value for m in StopMode}:
124
+ _fail(f"mode is not drain or now: {mode!r:.80}")
125
+ return cls(raw["invocation_id"], StopMode(mode), raw["requested_at"])
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class StopRead:
130
+ """What a reader found at ``stop-request``.
131
+
132
+ ``present`` is whether an entry of that name exists; ``request`` is set
133
+ when it parsed and ``problem`` (bounded) when it did not. A request that
134
+ does not parse is read as a drain for every invocation (D16).
135
+ """
136
+
137
+ present: bool
138
+ request: StopRequest | None = None
139
+ problem: str = ""
140
+
141
+ @property
142
+ def mode(self) -> StopMode | None:
143
+ if self.request is not None:
144
+ return self.request.mode
145
+ return StopMode.DRAIN if self.present else None
146
+
147
+ def applies_to(self, invocation_id: str) -> bool:
148
+ """Whether invocation ``invocation_id`` stops on it: its own, or one that does not parse."""
149
+ if not self.present:
150
+ return False
151
+ return self.request is None or self.request.invocation_id == invocation_id
152
+
153
+ def bound_elsewhere(self, invocation_id: str) -> bool:
154
+ """A request that parsed and names another invocation than ``invocation_id``."""
155
+ return self.request is not None and self.request.invocation_id != invocation_id
156
+
157
+
158
+ def _relative(epic_number: int) -> str:
159
+ if isinstance(epic_number, bool) or not isinstance(epic_number, int) or epic_number < 1:
160
+ raise SchedulerStateError(f"epic number must be a positive integer, got {epic_number!r}")
161
+ return "/".join((AUTOFORGE_DIRNAME, SCHEDULER_DIRNAME, EPICS_DIRNAME, str(epic_number)))
162
+
163
+
164
+ def stop_request_path(coordination_root: str | Path, epic_number: int) -> Path:
165
+ """``<common>/autoforge/scheduler/epics/<n>/stop-request``."""
166
+ return Path(coordination_root).joinpath(*_relative(epic_number).split("/"), STOP_FILENAME)
167
+
168
+
169
+ def _epic_root(coordination_root: str | Path, epic_number: int) -> SafeRoot | None:
170
+ """The EPIC's directory, never created; ``None`` when it (or a parent) does not exist."""
171
+ try:
172
+ anchor = SafeRoot.open(coordination_root)
173
+ except FileNotFoundError:
174
+ raise SchedulerStateError(f"coordination root {coordination_root} does not exist") from None
175
+ with anchor:
176
+ try:
177
+ return anchor.subroot(_relative(epic_number))
178
+ except FileNotFoundError:
179
+ return None
180
+
181
+
182
+ def _bounded(text: str) -> str:
183
+ return text if len(text) <= MAX_PROBLEM_CHARS else text[: MAX_PROBLEM_CHARS - 3] + "..."
184
+
185
+
186
+ def _unique(pairs: list[tuple[str, object]]) -> dict:
187
+ data: dict = {}
188
+ for key, value in pairs:
189
+ if key in data:
190
+ raise SchedulerStateError(f"has the field {key!r:.80} more than once")
191
+ data[key] = value
192
+ return data
193
+
194
+
195
+ def _no_constant(name: str) -> NoReturn:
196
+ raise SchedulerStateError(f"holds the non-JSON number {name}")
197
+
198
+
199
+ def _read_at(root: SafeRoot, where: str) -> StopRead:
200
+ st = root.lstat(STOP_FILENAME)
201
+ if st is None:
202
+ return StopRead(False)
203
+ kind = entry_kind(st.st_mode)
204
+ if kind is not None:
205
+ return StopRead(True, problem=f"{where} is a {kind}, not a regular file")
206
+ try:
207
+ raw = root.read_bytes(STOP_FILENAME, limit=MAX_STOP_BYTES)
208
+ except ReadLimitExceeded:
209
+ return StopRead(True, problem=f"{where} is larger than {MAX_STOP_BYTES} bytes")
210
+ except (StateError, OSError) as exc:
211
+ return StopRead(True, problem=_bounded(f"{where} cannot be read ({exc})"))
212
+ if raw is None:
213
+ return StopRead(False) # removed between the lstat and the open
214
+ try:
215
+ document = json.loads(
216
+ raw.decode("utf-8"), object_pairs_hook=_unique, parse_constant=_no_constant
217
+ )
218
+ return StopRead(True, StopRequest.from_dict(document))
219
+ except (SchedulerStateError, UnicodeDecodeError, ValueError, TypeError, RecursionError) as exc:
220
+ return StopRead(True, problem=_bounded(f"{where} does not parse ({exc})"))
221
+
222
+
223
+ def read_stop_request(coordination_root: str | Path, epic_number: int) -> StopRead:
224
+ """EPIC ``epic_number``'s stop request, strictly and lock-free; read-only.
225
+
226
+ Never raises for what it finds: a request that does not parse (a link,
227
+ a FIFO, another schema, bad JSON) is ``present`` with a ``problem`` and
228
+ reads as a drain. No directory is created. A link at a directory above
229
+ the file raises :class:`~autoforge.safefs.UnsafePathError` (a
230
+ :class:`~autoforge.errors.StateError`), for the caller to fail closed.
231
+ """
232
+ where = str(stop_request_path(coordination_root, epic_number))
233
+ root = _epic_root(coordination_root, epic_number)
234
+ if root is None:
235
+ return StopRead(False)
236
+ with root:
237
+ return _read_at(root, where)
238
+
239
+
240
+ @dataclass(frozen=True)
241
+ class StopDelivery:
242
+ """What :func:`request_stop` did.
243
+
244
+ ``written`` is whether this call published a request. ``mode`` is the
245
+ request's mode, or the existing request's when one was there.
246
+ ``scheduler_changed`` is the after-write check: ``scheduler.json`` names
247
+ another invocation than the request, so no live scheduler of the
248
+ request's invocation reads it. ``scheduler_ended`` is whether the
249
+ invocation the request is for had already ended: running workers still
250
+ drain, and ``now`` cannot signal them. ``message`` says it in a line.
251
+ """
252
+
253
+ written: bool
254
+ mode: StopMode
255
+ invocation_id: str
256
+ message: str
257
+ existing: StopRead | None = None
258
+ scheduler_changed: bool = False
259
+ scheduler_ended: bool = False
260
+
261
+
262
+ def _write_temporary(root: SafeRoot, payload: bytes) -> str:
263
+ for _ in range(8):
264
+ name = f"{_TMP_PREFIX}{secrets.token_hex(8)}{_TMP_SUFFIX}"
265
+ try:
266
+ fd = os.open(name, _TMP_FLAGS, 0o600, dir_fd=root.fd)
267
+ except FileExistsError:
268
+ continue
269
+ try:
270
+ view = memoryview(payload)
271
+ while view:
272
+ view = view[os.write(fd, view) :]
273
+ os.fsync(fd)
274
+ except BaseException:
275
+ os.close(fd)
276
+ _unlink_quietly(root, name)
277
+ raise
278
+ os.close(fd)
279
+ return name
280
+ raise SchedulerStateError( # pragma: no cover - 64 random bits, eight times
281
+ "cannot name a temporary for the stop request"
282
+ )
283
+
284
+
285
+ def _unlink_quietly(root: SafeRoot, name: str) -> None:
286
+ try:
287
+ os.unlink(name, dir_fd=root.fd)
288
+ except OSError:
289
+ pass
290
+
291
+
292
+ def _fsync_dir(root: SafeRoot) -> None:
293
+ try:
294
+ os.fsync(root.fd)
295
+ except OSError as exc: # pragma: no cover - fsync of a directory descriptor
296
+ if exc.errno not in (errno.EINVAL, errno.EBADF):
297
+ raise
298
+
299
+
300
+ def request_stop(coordination_root: str | Path, epic_number: int, mode: StopMode) -> StopDelivery:
301
+ """Publish a stop request for EPIC ``epic_number``'s scheduler (D16; see the module).
302
+
303
+ Refused (:class:`~autoforge.errors.SchedulerStateError`) when there is
304
+ no ``scheduler.json`` (nothing is created then, no directory either) or
305
+ when it does not load. Otherwise the request is bound to
306
+ ``scheduler.json``'s invocation id and linked into place, or, when a
307
+ request is already there, nothing is written and the existing one is
308
+ reported.
309
+ """
310
+ if not isinstance(mode, StopMode):
311
+ raise SchedulerStateError(f"stop mode must be drain or now, got {mode!r}")
312
+ where = str(stop_request_path(coordination_root, epic_number))
313
+ state = load_scheduler_state(coordination_root, epic_number)
314
+ if state is None:
315
+ raise SchedulerStateError(
316
+ f"no scheduler has run for EPIC #{epic_number} in this repository (no "
317
+ "scheduler.json); there is nothing to stop, and nothing was written"
318
+ )
319
+ invocation = state.invocation
320
+ ended = invocation.ended_at is not None
321
+ request = StopRequest(invocation.invocation_id, mode, utcnow_iso())
322
+ payload = (json.dumps(request.to_dict(), indent=2, sort_keys=True) + "\n").encode("utf-8")
323
+ assert_shared_git_lock_free(f"write {where}")
324
+ root = _epic_root(coordination_root, epic_number)
325
+ if root is None: # pragma: no cover - scheduler.json was just read there
326
+ raise SchedulerStateError(f"the directory of {where} is gone; nothing was written")
327
+ with root:
328
+ name = _write_temporary(root, payload)
329
+ try:
330
+ os.link(
331
+ name,
332
+ STOP_FILENAME,
333
+ src_dir_fd=root.fd,
334
+ dst_dir_fd=root.fd,
335
+ follow_symlinks=False,
336
+ )
337
+ except FileExistsError:
338
+ existing = _read_at(root, where)
339
+ mode_now = existing.mode or StopMode.DRAIN
340
+ if existing.bound_elsewhere(invocation.invocation_id):
341
+ message = (
342
+ f"a stop request of an earlier scheduler invocation is still at {where}; "
343
+ "the scheduler removes it at its next tick: repeat the stop then. "
344
+ "Nothing was written."
345
+ )
346
+ elif existing.request is None:
347
+ message = (
348
+ f"a stop request that does not parse is at {where}; every reader takes "
349
+ "it as a drain. Nothing was written."
350
+ )
351
+ else:
352
+ message = (
353
+ f"a {mode_now.value} stop request is already there ({where}); nothing "
354
+ "was written"
355
+ )
356
+ return StopDelivery(
357
+ False,
358
+ mode_now,
359
+ invocation.invocation_id,
360
+ message,
361
+ existing=existing,
362
+ scheduler_ended=ended,
363
+ )
364
+ except OSError as exc:
365
+ raise SchedulerStateError(f"cannot publish {where}: {exc}") from exc
366
+ finally:
367
+ _unlink_quietly(root, name)
368
+ _fsync_dir(root)
369
+ after = load_scheduler_state(coordination_root, epic_number)
370
+ if after is None or after.invocation.invocation_id != invocation.invocation_id:
371
+ return StopDelivery(
372
+ True,
373
+ mode,
374
+ invocation.invocation_id,
375
+ SCHEDULER_CHANGED,
376
+ scheduler_changed=True,
377
+ )
378
+ if ended:
379
+ message = (
380
+ f"{mode.value} stop requested for scheduler invocation "
381
+ f"{invocation.invocation_id}, which has ended: workers still running drain at "
382
+ "their next step boundary"
383
+ + ("; 'now' cannot signal them" if mode is StopMode.NOW else "")
384
+ )
385
+ else:
386
+ message = f"{mode.value} stop requested for scheduler invocation {invocation.invocation_id}"
387
+ return StopDelivery(True, mode, invocation.invocation_id, message, scheduler_ended=ended)
388
+
389
+
390
+ def remove_stale_stop_requests(
391
+ coordination_root: str | Path, epic_number: int, invocation_id: str, *, at_start: bool
392
+ ) -> tuple[str, ...]:
393
+ """The L0 holder's cleanup of ``stop-request`` (D16); the names it removed.
394
+
395
+ A request bound to another invocation than ``invocation_id`` is removed
396
+ always; one that does not parse, and every leftover temporary of an
397
+ interrupted :func:`request_stop`, only ``at_start``. Called by the
398
+ scheduler only, under L0: no other process removes anything here.
399
+ """
400
+ where = str(stop_request_path(coordination_root, epic_number))
401
+ root = _epic_root(coordination_root, epic_number)
402
+ if root is None:
403
+ return ()
404
+ removed: list[str] = []
405
+ with root:
406
+ found = _read_at(root, where)
407
+ if found.present and (
408
+ found.bound_elsewhere(invocation_id) or (at_start and found.request is None)
409
+ ):
410
+ root.unlink(STOP_FILENAME)
411
+ removed.append(STOP_FILENAME)
412
+ if at_start:
413
+ try:
414
+ names = os.listdir(root.fd)
415
+ except OSError as exc:
416
+ raise SchedulerStateError(f"cannot list the directory of {where}: {exc}") from exc
417
+ for name in sorted(names):
418
+ if _TMP_RE.fullmatch(name):
419
+ _unlink_quietly(root, name)
420
+ removed.append(name)
421
+ return tuple(removed)