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,985 @@
1
+ """Duplex child handle: a subprocess the controller writes to while it runs.
2
+
3
+ :func:`autoforge.executor.execute` is one-shot: the child's stdin is
4
+ ``/dev/null``, or a payload fixed before the spawn followed by EOF
5
+ (``ExecutionRequest.stdin_data``), and nothing the child writes back changes
6
+ what it is sent. An RPC transport needs a child it can keep writing records
7
+ to while reading records back. This module is the narrowest primitive that
8
+ does that and keeps every guarantee ``execute()`` gives (ADR 0002: nothing
9
+ the child starts outlives the invocation). It deals in bytes and LF-framed
10
+ records only: nothing here knows an encoding, a provider, a phase or
11
+ controller state; decoding a record is the caller's.
12
+
13
+ Guarantees:
14
+
15
+ - the spawn is ``execute()``'s: an argv list, ``cwd``, the allow-listed
16
+ environment plus ``env``, a session (and so a process group) of its own,
17
+ the terminal never inherited, a spawn failure an :class:`ExecutionError`,
18
+ and so is a setup failure after the spawn (the group is killed first and
19
+ nothing taken for the invocation is kept);
20
+ - stdout is split into records on LF (0x0A) **only**, one trailing CR
21
+ stripped per record; no other byte or Unicode line separator (U+2028,
22
+ U+2029, NUL, VT, FF, NEL) is a boundary. A record longer than
23
+ ``max_record_bytes`` is reported as :class:`Oversize` with at most its
24
+ first :data:`OVERSIZE_HEAD_BYTES` bytes, so that the caller can tell what
25
+ was dropped, and dropped through its LF; an unterminated fragment at EOF
26
+ is reported as :class:`Fragment`, never as a record;
27
+ - stdout and stderr are drained continuously on reader threads, whether or
28
+ not the caller reads, so the child never blocks on a full pipe. Complete
29
+ records wait in a queue bounded by ``max_pending_records`` and
30
+ ``max_pending_bytes``; exceeding it is :class:`Overflow`, which ends the
31
+ invocation. stderr goes to a bounded head/tail capture and never becomes
32
+ a record. Memory is bounded by those limits plus a constant;
33
+ - writes are whole records, serialized by one lock and non-blocking under a
34
+ selector, so a child that stops reading stdin cannot hold the controller
35
+ past the deadline; a child that closed its stdin raises
36
+ :class:`ChildStdinClosedError`;
37
+ - one deadline bounds every :meth:`DuplexChild.send_line`,
38
+ :meth:`DuplexChild.read_line` and :meth:`DuplexChild.finish`: the
39
+ ``execute()`` limits (#193), an idle limit that every chunk the child
40
+ writes on stdout or stderr restarts (``idle_timeout_seconds``) and a
41
+ wall-clock ceiling from the spawn (``max_runtime_seconds``, or
42
+ :data:`MAX_DEADLINE_SECONDS` when unset), whichever is due first. A
43
+ record written to the child is not activity. Past the deadline the whole
44
+ group is killed exactly as ``execute()`` kills it on timeout, and the
45
+ result is ``timed_out`` and names the limit; except that, as in
46
+ ``execute()``, the child is looked at first, and a ``read_line`` or
47
+ ``finish()`` that meets the deadline after the child has exited settles
48
+ the group with the exit grace instead, the child's status kept. A
49
+ caller that winds a child down in order once a limit falls due asks for
50
+ ``wind_down_seconds``: the limit still falls due on time
51
+ (:meth:`DuplexChild.limit_due`) and the deadline, the kill, comes that
52
+ much later. :meth:`DuplexChild.pin_deadline` stops output from moving
53
+ either, for a caller winding the child down;
54
+ - EOF is not the child's exit: a descendant holding stdout keeps EOF away,
55
+ and one that keeps writing keeps every read busy.
56
+ :meth:`DuplexChild.exited` says whether the child itself has exited,
57
+ without waiting, so a caller reading records until EOF asks it after
58
+ every read and calls :meth:`DuplexChild.finish` once it has (ADR 0002's
59
+ exit grace, then the kill, the child's status kept). After a
60
+ ``finish()`` that did not time out, :meth:`DuplexChild.read_line`
61
+ returns the records still queued and then the end item, never waiting;
62
+ - teardown runs on every exit from the ``with`` block, an exception or
63
+ ``KeyboardInterrupt`` included: stdin is closed, the child is given a
64
+ bounded wait to exit, then the ADR 0002 exit grace and empty-group check,
65
+ then the group kill if anything is left. The leftover facts
66
+ (``descendants_killed``, ``group_survived_kill``, ``capture_abandoned``)
67
+ mean what they mean in :class:`autoforge.executor.ExecutionResult`. With a
68
+ hostile child the whole invocation takes at most the deadline (the idle
69
+ limit after the last output, or the ceiling, plus any wind-down) plus the
70
+ exit grace plus two kill graces, the bound ``execute()`` has;
71
+ - ``contain_orphans`` catches what left the group exactly as in
72
+ ``execute()`` (the subreaper is held from before the spawn to the end of
73
+ the teardown), with the same three orphan facts;
74
+ - ``sandbox`` runs the child inside the outer sandbox exactly as in
75
+ ``execute()`` (ADR 0005): the backend is spawned in the command's place,
76
+ every guarantee above applies to it, and a sandbox that could not be
77
+ established raises :class:`~autoforge.errors.SandboxLaunchRefusedError`
78
+ from :func:`start_duplex` or, for a setup failure the backend reports
79
+ after the spawn, from :meth:`DuplexChild.finish`.
80
+
81
+ A server child that takes no input and is not spoken to over stdout (#126)
82
+ uses the same handle with ``stdin_pipe=False`` and
83
+ ``stdout_mode=StdoutMode.CAPTURE``.
84
+ """
85
+
86
+ from __future__ import annotations
87
+
88
+ import enum
89
+ import os
90
+ import selectors
91
+ import subprocess
92
+ import threading
93
+ import time
94
+ from collections import deque
95
+ from dataclasses import dataclass
96
+ from types import TracebackType
97
+ from typing import IO
98
+
99
+ from . import executor
100
+ from .errors import ChildStdinClosedError, ExecutionError, ExecutionTimeoutError
101
+ from .executor import (
102
+ DEFAULT_MAX_OUTPUT_BYTES,
103
+ LIMIT_IDLE,
104
+ MAX_DEADLINE_SECONDS,
105
+ BwrapLaunch,
106
+ LaunchDescription,
107
+ SandboxFacts,
108
+ _BoundedReader,
109
+ _child_environment,
110
+ _Containment,
111
+ _eof,
112
+ _group_gone,
113
+ _Limits,
114
+ _now,
115
+ _PipeDrain,
116
+ _prepare_sandbox,
117
+ _reaped,
118
+ _spawn,
119
+ _terminate_group,
120
+ _Termination,
121
+ check_limit,
122
+ describe_leftovers,
123
+ )
124
+
125
+ # A single record above this is not a protocol message but a runaway; the
126
+ # bound is per record, the queue below bounds what waits to be read.
127
+ DEFAULT_MAX_RECORD_BYTES = 4 * 1024 * 1024
128
+ DEFAULT_MAX_PENDING_RECORDS = 1024
129
+ DEFAULT_MAX_PENDING_BYTES = 16 * 1024 * 1024
130
+ # How much of a dropped record an :class:`Oversize` keeps: enough for a
131
+ # caller to read what kind of record it was from its leading bytes (#144),
132
+ # never its content. Never more than the record bound itself.
133
+ OVERSIZE_HEAD_BYTES = 64
134
+
135
+
136
+ class StdoutMode(enum.Enum):
137
+ """What the handle does with the child's stdout."""
138
+
139
+ # LF-framed records, read with :meth:`DuplexChild.read_line`.
140
+ RECORDS = "records"
141
+ # Bounded head/tail capture, returned in :attr:`DuplexResult.stdout`.
142
+ CAPTURE = "capture"
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class Record:
147
+ """One complete record: the bytes before an LF, one trailing CR stripped."""
148
+
149
+ data: bytes
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class Oversize:
154
+ """A record exceeded ``limit`` bytes; it was dropped through its LF.
155
+
156
+ ``head`` is its first ``min(OVERSIZE_HEAD_BYTES, limit)`` bytes, for
157
+ the caller to recognise the record by; the rest was never kept.
158
+ """
159
+
160
+ limit: int
161
+ head: bytes
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class Fragment:
166
+ """Bytes after the last LF when stdout reached EOF: not a record."""
167
+
168
+ data: bytes
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class Eof:
173
+ """stdout ended and every record before it was read. Sticky."""
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class Overflow:
178
+ """The pending-record queue exceeded its bound; records after the ones
179
+ already returned were dropped and the invocation is over. Sticky."""
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class Timeout:
184
+ """No record arrived in time. ``deadline_exceeded``: the invocation's
185
+ deadline passed and the group was killed (sticky); otherwise only the
186
+ ``read_line`` timeout ran out and the child is still running."""
187
+
188
+ deadline_exceeded: bool
189
+
190
+
191
+ ReadOutcome = Record | Oversize | Fragment | Eof | Overflow | Timeout
192
+
193
+
194
+ @dataclass
195
+ class DuplexRequest:
196
+ command: list[str]
197
+ cwd: str | None = None
198
+ # Layered over the inherited (or allow-listed) environment when given.
199
+ env: dict[str, str] | None = None
200
+ # As :attr:`autoforge.executor.ExecutionRequest.env_allowlist`.
201
+ env_allowlist: tuple[str, ...] | None = None
202
+ # Kill the child once it has written nothing on stdout or stderr for
203
+ # this long (every chunk restarts it); ``None``: no idle limit.
204
+ idle_timeout_seconds: float | None = None
205
+ # The wall-clock ceiling, measured from the spawn; ``None``:
206
+ # :data:`MAX_DEADLINE_SECONDS`. With the idle limit it bounds every call
207
+ # on the handle.
208
+ max_runtime_seconds: float | None = None
209
+ # How long after a limit falls due a child still running is given before
210
+ # its group is killed, for a caller that winds it down in order (Pi's
211
+ # `abort`): the limit falls due on time (:meth:`DuplexChild.limit_due`),
212
+ # the kill comes this much later (:meth:`DuplexChild.deadline`). 0: as
213
+ # the limit falls due.
214
+ wind_down_seconds: float = 0.0
215
+ max_record_bytes: int = DEFAULT_MAX_RECORD_BYTES
216
+ max_pending_records: int = DEFAULT_MAX_PENDING_RECORDS
217
+ max_pending_bytes: int = DEFAULT_MAX_PENDING_BYTES
218
+ max_stderr_bytes: int = DEFAULT_MAX_OUTPUT_BYTES
219
+ # False: the child's stdin is ``/dev/null`` and ``send_line`` refuses.
220
+ stdin_pipe: bool = True
221
+ stdout_mode: StdoutMode = StdoutMode.RECORDS
222
+ # The capture bound in ``StdoutMode.CAPTURE``; unused for records.
223
+ max_stdout_bytes: int = DEFAULT_MAX_OUTPUT_BYTES
224
+ # As :attr:`autoforge.executor.ExecutionRequest.contain_orphans`.
225
+ contain_orphans: bool = False
226
+ # As :attr:`autoforge.executor.ExecutionRequest.sandbox`. The thread that
227
+ # calls :func:`start_duplex` is the one ``bwrap --die-with-parent``
228
+ # watches: it must outlive the handle (ADR 0005 D10.6), whichever
229
+ # threads then send, read and finish.
230
+ sandbox: LaunchDescription | None = None
231
+
232
+
233
+ @dataclass
234
+ class DuplexResult:
235
+ command: list[str]
236
+ cwd: str | None
237
+ # The child's own exit status; -1 on a timeout (as in ``execute()``) and
238
+ # when the controller killed the child before it exited (an overflow, or
239
+ # an exception in the ``with``).
240
+ exit_code: int
241
+ stderr: str
242
+ started_at: str
243
+ finished_at: str
244
+ timed_out: bool = False
245
+ stderr_truncated: bool = False
246
+ # The pending-record queue overflowed (see :class:`Overflow`).
247
+ records_overflowed: bool = False
248
+ # ``StdoutMode.CAPTURE`` only: the bounded capture, as in ``execute()``.
249
+ stdout: str = ""
250
+ stdout_truncated: bool = False
251
+ # The ADR 0002 facts, as in :class:`autoforge.executor.ExecutionResult`.
252
+ descendants_killed: bool = False
253
+ group_survived_kill: bool = False
254
+ capture_abandoned: bool = False
255
+ orphans_killed: bool = False
256
+ orphan_survived_kill: bool = False
257
+ orphans_unchecked: bool = False
258
+ # As in :class:`autoforge.executor.ExecutionResult`: the limit that
259
+ # killed the child when ``timed_out`` (``"idle"`` or ``"max_runtime"``),
260
+ # and when it last wrote anything.
261
+ timeout_limit: str = ""
262
+ last_activity_at: str | None = None
263
+ # The caller ended the invocation with :meth:`DuplexChild.stop` before
264
+ # the child exited; ``exit_code`` is then -1. Never set with ``timed_out``.
265
+ stopped: bool = False
266
+ # As in :class:`autoforge.executor.ExecutionResult`.
267
+ sandbox: SandboxFacts | None = None
268
+
269
+ @property
270
+ def leftovers(self) -> str:
271
+ """What the invocation left behind, for a log line; empty when nothing."""
272
+ return describe_leftovers(
273
+ descendants_killed=self.descendants_killed,
274
+ group_survived_kill=self.group_survived_kill,
275
+ capture_abandoned=self.capture_abandoned,
276
+ orphans_killed=self.orphans_killed,
277
+ orphan_survived_kill=self.orphan_survived_kill,
278
+ orphans_unchecked=self.orphans_unchecked,
279
+ sandbox_teardown=self.sandbox is not None and self.sandbox.enforced,
280
+ )
281
+
282
+
283
+ class _LineFramer:
284
+ """Split a byte stream into records on LF only, holding at most one record.
285
+
286
+ The partial record is held in one ``bytearray`` of at most ``limit + 1``
287
+ bytes (the extra byte is room for the CR an LF ending strips). Past
288
+ that, the record is reported once as :class:`Oversize`, with its head,
289
+ the moment it crosses the bound and its remaining bytes are counted out
290
+ up to the next LF, so a stream with no LF at all costs the bound, not
291
+ its length.
292
+ """
293
+
294
+ def __init__(self, limit: int) -> None:
295
+ self._limit = limit
296
+ self._head = min(OVERSIZE_HEAD_BYTES, limit)
297
+ self._buf = bytearray()
298
+ self._dropping = False
299
+
300
+ @property
301
+ def retained(self) -> int:
302
+ return len(self._buf)
303
+
304
+ def feed(self, chunk: bytes) -> list[Record | Oversize]:
305
+ out: list[Record | Oversize] = []
306
+ start = 0
307
+ while True:
308
+ lf = chunk.find(b"\n", start)
309
+ if lf < 0:
310
+ self._take(chunk[start:], out)
311
+ return out
312
+ self._take(chunk[start:lf], out)
313
+ if self._dropping:
314
+ self._dropping = False
315
+ else:
316
+ record = bytes(self._buf)
317
+ self._buf.clear()
318
+ if record.endswith(b"\r"):
319
+ record = record[:-1]
320
+ if len(record) > self._limit:
321
+ out.append(Oversize(self._limit, record[: self._head]))
322
+ else:
323
+ out.append(Record(record))
324
+ start = lf + 1
325
+
326
+ def _take(self, piece: bytes, out: list[Record | Oversize]) -> None:
327
+ if self._dropping or not piece:
328
+ return
329
+ if len(self._buf) + len(piece) > self._limit + 1:
330
+ head = bytes(self._buf[: self._head])
331
+ head += piece[: self._head - len(head)]
332
+ self._buf.clear()
333
+ self._dropping = True
334
+ out.append(Oversize(self._limit, head))
335
+ return
336
+ self._buf += piece
337
+
338
+ def finish(self) -> list[Fragment]:
339
+ """At EOF: the unterminated tail, if one is held."""
340
+ if self._dropping or not self._buf:
341
+ return []
342
+ fragment = Fragment(bytes(self._buf))
343
+ self._buf.clear()
344
+ return [fragment]
345
+
346
+
347
+ class _RecordQueue:
348
+ """Records waiting to be read, bounded by count and bytes; never blocks a put.
349
+
350
+ A put past either bound ends the queue with :class:`Overflow` instead
351
+ of waiting, so the reader thread keeps draining the pipe. The end item
352
+ (:class:`Eof` or :class:`Overflow`) is returned after every queued item,
353
+ and then on every later get. An :class:`Oversize` counts its head.
354
+ """
355
+
356
+ def __init__(self, max_items: int, max_bytes: int) -> None:
357
+ self._max_items = max_items
358
+ self._max_bytes = max_bytes
359
+ self._items: deque[Record | Oversize | Fragment] = deque()
360
+ self._bytes = 0
361
+ self._end: Eof | Overflow | None = None
362
+ self._cond = threading.Condition()
363
+
364
+ @property
365
+ def ended(self) -> bool:
366
+ with self._cond:
367
+ return self._end is not None
368
+
369
+ @property
370
+ def overflowed(self) -> bool:
371
+ with self._cond:
372
+ return isinstance(self._end, Overflow)
373
+
374
+ def put(self, item: Record | Oversize | Fragment) -> bool:
375
+ """Queue ``item``; False once the queue has ended (overflow included)."""
376
+ size = _held_bytes(item)
377
+ with self._cond:
378
+ if self._end is not None:
379
+ return False
380
+ if len(self._items) >= self._max_items or self._bytes + size > self._max_bytes:
381
+ self._end = Overflow()
382
+ self._cond.notify_all()
383
+ return False
384
+ self._items.append(item)
385
+ self._bytes += size
386
+ self._cond.notify_all()
387
+ return True
388
+
389
+ def end(self) -> None:
390
+ with self._cond:
391
+ if self._end is None:
392
+ self._end = Eof()
393
+ self._cond.notify_all()
394
+
395
+ def get(self, until: float) -> Record | Oversize | Fragment | Eof | Overflow | None:
396
+ """The next item, or None when ``until`` (``time.monotonic()``) passes first."""
397
+ with self._cond:
398
+ while True:
399
+ if self._items:
400
+ item = self._items.popleft()
401
+ self._bytes -= _held_bytes(item)
402
+ return item
403
+ if self._end is not None:
404
+ return self._end
405
+ remaining = until - time.monotonic()
406
+ if remaining <= 0:
407
+ return None
408
+ self._cond.wait(remaining)
409
+
410
+
411
+ def _held_bytes(item: Record | Oversize | Fragment) -> int:
412
+ """The bytes ``item`` holds while it waits in the queue."""
413
+ return len(item.head) if isinstance(item, Oversize) else len(item.data)
414
+
415
+
416
+ class _RecordReader(_PipeDrain):
417
+ """Drain stdout through a :class:`_LineFramer` into a :class:`_RecordQueue`.
418
+
419
+ After an overflow the pipe is still drained, and what it carries
420
+ dropped, so the child never blocks on it and the teardown sees EOF the
421
+ way it does for any other child.
422
+ """
423
+
424
+ def __init__(
425
+ self,
426
+ stream: IO[bytes],
427
+ max_record_bytes: int,
428
+ queue: _RecordQueue,
429
+ activity: _Limits | None = None,
430
+ ) -> None:
431
+ super().__init__(stream, "stdout", activity)
432
+ self.framer = _LineFramer(max_record_bytes)
433
+ self._queue = queue
434
+
435
+ def _feed(self, chunk: bytes) -> None:
436
+ if self._queue.ended:
437
+ return
438
+ for item in self.framer.feed(chunk):
439
+ if not self._queue.put(item):
440
+ return
441
+
442
+ def _ended(self, eof: bool) -> None:
443
+ if eof:
444
+ for fragment in self.framer.finish():
445
+ self._queue.put(fragment)
446
+ self._queue.end()
447
+
448
+
449
+ def _validate(req: DuplexRequest) -> None:
450
+ if not req.command:
451
+ raise ExecutionError("empty command")
452
+ check_limit("idle_timeout_seconds", req.idle_timeout_seconds)
453
+ check_limit("max_runtime_seconds", req.max_runtime_seconds)
454
+ if req.wind_down_seconds != 0:
455
+ check_limit("wind_down_seconds", req.wind_down_seconds)
456
+ for name in (
457
+ "max_record_bytes",
458
+ "max_pending_records",
459
+ "max_pending_bytes",
460
+ "max_stderr_bytes",
461
+ "max_stdout_bytes",
462
+ ):
463
+ value = getattr(req, name)
464
+ if value <= 0:
465
+ raise ExecutionError(f"{name} must be > 0, got {value}")
466
+ if req.max_record_bytes > req.max_pending_bytes:
467
+ raise ExecutionError(
468
+ f"max_record_bytes ({req.max_record_bytes}) must not exceed "
469
+ f"max_pending_bytes ({req.max_pending_bytes}): one record would overflow the queue"
470
+ )
471
+
472
+
473
+ def start_duplex(req: DuplexRequest) -> DuplexChild:
474
+ """Spawn the child and start draining it; use the result as a context manager.
475
+
476
+ ``with start_duplex(req) as child: ... result = child.finish()``. The
477
+ ``with`` guarantees the teardown whatever happens inside it. A child
478
+ that cannot be spawned, or whose readers cannot be set up, raises
479
+ :class:`ExecutionError`; anything else that fails or interrupts the
480
+ launch after the spawn is raised as itself. Either way the group is
481
+ killed first. A sandbox that cannot be established is a
482
+ :class:`~autoforge.errors.SandboxLaunchRefusedError`, before anything is
483
+ spawned or taken.
484
+ """
485
+ _validate(req)
486
+ launch = None
487
+ if req.sandbox is not None:
488
+ launch = _prepare_sandbox(
489
+ req.sandbox,
490
+ req.command,
491
+ cwd=req.cwd,
492
+ env_allowlist=req.env_allowlist,
493
+ env=req.env,
494
+ )
495
+ try:
496
+ return _start(req, launch)
497
+ except BaseException:
498
+ if launch is not None:
499
+ launch.close()
500
+ raise
501
+
502
+
503
+ def _start(req: DuplexRequest, launch: BwrapLaunch | None) -> DuplexChild:
504
+ """:func:`start_duplex` once the launch is prepared; the handle owns it
505
+ from its return, and the caller closes it on a failure."""
506
+ started = _now()
507
+ contained = _Containment.begin(adopts_group=launch is not None) if req.contain_orphans else None
508
+ try:
509
+ proc = _spawn(
510
+ req.command,
511
+ cwd=req.cwd,
512
+ env={} if launch is not None else _child_environment(req.env_allowlist, req.env),
513
+ stdin=subprocess.PIPE if req.stdin_pipe else subprocess.DEVNULL,
514
+ launch=launch,
515
+ )
516
+ except BaseException:
517
+ if contained is not None:
518
+ contained.release()
519
+ raise
520
+ if contained is not None:
521
+ contained.child = proc.pid
522
+ # The child is running from here on, and the caller has no handle to
523
+ # tear down until this returns, so every step from here to the return
524
+ # runs under one guard, as in ``execute()``: whatever fails or interrupts it
525
+ # (a thread or descriptor limit, an allocation, Ctrl-C) kills the group
526
+ # and releases the containment and the pipes before the launch fails,
527
+ # never leaving the child, the orphan reaper or the subreaper setting
528
+ # behind.
529
+ child: DuplexChild | None = None
530
+ try:
531
+ try:
532
+ child = DuplexChild(req, proc, started, contained, launch)
533
+ child._start()
534
+ except (OSError, RuntimeError) as exc:
535
+ raise ExecutionError(f"failed to start {' '.join(req.command)}: {exc}") from exc
536
+ except BaseException:
537
+ _abort_start(proc, () if child is None else child._readers, contained)
538
+ raise
539
+ return child
540
+
541
+
542
+ def _abort_start(
543
+ proc: subprocess.Popen[bytes],
544
+ readers: tuple[_PipeDrain, ...],
545
+ contained: _Containment | None,
546
+ ) -> None:
547
+ """The launch failed after the spawn: kill the group at once and release
548
+ everything taken so far, since no teardown will run."""
549
+ # A reader whose thread never started has nothing to stop.
550
+ started = tuple(reader for reader in readers if reader.ident is not None)
551
+ try:
552
+ _terminate_group(proc.pid, proc, started, contained)
553
+ finally:
554
+ if contained is not None:
555
+ contained.release()
556
+ if proc.stdin is not None:
557
+ try:
558
+ proc.stdin.close()
559
+ except OSError:
560
+ pass # nothing was written; the fd is closed either way
561
+ for reader in readers:
562
+ reader.close()
563
+ assert proc.stdout is not None and proc.stderr is not None
564
+ proc.stdout.close()
565
+ proc.stderr.close()
566
+
567
+
568
+ class DuplexChild:
569
+ """A running child with a writable stdin and a framed stdout.
570
+
571
+ ``send_line`` and ``read_line`` may be called from different threads;
572
+ writes are serialized, so a record is never interleaved with another.
573
+ Every call is bounded by the request's deadline. Made by
574
+ :func:`start_duplex`, which starts it under its launch guard.
575
+ """
576
+
577
+ def __init__(
578
+ self,
579
+ req: DuplexRequest,
580
+ proc: subprocess.Popen[bytes],
581
+ started: str,
582
+ contained: _Containment | None = None,
583
+ launch: BwrapLaunch | None = None,
584
+ ) -> None:
585
+ self._req = req
586
+ self._contained = contained
587
+ self._launch = launch
588
+ self._proc = proc
589
+ self._started = started
590
+ ceiling = req.max_runtime_seconds
591
+ if ceiling is None:
592
+ ceiling = MAX_DEADLINE_SECONDS
593
+ self._limits = _Limits(req.idle_timeout_seconds, ceiling)
594
+ self._pgid = proc.pid # start_new_session: the child leads a group of its own
595
+ self._write_lock = threading.RLock()
596
+ self._lifecycle_lock = threading.RLock()
597
+ self._stdin_open = proc.stdin is not None
598
+ self._queue: _RecordQueue | None = None
599
+ self._capture: _BoundedReader | None = None
600
+ self._readers: tuple[_PipeDrain, ...] = ()
601
+ # Set once the group has been dealt with: killed, or found empty after
602
+ # the child's own exit.
603
+ self._left: _Termination | None = None
604
+ self._timed_out = False
605
+ self._timeout_limit = ""
606
+ self._stopped = False
607
+ self._child_killed = False
608
+ self._descendants_killed = False
609
+ self._closed = False
610
+ self._result: DuplexResult | None = None
611
+
612
+ def _start(self) -> None:
613
+ """Start draining the child's pipes. A reader takes its wake pipe
614
+ only as it starts, and each is in ``_readers`` before it starts, so
615
+ a failure part-way leaves everything taken where
616
+ :func:`start_duplex`'s guard releases it."""
617
+ proc, req = self._proc, self._req
618
+ assert proc.stdout is not None and proc.stderr is not None
619
+ if proc.stdin is not None:
620
+ os.set_blocking(proc.stdin.fileno(), False)
621
+ stdout_reader: _PipeDrain
622
+ if req.stdout_mode is StdoutMode.RECORDS:
623
+ self._queue = _RecordQueue(req.max_pending_records, req.max_pending_bytes)
624
+ stdout_reader = _RecordReader(
625
+ proc.stdout, req.max_record_bytes, self._queue, self._limits
626
+ )
627
+ else:
628
+ self._capture = _BoundedReader(
629
+ proc.stdout, req.max_stdout_bytes, "stdout", activity=self._limits
630
+ )
631
+ stdout_reader = self._capture
632
+ self._readers = (stdout_reader,)
633
+ self._stderr = _BoundedReader(
634
+ proc.stderr, req.max_stderr_bytes, "stderr", activity=self._limits
635
+ )
636
+ self._readers += (self._stderr,)
637
+ for reader in self._readers:
638
+ reader.start()
639
+
640
+ @property
641
+ def pid(self) -> int:
642
+ """The child's pid, which is also its process group id."""
643
+ return self._proc.pid
644
+
645
+ def limit_due(self) -> float:
646
+ """When a limit falls due unless the child writes first
647
+ (``time.monotonic()``): the idle limit after its last output or the
648
+ ceiling, whichever is due first. Recomputed on every call."""
649
+ due = self._limits.deadline()
650
+ assert due is not None # the ceiling always applies
651
+ return due
652
+
653
+ def deadline(self) -> float:
654
+ """When the invocation is over unless the child writes first
655
+ (``time.monotonic()``): :meth:`limit_due` plus the request's
656
+ ``wind_down_seconds``. Recomputed on every call."""
657
+ return self.limit_due() + self._req.wind_down_seconds
658
+
659
+ @property
660
+ def deadline_limit(self) -> str:
661
+ """The limit :meth:`deadline` belongs to: ``"idle"`` or ``"max_runtime"``."""
662
+ return self._limits.limit()
663
+
664
+ def pin_deadline(self) -> None:
665
+ """Fix the deadline where it is: output from here on no longer moves it.
666
+
667
+ For a caller that has decided to end the invocation (an abort, an
668
+ orderly shutdown) and gives the child a bounded chance to settle
669
+ within the deadline it had; a child that keeps writing meanwhile
670
+ cannot stretch that chance. :meth:`limit_due` is fixed with it.
671
+ Idempotent.
672
+ """
673
+ self._limits.pin()
674
+
675
+ def _expired(self) -> bool:
676
+ return time.monotonic() >= self.deadline()
677
+
678
+ def __enter__(self) -> DuplexChild:
679
+ return self
680
+
681
+ def __exit__(
682
+ self,
683
+ exc_type: type[BaseException] | None,
684
+ exc: BaseException | None,
685
+ tb: TracebackType | None,
686
+ ) -> None:
687
+ if exc_type is None:
688
+ self.finish()
689
+ return
690
+ # An exception (KeyboardInterrupt included) is in flight: the child is
691
+ # given a short chance to exit on stdin EOF and run its own cleanup,
692
+ # never the rest of the deadline, and then the group goes.
693
+ exit_by = min(self.deadline(), time.monotonic() + executor._EXIT_GRACE_SECONDS)
694
+ try:
695
+ self._teardown(exit_by)
696
+ except ExecutionError:
697
+ pass # the exception in flight is the one to report
698
+
699
+ def send_line(self, data: bytes) -> None:
700
+ """Write ``data`` and an LF as one whole record, bounded by the deadline.
701
+
702
+ ``data`` must not contain an LF: one call is one record. A child
703
+ that closed its stdin raises :class:`ChildStdinClosedError`; the
704
+ deadline passing mid-write kills the group and raises
705
+ :class:`ExecutionTimeoutError` (the stream is then over, so a partly
706
+ written record is never continued).
707
+ """
708
+ if b"\n" in data:
709
+ raise ExecutionError("a record must not contain LF (0x0A)")
710
+ with self._write_lock:
711
+ if self._timed_out:
712
+ raise self._timeout_error()
713
+ if not self._stdin_open:
714
+ raise ExecutionError("the child's stdin is not open for writing")
715
+ assert self._proc.stdin is not None
716
+ fd = self._proc.stdin.fileno()
717
+ view = memoryview(data + b"\n")
718
+ with selectors.DefaultSelector() as sel:
719
+ sel.register(fd, selectors.EVENT_WRITE)
720
+ while view:
721
+ remaining = self.deadline() - time.monotonic()
722
+ if remaining <= 0:
723
+ self._expire()
724
+ raise self._timeout_error()
725
+ if not sel.select(remaining):
726
+ continue
727
+ try:
728
+ written = os.write(fd, view)
729
+ except BlockingIOError:
730
+ continue
731
+ except BrokenPipeError as exc:
732
+ raise ChildStdinClosedError(
733
+ f"the child closed its stdin: {' '.join(self._req.command)}"
734
+ ) from exc
735
+ view = view[written:]
736
+
737
+ def exited(self) -> bool:
738
+ """Whether the child itself has exited; never waits.
739
+
740
+ Its group may live on and hold its stdout, so EOF can come long
741
+ after this, or never; and a descendant that keeps writing keeps
742
+ every read busy, so no read going quiet says so either. A caller
743
+ that reads records until EOF asks this after every read, a record
744
+ included, and once it is True calls :meth:`finish`, which gives
745
+ what is left ADR 0002's exit grace and then kills it, keeping the
746
+ child's own status; :meth:`read_line` then returns what stdout
747
+ carried up to its end.
748
+ """
749
+ return self._proc.poll() is not None
750
+
751
+ def read_line(self, timeout: float | None = None) -> ReadOutcome:
752
+ """The next stdout outcome, waiting at most ``timeout`` seconds and
753
+ never past the deadline (see :data:`ReadOutcome`). After a
754
+ :meth:`finish` that did not time out it never waits: the records
755
+ still queued, then the end item, whatever the deadline. When the
756
+ deadline comes, a child still running is an overrun; one that has
757
+ exited by then is not, and is settled as :meth:`finish` settles it
758
+ (see :meth:`_deadline_reached`)."""
759
+ if self._queue is None:
760
+ raise ExecutionError("stdout is captured, not framed into records")
761
+ if self._timed_out:
762
+ return Timeout(deadline_exceeded=True)
763
+ if self._closed:
764
+ return self._rest()
765
+ now = time.monotonic()
766
+ give_up = None if timeout is None else now + max(0.0, timeout)
767
+ while True:
768
+ # Output (stderr included) moves the deadline while this waits,
769
+ # so a wait that ends at the deadline it started with looks again.
770
+ deadline = self.deadline()
771
+ if now >= deadline:
772
+ return self._deadline_reached()
773
+ item = self._queue.get(deadline if give_up is None else min(deadline, give_up))
774
+ if item is not None:
775
+ return item
776
+ now = time.monotonic()
777
+ if give_up is not None and now >= give_up and now < self.deadline():
778
+ return Timeout(deadline_exceeded=False)
779
+
780
+ def _rest(self) -> ReadOutcome:
781
+ """Torn down: every reader has ended, so the rest of stdout is queued
782
+ up to its end item, and there is nothing left to wait for and no
783
+ deadline left to enforce."""
784
+ assert self._queue is not None
785
+ rest = self._queue.get(time.monotonic())
786
+ assert rest is not None
787
+ return rest
788
+
789
+ def _deadline_reached(self) -> ReadOutcome:
790
+ """A read met the deadline. The child is looked at first, as
791
+ ``execute()``'s last look at it is at the deadline: one
792
+ still running overran it, and the group is killed and the read is
793
+ ``Timeout``. One that has exited ran within it, whatever still
794
+ holds its stdout, so the group gets ADR 0002's exit grace and the
795
+ kill exactly as in :meth:`finish`, the child's status is kept, and
796
+ the read returns the rest of stdout."""
797
+ if not self.exited():
798
+ self._expire()
799
+ return Timeout(deadline_exceeded=True)
800
+ self._teardown(time.monotonic())
801
+ return self._rest()
802
+
803
+ def close_stdin(self) -> None:
804
+ """Close the child's stdin (its orderly-shutdown request). Idempotent."""
805
+ with self._write_lock:
806
+ if not self._stdin_open:
807
+ return
808
+ self._stdin_open = False
809
+ assert self._proc.stdin is not None
810
+ try:
811
+ self._proc.stdin.close()
812
+ except OSError:
813
+ pass # nothing was buffered; the fd is closed either way
814
+
815
+ def finish(self) -> DuplexResult:
816
+ """Close stdin, wait for the child within the deadline, tear down, report.
817
+
818
+ Legal at any time: a running child is asked to stop by stdin EOF and
819
+ given until the deadline; past it the group is killed and the
820
+ result is ``timed_out``. Once the child has exited on its own, ADR
821
+ 0002's exit grace applies exactly as in ``execute()``. After an
822
+ :class:`Overflow` a child still running is killed at once. A second
823
+ call returns the same result. A sandboxed child whose backend
824
+ reports that the sandbox was never set up raises
825
+ :class:`~autoforge.errors.SandboxLaunchRefusedError` here, after the
826
+ teardown, as ``execute()`` does.
827
+ """
828
+ if self._result is not None:
829
+ return self._result
830
+ self._teardown(None)
831
+ for reader in self._readers:
832
+ if reader.error is not None:
833
+ raise ExecutionError(
834
+ f"failed to read {reader.name.removeprefix('autoforge-capture-')} of "
835
+ f"{' '.join(self._req.command)}: {reader.error}"
836
+ ) from reader.error
837
+ err = self._stderr.captured()
838
+ out = self._capture.captured() if self._capture is not None else None
839
+ assert self._left is not None
840
+ facts = None
841
+ if self._launch is not None:
842
+ facts = self._launch.outcome(
843
+ returncode=self._proc.returncode,
844
+ ended_by_executor=self._timed_out or self._stopped or self._child_killed,
845
+ stderr=err.text,
846
+ )
847
+ self._result = DuplexResult(
848
+ command=list(self._req.command),
849
+ cwd=self._req.cwd,
850
+ exit_code=-1 if self._timed_out or self._child_killed else self._proc.returncode,
851
+ stderr=err.text,
852
+ started_at=self._started,
853
+ finished_at=_now(),
854
+ timed_out=self._timed_out,
855
+ stderr_truncated=err.truncated,
856
+ records_overflowed=self._queue is not None and self._queue.overflowed,
857
+ stdout=out.text if out is not None else "",
858
+ stdout_truncated=out.truncated if out is not None else False,
859
+ descendants_killed=self._descendants_killed,
860
+ group_survived_kill=self._left.group_survived,
861
+ capture_abandoned=self._left.capture_abandoned,
862
+ orphans_killed=self._contained is not None and self._contained.found,
863
+ orphan_survived_kill=self._left.orphan_survived,
864
+ orphans_unchecked=self._req.contain_orphans and self._contained is None,
865
+ timeout_limit=self._timeout_limit,
866
+ last_activity_at=self._limits.last_activity_at(),
867
+ stopped=self._stopped,
868
+ sandbox=facts,
869
+ )
870
+ return self._result
871
+
872
+ def _timeout_error(self) -> ExecutionTimeoutError:
873
+ if (self._timeout_limit or self._limits.limit()) == LIMIT_IDLE:
874
+ what = f"wrote nothing for {self._limits.idle:g}s"
875
+ else:
876
+ what = f"reached its {self._limits.ceiling:g}s maximum runtime"
877
+ return ExecutionTimeoutError(
878
+ f"command {what} and was killed: {' '.join(self._req.command)}"
879
+ )
880
+
881
+ def _mark_timed_out(self) -> None:
882
+ """Record the timeout and the limit it was; caller holds the lock."""
883
+ self._timed_out = True
884
+ self._timeout_limit = self._limits.limit()
885
+
886
+ def _kill(self) -> None:
887
+ """Kill the group before the child exited on its own; caller holds the lock."""
888
+ self._child_killed = self._proc.poll() is None
889
+ self._left = _terminate_group(self._pgid, self._proc, self._readers, self._contained)
890
+
891
+ def stop(self) -> bool:
892
+ """End the invocation now, for a reason of the caller's (#194's loop
893
+ detector): the group is killed as on a timeout, through the same
894
+ path and within the same bounds, and the result says ``stopped``.
895
+ False, and nothing done, once the child has exited on its own or the
896
+ group has been dealt with (a limit fired first): the caller then
897
+ settles it as usual. The caller follows a stop with :meth:`finish`."""
898
+ with self._lifecycle_lock:
899
+ if self._left is not None or self._proc.poll() is not None:
900
+ return False
901
+ self._stopped = True
902
+ self._kill()
903
+ return True
904
+
905
+ def _expire(self) -> None:
906
+ """The deadline passed: kill the group as ``execute()`` does on timeout."""
907
+ with self._lifecycle_lock:
908
+ if self._left is not None:
909
+ return
910
+ self._mark_timed_out()
911
+ self._kill()
912
+
913
+ def _teardown(self, exit_by: float | None) -> None:
914
+ """Close stdin, give the child until ``exit_by`` to exit (``None``:
915
+ until the deadline, which output still moves), apply the exit grace
916
+ or kill the group, and release the pipes. Idempotent."""
917
+ self.close_stdin()
918
+ with self._lifecycle_lock:
919
+ try:
920
+ self._settle(exit_by)
921
+ except BaseException:
922
+ # Interrupted (Ctrl-C) before the group was dealt with: kill it
923
+ # and the orphans while the subreaper still catches them, as
924
+ # ``execute()`` does, and only then restore the setting.
925
+ try:
926
+ if self._left is None:
927
+ self._kill()
928
+ finally:
929
+ if self._contained is not None:
930
+ self._contained.release()
931
+ if self._launch is not None:
932
+ self._launch.collect()
933
+ self._launch.close()
934
+ raise
935
+ if self._closed:
936
+ return
937
+ self._closed = True
938
+ if self._contained is not None:
939
+ self._contained.release()
940
+ if self._launch is not None:
941
+ # The backend has been reaped or killed: what it recorded is
942
+ # in the status pipe, read now for :meth:`finish`.
943
+ self._launch.collect()
944
+ self._launch.close()
945
+ for reader in self._readers:
946
+ reader.close()
947
+ assert self._proc.stdout is not None and self._proc.stderr is not None
948
+ self._proc.stdout.close()
949
+ self._proc.stderr.close()
950
+
951
+ def _exits_by(self, exit_by: float | None) -> bool:
952
+ """Wait for the child's exit until ``exit_by``, or (``None``) until the
953
+ deadline as output moves it; True when it exited."""
954
+ if exit_by is not None:
955
+ return _reaped(self._proc, exit_by)
956
+ while not _reaped(self._proc, self.deadline()):
957
+ if self._expired():
958
+ return False
959
+ return True
960
+
961
+ def _settle(self, exit_by: float | None) -> None:
962
+ """Deal with the group once: the exit grace, or the kill; caller holds the lock."""
963
+ if self._left is None:
964
+ overflowed = self._queue is not None and self._queue.overflowed
965
+ if overflowed and self._proc.poll() is None:
966
+ self._kill()
967
+ elif not self._exits_by(exit_by):
968
+ if self._expired():
969
+ self._mark_timed_out()
970
+ self._kill()
971
+ else:
972
+ # The child exited on its own: ADR 0002's exit grace, then
973
+ # whatever is still in the group (or holds the pipes) is
974
+ # killed and the child's own status kept.
975
+ grace = time.monotonic() + executor._EXIT_GRACE_SECONDS
976
+ contained = self._contained
977
+ group_settled = _eof(self._readers, grace) and _group_gone(
978
+ self._pgid, grace, contained
979
+ )
980
+ if group_settled and (contained is None or contained.settled(grace)):
981
+ self._left = _Termination(group_survived=False, capture_abandoned=False)
982
+ else:
983
+ # Only an orphan left over is an orphan kill, not a group one.
984
+ self._descendants_killed = not group_settled
985
+ self._left = _terminate_group(self._pgid, self._proc, self._readers, contained)