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
autoforge/runlog.py ADDED
@@ -0,0 +1,764 @@
1
+ """Run logging: one directory per agent invocation, everything redacted.
2
+
3
+ Layout::
4
+
5
+ <state_dir>/logs/<run-id>/
6
+ events.jsonl # one JSON line per invocation, appended
7
+ # in place; never read by the controller
8
+ <seq>-<phase>-<attempt>/
9
+ progress.log # agent steps only: the live progress lines, appended while
10
+ # the agent runs (bounded, redacted; ``tail -f`` it)
11
+ request.json # phase, profile, provider, model, effort, prompt version,
12
+ # cwd, the limits (a command's timeout; an agent's idle
13
+ # timeout and max runtime), redacted argv (no environment
14
+ # dump)
15
+ prompt.md # rendered prompt sent to the agent (redacted)
16
+ execution.json # timestamps, exit code, timed_out and, when it did, the
17
+ # limit that fired (idle / max_runtime) with the configured
18
+ # limits and the last output seen, stdout/stderr sizes,
19
+ # whether either stream was truncated at the capture bound
20
+ # and what the invocation left behind (descendants killed,
21
+ # a group member that survived SIGKILL, an abandoned capture);
22
+ # the provider's own summary (provider_summary) when it gave one;
23
+ # the outer sandbox's facts (sandbox: enforced, backend and
24
+ # version, teardown, network, tmpfs bounds, unset environment
25
+ # names, a setup failure) when the launch had one
26
+ stdout.log # redacted stdout (head + omission marker + tail when truncated)
27
+ stderr.log # redacted stderr (same)
28
+ control-result.json # parsed CONTROL_RESULT when one was accepted (redacted)
29
+ error.txt # controller-side error, when the step failed
30
+
31
+ Logs never pollute state.json.
32
+
33
+ An agent step's directory is published *before* the launch
34
+ (:meth:`RunLogger.begin_step`), so that its ``progress.log`` can be followed
35
+ while the agent runs and survives a kill; the rest of its artifacts are
36
+ written after the agent returned, as for every other step. A crash in
37
+ between leaves a step directory holding only ``progress.log``, which
38
+ advances the sequence like any other step directory.
39
+
40
+ The step sequence is recovered from the step directory names, not from the
41
+ journal: a step directory is published (and fsynced) before its journal line
42
+ is appended, so the highest directory number is never below the highest
43
+ ``seq`` in the journal, and listing the run's own directory is bounded
44
+ (``MAX_RUN_LOG_ENTRIES``) while the journal is not. Those names are the
45
+ one input the sequence has, and the run directory is where the agents
46
+ write too, so only an entry of exactly the shape the controller publishes
47
+ (``STEP_DIR_RE``) counts: an entry named like a step that is not a
48
+ directory, or that numbers a step past ``MAX_STEP_SEQ``, is refused as a
49
+ corrupt run log when the logger is opened, before any agent is launched,
50
+ rather than believed and turned into the next step's path. The journal is
51
+ write-only for the controller: it is opened for appending when the logger
52
+ is opened, before any agent is launched -- created empty if absent, proved
53
+ a single-named regular file no larger than ``MAX_EVENT_JOURNAL_BYTES``
54
+ without being read -- and that descriptor is held until the line is
55
+ appended after the agent returned, so the line can only ever land in the
56
+ inode that was inspected before the launch. A file a same-user agent put
57
+ at the journal's name while it ran (renamed over it, linked to it, planted
58
+ after unlinking it) is refused at the append and receives nothing. The
59
+ cost of recording an invocation is the line, however long the run.
60
+ Nothing inspects the tail before a line is appended either, so a last line
61
+ torn by a crash is glued to the line appended after it: a reader of the
62
+ journal must resync on its own and cannot assume at most one bad line
63
+ (ADR 0001 §5.5).
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import json
69
+ import os
70
+ import re
71
+ import secrets
72
+ import threading
73
+ from collections.abc import Callable, Iterator
74
+ from contextlib import contextmanager, suppress
75
+ from dataclasses import asdict, dataclass, field
76
+ from pathlib import Path
77
+
78
+ from .errors import StateError
79
+ from .redaction import redact, redact_argv, redact_dict
80
+ from .safefs import (
81
+ STATE_DIR_NEEDS_HARD_LINKS,
82
+ AppendHandle,
83
+ HardLinksUnavailable,
84
+ ReadLimitExceeded,
85
+ SafeRoot,
86
+ UnreadableEntryError,
87
+ WalkBudgetExceeded,
88
+ entry_kind,
89
+ )
90
+
91
+ # A run identifier is a *file name*: it names the directory this run's logs
92
+ # live in. `generate_run_id` produces "af-<UTC stamp>-<hex>", but the value
93
+ # actually used comes back from `state.json`, so it is validated wherever it
94
+ # is turned into a path rather than trusted because the controller once
95
+ # generated it.
96
+ RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
97
+
98
+ # The largest an event journal may be before the controller refuses to
99
+ # extend it. The controller never reads the journal (the sequence comes from
100
+ # the step directory names), so this bounds no read; it is the size past
101
+ # which the file at that name is not one the controller wrote -- a real
102
+ # journal is a few kilobytes to a few hundred kilobytes per line (the record
103
+ # carries the redacted argv, which holds the rendered prompt, and the
104
+ # accepted CONTROL_RESULT, itself capped) and at most a few thousand lines
105
+ # (one per invocation, bounded by the run's step budget and the correction
106
+ # attempts). It is checked on the opened descriptor's ``st_size``, which is
107
+ # O(1) whatever the file holds, once before the agent is launched (so a
108
+ # journal a same-user agent planted refuses before any work is done, #57)
109
+ # and once at the append after it returned (the one moment the agent has
110
+ # had to enlarge it, #55), on the descriptor held since the first. The
111
+ # budget bounds what the controller is willing to append to, not the
112
+ # file's final size: a journal at exactly the budget is still appended to,
113
+ # and the file that results is refused by the next check.
114
+ MAX_EVENT_JOURNAL_BYTES = 64 * 1024 * 1024
115
+ # The most entries the run's own log directory may hold before the listing
116
+ # that recovers the step sequence refuses instead of listing without end
117
+ # (#56). A run publishes one step directory per invocation, and a run's
118
+ # invocations number in the thousands at most (the step budget times the
119
+ # correction attempts); the journal itself, and the copies an operator moved
120
+ # aside as a refusal told them to, are a handful of names against this. Only
121
+ # the run's own directory is listed: sibling runs under ``logs/`` never
122
+ # count, so a state directory with a long history of runs cannot exhaust
123
+ # this for a reason unrelated to the run being resumed. It is a budget on the
124
+ # listing work, checked when the logger is opened, not a ceiling on what the
125
+ # directory may come to hold: a logger opened at the budget still publishes
126
+ # its step directory, and the next open refuses.
127
+ MAX_RUN_LOG_ENTRIES = 200_000
128
+ # The highest step number the controller will believe from a directory
129
+ # name. A step number is assigned once per invocation and one directory is
130
+ # published for each, so the number of a run's newest step is at most the
131
+ # number of invocations the run has made, and ``MAX_RUN_LOG_ENTRIES`` is
132
+ # already the statement of how many of those a run can have. A name
133
+ # numbering a step past it was not published by this controller, and the
134
+ # number would decide the width of the next step's path (``_step_name``):
135
+ # a same-user agent that plants a name with a few hundred digits must be
136
+ # refused at the logger open, not believed and turned into a name the
137
+ # filesystem rejects after the agent has returned (PR #91 review, fourth
138
+ # round). Like the listing budget it bounds what is believed, not what is
139
+ # published: a logger opened at the bound still records its step, and the
140
+ # next open refuses the result.
141
+ MAX_STEP_SEQ = MAX_RUN_LOG_ENTRIES
142
+ # The exact shape of a step directory name, as ``RunLogger._step_name``
143
+ # publishes it: the zero-padded step number, the lower-cased phase and the
144
+ # attempt. Only an entry of this shape is a sequence record; the journal,
145
+ # the pre-launch probe, and whatever an operator moved aside are not, and
146
+ # are ignored rather than parsed for a number.
147
+ STEP_DIR_RE = re.compile(r"^(\d+)-[a-z0-9._-]+-\d+$")
148
+ # The most one step's ``progress.log`` may grow to. A line is at most a few
149
+ # hundred bytes and thinking is coalesced, so an hour of a busy agent is
150
+ # well under this; past it, lines are counted and a last line says how many
151
+ # were left out.
152
+ MAX_PROGRESS_LOG_BYTES = 1024 * 1024
153
+
154
+
155
+ def validate_run_id(run_id: str) -> str:
156
+ """Return ``run_id`` if it is a safe single path component, else raise.
157
+
158
+ ``logs/<run_id>` is a controller write path, so a ``run_id`` of
159
+ ``"../.."`` (or ``"/etc"``, or one holding a NUL) would move every log
160
+ artifact of the run out of the state directory. A state file carrying
161
+ one is corruption and must fail loudly; :func:`validate_run_id` is the
162
+ last line before the path is built, and
163
+ :meth:`autoforge.state.AutoForgeState.from_dict` applies the same rule on
164
+ load so the failure names the state file rather than the first log write.
165
+ """
166
+ if not isinstance(run_id, str) or not RUN_ID_RE.match(run_id) or run_id in (".", ".."):
167
+ raise StateError(
168
+ f"invalid run_id {run_id!r}: it names the run's log directory, so it must be a "
169
+ "single path component of letters, digits, '.', '_' or '-' (at most 128 "
170
+ "characters, not starting with '.'), never a path, an absolute name or '..'"
171
+ )
172
+ return run_id
173
+
174
+
175
+ class StepLog:
176
+ """An agent step's directory, published before the launch, and its ``progress.log``.
177
+
178
+ Returned by :meth:`RunLogger.begin_step`. :meth:`append` is a progress
179
+ output (:class:`autoforge.progress.ProgressReporter`): each line is
180
+ redacted and written through the descriptor opened before the launch
181
+ (:meth:`~autoforge.safefs.SafeRoot.append_to`), so a file an agent put
182
+ at that name while it ran receives nothing and the append refuses. Past
183
+ :data:`MAX_PROGRESS_LOG_BYTES` lines are counted instead, and
184
+ :meth:`close` appends one line saying how many were left out.
185
+ """
186
+
187
+ def __init__(self, seq: int, name: str, path: Path, root: SafeRoot, handle: AppendHandle):
188
+ self.seq = seq
189
+ self.name = name
190
+ self.path = path
191
+ self._root = root
192
+ self._handle = handle
193
+ self._written = 0
194
+ self._omitted = 0
195
+ self._closed = False
196
+ self._lock = threading.Lock()
197
+
198
+ def append(self, line: str) -> None:
199
+ """Append one line (an LF is added), within the bound."""
200
+ data = redact(line).replace("\n", " ") + "\n"
201
+ size = len(data.encode("utf-8"))
202
+ with self._lock:
203
+ if self._closed:
204
+ return
205
+ if self._written + size > MAX_PROGRESS_LOG_BYTES:
206
+ self._omitted += 1
207
+ return
208
+ self._root.append_to(self._handle, data)
209
+ self._written += size
210
+
211
+ def close(self) -> None:
212
+ """Note the omitted lines, if any, and release the file. Idempotent."""
213
+ with self._lock:
214
+ if self._closed:
215
+ return
216
+ self._closed = True
217
+ try:
218
+ if self._omitted:
219
+ # Past the budget by one bounded line at most.
220
+ with suppress(StateError):
221
+ self._root.append_to(
222
+ self._handle,
223
+ f"[{self._omitted} progress line(s) omitted past the "
224
+ f"{MAX_PROGRESS_LOG_BYTES}-byte bound]\n",
225
+ )
226
+ finally:
227
+ self._handle.close()
228
+ self._root.close()
229
+
230
+
231
+ @dataclass
232
+ class ExecutionRecord:
233
+ run_id: str
234
+ seq: int
235
+ phase: str
236
+ attempt: int = 1
237
+ correction: bool = False
238
+ issue_url: str = ""
239
+ pr_url: str = ""
240
+ review_round: int = 0
241
+ profile: str = ""
242
+ provider: str = ""
243
+ model: str = ""
244
+ effort: str = ""
245
+ prompt_version: str = ""
246
+ command: list[str] = field(default_factory=list)
247
+ cwd: str = ""
248
+ # The wall-clock limit of a controller-run command (a validation or
249
+ # pre-merge verification command); 0 for an agent, which runs under
250
+ # the limits below instead.
251
+ timeout_seconds: int = 0
252
+ # The agent's limits (``config.AgentLimits``, #193): ``None`` is an idle
253
+ # limit its CLI cannot be held to, or no wall-clock ceiling.
254
+ idle_timeout_seconds: int | None = None
255
+ max_runtime_seconds: int | None = None
256
+ started_at: str = ""
257
+ finished_at: str = ""
258
+ exit_code: int = 0
259
+ timed_out: bool = False
260
+ # When it timed out: which limit fired (``executor.LIMIT_IDLE`` /
261
+ # ``LIMIT_MAX_RUNTIME``). Whatever happened: when its last output was
262
+ # seen (UTC ISO 8601), ``None`` when it wrote nothing.
263
+ timeout_limit: str = ""
264
+ last_activity_at: str | None = None
265
+ # An agent's invocation (#194): how it ended (``exit``, or the limit that
266
+ # killed it: ``idle``, ``max_runtime`` or ``loop``; empty when it could
267
+ # not be run), the loop detector's settings
268
+ # (``LoopDetectionConfig.as_dict``), what the detector found
269
+ # (``LoopReport.record``: names, counts and times, never a tool input,
270
+ # result, output line or digest; ``None`` when nothing) and how many
271
+ # warnings it printed. All empty for a controller-run command.
272
+ ended_by: str = ""
273
+ loop_detection: dict[str, object] = field(default_factory=dict)
274
+ loop: dict[str, object] | None = None
275
+ loop_warnings: int = 0
276
+ # The executor kept head + marker + tail of the stream (see
277
+ # ``executor.DEFAULT_MAX_OUTPUT_BYTES``); ``stdout.log`` shows the cut.
278
+ stdout_truncated: bool = False
279
+ stderr_truncated: bool = False
280
+ # What the invocation left behind (see ``executor.ExecutionResult``):
281
+ # the child exited but its leftovers had to be killed; a process was
282
+ # still in its group after SIGKILL; a pipe never reached EOF. All False
283
+ # for a clean exit or a clean kill.
284
+ descendants_killed: bool = False
285
+ group_survived_kill: bool = False
286
+ capture_abandoned: bool = False
287
+ # Processes it left outside its group (re-parented to the controller)
288
+ # were killed; one survived SIGKILL; the platform could not check (see
289
+ # ``executor._Containment``).
290
+ orphans_killed: bool = False
291
+ orphan_survived_kill: bool = False
292
+ orphans_unchecked: bool = False
293
+ dry_run: bool = False
294
+ parsed_result: dict | None = None
295
+ error: str = ""
296
+ log_dir: str = ""
297
+ metadata: dict[str, object] = field(default_factory=dict)
298
+ # A provider's own summary of the run (``AgentExecutionResult.provider_summary``),
299
+ # written to ``execution.json`` only when the provider gave one.
300
+ provider_summary: dict[str, object] = field(default_factory=dict)
301
+ # What the outer sandbox recorded for a sandboxed launch
302
+ # (``sandbox.SandboxFacts.record``, ADR 0005 D10.4): names, paths, sizes
303
+ # and a bounded reason, never an environment value. Written to
304
+ # ``execution.json`` only when the launch had a sandbox.
305
+ sandbox: dict[str, object] = field(default_factory=dict)
306
+ # What the launch's interaction broker recorded (``InteractionBroker.record``
307
+ # plus the ``lifecycle``, ADR 0007 D5.2, #134): the policy, its limits
308
+ # and, per request, its id, kind, what was asked, the offers, the
309
+ # directive and the kinds of decision delivered; never an answer's text,
310
+ # guidance or a tool input. Written to ``execution.json`` only when the
311
+ # launch had a broker.
312
+ interaction: dict[str, object] = field(default_factory=dict)
313
+
314
+
315
+ class RunLogger:
316
+ """Writes one directory per agent invocation under ``<logs_dir>/<run_id>``.
317
+
318
+ The log tree lives in the controller's state directory, on a machine
319
+ where the agents the controller launches run as the same user. So no
320
+ path here is trusted by name: ``run_id`` must be a single safe path
321
+ component (it comes from ``state.json``, which a hand edit or a truncated
322
+ write can corrupt), and every directory and artifact is reached through a
323
+ :class:`~autoforge.safefs.SafeRoot` -- descriptor-relative, so neither
324
+ ``logs`` nor ``logs/<run_id>`` nor any step directory can be swapped for a
325
+ symbolic link that puts controller artifacts somewhere else, and no
326
+ artifact write can land on a FIFO, a device or a second name for someone
327
+ else's file.
328
+
329
+ The engine passes ``open_state_root`` -- :meth:`autoforge.state
330
+ .StatePaths.open_root`, which walks from the one directory AutoForge did
331
+ not create down to the state directory -- so every component from that
332
+ anchor to a step artifact is checked. A caller with only a pathname gets
333
+ an anchor at the state directory's parent instead, which still reaches
334
+ ``logs`` and everything below it without following a link.
335
+
336
+ Opening the logger opens the run's event journal for appending and
337
+ holds it (:class:`~autoforge.safefs.AppendHandle`) until the logger is
338
+ closed or dropped; every :meth:`log_execution` appends through that
339
+ handle, so a line reaches the inode proved before the launch or is
340
+ refused. The engine opens one logger per invocation, immediately before
341
+ the launch, and lets it go once the invocation is recorded.
342
+ """
343
+
344
+ def __init__(
345
+ self,
346
+ logs_dir: str | Path,
347
+ run_id: str,
348
+ *,
349
+ open_state_root: Callable[[], SafeRoot] | None = None,
350
+ ) -> None:
351
+ self.run_id = validate_run_id(run_id)
352
+ self._open_state_root = open_state_root
353
+ absolute = Path(os.path.abspath(logs_dir))
354
+ self._anchor = absolute.parent
355
+ self._logs_name = absolute.name
356
+ self.run_dir = Path(logs_dir) / self.run_id
357
+ self.events_path = self.run_dir / "events.jsonl"
358
+ with self._logs_root() as logs:
359
+ logs.ensure_dir(self.run_id)
360
+ self._verify_publishable(logs)
361
+ # Sequence counter resumes across process restarts.
362
+ self._seq = self._recover_sequence(logs)
363
+ # Last, so that nothing above can fail with the handle open.
364
+ self._journal = self._open_journal(logs)
365
+
366
+ def close(self) -> None:
367
+ """Release the held journal; a later :meth:`log_execution` refuses."""
368
+ self._journal.close()
369
+
370
+ def __enter__(self) -> RunLogger:
371
+ return self
372
+
373
+ def __exit__(self, *exc: object) -> None:
374
+ self.close()
375
+
376
+ @contextmanager
377
+ def _logs_root(self) -> Iterator[SafeRoot]:
378
+ """The ``logs`` directory as a capability, for the duration of one call."""
379
+ base = (
380
+ self._open_state_root()
381
+ if self._open_state_root is not None
382
+ else SafeRoot.open(self._anchor, create=True)
383
+ )
384
+ try:
385
+ root = base.subroot(self._logs_name, create=True)
386
+ finally:
387
+ base.close()
388
+ try:
389
+ yield root
390
+ finally:
391
+ root.close()
392
+
393
+ def _journal_remedy(self, step: str) -> str:
394
+ """The way to resume that a refused journal leaves the operator."""
395
+ return (
396
+ f"The controller only appends to the journal and never reads it, so {step} to "
397
+ "resume; the step directories are kept and the sequence continues from their "
398
+ "names"
399
+ )
400
+
401
+ @contextmanager
402
+ def _refusing_journal(self, unrecorded: str = "") -> Iterator[None]:
403
+ """Word a refusal of the journal so that it names the manual step.
404
+
405
+ The journal is proved twice per invocation, at the open before the
406
+ launch (:meth:`_open_journal`) and again on the held descriptor at
407
+ the append after the agent returned (:meth:`log_execution`), and
408
+ both refuse for the same causes. Every refusal must leave the
409
+ operator a way to resume, and the cause decides what that is: a
410
+ file past the size budget is not a controller journal and is moved
411
+ aside; a journal the controller may not open for writing
412
+ (``EACCES``: the in-place append needs a writable file where a
413
+ whole-file replacement needed only a writable directory, and the
414
+ controller creates the journal ``0600``, so this is a file planted
415
+ or ``chmod``-ed by someone else) is made writable or moved aside; a
416
+ link, a FIFO, a directory, or a journal replaced or removed while
417
+ the agent ran already says what was found and what to move aside.
418
+ The error keeps its type, so the filesystem cause stays
419
+ distinguishable; only the message grows. ``unrecorded`` says where
420
+ an invocation's artifacts are when the refusal is the
421
+ append's.
422
+ """
423
+ journal = f"logs/{self.run_id}/events.jsonl"
424
+ tail = f"; {unrecorded}" if unrecorded else ""
425
+ try:
426
+ yield
427
+ except ReadLimitExceeded:
428
+ raise StateError(
429
+ f"corrupted event journal for run {self.run_id}: larger than "
430
+ f"{MAX_EVENT_JOURNAL_BYTES} bytes, which no controller journal can be. "
431
+ f"{self._journal_remedy(f'move {journal} aside')}{tail}"
432
+ ) from None
433
+ except UnreadableEntryError as exc:
434
+ remedy = self._journal_remedy(f"make {journal} writable, or move it aside,")
435
+ exc.args = (f"{exc}. {remedy}{tail}",)
436
+ raise
437
+ except StateError as exc:
438
+ if tail:
439
+ exc.args = (f"{exc}{tail}",)
440
+ raise
441
+
442
+ def _refuse_run_dir(self, why: str) -> StateError:
443
+ return StateError(
444
+ f"corrupted run log directory for run {self.run_id}: {why}. The directory is "
445
+ "only listed to continue the step sequence from the step directories it "
446
+ f"holds, so move the entries AutoForge did not create out of "
447
+ f"logs/{self.run_id}/ to resume"
448
+ )
449
+
450
+ def _verify_publishable(self, logs: SafeRoot) -> None:
451
+ """Prove, before the launch, that the run directory takes a new entry.
452
+
453
+ The logger is opened before the agent is launched so that a refusal
454
+ of the run log lands then, charging no launch (#57), rather than
455
+ after a write-capable agent has returned with work that would go
456
+ unlogged. That refusal is only as good as what is proved here, and
457
+ the post-agent record needs the run directory to take a new entry
458
+ (the step directory's ``mkdir``, then its artifacts). ``logs/<run>``
459
+ is created ``0700`` by this controller, but an existing one is
460
+ whatever is there -- a ``0500`` directory, a read-only mount, a
461
+ filesystem without ``link(2)`` -- and it passes every other check.
462
+ The last is refused on purpose, not worked around: every artifact is
463
+ published by ``link(2)`` (``SafeRoot.create_exclusive``), and the
464
+ refusal says so (:data:`~autoforge.safefs.STATE_DIR_NEEDS_HARD_LINKS`)
465
+ rather than leaving a generic failed write to explain it (#120).
466
+ So it is proved the way ``doctor`` proves the state directory: an
467
+ entry is created there through the same capability and removed
468
+ again. The proof does not outlive the launch, since the agent runs
469
+ as the same user and can undo it while it runs; it is the refusal
470
+ that can land before it does. A crash between the probe's create
471
+ and its unlink leaves one ``.af-probe-*`` entry, which the sequence
472
+ listing ignores like any other non-step name. The journal is proved
473
+ separately, by being opened and held (:meth:`_open_journal`).
474
+ """
475
+ run_dir = f"logs/{self.run_id}/"
476
+ probe = f"{self.run_id}/.af-probe-{os.getpid():x}-{secrets.token_hex(8)}"
477
+ try:
478
+ try:
479
+ logs.create_exclusive(probe, b"")
480
+ except StateError:
481
+ # The probe is published before its directory entry is
482
+ # fsynced, so a create that failed at that last step left
483
+ # the name behind; it is this controller's and empty, and
484
+ # removing it is best effort behind the refusal itself.
485
+ with suppress(StateError):
486
+ logs.unlink(probe)
487
+ raise
488
+ logs.unlink(probe)
489
+ except UnreadableEntryError as exc:
490
+ exc.args = (
491
+ f"cannot publish into {run_dir}: {exc}. A step directory and its artifacts "
492
+ f"are published there after every agent invocation, so make {run_dir} "
493
+ "writable to resume; the step directories are kept and the sequence "
494
+ "continues from their names",
495
+ )
496
+ raise
497
+ except HardLinksUnavailable as exc:
498
+ exc.args = (
499
+ f"cannot publish into {run_dir}: {exc}. A step directory and its artifacts "
500
+ "are published there after every agent invocation, and "
501
+ f"{STATE_DIR_NEEDS_HARD_LINKS}",
502
+ )
503
+ raise
504
+ except StateError as exc:
505
+ exc.args = (
506
+ f"cannot publish into {run_dir}: {exc}. A step directory and its artifacts "
507
+ "are published there after every agent invocation",
508
+ )
509
+ raise
510
+
511
+ def _open_journal(self, logs: SafeRoot) -> AppendHandle:
512
+ """Open the journal for appending, before the launch, and keep it open.
513
+
514
+ The open is the pre-launch proof of the journal (a link, a FIFO, a
515
+ second name, a file the controller may not write or one past
516
+ ``MAX_EVENT_JOURNAL_BYTES`` refuses here, charging no launch, #57)
517
+ and, because the descriptor is held rather than closed, the proof
518
+ the post-agent append relies on: the line is written through this
519
+ descriptor, so whatever a same-user agent puts at the journal's name
520
+ while it runs -- a foreign file renamed over it, a hard link, a
521
+ fresh file after an unlink -- can never receive it (PR #91 review,
522
+ fifth round). An absent journal is created here, empty, so that
523
+ there is an inode to hold; a run directory holding only
524
+ ``events.jsonl`` is a run whose first launch has not returned.
525
+ """
526
+ with self._refusing_journal():
527
+ return logs.open_append(f"{self.run_id}/events.jsonl", limit=MAX_EVENT_JOURNAL_BYTES)
528
+
529
+ def _recover_sequence(self, logs: SafeRoot) -> int:
530
+ """The highest step number this run has published, from the directory names.
531
+
532
+ The journal is not consulted. A step directory is published before
533
+ its journal line, so the directory names are never behind the
534
+ journal, and they are what a crash between the two leaves behind;
535
+ reading the journal as well would only add a cost that grows with
536
+ the run (#51).
537
+
538
+ The names are untrusted: the agents write under the same user. An
539
+ entry counts only if it has exactly the shape :meth:`_step_name`
540
+ publishes (``STEP_DIR_RE``), and an entry of that shape must then
541
+ be what the controller would have published, a directory numbered
542
+ at most ``MAX_STEP_SEQ``, or the run log is refused as corrupt
543
+ here, before the launch, with the manual step (move the entry
544
+ aside). Believing the number would build the next step's path
545
+ from it; ignoring a step-shaped file would let the next step's
546
+ ``mkdir`` collide with it after the agent had returned. Any other
547
+ name (the journal, the probe, an operator's moved-aside copy) is
548
+ not a step and is skipped without being parsed.
549
+ """
550
+ # Only the run's own directory and its immediate children matter, so
551
+ # the walk starts *at* the run directory (a sub-root, so sibling runs
552
+ # under ``logs/`` are never listed -- #56) and descends no further:
553
+ # every entry is skipped, so the step directories' contents are not
554
+ # listed either. The listing is budgeted (``MAX_RUN_LOG_ENTRIES``):
555
+ # more entries than a run can publish is not a run directory the
556
+ # controller wrote, and the walk refuses while listing rather than
557
+ # after holding a planted million names.
558
+ highest = 0
559
+ with logs.subroot(self.run_id) as run:
560
+ try:
561
+ for entry in run.walk(max_entries=MAX_RUN_LOG_ENTRIES):
562
+ entry.skip = True
563
+ match = STEP_DIR_RE.match(entry.name)
564
+ if match is None:
565
+ continue
566
+ if not entry.is_dir:
567
+ kind = entry_kind(entry.st.st_mode) or "regular file"
568
+ raise self._refuse_run_dir(
569
+ f"{entry.name} is named like a step directory but is a {kind}"
570
+ )
571
+ # A name is at most NAME_MAX bytes, so the conversion
572
+ # is bounded by the filesystem before it is bounded here.
573
+ seq = int(match.group(1))
574
+ if seq > MAX_STEP_SEQ:
575
+ raise self._refuse_run_dir(
576
+ f"{entry.name} numbers a step past {MAX_STEP_SEQ}, which is more "
577
+ "invocations than a run can make"
578
+ )
579
+ highest = max(highest, seq)
580
+ except WalkBudgetExceeded:
581
+ raise self._refuse_run_dir(
582
+ f"more than {MAX_RUN_LOG_ENTRIES} entries, which no controller run "
583
+ "directory can hold (at most one step directory per invocation)"
584
+ ) from None
585
+ return highest
586
+
587
+ def _step_name(self, seq: int, phase: str, attempt: int) -> str:
588
+ safe_phase = re.sub(r"[^a-z0-9._-]", "-", phase.lower()) or "unknown"
589
+ return f"{seq:03d}-{safe_phase}-{attempt}"
590
+
591
+ def begin_step(self, phase: str, attempt: int) -> StepLog:
592
+ """Publish the next step's directory and open its ``progress.log``, before a launch.
593
+
594
+ The step number is taken here, and the record written after the
595
+ agent returns (:meth:`log_execution` with ``step=``) lands in this
596
+ directory. Like the logger's own open, this runs before the agent is
597
+ launched, so a refusal (a name planted where the directory or the
598
+ file goes) lands while nothing has run. The directory is held as a
599
+ capability for the appends; the caller closes the returned log, and
600
+ :meth:`log_execution` closes it too.
601
+ """
602
+ seq = self._seq + 1
603
+ name = self._step_name(seq, phase, attempt)
604
+ with self._logs_root() as logs:
605
+ logs.ensure_dir(f"{self.run_id}/{name}")
606
+ root = logs.subroot(f"{self.run_id}/{name}")
607
+ try:
608
+ handle = root.open_append("progress.log", limit=MAX_PROGRESS_LOG_BYTES)
609
+ except BaseException:
610
+ root.close()
611
+ raise
612
+ self._seq = seq
613
+ return StepLog(seq, name, self.run_dir / name, root, handle)
614
+
615
+ def log_execution(
616
+ self,
617
+ record: ExecutionRecord,
618
+ prompt: str = "",
619
+ stdout: str = "",
620
+ stderr: str = "",
621
+ *,
622
+ step_log: StepLog | None = None,
623
+ ) -> Path:
624
+ """Persist one execution record + artifacts. Returns the step dir.
625
+
626
+ With ``step_log`` (an agent step, :meth:`begin_step`) the artifacts
627
+ go into the directory published before the launch, under its step
628
+ number, and the progress log is closed first.
629
+ """
630
+ if step_log is not None:
631
+ step_log.close()
632
+ record.seq = step_log.seq
633
+ step = step_log.name
634
+ else:
635
+ self._seq += 1
636
+ record.seq = self._seq
637
+ step = self._step_name(self._seq, record.phase, record.attempt)
638
+ step_dir = self.run_dir / step
639
+ record.log_dir = str(step_dir)
640
+ record.command = redact_argv(record.command)
641
+ # Metadata is caller-supplied and can quote untrusted text (a
642
+ # validation command's argv, a feature path). It is written to both
643
+ # request.json and events.jsonl, so it passes the same boundary the
644
+ # command and the parsed result do.
645
+ record.metadata = redact_dict(record.metadata)
646
+ # The adapter bounds and redacts its summary already; it is redacted
647
+ # again here because it is written twice, like the metadata.
648
+ record.provider_summary = redact_dict(record.provider_summary)
649
+ # Paths and a backend's message; redacted for the same reason.
650
+ record.sandbox = redact_dict(record.sandbox)
651
+ # Built from bounded, redacted request fields; redacted again
652
+ # because it is written twice, like the summary.
653
+ record.interaction = redact_dict(record.interaction)
654
+ if record.loop is not None:
655
+ # Built from cleaned tool names and counts; redacted again
656
+ # because it is written twice, like the summary.
657
+ record.loop = redact_dict(record.loop)
658
+ # Redacted on the record itself, not at each write site: `error` is
659
+ # persisted three times (execution.json, error.txt and the whole
660
+ # record in events.jsonl) and a controller-side error quotes agent
661
+ # stdout, a failing command line and the occasional environment
662
+ # value. Redacting per call site left the journal -- which serialises
663
+ # the record wholesale -- with the only unredacted copy.
664
+ record.error = redact(record.error)
665
+ # String by string, like the metadata, never as its serialized text:
666
+ # redacting the JSON and parsing it back let a pattern consume the
667
+ # syntax around a value (an empty ``GITHUB_TOKEN=`` took the closing
668
+ # quote, a value before an escaped quote its backslash), so an
669
+ # accepted result crashed the controller here, and it missed a secret
670
+ # right after an escape such as ``\n`` (#206).
671
+ if isinstance(record.parsed_result, dict):
672
+ record.parsed_result = redact_dict(record.parsed_result)
673
+ request = {
674
+ "run_id": record.run_id,
675
+ "seq": record.seq,
676
+ "phase": record.phase,
677
+ "attempt": record.attempt,
678
+ "correction": record.correction,
679
+ "issue_url": record.issue_url,
680
+ "pr_url": record.pr_url,
681
+ "review_round": record.review_round,
682
+ "profile": record.profile,
683
+ "provider": record.provider,
684
+ "model": record.model,
685
+ "effort": record.effort,
686
+ "prompt_version": record.prompt_version,
687
+ "cwd": record.cwd,
688
+ "timeout_seconds": record.timeout_seconds,
689
+ "idle_timeout_seconds": record.idle_timeout_seconds,
690
+ "max_runtime_seconds": record.max_runtime_seconds,
691
+ "command": record.command,
692
+ "metadata": record.metadata,
693
+ }
694
+ if record.loop_detection:
695
+ request["loop_detection"] = record.loop_detection
696
+ execution = {
697
+ "started_at": record.started_at,
698
+ "finished_at": record.finished_at,
699
+ "exit_code": record.exit_code,
700
+ "timed_out": record.timed_out,
701
+ "timeout_limit": record.timeout_limit,
702
+ "idle_timeout_seconds": record.idle_timeout_seconds,
703
+ "max_runtime_seconds": record.max_runtime_seconds,
704
+ "last_activity_at": record.last_activity_at,
705
+ "stdout_truncated": record.stdout_truncated,
706
+ "stderr_truncated": record.stderr_truncated,
707
+ "descendants_killed": record.descendants_killed,
708
+ "group_survived_kill": record.group_survived_kill,
709
+ "capture_abandoned": record.capture_abandoned,
710
+ "orphans_killed": record.orphans_killed,
711
+ "orphan_survived_kill": record.orphan_survived_kill,
712
+ "orphans_unchecked": record.orphans_unchecked,
713
+ "stdout_chars": len(stdout or ""),
714
+ "stderr_chars": len(stderr or ""),
715
+ "error": record.error,
716
+ }
717
+ if record.provider_summary:
718
+ execution["provider_summary"] = record.provider_summary
719
+ if record.sandbox:
720
+ execution["sandbox"] = record.sandbox
721
+ if record.interaction:
722
+ execution["interaction"] = record.interaction
723
+ if record.loop_detection:
724
+ execution["ended_by"] = record.ended_by
725
+ execution["loop_detection"] = record.loop_detection
726
+ execution["loop_warnings"] = record.loop_warnings
727
+ if record.loop is not None:
728
+ execution["loop"] = record.loop
729
+ base = f"{self.run_id}/{step}"
730
+ with self._logs_root() as logs:
731
+ logs.ensure_dir(base)
732
+ logs.write_text(
733
+ f"{base}/request.json", json.dumps(request, indent=2, sort_keys=True) + "\n"
734
+ )
735
+ logs.write_text(f"{base}/prompt.md", redact(prompt or ""))
736
+ logs.write_text(
737
+ f"{base}/execution.json", json.dumps(execution, indent=2, sort_keys=True) + "\n"
738
+ )
739
+ logs.write_text(f"{base}/stdout.log", redact(stdout))
740
+ logs.write_text(f"{base}/stderr.log", redact(stderr))
741
+ if isinstance(record.parsed_result, dict):
742
+ logs.write_text(
743
+ f"{base}/control-result.json",
744
+ json.dumps(record.parsed_result, indent=2, sort_keys=True) + "\n",
745
+ )
746
+ if record.error:
747
+ logs.write_text(f"{base}/error.txt", record.error + "\n")
748
+ # The step directory and its artifacts are published above,
749
+ # before the journal line, so a refusal here leaves the sequence
750
+ # recoverable from the directory names. The append happens after
751
+ # the agent returned, which is the one moment an agent has had
752
+ # to replace or enlarge the journal; it goes through the
753
+ # descriptor held since before the launch, after proving that
754
+ # the name under this (re-verified) root still names that
755
+ # inode, that the inode still has one name, and that it is
756
+ # within the budget, none of it reading a byte (#55). A refusal
757
+ # of any cause says where the invocation's record is.
758
+ unrecorded = (
759
+ f"the invocation's artifacts are in logs/{self.run_id}/{step}/ but its "
760
+ "journal line was not written"
761
+ )
762
+ with self._refusing_journal(unrecorded):
763
+ logs.append_to(self._journal, json.dumps(asdict(record), sort_keys=True) + "\n")
764
+ return step_dir