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/executor.py ADDED
@@ -0,0 +1,1601 @@
1
+ """CLI execution abstraction over subprocess.
2
+
3
+ Represents every external invocation (Claude Code, OpenCode, gh, git) as a
4
+ structured ExecutionRequest -> ExecutionResult.
5
+
6
+ Safety properties:
7
+ - argv lists only, never ``shell=True`` — prompt text containing quotes,
8
+ ``$``, ``;``, ``|`` or ``$(...)`` is passed as one opaque argument;
9
+ - stdin is ``/dev/null`` so a CLI that expects an interactive TTY cannot
10
+ hang waiting for input, unless the request carries ``stdin_data``: that
11
+ is written in full by a non-blocking feeder and stdin is then closed, so
12
+ the child sees EOF, a child that never reads costs the controller
13
+ nothing past the invocation, and what the child did not read is counted
14
+ (:class:`_StdinFeeder`, :attr:`ExecutionResult.stdin_unread`);
15
+ - the child runs in its own session/process group, and nothing in that
16
+ group outlives the invocation: on timeout the whole group is terminated
17
+ (SIGTERM, then SIGKILL), and after a normal exit a group that still has
18
+ members or pipes still open past a short grace is terminated the same
19
+ way, so grandchildren spawned by an agent (test runners, editors, servers)
20
+ do not linger into the next phase, whether or not they hold the agent's
21
+ pipes. The child's own exit status and output are kept in the second
22
+ case; the result records that descendants were killed. The kill is
23
+ complete only when no process is left in the group, and every wait in it
24
+ is bounded: what survives SIGKILL, or left the group (``setsid``) and so
25
+ holds the pipes out of reach, is abandoned rather than waited for, and the
26
+ result says so (:attr:`ExecutionResult.group_survived_kill`,
27
+ :attr:`ExecutionResult.capture_abandoned`) so an operator looks for the
28
+ leftover instead of assuming the kill was clean;
29
+ - capture is bounded: each stream keeps at most ``max_output_bytes`` (the
30
+ first half and the last half of what the child wrote) in a buffer whose
31
+ memory is that bound plus a constant, so a runaway or adversarial child
32
+ costs the controller a bounded amount of memory, not the size of its
33
+ output, however it chunks it. The tail is kept because the CONTROL_RESULT
34
+ block is the last thing on stdout; :attr:`ExecutionResult.stdout_tail` is
35
+ the part known to be contiguous up to EOF, so a caller that parses a
36
+ truncated stream never sees text that spans the cut;
37
+ - the environment is allow-listed on request: a request carrying
38
+ ``env_allowlist`` starts the child from only the named variables of the
39
+ controller's environment (:func:`select_environment`) instead of a copy
40
+ of all of it, so an agent or a repository-defined command never sees a
41
+ credential that was in the operator's shell but has nothing to do with
42
+ the run. Which names are allowed is policy and belongs to the caller; the
43
+ executor only applies the selection;
44
+ - on request (``contain_orphans``), what left the group is caught too: on
45
+ Linux the controller is a child subreaper for the invocation
46
+ (``prctl(PR_SET_CHILD_SUBREAPER)``), so a process that called ``setsid``
47
+ (or was started detached) and whose parent then died is re-parented to the
48
+ controller instead of to init; the ones that die while the child runs are
49
+ reaped as they die, so they cannot pile up as zombies against the
50
+ session's process limit, and at teardown every orphan still alive is
51
+ killed (SIGTERM, then SIGKILL, its own process group with it) and reported
52
+ (:attr:`ExecutionResult.orphans_killed`,
53
+ :attr:`ExecutionResult.orphan_survived_kill`). Where no subreaper exists
54
+ the result says the check could not be made
55
+ (:attr:`ExecutionResult.orphans_unchecked`). See the ADR 0002 amendment;
56
+ - the child runs under two limits (:class:`_Limits`, #193): a wall-clock
57
+ ceiling (``timeout_seconds``, measured from the spawn) and, on request, an
58
+ idle limit (``idle_timeout_seconds``) that every chunk the child writes
59
+ on stdout or stderr restarts, so a child that keeps producing output is
60
+ never killed for being slow and a silent one is killed once it has been
61
+ silent that long. The wait runs in slices, each bounded by whichever
62
+ limit is due first, and the kill on either is the group kill above. The
63
+ result says which limit fired (:attr:`ExecutionResult.timeout_limit`) and
64
+ when the child last wrote anything (:attr:`ExecutionResult.last_activity_at`).
65
+ Only the child's output is activity: what the controller writes to it is
66
+ not, and neither is time spent waiting on anyone else;
67
+ - on request (``sandbox``), the command runs inside the outer sandbox a
68
+ :class:`~autoforge.sandbox.LaunchDescription` describes (ADR 0005): the
69
+ Linux backend (:mod:`autoforge.sandbox_bwrap`) is spawned in its place,
70
+ with the same session, pipes, limits, containment and group kill, and a
71
+ host that cannot establish the sandbox refuses the launch
72
+ (:class:`~autoforge.errors.SandboxLaunchRefusedError`) instead of running
73
+ it without one. A request without a description takes exactly the path
74
+ it took before (ADR 0002 §4e).
75
+ """
76
+
77
+ from __future__ import annotations
78
+
79
+ import ctypes
80
+ import math
81
+ import os
82
+ import re
83
+ import selectors
84
+ import signal
85
+ import subprocess
86
+ import sys
87
+ import threading
88
+ import time
89
+ from collections.abc import Callable, Iterable, Mapping
90
+ from dataclasses import dataclass
91
+ from datetime import UTC, datetime
92
+ from typing import IO
93
+
94
+ from . import sandbox_bwrap
95
+ from .errors import ExecutionError, ExecutionTimeoutError, SandboxLaunchRefusedError
96
+ from .sandbox import LaunchDescription, SandboxFacts
97
+ from .sandbox_bwrap import BwrapLaunch
98
+
99
+ _KILL_GRACE_SECONDS = 5.0
100
+ # After the child has exited on its own, how long its pipes may stay open
101
+ # and its group may keep a member before the group is killed. A child that
102
+ # left nothing behind clears both at once; the grace is for a helper the
103
+ # child is shutting down as it exits, not for a server it meant to leave.
104
+ _EXIT_GRACE_SECONDS = 2.0
105
+ _GROUP_POLL_SECONDS = 0.02
106
+ _READ_CHUNK_BYTES = 64 * 1024
107
+ # How often the wait for the child's exit looks at :attr:`ExecutionRequest.stop`,
108
+ # and so how late after it is set the group kill can begin; and what the wait
109
+ # returns when it was set.
110
+ _STOP_POLL_SECONDS = 0.1
111
+ _STOPPED = "stopped"
112
+
113
+ # Per-stream capture bound. A Claude Code ``-p`` transcript is kilobytes and
114
+ # an OpenCode ``run`` transcript at most a few megabytes, so the default is
115
+ # well above any legitimate agent output; ``gh``/``git`` plumbing output is
116
+ # smaller still. It is a request field rather than configuration because
117
+ # nothing legitimate should reach it.
118
+ DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024
119
+
120
+ # The backstop under every agent limit: an agent with no wall-clock ceiling
121
+ # of its own still ends here. Every wait is bounded by the remaining time,
122
+ # and the wait primitives cannot take an arbitrarily large timeout (epoll's
123
+ # is an int of milliseconds, about 24.8 days); a week is far above any run.
124
+ MAX_DEADLINE_SECONDS = 7 * 24 * 3600
125
+
126
+ # The names :attr:`ExecutionResult.timeout_limit` takes when a limit fired.
127
+ LIMIT_IDLE = "idle"
128
+ LIMIT_MAX_RUNTIME = "max_runtime"
129
+
130
+ # An environment allow-list entry: a variable name, or a name prefix followed
131
+ # by ``*`` (``LC_*``, ``ANTHROPIC_*``). Nothing else -- no other glob
132
+ # characters, no empty prefix -- so an entry cannot quietly widen into
133
+ # "everything".
134
+ ENV_PATTERN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\*?$")
135
+
136
+
137
+ def is_env_pattern(name: str) -> bool:
138
+ """Whether ``name`` is a valid environment allow-list entry."""
139
+ return bool(ENV_PATTERN_RE.match(name))
140
+
141
+
142
+ def select_environment(
143
+ names: Iterable[str], source: Mapping[str, str] | None = None
144
+ ) -> dict[str, str]:
145
+ """The variables of ``source`` (default: this process's environment) named by ``names``.
146
+
147
+ An entry is an exact variable name or a prefix ending in ``*``; the
148
+ result is the subset of ``source`` some entry names, in ``source``'s
149
+ order, and a name that is absent from ``source`` is simply not there
150
+ (the child gets no empty placeholder). An entry that is not a valid
151
+ pattern raises :class:`ExecutionError`, so a typo in a configured list
152
+ is a refusal to launch, never a silently narrower or wider environment.
153
+ """
154
+ env = os.environ if source is None else source
155
+ exact: set[str] = set()
156
+ prefixes: list[str] = []
157
+ for name in names:
158
+ if not is_env_pattern(name):
159
+ raise ExecutionError(
160
+ f"invalid environment allow-list entry {name!r}: expected a variable name "
161
+ "or a name prefix followed by '*'"
162
+ )
163
+ if name.endswith("*"):
164
+ prefixes.append(name[:-1])
165
+ else:
166
+ exact.add(name)
167
+ return {
168
+ key: value
169
+ for key, value in env.items()
170
+ if key in exact or any(key.startswith(prefix) for prefix in prefixes)
171
+ }
172
+
173
+
174
+ @dataclass
175
+ class ExecutionRequest:
176
+ command: list[str]
177
+ cwd: str | None = None
178
+ # Layered over the inherited (or allow-listed) environment when given.
179
+ env: dict[str, str] | None = None
180
+ # The wall-clock ceiling, measured from the spawn; 0 or less: none.
181
+ timeout_seconds: int = 1800
182
+ # Kill the child once it has written nothing on stdout or stderr for
183
+ # this many seconds; every chunk restarts it (#193). ``None``: no idle
184
+ # limit, only the ceiling. For an agent; plumbing leaves it off.
185
+ idle_timeout_seconds: float | None = None
186
+ max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES
187
+ # ``None``: the child inherits the controller's whole environment (the
188
+ # controller's own git/gh plumbing). A tuple of names/prefixes: the child
189
+ # starts from only those variables (see :func:`select_environment`); an
190
+ # empty tuple is an empty environment.
191
+ env_allowlist: tuple[str, ...] | None = None
192
+ # Catch and kill what the child started outside its process group (see
193
+ # :class:`_Containment`). For agents and repository-defined commands; the
194
+ # controller's own git/gh plumbing leaves it off, so a ``git gc --auto``
195
+ # that git daemonized on purpose is left alone.
196
+ contain_orphans: bool = False
197
+ # Written to the child's stdin, which is then closed; ``None`` keeps
198
+ # stdin on ``/dev/null``. For a CLI whose argv cannot carry a payload
199
+ # verbatim (the OpenCode v2 message). Bounded by the timeout, not by size.
200
+ stdin_data: bytes | None = None
201
+ # Called on the reader thread with the stream's name (``"stdout"`` or
202
+ # ``"stderr"``) each time a chunk of it arrives: a liveness signal for an
203
+ # agent that streams nothing parseable (#192). It never sees the bytes,
204
+ # so what the child wrote stays in the bounded capture alone. Calls from
205
+ # the two streams are serialized; one that raises is not called again
206
+ # for this invocation, on either stream, and both drains go on.
207
+ on_output: Callable[[str], None] | None = None
208
+ # Called the same way, under the same lock, with the stream's name and
209
+ # the chunk itself: for a caller that digests what it reads as it
210
+ # arrives (the loop detector's line repetition, #194) and keeps none of
211
+ # it. The capture is not affected; one that raises is unhooked like
212
+ # ``on_output``, independently of it.
213
+ on_chunk: Callable[[str, bytes], None] | None = None
214
+ # Set by the caller, from any thread (an ``on_chunk`` hook included), to
215
+ # end the invocation: the group is killed as on a timeout, through the
216
+ # same path and within the same bounds, and the result says ``stopped``.
217
+ # Why the caller stopped it is the caller's; the executor never sets it.
218
+ stop: threading.Event | None = None
219
+ # Run the command inside the outer sandbox this describes (ADR 0005);
220
+ # ``None`` runs it as every launch ran before the sandbox existed. A
221
+ # sandboxed launch is spawned on the calling thread, which waits for it,
222
+ # as ``bwrap --die-with-parent`` requires (D10.6). A host that cannot
223
+ # establish the sandbox raises :class:`SandboxLaunchRefusedError`; it never
224
+ # runs the command without it.
225
+ sandbox: LaunchDescription | None = None
226
+
227
+
228
+ @dataclass
229
+ class ExecutionResult:
230
+ command: list[str]
231
+ cwd: str | None
232
+ exit_code: int
233
+ stdout: str
234
+ stderr: str
235
+ started_at: str
236
+ finished_at: str
237
+ timed_out: bool = False
238
+ # True when the stream exceeded the capture bound: ``stdout``/``stderr``
239
+ # then hold its head, an omission marker and its tail.
240
+ stdout_truncated: bool = False
241
+ stderr_truncated: bool = False
242
+ # Index into ``stdout`` where the contiguous-to-EOF tail begins (0 when
243
+ # nothing was omitted, so ``stdout_tail`` is then the whole stream).
244
+ stdout_tail_offset: int = 0
245
+ # How many bytes the child wrote on each stream, the omitted ones
246
+ # included: the capture keeps at most ``max_output_bytes`` of them.
247
+ stdout_bytes: int = 0
248
+ stderr_bytes: int = 0
249
+ # The child exited on its own but left processes behind (its pipes were
250
+ # still open or its group still had a member past the exit grace), so
251
+ # the group was killed; ``exit_code`` and the streams are the child's own.
252
+ # Never set together with ``timed_out``.
253
+ descendants_killed: bool = False
254
+ # A process was still in the group after the SIGKILL grace (stuck in the
255
+ # kernel, or not signallable from here): the kill was not clean and a
256
+ # leftover may still be running.
257
+ group_survived_kill: bool = False
258
+ # A pipe never reached EOF: a writer the group kill could not reach
259
+ # (one that left the group) still holds it; what was read before the
260
+ # capture was abandoned is what the streams contain.
261
+ capture_abandoned: bool = False
262
+ # ``contain_orphans`` only. A process the invocation started had left the
263
+ # group and outlived its parent (re-parented to the controller), so it
264
+ # was killed; set on any path, a timeout included.
265
+ orphans_killed: bool = False
266
+ # Such an orphan was still alive after the SIGKILL grace.
267
+ orphan_survived_kill: bool = False
268
+ # Containment was requested but this platform has no child subreaper, so
269
+ # a process that left the group was neither seen nor killed.
270
+ orphans_unchecked: bool = False
271
+ # Bytes of ``stdin_data`` the child never read: never written because
272
+ # the invocation ended first, or written and still in the pipe when it
273
+ # ended. Always the payload's tail, since the child reads a prefix. Read
274
+ # by any process holding the child's stdin counts as read; whether the
275
+ # child then used what it read is beyond what the executor can see.
276
+ stdin_unread: int = 0
277
+ # Which limit killed the child when ``timed_out``: ``"idle"`` (it wrote
278
+ # nothing for ``idle_timeout_seconds``) or ``"max_runtime"`` (the
279
+ # wall-clock ceiling); empty otherwise.
280
+ timeout_limit: str = ""
281
+ # When the child last wrote anything on stdout or stderr (UTC, ISO
282
+ # 8601); ``None`` when it wrote nothing at all.
283
+ last_activity_at: str | None = None
284
+ # The caller set :attr:`ExecutionRequest.stop` before the child exited,
285
+ # so the group was killed; ``exit_code`` is then -1. Never set together
286
+ # with ``timed_out``.
287
+ stopped: bool = False
288
+ # What the outer sandbox recorded for a request that carried a
289
+ # description (ADR 0005 D10.4); ``None`` for one that did not.
290
+ sandbox: SandboxFacts | None = None
291
+
292
+ @property
293
+ def stdout_tail(self) -> str:
294
+ """The part of stdout captured contiguously up to EOF.
295
+
296
+ This is what a CONTROL_RESULT parser should search: a block found
297
+ here is one the child actually wrote whole, never one assembled
298
+ across the omission marker or a stale earlier block from the head.
299
+ """
300
+ return self.stdout[self.stdout_tail_offset :]
301
+
302
+ @property
303
+ def truncated(self) -> bool:
304
+ return self.stdout_truncated or self.stderr_truncated
305
+
306
+ @property
307
+ def leftovers(self) -> str:
308
+ """What the invocation left behind, for a log line; empty when nothing."""
309
+ return describe_leftovers(
310
+ descendants_killed=self.descendants_killed,
311
+ group_survived_kill=self.group_survived_kill,
312
+ capture_abandoned=self.capture_abandoned,
313
+ orphans_killed=self.orphans_killed,
314
+ orphan_survived_kill=self.orphan_survived_kill,
315
+ orphans_unchecked=self.orphans_unchecked,
316
+ sandbox_teardown=self.sandbox is not None and self.sandbox.enforced,
317
+ )
318
+
319
+ @property
320
+ def ok(self) -> bool:
321
+ return (
322
+ not self.timed_out and not self.stopped and self.exit_code == 0 and not self.truncated
323
+ )
324
+
325
+ def raise_if_failed(self) -> ExecutionResult:
326
+ if self.timed_out:
327
+ limit = f" ({self.timeout_limit} limit)" if self.timeout_limit else ""
328
+ raise ExecutionTimeoutError(f"command timed out{limit}: {' '.join(self.command)}")
329
+ if self.stopped:
330
+ raise ExecutionError(f"command was stopped: {' '.join(self.command)}")
331
+ if self.exit_code != 0:
332
+ raise ExecutionError(
333
+ f"command exited {self.exit_code}: {' '.join(self.command)}\n"
334
+ f"stderr (tail): {self.stderr[-2000:]}"
335
+ )
336
+ if self.truncated:
337
+ raise ExecutionError(
338
+ f"command output was truncated at the capture bound: {' '.join(self.command)}"
339
+ )
340
+ return self
341
+
342
+
343
+ def describe_leftovers(
344
+ *,
345
+ descendants_killed: bool,
346
+ group_survived_kill: bool,
347
+ capture_abandoned: bool,
348
+ orphans_killed: bool = False,
349
+ orphan_survived_kill: bool = False,
350
+ orphans_unchecked: bool = False,
351
+ sandbox_teardown: bool = False,
352
+ ) -> str:
353
+ """One sentence naming what an invocation left behind; empty when nothing.
354
+
355
+ The facts are the executor's (see :class:`ExecutionResult`); the
356
+ sentence is for a run log or an error, so an operator is pointed at a
357
+ leftover process rather than at a slow agent or a clean kill.
358
+ ``sandbox_teardown`` is every enforced sandboxed launch: what it left
359
+ inside the sandbox was killed with the namespace and never counted, so
360
+ the sentence says so rather than reporting nothing (ADR 0005 D10.4).
361
+ """
362
+ parts: list[str] = []
363
+ if descendants_killed:
364
+ parts.append(
365
+ "the child exited but left processes behind (its output pipes stayed open or "
366
+ "its process group still had a member past the exit grace), so the group was killed"
367
+ )
368
+ if group_survived_kill:
369
+ parts.append(
370
+ "its process group still had a member after SIGKILL, so a leftover process "
371
+ "may still be running"
372
+ )
373
+ if capture_abandoned:
374
+ parts.append(
375
+ "its output pipes never reached EOF, so a process outside its group (one that "
376
+ "called setsid) still holds them and may still be running"
377
+ )
378
+ if orphans_killed:
379
+ parts.append(
380
+ "it left processes outside its process group (re-parented to the controller), "
381
+ "so they were killed"
382
+ )
383
+ if orphan_survived_kill:
384
+ parts.append(
385
+ "a process it left outside its process group was still alive after SIGKILL, so "
386
+ "it may still be running"
387
+ )
388
+ if orphans_unchecked:
389
+ parts.append(
390
+ "processes it may have left outside its process group could not be checked on "
391
+ "this platform (no child subreaper), so one may still be running"
392
+ )
393
+ if sandbox_teardown:
394
+ parts.append(
395
+ "whatever it left inside the sandbox was killed when the sandbox's namespace was "
396
+ "torn down, uncounted"
397
+ )
398
+ return "; ".join(parts)
399
+
400
+
401
+ @dataclass
402
+ class _Captured:
403
+ text: str
404
+ truncated: bool
405
+ tail_offset: int
406
+ # Every byte the stream carried, kept or not.
407
+ total: int = 0
408
+
409
+
410
+ class _BoundedBuffer:
411
+ """Keep at most ``limit`` bytes of a stream: its head and its tail.
412
+
413
+ The first ``limit - limit // 2`` bytes are the head and the last
414
+ ``limit // 2`` the tail; everything between is counted and dropped. The
415
+ tail is a ring buffer (one ``bytearray``, filled once and then
416
+ overwritten in place), so the memory the buffer holds is the limit plus a
417
+ constant, whatever the stream's chunking: a writer that produces one byte
418
+ per read costs no per-chunk objects. The tail is trimmed to the byte, so
419
+ what is retained never exceeds ``limit``, and a bound smaller than one
420
+ read is honoured too.
421
+ """
422
+
423
+ def __init__(self, limit: int, name: str) -> None:
424
+ self._name = name
425
+ self._limit = limit
426
+ self._head_limit = limit - limit // 2
427
+ self._tail_limit = limit // 2
428
+ self._head = bytearray()
429
+ # The ring grows by appending until it holds ``_tail_limit`` bytes and
430
+ # is overwritten in place from ``_pos`` after that; ``_pos`` is the
431
+ # oldest byte once the ring is full and 0 before.
432
+ self._ring = bytearray()
433
+ self._pos = 0
434
+ self._total = 0
435
+
436
+ @property
437
+ def retained(self) -> int:
438
+ """Bytes currently held; never more than the limit."""
439
+ return len(self._head) + len(self._ring)
440
+
441
+ def feed(self, chunk: bytes) -> None:
442
+ self._total += len(chunk)
443
+ room = self._head_limit - len(self._head)
444
+ if room > 0:
445
+ self._head += chunk[:room]
446
+ chunk = chunk[room:]
447
+ size = self._tail_limit
448
+ if not chunk or size == 0:
449
+ return
450
+ if len(self._ring) < size:
451
+ fill = size - len(self._ring)
452
+ self._ring += chunk[:fill]
453
+ chunk = chunk[fill:]
454
+ if not chunk:
455
+ return
456
+ if len(chunk) >= size:
457
+ self._ring[:] = chunk[len(chunk) - size :]
458
+ self._pos = 0
459
+ return
460
+ end = self._pos + len(chunk)
461
+ if end <= size:
462
+ self._ring[self._pos : end] = chunk
463
+ else:
464
+ split = size - self._pos
465
+ self._ring[self._pos :] = chunk[:split]
466
+ self._ring[: len(chunk) - split] = chunk[split:]
467
+ self._pos = end % size
468
+
469
+ def _tail(self) -> bytes:
470
+ return bytes(self._ring[self._pos :] + self._ring[: self._pos])
471
+
472
+ def captured(self) -> _Captured:
473
+ tail = self._tail()
474
+ omitted = self._total - len(self._head) - len(tail)
475
+ if omitted <= 0:
476
+ # Nothing was dropped, so the split between head and tail is an
477
+ # internal detail: decode the stream whole, or a multi-byte
478
+ # character that straddles it would come out as replacements.
479
+ return _Captured(
480
+ (self._head + tail).decode("utf-8", errors="replace"), False, 0, self._total
481
+ )
482
+ # Past the bound the head and the tail are separate pieces of the
483
+ # stream; a character cut by the bound decodes to replacements on
484
+ # either side of the marker, which is what was captured.
485
+ head_text = self._head.decode("utf-8", errors="replace")
486
+ tail_text = tail.decode("utf-8", errors="replace")
487
+ marker = (
488
+ f"\n[autoforge: {omitted} bytes of {self._name} omitted; the capture bound is "
489
+ f"{self._limit} bytes, the first {len(self._head)} and last {len(tail)} "
490
+ "bytes were kept]\n"
491
+ )
492
+ return _Captured(
493
+ head_text + marker + tail_text, True, len(head_text) + len(marker), self._total
494
+ )
495
+
496
+
497
+ class _Limits:
498
+ """The limits one invocation runs under, and the activity that moves one (#193).
499
+
500
+ ``idle`` (seconds, or ``None``): the invocation is over once the child
501
+ has written nothing on stdout or stderr for that long, counted from the
502
+ spawn until the first chunk and from the latest chunk after it.
503
+ ``ceiling`` (seconds, or ``None``): measured from the spawn and never
504
+ moved. :meth:`deadline` is whichever is due first, recomputed on every
505
+ call, so a waiter that wakes at a deadline activity has since moved
506
+ simply waits again.
507
+
508
+ :meth:`touch` is called on the reader threads; it publishes the time of
509
+ the chunk in one assignment, so a waiter on another thread reads either
510
+ the previous chunk's time or this one's, never a mixture. Only output
511
+ touches it: what the controller writes to the child is not activity,
512
+ and neither is any time spent waiting on someone else.
513
+ """
514
+
515
+ def __init__(self, idle: float | None, ceiling: float | None) -> None:
516
+ self.idle = idle
517
+ self.ceiling = ceiling
518
+ self._started = time.monotonic()
519
+ # (monotonic, wall clock) of the latest chunk; None before the first.
520
+ self._last: tuple[float, float] | None = None
521
+ # (deadline, limit) once :meth:`pin` has fixed them.
522
+ self._pinned: tuple[float, str] | None = None
523
+
524
+ def touch(self) -> None:
525
+ """A chunk of output arrived."""
526
+ self._last = (time.monotonic(), time.time())
527
+
528
+ def _due(self) -> tuple[float, str] | None:
529
+ idle_at = None
530
+ if self.idle is not None:
531
+ last = self._last
532
+ idle_at = (self._started if last is None else last[0]) + self.idle
533
+ ceiling_at = None if self.ceiling is None else self._started + self.ceiling
534
+ if ceiling_at is not None and (idle_at is None or ceiling_at <= idle_at):
535
+ return ceiling_at, LIMIT_MAX_RUNTIME
536
+ if idle_at is not None:
537
+ return idle_at, LIMIT_IDLE
538
+ return None
539
+
540
+ def deadline(self) -> float | None:
541
+ """When the invocation is over unless output arrives first
542
+ (``time.monotonic()``); ``None`` when no limit applies."""
543
+ due = self._pinned or self._due()
544
+ return None if due is None else due[0]
545
+
546
+ def limit(self) -> str:
547
+ """The limit :meth:`deadline` belongs to; empty when none applies."""
548
+ due = self._pinned or self._due()
549
+ return "" if due is None else due[1]
550
+
551
+ def expired(self) -> bool:
552
+ deadline = self.deadline()
553
+ return deadline is not None and time.monotonic() >= deadline
554
+
555
+ def pin(self) -> None:
556
+ """Fix the current deadline: output from here on no longer moves it.
557
+
558
+ For a caller that has decided to end the invocation and is giving
559
+ the child a bounded chance to shut down in order. Idempotent.
560
+ """
561
+ if self._pinned is None:
562
+ self._pinned = self._due()
563
+
564
+ def last_activity_at(self) -> str | None:
565
+ """When the latest chunk arrived (UTC, ISO 8601); ``None`` before any."""
566
+ last = self._last
567
+ return None if last is None else datetime.fromtimestamp(last[1], UTC).isoformat()
568
+
569
+
570
+ def _wait_within(
571
+ proc: subprocess.Popen, limits: _Limits, stop: threading.Event | None = None
572
+ ) -> str:
573
+ """Wait for the child's exit while it stays within ``limits`` and ``stop``
574
+ is unset.
575
+
576
+ Returns ``""`` once the child has exited, the limit that ran out
577
+ (:data:`LIMIT_IDLE`, :data:`LIMIT_MAX_RUNTIME`), or :data:`_STOPPED` when
578
+ ``stop`` was set. Each slice waits until the deadline due at its start;
579
+ one that ends without the exit looks again, since output may have moved
580
+ the deadline meanwhile, and only a deadline still past when looked at
581
+ ends the wait. With ``stop`` no slice is longer than
582
+ :data:`_STOP_POLL_SECONDS`.
583
+ """
584
+ while True:
585
+ # A child that has exited on its own by then is not stopped.
586
+ if stop is not None and stop.is_set() and proc.poll() is None:
587
+ return _STOPPED
588
+ deadline = limits.deadline()
589
+ timeout = None if deadline is None else max(0.0, deadline - time.monotonic())
590
+ if stop is not None:
591
+ timeout = _STOP_POLL_SECONDS if timeout is None else min(timeout, _STOP_POLL_SECONDS)
592
+ try:
593
+ proc.wait(timeout=timeout)
594
+ except subprocess.TimeoutExpired:
595
+ if limits.expired():
596
+ return limits.limit()
597
+ continue
598
+ return ""
599
+
600
+
601
+ class _PipeDrain(threading.Thread):
602
+ """Drain one pipe until EOF or abandoned, handing each chunk to :meth:`_feed`.
603
+
604
+ The pipe is drained whatever the child writes, so the child never blocks
605
+ on a full pipe the way it would if the controller stopped reading. EOF
606
+ arrives only once every writer is gone; :meth:`abandon` ends the read
607
+ without it, for a writer nothing here can kill. What a chunk becomes
608
+ (bounded capture here, LF-framed records in ``executor_duplex``) is the
609
+ subclass's. ``activity``, when given, is touched before each chunk is
610
+ handed on (see :class:`_Limits`), so whoever is woken by the chunk
611
+ already sees the deadline it moved.
612
+
613
+ Building a reader takes nothing that must be given back: the wake pipe
614
+ is opened by :meth:`start` and released by :meth:`close`. A launch holds
615
+ each reader before it starts it, so its cleanup reaches every pipe
616
+ taken, and a reader that fails while it is built (its buffer cannot be
617
+ allocated, say) holds nothing for it to reach.
618
+ """
619
+
620
+ def __init__(self, stream: IO[bytes], name: str, activity: _Limits | None = None) -> None:
621
+ super().__init__(name=f"autoforge-capture-{name}", daemon=True)
622
+ self._fd = stream.fileno()
623
+ self._activity = activity
624
+ # (read end, write end): opened by :meth:`start`, closed by :meth:`close`.
625
+ self._wake: tuple[int, int] | None = None
626
+ self.error: OSError | None = None
627
+
628
+ def start(self) -> None:
629
+ """Open the wake pipe and start the thread. A thread that cannot be
630
+ started leaves the pipe open for :meth:`close`."""
631
+ self._wake = os.pipe()
632
+ super().start()
633
+
634
+ def run(self) -> None:
635
+ assert self._wake is not None # opened by start() before the thread began
636
+ wake_r = self._wake[0]
637
+ eof = False
638
+ try:
639
+ with selectors.DefaultSelector() as sel:
640
+ sel.register(self._fd, selectors.EVENT_READ)
641
+ sel.register(wake_r, selectors.EVENT_READ)
642
+ while True:
643
+ ready = {key.fd for key, _ in sel.select()}
644
+ if wake_r in ready:
645
+ return
646
+ chunk = os.read(self._fd, _READ_CHUNK_BYTES)
647
+ if not chunk:
648
+ eof = True
649
+ return
650
+ if self._activity is not None:
651
+ self._activity.touch()
652
+ self._feed(chunk)
653
+ except OSError as exc:
654
+ self.error = exc
655
+ finally:
656
+ self._ended(eof)
657
+
658
+ def _feed(self, chunk: bytes) -> None:
659
+ raise NotImplementedError
660
+
661
+ def _ended(self, eof: bool) -> None:
662
+ """Called once on the reader thread when the read ends; ``eof`` is
663
+ False when it was abandoned or failed."""
664
+
665
+ def wait(self, deadline: float | None) -> bool:
666
+ """Join until EOF or ``deadline`` (``time.monotonic()``); True on EOF."""
667
+ self.join(None if deadline is None else max(0.0, deadline - time.monotonic()))
668
+ return not self.is_alive()
669
+
670
+ def abandon(self) -> None:
671
+ """Stop reading without EOF; what was read so far is what is captured."""
672
+ assert self._wake is not None # only a started reader is abandoned
673
+ os.write(self._wake[1], b"\0")
674
+ self.join()
675
+
676
+ def close(self) -> None:
677
+ """Release the wake pipe, if :meth:`start` opened one. Idempotent."""
678
+ if self._wake is None:
679
+ return
680
+ for fd in self._wake:
681
+ try:
682
+ os.close(fd)
683
+ except OSError:
684
+ pass
685
+ self._wake = None
686
+
687
+
688
+ class _OutputObserver:
689
+ """:attr:`ExecutionRequest.on_output` and :attr:`~ExecutionRequest.on_chunk`,
690
+ shared by both readers of one invocation.
691
+
692
+ Calls are serialized, and the first exception a hook raises unhooks that
693
+ hook for both streams: a call the other reader was waiting to make finds
694
+ it gone, so a failing hook is called once and can never stop either
695
+ drain, nor the other hook.
696
+ """
697
+
698
+ def __init__(
699
+ self,
700
+ on_output: Callable[[str], None] | None,
701
+ on_chunk: Callable[[str, bytes], None] | None = None,
702
+ ) -> None:
703
+ self._on_output = on_output
704
+ self._on_chunk = on_chunk
705
+ self._lock = threading.Lock()
706
+
707
+ def __call__(self, stream: str, chunk: bytes) -> None:
708
+ with self._lock:
709
+ if self._on_chunk is not None:
710
+ try:
711
+ self._on_chunk(stream, chunk)
712
+ except Exception:
713
+ self._on_chunk = None
714
+ if self._on_output is not None:
715
+ try:
716
+ self._on_output(stream)
717
+ except Exception:
718
+ self._on_output = None
719
+
720
+
721
+ class _BoundedReader(_PipeDrain):
722
+ """Drain one pipe into a :class:`_BoundedBuffer` until EOF or abandoned.
723
+
724
+ ``observer``, when given, is handed the stream's name and the chunk
725
+ after each chunk is captured.
726
+ """
727
+
728
+ def __init__(
729
+ self,
730
+ stream: IO[bytes],
731
+ limit: int,
732
+ name: str,
733
+ observer: _OutputObserver | None = None,
734
+ activity: _Limits | None = None,
735
+ ) -> None:
736
+ super().__init__(stream, name, activity)
737
+ self.buffer = _BoundedBuffer(limit, name)
738
+ self._stream_name = name
739
+ self._observer = observer
740
+
741
+ def _feed(self, chunk: bytes) -> None:
742
+ self.buffer.feed(chunk)
743
+ if self._observer is not None:
744
+ self._observer(self._stream_name, chunk)
745
+
746
+ def captured(self) -> _Captured:
747
+ """Decode what was kept; call only once the thread has ended."""
748
+ return self.buffer.captured()
749
+
750
+
751
+ class _StdinFeeder(threading.Thread):
752
+ """Write ``data`` to the child's stdin, close it so the child sees EOF,
753
+ and count what the child never read.
754
+
755
+ The child's stdin is :attr:`child_end`, the read end of a pipe the
756
+ feeder owns. The feeder holds that read end too, until :meth:`stop`:
757
+ written is not read, and a pipe discards what is in it once its last
758
+ reader is gone, so only a read end kept past the child's exit can show
759
+ that the child left part of the payload unread (:attr:`unread`). With a
760
+ reader always present a write never fails with EPIPE: a child that
761
+ stops reading leaves the pipe full and the thread parked.
762
+
763
+ The write end is non-blocking and the thread waits on it and on a wake
764
+ pipe, so a child that never reads its stdin (or a descendant that holds
765
+ the pipe and never reads) costs a parked thread that :meth:`stop` ends
766
+ at once, never a hung invocation.
767
+ """
768
+
769
+ def __init__(self, data: bytes) -> None:
770
+ super().__init__(name="autoforge-feed-stdin", daemon=True)
771
+ self._data = data
772
+ self.child_end, self._write_end = os.pipe()
773
+ try:
774
+ self._wake_r, self._wake_w = os.pipe()
775
+ except BaseException:
776
+ os.close(self.child_end)
777
+ os.close(self._write_end)
778
+ raise
779
+ self._unwritten = len(data)
780
+ # Final once :meth:`stop` has returned.
781
+ self.unread = len(data)
782
+ self.error: OSError | None = None
783
+
784
+ def run(self) -> None:
785
+ view = memoryview(self._data)
786
+ try:
787
+ os.set_blocking(self._write_end, False)
788
+ with selectors.DefaultSelector() as sel:
789
+ sel.register(self._write_end, selectors.EVENT_WRITE)
790
+ sel.register(self._wake_r, selectors.EVENT_READ)
791
+ while view:
792
+ ready = {key.fd for key, _ in sel.select()}
793
+ if self._wake_r in ready:
794
+ return
795
+ try:
796
+ view = view[os.write(self._write_end, view) :]
797
+ except BlockingIOError:
798
+ continue
799
+ except OSError as exc:
800
+ self.error = exc
801
+ finally:
802
+ self._unwritten = len(view)
803
+ os.close(self._write_end)
804
+
805
+ def stop(self) -> None:
806
+ """End the feed if it is still writing, wait for the thread, count
807
+ what was left unread and release the pipes.
808
+
809
+ Called once the invocation is over (or its spawn failed, in which
810
+ case the thread never started and nothing was written).
811
+ """
812
+ if self.ident is None:
813
+ os.close(self._write_end)
814
+ else:
815
+ os.write(self._wake_w, b"\0")
816
+ self.join()
817
+ written = len(self._data) - self._unwritten
818
+ self.unread = self._unwritten + _drain(self.child_end, written)
819
+ for fd in (self.child_end, self._wake_r, self._wake_w):
820
+ os.close(fd)
821
+
822
+
823
+ def _drain(fd: int, limit: int) -> int:
824
+ """Read and count what is left in the pipe ``fd`` reads from, at most
825
+ ``limit`` bytes (all that was ever written to it).
826
+
827
+ Every write end is closed by then, so the pipe reads to EOF; the
828
+ descriptor is non-blocking anyway, so a writer that is somehow still
829
+ there ends the count rather than hanging it.
830
+ """
831
+ os.set_blocking(fd, False)
832
+ count = 0
833
+ while count < limit:
834
+ try:
835
+ chunk = os.read(fd, min(limit - count, 1 << 16))
836
+ except BlockingIOError:
837
+ break
838
+ if not chunk:
839
+ break
840
+ count += len(chunk)
841
+ return count
842
+
843
+
844
+ def _now() -> str:
845
+ return datetime.now(UTC).isoformat()
846
+
847
+
848
+ def _signal_group(pgid: int, sig: signal.Signals) -> bool:
849
+ """Signal a process group; False when no process is left in it."""
850
+ try:
851
+ os.killpg(pgid, sig)
852
+ except ProcessLookupError:
853
+ return False
854
+ return True
855
+
856
+
857
+ def _reaped(proc: subprocess.Popen, deadline: float) -> bool:
858
+ try:
859
+ proc.wait(timeout=max(0.0, deadline - time.monotonic()))
860
+ except subprocess.TimeoutExpired:
861
+ return False
862
+ return True
863
+
864
+
865
+ def _eof(readers: tuple[_PipeDrain, ...], deadline: float | None) -> bool:
866
+ return all(reader.wait(deadline) for reader in readers)
867
+
868
+
869
+ def _group_alive(pgid: int) -> bool:
870
+ """True while any process still belongs to the group.
871
+
872
+ The group id stays reserved while a member lives, however the member was
873
+ started and wherever its output goes; ``EPERM`` names a member that
874
+ cannot be signalled from here, which is still a member.
875
+ """
876
+ try:
877
+ os.killpg(pgid, 0)
878
+ except ProcessLookupError:
879
+ return False
880
+ except PermissionError:
881
+ return True
882
+ return True
883
+
884
+
885
+ def _group_gone(pgid: int, deadline: float, contained: _Containment | None = None) -> bool:
886
+ """Poll until the group has no member left or ``deadline``; True when gone.
887
+
888
+ A ``contained`` invocation that adopts its group's dead
889
+ (:attr:`_Containment.adopts_group`) has them reaped as the poll goes,
890
+ since a zombie still counts as a member until it is reaped.
891
+ """
892
+ while _group_alive(pgid):
893
+ if contained is not None and contained.adopts_group:
894
+ contained.reap_dead()
895
+ if not _group_alive(pgid):
896
+ break
897
+ if time.monotonic() >= deadline:
898
+ return False
899
+ time.sleep(_GROUP_POLL_SECONDS)
900
+ return True
901
+
902
+
903
+ # prctl(2) options; Linux only.
904
+ _PR_SET_CHILD_SUBREAPER = 36
905
+ _PR_GET_CHILD_SUBREAPER = 37
906
+ # How often a contained invocation reaps the orphans that died while its
907
+ # child runs. Each zombie holds a pid against the session's pids.max until it
908
+ # is reaped, so this bounds the backlog to what the child's tools can orphan
909
+ # in one interval.
910
+ _REAP_INTERVAL_SECONDS = 0.25
911
+
912
+ # One contained invocation at a time per controller process: the orphans of
913
+ # an invocation are told apart from the controller's other children by being
914
+ # new, which holds only while nothing else spawns meanwhile. The engine runs
915
+ # its invocations one after another, so this never waits; a second one is a
916
+ # refusal, not a queue.
917
+ _CONTAINMENT_LOCK = threading.Lock()
918
+
919
+
920
+ def _prctl() -> Callable[..., int] | None:
921
+ """libc's ``prctl``, or None where there is none (not Linux)."""
922
+ if not sys.platform.startswith("linux"):
923
+ return None
924
+ try:
925
+ return ctypes.CDLL(None, use_errno=True).prctl # type: ignore[no-any-return]
926
+ except (OSError, AttributeError):
927
+ return None
928
+
929
+
930
+ def orphan_containment_available() -> bool:
931
+ """Whether a contained invocation can catch its orphans here (ADR 0002 §4b).
932
+
933
+ True where the controller can make itself a child subreaper, as
934
+ :meth:`_Containment.begin` does: the setting is read, set and restored,
935
+ so a host whose security policy permits reading it but denies setting it
936
+ (a seccomp filter, say) is not taken for one that can contain. Elsewhere
937
+ a contained request runs uncontained and its result carries
938
+ ``orphans_unchecked``: a process that left the child's group may outlive
939
+ the invocation.
940
+ """
941
+ prctl = _prctl()
942
+ if prctl is None:
943
+ return False
944
+ current = ctypes.c_int(0)
945
+ if not _CONTAINMENT_LOCK.acquire(blocking=False):
946
+ # A contained invocation of this process owns the setting, which is
947
+ # only read here: it is set when that invocation could set it.
948
+ return (
949
+ prctl(_PR_GET_CHILD_SUBREAPER, ctypes.byref(current), 0, 0, 0) == 0
950
+ and current.value == 1
951
+ )
952
+ try:
953
+ if prctl(_PR_GET_CHILD_SUBREAPER, ctypes.byref(current), 0, 0, 0) != 0:
954
+ return False
955
+ if prctl(_PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0:
956
+ return False
957
+ prctl(_PR_SET_CHILD_SUBREAPER, current.value, 0, 0, 0)
958
+ return True
959
+ finally:
960
+ _CONTAINMENT_LOCK.release()
961
+
962
+
963
+ def _children() -> dict[tuple[int, int], bool]:
964
+ """This process's children as ``(pid, start time) -> is a zombie``.
965
+
966
+ The start time (``/proc/<pid>/stat`` field 22) makes the key unique across
967
+ a pid reuse. Children are listed through ``/proc/self/task/*/children``
968
+ when the kernel provides it and by a scan of ``/proc`` otherwise; a
969
+ process that is gone by the time it is read is simply not listed.
970
+ """
971
+ own = os.getpid()
972
+ pids: set[int] = set()
973
+ try:
974
+ for task in os.scandir(f"/proc/{own}/task"):
975
+ with open(f"{task.path}/children") as fh:
976
+ pids.update(int(pid) for pid in fh.read().split())
977
+ scan = False
978
+ except OSError:
979
+ scan = True
980
+ if scan:
981
+ pids = {int(entry.name) for entry in os.scandir("/proc") if entry.name.isdigit()}
982
+ found: dict[tuple[int, int], bool] = {}
983
+ for pid in pids:
984
+ try:
985
+ with open(f"/proc/{pid}/stat", "rb") as fh:
986
+ stat = fh.read()
987
+ except OSError:
988
+ continue
989
+ # ``pid (comm) state ppid ...``: comm may hold spaces and parentheses,
990
+ # so the fields are counted from the last ``)``.
991
+ fields = stat[stat.rfind(b")") + 2 :].split()
992
+ if len(fields) < 20 or int(fields[1]) != own:
993
+ continue
994
+ found[(pid, int(fields[19]))] = fields[0] == b"Z"
995
+ return found
996
+
997
+
998
+ class _Containment:
999
+ """Catch the orphans of one invocation (the ADR 0002 amendment, #132).
1000
+
1001
+ A process that calls ``setsid`` (or is spawned detached, as Pi's bash tool
1002
+ spawns every command) leaves the child's process group, and the group kill
1003
+ cannot reach it. On Linux the controller marks itself a child subreaper
1004
+ for the invocation, so when such a process's parent dies it is
1005
+ re-parented to the controller rather than to init: the orphans are then
1006
+ exactly the controller's children that were not there before the
1007
+ invocation and are not the child itself. Being the controller's unreaped
1008
+ children, their pids cannot be reused, so signalling them is safe.
1009
+
1010
+ Each orphan is signalled with its process group (unless that is the
1011
+ controller's own), which reaches what it started in turn; whatever is
1012
+ re-parented as its parents die is signalled when it appears. Dead orphans
1013
+ are reaped here, since nothing else waits for them, and from the start:
1014
+ a reaper thread reaps them every ``_REAP_INTERVAL_SECONDS`` while the
1015
+ child runs. An agent's tools orphan short-lived processes all along (a
1016
+ test suite's helpers, a shell's background jobs), and every zombie
1017
+ holds a pid against the session's ``pids.max``, so over an hour-long
1018
+ run they pile up until no fork in the session succeeds and the agent
1019
+ itself aborts. The reaper never reaps the child (its exit status
1020
+ belongs to its ``Popen``) or a child the controller had before. The
1021
+ first look at the orphans after the child's run (the exit grace, the
1022
+ kill) stops it, so from there the caller's own polling reaps and
1023
+ signals alone. :meth:`release` restores the previous subreaper setting;
1024
+ an orphan still alive then stays the controller's child, and is
1025
+ reported, not hidden.
1026
+ """
1027
+
1028
+ def __init__(self, prctl: Callable[..., int], previous: int, adopts_group: bool) -> None:
1029
+ self._prctl = prctl
1030
+ self._previous = previous
1031
+ # A sandboxed launch (ADR 0005): when the backend is killed, its
1032
+ # process inside the PID namespace is re-parented here and dies at
1033
+ # once (the namespace's teardown), a zombie still in the child's
1034
+ # group. The group checks reap it as they poll (:func:`_group_gone`)
1035
+ # instead of waiting out a grace for a member that is already dead.
1036
+ self.adopts_group = adopts_group
1037
+ self._before = frozenset(_children())
1038
+ self.child: int | None = None
1039
+ self.found = False
1040
+ self._signalled: dict[signal.Signals, set[tuple[int, int]]] = {}
1041
+ self._released = False
1042
+ self._stop_reaper = threading.Event()
1043
+ self._reaper = threading.Thread(
1044
+ target=self._reap_until_stopped, name="autoforge-reaper", daemon=True
1045
+ )
1046
+
1047
+ @classmethod
1048
+ def begin(cls, *, adopts_group: bool = False) -> _Containment | None:
1049
+ """Start containing; None when this platform has no child subreaper.
1050
+
1051
+ Raises :class:`ExecutionError` when another contained invocation is
1052
+ running in this process, or the reaper thread cannot be started
1053
+ (nothing has been spawned yet, so nothing is left behind).
1054
+ """
1055
+ prctl = _prctl()
1056
+ if prctl is None:
1057
+ return None
1058
+ if not _CONTAINMENT_LOCK.acquire(blocking=False):
1059
+ raise ExecutionError(
1060
+ "another orphan-contained invocation is already running in this process"
1061
+ )
1062
+ try:
1063
+ previous = ctypes.c_int(0)
1064
+ if prctl(_PR_GET_CHILD_SUBREAPER, ctypes.byref(previous), 0, 0, 0) != 0:
1065
+ _CONTAINMENT_LOCK.release()
1066
+ return None
1067
+ contained = cls(prctl, previous.value, adopts_group)
1068
+ if prctl(_PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0:
1069
+ _CONTAINMENT_LOCK.release()
1070
+ return None
1071
+ except BaseException:
1072
+ _CONTAINMENT_LOCK.release()
1073
+ raise
1074
+ try:
1075
+ contained._reaper.start()
1076
+ except BaseException as exc:
1077
+ contained.release()
1078
+ if isinstance(exc, RuntimeError):
1079
+ raise ExecutionError(f"failed to start the orphan reaper: {exc}") from exc
1080
+ raise
1081
+ return contained
1082
+
1083
+ def _reap_until_stopped(self) -> None:
1084
+ """The reaper thread: reap the dead orphans until told to stop."""
1085
+ while not self._stop_reaper.wait(_REAP_INTERVAL_SECONDS):
1086
+ child = self.child
1087
+ if child is None:
1088
+ continue # not spawned yet: a zombie now could be the child itself
1089
+ try:
1090
+ found = _children()
1091
+ except OSError:
1092
+ continue # /proc unreadable for a moment; the next pass looks again
1093
+ for key, zombie in found.items():
1094
+ if zombie and key not in self._before and key[0] != child:
1095
+ try:
1096
+ os.waitpid(key[0], os.WNOHANG)
1097
+ except ChildProcessError:
1098
+ pass
1099
+
1100
+ def _stop_reaping(self) -> None:
1101
+ """Stop the reaper thread and wait for it; from here the caller reaps.
1102
+
1103
+ Only the thread that signals the orphans reaps them from then on, so
1104
+ an orphan it is about to signal cannot be reaped, and its pid reused,
1105
+ in between.
1106
+ """
1107
+ self._stop_reaper.set()
1108
+ if self._reaper.ident is not None:
1109
+ self._reaper.join()
1110
+
1111
+ def _live(self) -> set[tuple[int, int]]:
1112
+ """The orphans still alive; the dead ones are reaped on the way.
1113
+
1114
+ A process still in the child's group was re-parented here too when
1115
+ its parent died, but it is the group kill's, not an orphan: it is
1116
+ reaped once dead and otherwise left to the group checks. The first
1117
+ call stops the reaper thread.
1118
+ """
1119
+ self._stop_reaping()
1120
+ live: set[tuple[int, int]] = set()
1121
+ for key, zombie in _children().items():
1122
+ pid = key[0]
1123
+ if key in self._before or pid == self.child:
1124
+ continue
1125
+ if zombie:
1126
+ try:
1127
+ os.waitpid(pid, os.WNOHANG)
1128
+ except ChildProcessError:
1129
+ pass
1130
+ for sent in self._signalled.values():
1131
+ sent.discard(key)
1132
+ continue
1133
+ try:
1134
+ in_group = os.getpgid(pid) == self.child
1135
+ except OSError:
1136
+ continue # gone meanwhile; reaped on the next look
1137
+ if not in_group:
1138
+ live.add(key)
1139
+ return live
1140
+
1141
+ def _send(self, key: tuple[int, int], sig: signal.Signals) -> None:
1142
+ pid = key[0]
1143
+ self._signalled.setdefault(sig, set()).add(key)
1144
+ self.found = True
1145
+ try:
1146
+ pgid = os.getpgid(pid)
1147
+ if pgid != os.getpgrp():
1148
+ os.killpg(pgid, sig)
1149
+ os.kill(pid, sig)
1150
+ except OSError:
1151
+ pass # exited meanwhile; it is reaped on the next look
1152
+
1153
+ def send_all(self, sig: signal.Signals) -> bool:
1154
+ """Send ``sig`` to every live orphan; False when there is none."""
1155
+ live = self._live()
1156
+ for key in live:
1157
+ self._send(key, sig)
1158
+ return bool(live)
1159
+
1160
+ def gone(self, deadline: float, sig: signal.Signals) -> bool:
1161
+ """Poll until no orphan is alive or ``deadline``; True when none is.
1162
+
1163
+ An orphan that appears meanwhile (re-parented as its parent died) is
1164
+ sent ``sig`` too.
1165
+ """
1166
+ while True:
1167
+ live = self._live()
1168
+ for key in live - self._signalled.get(sig, set()):
1169
+ self._send(key, sig)
1170
+ if not live:
1171
+ return True
1172
+ if time.monotonic() >= deadline:
1173
+ return False
1174
+ time.sleep(_GROUP_POLL_SECONDS)
1175
+
1176
+ def settled(self, deadline: float) -> bool:
1177
+ """Poll until no orphan is alive or ``deadline``, signalling nothing."""
1178
+ while self._live():
1179
+ if time.monotonic() >= deadline:
1180
+ return False
1181
+ time.sleep(_GROUP_POLL_SECONDS)
1182
+ return True
1183
+
1184
+ def alive(self) -> bool:
1185
+ return bool(self._live())
1186
+
1187
+ def reap_dead(self) -> None:
1188
+ """Reap the dead among the invocation's new children now (:meth:`_live`)."""
1189
+ self._live()
1190
+
1191
+ def release(self) -> None:
1192
+ """Stop the reaper, reap what has died and restore the previous
1193
+ subreaper setting. Idempotent."""
1194
+ if self._released:
1195
+ return
1196
+ self._released = True
1197
+ try:
1198
+ self._live()
1199
+ finally:
1200
+ self._prctl(_PR_SET_CHILD_SUBREAPER, self._previous, 0, 0, 0)
1201
+ _CONTAINMENT_LOCK.release()
1202
+
1203
+
1204
+ @dataclass(frozen=True)
1205
+ class _Termination:
1206
+ """What a group kill left behind: nothing, when both are False."""
1207
+
1208
+ # A process was still in the group after the SIGKILL grace.
1209
+ group_survived: bool
1210
+ # A pipe never reached EOF and the capture was abandoned.
1211
+ capture_abandoned: bool
1212
+ # An orphan (see :class:`_Containment`) was still alive after SIGKILL.
1213
+ orphan_survived: bool = False
1214
+
1215
+
1216
+ def _terminate_group(
1217
+ pgid: int,
1218
+ proc: subprocess.Popen,
1219
+ readers: tuple[_PipeDrain, ...],
1220
+ contained: _Containment | None = None,
1221
+ ) -> _Termination:
1222
+ """SIGTERM the child's process group, escalate to SIGKILL, and stop reading.
1223
+
1224
+ ``pgid`` is signalled rather than ``proc``: the child leads its own group
1225
+ (``start_new_session``), so its descendants can be reached after the
1226
+ child itself has exited and been reaped, and the group id stays reserved
1227
+ while any of them lives. The group is dead when the child is reaped, its
1228
+ pipes reached EOF *and* no process is left in the group; the last check
1229
+ is what catches a descendant that closed its inherited pipes and ignores
1230
+ SIGTERM, which the first two cannot see. Every wait here is bounded by
1231
+ the kill grace, so the whole function returns within two grace periods
1232
+ (SIGTERM, then SIGKILL) whatever the group left behind. What survives
1233
+ the SIGKILL grace cannot be dealt with from here and is not waited for:
1234
+ a member stuck in the kernel (uninterruptible sleep) or not signallable,
1235
+ the direct child included, and a writer that left the group (``setsid``)
1236
+ and so was never reached. The capture is then abandoned, and a child
1237
+ still unreaped is left to the ``subprocess`` module, which reaps it when
1238
+ it finally dies. The return value names what was left: a member still
1239
+ in the group, and a capture that had to be abandoned, so the caller can
1240
+ report an unclean kill instead of a clean one.
1241
+
1242
+ With ``contained``, the invocation's orphans get each signal with the
1243
+ group, and the kill is complete only once none is alive either; a
1244
+ ``setsid`` writer is then one of them, so its pipes reach EOF instead of
1245
+ being abandoned.
1246
+ """
1247
+ deadline = time.monotonic() + _KILL_GRACE_SECONDS
1248
+ for sig in (signal.SIGTERM, signal.SIGKILL):
1249
+ signalled = _signal_group(pgid, sig)
1250
+ if contained is not None and contained.send_all(sig):
1251
+ signalled = True
1252
+ if not signalled:
1253
+ break
1254
+ deadline = time.monotonic() + _KILL_GRACE_SECONDS
1255
+ if (
1256
+ _reaped(proc, deadline)
1257
+ and _eof(readers, deadline)
1258
+ and _group_gone(pgid, deadline, contained)
1259
+ and (contained is None or contained.gone(deadline, sig))
1260
+ ):
1261
+ return _Termination(group_survived=False, capture_abandoned=False)
1262
+ # The group is empty (the signal found nobody) or its remains are past
1263
+ # help; the remaining EOF wait runs out the deadline already in hand,
1264
+ # never a fresh one, so the bound above holds.
1265
+ abandoned = not _eof(readers, deadline)
1266
+ if abandoned:
1267
+ for reader in readers:
1268
+ reader.abandon()
1269
+ return _Termination(
1270
+ group_survived=_group_alive(pgid),
1271
+ capture_abandoned=abandoned,
1272
+ orphan_survived=contained is not None and contained.alive(),
1273
+ )
1274
+
1275
+
1276
+ def _child_environment(
1277
+ env_allowlist: tuple[str, ...] | None, env: Mapping[str, str] | None
1278
+ ) -> dict[str, str]:
1279
+ """The child's environment: the whole of this process's, or only the
1280
+ allow-listed names (:func:`select_environment`), with ``env`` layered over."""
1281
+ child = dict(os.environ) if env_allowlist is None else select_environment(env_allowlist)
1282
+ if env:
1283
+ child.update(env)
1284
+ return child
1285
+
1286
+
1287
+ def _spawn(
1288
+ command: list[str],
1289
+ *,
1290
+ cwd: str | None,
1291
+ env: dict[str, str],
1292
+ stdin: int,
1293
+ launch: BwrapLaunch | None = None,
1294
+ ) -> subprocess.Popen[bytes]:
1295
+ """Start ``command`` in a session of its own with stdout and stderr piped.
1296
+
1297
+ ``start_new_session`` makes the child lead a process group the executor
1298
+ can reach and kill after the child itself is gone; the terminal is never
1299
+ inherited. A spawn failure is an :class:`ExecutionError`. With
1300
+ ``launch`` the sandbox backend is started in the command's place
1301
+ (:func:`_spawn_sandboxed`) and ``env`` is not used: the launch carries
1302
+ the command's environment.
1303
+ """
1304
+ if launch is not None:
1305
+ return _spawn_sandboxed(launch, cwd=cwd, stdin=stdin)
1306
+ try:
1307
+ return subprocess.Popen( # noqa: S603 - argv list, no shell
1308
+ command,
1309
+ cwd=cwd,
1310
+ env=env,
1311
+ stdin=stdin,
1312
+ stdout=subprocess.PIPE,
1313
+ stderr=subprocess.PIPE,
1314
+ start_new_session=True,
1315
+ )
1316
+ except FileNotFoundError as exc:
1317
+ raise ExecutionError(f"executable not found: {command[0]} ({exc})") from exc
1318
+ except OSError as exc:
1319
+ raise ExecutionError(f"failed to spawn {' '.join(command)}: {exc}") from exc
1320
+
1321
+
1322
+ def _spawn_sandboxed(
1323
+ launch: BwrapLaunch, *, cwd: str | None, stdin: int
1324
+ ) -> subprocess.Popen[bytes]:
1325
+ """Start the sandbox backend in place of the command, as :func:`_spawn` does.
1326
+
1327
+ The same session and pipes, plus the descriptors only ``bwrap`` reads
1328
+ (its options, its status stream) and the launch's own process scope,
1329
+ applied between fork and exec (:class:`BwrapLaunch`). The
1330
+ fork happens on this thread, which is the thread ``--die-with-parent``
1331
+ watches, so the caller must outlive the invocation (ADR 0005 D10.6). A
1332
+ failure of that scope, or a backend gone since it was prepared, is a
1333
+ :class:`SandboxLaunchRefusedError`.
1334
+ """
1335
+ try:
1336
+ return subprocess.Popen( # noqa: S603 - argv list, no shell
1337
+ launch.argv,
1338
+ cwd=cwd,
1339
+ env=launch.spawn_env,
1340
+ stdin=stdin,
1341
+ stdout=subprocess.PIPE,
1342
+ stderr=subprocess.PIPE,
1343
+ start_new_session=True,
1344
+ pass_fds=launch.pass_fds,
1345
+ preexec_fn=launch.preexec_fn, # noqa: PLW1509 - prepared foreign calls only
1346
+ )
1347
+ except FileNotFoundError as exc:
1348
+ raise SandboxLaunchRefusedError(
1349
+ f"the sandbox cannot be established: {launch.argv[0]} is gone ({exc})"
1350
+ ) from exc
1351
+ except subprocess.SubprocessError as exc:
1352
+ raise launch.spawn_error(exc) from exc
1353
+ except OSError as exc:
1354
+ raise ExecutionError(f"failed to spawn {' '.join(launch.argv)}: {exc}") from exc
1355
+ finally:
1356
+ launch.spawned()
1357
+
1358
+
1359
+ def _prepare_sandbox(
1360
+ description: LaunchDescription,
1361
+ command: list[str],
1362
+ *,
1363
+ cwd: str | None,
1364
+ env_allowlist: tuple[str, ...] | None,
1365
+ env: Mapping[str, str] | None,
1366
+ ) -> BwrapLaunch:
1367
+ """The backend's launch for ``command`` inside ``description``, or a refusal.
1368
+
1369
+ The command's environment is selected as for any launch
1370
+ (:func:`_child_environment`) and handed to the backend, which applies the
1371
+ description's overrides over it. Done before an invocation's containment
1372
+ begins, since the backend may read its own version first.
1373
+ """
1374
+ return sandbox_bwrap.prepare(
1375
+ description, command, cwd=cwd, env=_child_environment(env_allowlist, env)
1376
+ )
1377
+
1378
+
1379
+ def execute(req: ExecutionRequest) -> ExecutionResult:
1380
+ """Run one subprocess to completion, capturing output.
1381
+
1382
+ Completion is the child's exit, EOF on both pipes and an empty process
1383
+ group. The child's exit is bounded by ``timeout_seconds`` from the spawn
1384
+ and, with ``idle_timeout_seconds``, by that long after the last chunk
1385
+ the child wrote on either stream (see :class:`_Limits`); past either the
1386
+ whole group is killed and the result is a timeout naming the limit
1387
+ (``timeout_limit``). A caller that sets ``stop`` ends it the same way,
1388
+ and the result is then ``stopped``. Once the child has
1389
+ exited on its own, the other two are given ``_EXIT_GRACE_SECONDS``: a
1390
+ descendant that outlives the child (a server it left running, holding
1391
+ the inherited pipes or with its stdio redirected) is then killed with
1392
+ the rest of the group, the child's own exit status and output are
1393
+ returned, and ``descendants_killed`` records the kill. Nothing an agent
1394
+ starts outlives its invocation; the next invocation begins with no
1395
+ process of the previous one holding a port or writing into the tree.
1396
+
1397
+ Every wait past the child's exit is bounded, so what a kill cannot
1398
+ remove is reported (``group_survived_kill``, ``capture_abandoned``)
1399
+ rather than waited for, and ``execute()`` returns within the limit that
1400
+ fires (the idle limit after the last output, or the ceiling after the
1401
+ spawn, whichever comes first) plus the exit grace plus two kill grace
1402
+ periods whatever the child left behind.
1403
+
1404
+ Timeouts, non-zero exits and truncated output are *returned* (not
1405
+ raised) so callers can log stdout/stderr first; use ``raise_if_failed()``
1406
+ to convert. ExecutionError is raised only when the process cannot be
1407
+ spawned, the threads that feed, read and reap for it cannot be started
1408
+ (the group is killed first once it runs), or its output cannot be read.
1409
+
1410
+ With ``contain_orphans`` the invocation's orphans (:class:`_Containment`)
1411
+ count as part of what must be gone: they get the exit grace and the kill
1412
+ the group gets, within the same bounds. Those that die while the child
1413
+ runs are reaped meanwhile.
1414
+
1415
+ With ``sandbox`` the backend runs in the command's place
1416
+ (:func:`_prepare_sandbox`) and everything above applies to it: its group
1417
+ is the command's group, and the kernel removes what is left inside the
1418
+ sandbox when the command or the backend ends. A host that cannot
1419
+ establish the sandbox, before or after the spawn, raises
1420
+ :class:`SandboxLaunchRefusedError`, and nothing the backend wrote is
1421
+ returned as the command's; a backend ended by a signal the executor did
1422
+ not send is the command's abnormal end, as for any command
1423
+ (:meth:`BwrapLaunch.outcome`).
1424
+ """
1425
+ if not req.command:
1426
+ raise ExecutionError("empty command")
1427
+ if req.max_output_bytes <= 0:
1428
+ raise ExecutionError(f"max_output_bytes must be > 0, got {req.max_output_bytes}")
1429
+ check_limit("idle_timeout_seconds", req.idle_timeout_seconds)
1430
+ launch = None
1431
+ if req.sandbox is not None:
1432
+ launch = _prepare_sandbox(
1433
+ req.sandbox,
1434
+ req.command,
1435
+ cwd=req.cwd,
1436
+ env_allowlist=req.env_allowlist,
1437
+ env=req.env,
1438
+ )
1439
+ try:
1440
+ contained = (
1441
+ _Containment.begin(adopts_group=launch is not None) if req.contain_orphans else None
1442
+ )
1443
+ try:
1444
+ return _execute(req, contained, launch)
1445
+ finally:
1446
+ if contained is not None:
1447
+ contained.release()
1448
+ finally:
1449
+ if launch is not None:
1450
+ launch.close()
1451
+
1452
+
1453
+ def check_limit(name: str, value: float | None) -> None:
1454
+ """Raise :class:`ExecutionError` unless ``value`` is ``None`` or a finite
1455
+ number of seconds in ``(0, MAX_DEADLINE_SECONDS]``."""
1456
+ if value is None:
1457
+ return
1458
+ if not (math.isfinite(value) and 0 < value <= MAX_DEADLINE_SECONDS):
1459
+ raise ExecutionError(
1460
+ f"{name} must be finite, > 0 and <= {MAX_DEADLINE_SECONDS}, got {value}"
1461
+ )
1462
+
1463
+
1464
+ def _execute(
1465
+ req: ExecutionRequest,
1466
+ contained: _Containment | None,
1467
+ launch: BwrapLaunch | None = None,
1468
+ ) -> ExecutionResult:
1469
+ started = _now()
1470
+ ceiling = req.timeout_seconds if req.timeout_seconds > 0 else None
1471
+ limits = _Limits(req.idle_timeout_seconds, ceiling)
1472
+ try:
1473
+ feeder = None if req.stdin_data is None else _StdinFeeder(req.stdin_data)
1474
+ except OSError as exc:
1475
+ raise ExecutionError(f"failed to spawn {' '.join(req.command)}: {exc}") from exc
1476
+ readers: tuple[_BoundedReader, ...] = ()
1477
+ timed_out = False
1478
+ stopped = False
1479
+ timeout_limit = ""
1480
+ descendants_killed = False
1481
+ left = _Termination(group_survived=False, capture_abandoned=False)
1482
+ try:
1483
+ proc = _spawn(
1484
+ req.command,
1485
+ cwd=req.cwd,
1486
+ env={} if launch is not None else _child_environment(req.env_allowlist, req.env),
1487
+ stdin=subprocess.DEVNULL if feeder is None else feeder.child_end,
1488
+ launch=launch,
1489
+ )
1490
+ except BaseException:
1491
+ if feeder is not None:
1492
+ feeder.stop()
1493
+ raise
1494
+ assert proc.stdout is not None and proc.stderr is not None
1495
+ if contained is not None:
1496
+ contained.child = proc.pid
1497
+ pgid = proc.pid # start_new_session: the child leads a group of its own
1498
+ try:
1499
+ # The child is running from here on, so everything that serves it
1500
+ # is set up under the same guard as the wait, and nothing that can
1501
+ # fail runs before it: a thread the system refuses (a thread or
1502
+ # descriptor limit) kills the group and fails the launch as a
1503
+ # refused spawn would, and anything else (an allocation, Ctrl-C)
1504
+ # kills it too, never leaving the child behind.
1505
+ try:
1506
+ if feeder is not None:
1507
+ feeder.start()
1508
+ # Output is read as bytes and decoded with replacement: agent
1509
+ # output is untrusted (a dumped binary, a mis-encoded file the
1510
+ # agent cats), and a decode error is not an AutoForgeError, so it
1511
+ # would leave the invocation unlogged (#17).
1512
+ observer = (
1513
+ None
1514
+ if req.on_output is None and req.on_chunk is None
1515
+ else _OutputObserver(req.on_output, req.on_chunk)
1516
+ )
1517
+ for stream, name in ((proc.stdout, "stdout"), (proc.stderr, "stderr")):
1518
+ readers += (_BoundedReader(stream, req.max_output_bytes, name, observer, limits),)
1519
+ readers[-1].start()
1520
+ except (OSError, RuntimeError) as exc:
1521
+ raise ExecutionError(f"failed to start {' '.join(req.command)}: {exc}") from exc
1522
+ ended = _wait_within(proc, limits, req.stop)
1523
+ stopped = ended == _STOPPED
1524
+ timed_out = bool(ended) and not stopped
1525
+ timeout_limit = ended if timed_out else ""
1526
+ if timed_out or stopped:
1527
+ left = _terminate_group(pgid, proc, readers, contained)
1528
+ else:
1529
+ # The child has exited, but the invocation is over only at EOF
1530
+ # and once no process is left in its group. A child that left
1531
+ # nothing behind clears both at once; a descendant that holds
1532
+ # the inherited pipes or merely stays in the group is given the
1533
+ # exit grace and then killed with the group, the child's own
1534
+ # result kept. The group id cannot name an unrelated process
1535
+ # while a member lives; once the group is empty the id is free
1536
+ # for reuse, which only a pid wrap-around inside this window
1537
+ # could bring about.
1538
+ # An orphan still alive past the same grace is killed with
1539
+ # them, and reported as an orphan, not as a group member.
1540
+ grace = time.monotonic() + _EXIT_GRACE_SECONDS
1541
+ group_settled = _eof(readers, grace) and _group_gone(pgid, grace, contained)
1542
+ if not (group_settled and (contained is None or contained.settled(grace))):
1543
+ descendants_killed = not group_settled
1544
+ left = _terminate_group(pgid, proc, readers, contained)
1545
+ except BaseException:
1546
+ # A reader whose thread never started has nothing to stop.
1547
+ started_readers = tuple(reader for reader in readers if reader.ident is not None)
1548
+ _terminate_group(pgid, proc, started_readers, contained)
1549
+ raise
1550
+ finally:
1551
+ if feeder is not None:
1552
+ feeder.stop()
1553
+ for reader in readers:
1554
+ reader.close()
1555
+ proc.stdout.close()
1556
+ proc.stderr.close()
1557
+ for reader in readers:
1558
+ if reader.error is not None:
1559
+ raise ExecutionError(
1560
+ f"failed to read {reader.name.removeprefix('autoforge-capture-')} of "
1561
+ f"{' '.join(req.command)}: {reader.error}"
1562
+ ) from reader.error
1563
+ if feeder is not None and feeder.error is not None:
1564
+ raise ExecutionError(
1565
+ f"failed to write the stdin of {' '.join(req.command)}: {feeder.error}"
1566
+ ) from feeder.error
1567
+ out, err = (reader.captured() for reader in readers)
1568
+ facts = None
1569
+ if launch is not None:
1570
+ # The backend has been reaped (or abandoned past SIGKILL), so what it
1571
+ # recorded about the command is complete; a setup failure raises
1572
+ # here, after the teardown, with nothing returned as the command's.
1573
+ facts = launch.outcome(
1574
+ returncode=proc.returncode, ended_by_executor=timed_out or stopped, stderr=err.text
1575
+ )
1576
+ return ExecutionResult(
1577
+ command=list(req.command),
1578
+ cwd=req.cwd,
1579
+ exit_code=-1 if timed_out or stopped else proc.returncode,
1580
+ stdout=out.text,
1581
+ stderr=err.text,
1582
+ started_at=started,
1583
+ finished_at=_now(),
1584
+ timed_out=timed_out,
1585
+ stdout_truncated=out.truncated,
1586
+ stderr_truncated=err.truncated,
1587
+ stdout_tail_offset=out.tail_offset,
1588
+ stdout_bytes=out.total,
1589
+ stderr_bytes=err.total,
1590
+ descendants_killed=descendants_killed,
1591
+ group_survived_kill=left.group_survived,
1592
+ capture_abandoned=left.capture_abandoned,
1593
+ orphans_killed=contained is not None and contained.found,
1594
+ orphan_survived_kill=left.orphan_survived,
1595
+ orphans_unchecked=req.contain_orphans and contained is None,
1596
+ stdin_unread=0 if feeder is None else feeder.unread,
1597
+ timeout_limit=timeout_limit,
1598
+ last_activity_at=limits.last_activity_at(),
1599
+ stopped=stopped,
1600
+ sandbox=facts,
1601
+ )