millforge 0.1.0__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 (116) hide show
  1. millforge/__init__.py +1174 -0
  2. millforge/_forge/LICENSE +21 -0
  3. millforge/_forge/PROVENANCE.json +295 -0
  4. millforge/_forge/UPDATE_POLICY.md +24 -0
  5. millforge/_forge/__init__.py +14 -0
  6. millforge/_forge/adapter.py +2232 -0
  7. millforge/_forge/base_runner.py +121 -0
  8. millforge/_forge/clients/__init__.py +10 -0
  9. millforge/_forge/clients/base.py +200 -0
  10. millforge/_forge/context/__init__.py +23 -0
  11. millforge/_forge/context/manager.py +178 -0
  12. millforge/_forge/context/strategies.py +335 -0
  13. millforge/_forge/core/__init__.py +16 -0
  14. millforge/_forge/core/inference.py +433 -0
  15. millforge/_forge/core/messages.py +119 -0
  16. millforge/_forge/core/runner.py +479 -0
  17. millforge/_forge/core/steps.py +108 -0
  18. millforge/_forge/core/workflow.py +400 -0
  19. millforge/_forge/errors.py +222 -0
  20. millforge/_forge/guardrails/__init__.py +21 -0
  21. millforge/_forge/guardrails/error_tracker.py +71 -0
  22. millforge/_forge/guardrails/guardrails.py +194 -0
  23. millforge/_forge/guardrails/nudge.py +47 -0
  24. millforge/_forge/guardrails/response_validator.py +119 -0
  25. millforge/_forge/guardrails/step_enforcer.py +183 -0
  26. millforge/_forge/prompts/__init__.py +16 -0
  27. millforge/_forge/prompts/nudges.py +95 -0
  28. millforge/_forge/prompts/templates.py +285 -0
  29. millforge/_version.py +3 -0
  30. millforge/artifacts.py +570 -0
  31. millforge/base/__init__.py +97 -0
  32. millforge/base/composition.py +402 -0
  33. millforge/base/context.py +285 -0
  34. millforge/base/harness.py +138 -0
  35. millforge/base/identity.py +465 -0
  36. millforge/base/options.py +34 -0
  37. millforge/base/platform.py +17 -0
  38. millforge/base/prompt.py +317 -0
  39. millforge/base/runner.py +546 -0
  40. millforge/compiled_plan.py +970 -0
  41. millforge/compiler/__init__.py +231 -0
  42. millforge/compiler/artifact_validation.py +257 -0
  43. millforge/compiler/canonicalization.py +169 -0
  44. millforge/compiler/capabilities.py +66 -0
  45. millforge/compiler/catalogs.py +500 -0
  46. millforge/compiler/diagnostics.py +491 -0
  47. millforge/compiler/graph.py +678 -0
  48. millforge/compiler/lowering.py +198 -0
  49. millforge/compiler/output.py +692 -0
  50. millforge/compiler/parsing.py +1424 -0
  51. millforge/compiler/requests.py +1180 -0
  52. millforge/compiler/schema_validation.py +272 -0
  53. millforge/compiler/semantic.py +490 -0
  54. millforge/compiler/service.py +448 -0
  55. millforge/compiler/source.py +375 -0
  56. millforge/compiler/validators.py +184 -0
  57. millforge/connectors/__init__.py +95 -0
  58. millforge/connectors/admission.py +801 -0
  59. millforge/connectors/broker.py +202 -0
  60. millforge/connectors/contracts.py +1159 -0
  61. millforge/connectors/diagnostics.py +189 -0
  62. millforge/connectors/fake.py +66 -0
  63. millforge/connectors/runtime.py +236 -0
  64. millforge/contracts.py +2860 -0
  65. millforge/custom_tools/__init__.py +67 -0
  66. millforge/custom_tools/compiler.py +724 -0
  67. millforge/custom_tools/contracts.py +1093 -0
  68. millforge/custom_tools/diagnostics.py +205 -0
  69. millforge/eval_artifacts.py +952 -0
  70. millforge/eval_boundary.py +2435 -0
  71. millforge/eval_fixtures/__init__.py +1 -0
  72. millforge/eval_fixtures/default_pack/__init__.py +1 -0
  73. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.bug_diagnosis.traceback.v1.json +52 -0
  74. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.direct_edit.import_sort.v1.json +52 -0
  75. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.evidence_discipline.no_source_change.v1.json +51 -0
  76. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.false_closure.visible_green.v1.json +52 -0
  77. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.multi_file.api_contract.v1.json +54 -0
  78. millforge/eval_fixtures/default_pack/fixtures/fixture.08a.recovery.malformed_artifact.v1.json +54 -0
  79. millforge/eval_fixtures/default_pack/manifest.json +12 -0
  80. millforge/eval_modes.py +1282 -0
  81. millforge/eval_presets.py +1398 -0
  82. millforge/eval_reports.py +2517 -0
  83. millforge/eval_suite.py +2429 -0
  84. millforge/eval_trials.py +2632 -0
  85. millforge/eval_workflow.py +794 -0
  86. millforge/exceptions.py +122 -0
  87. millforge/model_backend.py +2098 -0
  88. millforge/protocols.py +340 -0
  89. millforge/py.typed +0 -0
  90. millforge/runtime.py +1791 -0
  91. millforge/testing/__init__.py +1089 -0
  92. millforge/tools/__init__.py +83 -0
  93. millforge/tools/builtin_runtime.py +1339 -0
  94. millforge/tools/builtins.py +773 -0
  95. millforge/tools/execution.py +1545 -0
  96. millforge/tools/path_policy.py +155 -0
  97. millforge/tools/pi_compat/PI_LICENSE +21 -0
  98. millforge/tools/pi_compat/PROVENANCE.json +55 -0
  99. millforge/tools/pi_compat/UPDATE_POLICY.md +36 -0
  100. millforge/tools/pi_compat/__init__.py +34 -0
  101. millforge/tools/pi_compat/contracts.py +49 -0
  102. millforge/tools/pi_compat/editing.py +390 -0
  103. millforge/tools/pi_compat/mutations.py +57 -0
  104. millforge/tools/pi_compat/operations.py +401 -0
  105. millforge/tools/pi_compat/paths.py +155 -0
  106. millforge/tools/pi_compat/process.py +1375 -0
  107. millforge/tools/pi_compat/search.py +738 -0
  108. millforge/tools/pi_compat/truncation.py +267 -0
  109. millforge/tools/pi_compat_catalog.py +396 -0
  110. millforge/tools/pi_compat_runtime.py +460 -0
  111. millforge/tools/registry.py +553 -0
  112. millforge/tools/results.py +533 -0
  113. millforge-0.1.0.dist-info/METADATA +844 -0
  114. millforge-0.1.0.dist-info/RECORD +116 -0
  115. millforge-0.1.0.dist-info/WHEEL +4 -0
  116. millforge-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,1375 @@
1
+ """Pi 0.79.6-compatible local shell execution.
2
+
3
+ One ``execute_bash`` call owns its launch task, shell process, merged output
4
+ stream, raw-output writer, cancellation waits, and cleanup. In particular,
5
+ the post-exit drain is necessary because a shell can exit while a descendant
6
+ still holds an inherited output pipe open.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import codecs
13
+ import math
14
+ import os
15
+ import shutil
16
+ import signal
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ from collections.abc import Callable, Iterable
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import BinaryIO, Protocol, TypeVar, runtime_checkable
24
+
25
+ from .contracts import (
26
+ PiCompatErrorKind,
27
+ PiCompatOperationResult,
28
+ PiCompatSideEffectState,
29
+ )
30
+ from .paths import _is_absolute_cwd
31
+ from .truncation import format_size, truncate_tail
32
+
33
+ __all__ = [
34
+ "PiCompatCancellation",
35
+ "PiCompatShellConfig",
36
+ "PiCompatShellResolutionError",
37
+ "execute_bash",
38
+ "resolve_pi_compat_shell",
39
+ ]
40
+
41
+ _DEFAULT_MAX_LINES = 2_000
42
+ _DEFAULT_MAX_BYTES = 50 * 1024
43
+ _POST_EXIT_IDLE_SECONDS = 0.1
44
+ _PROCESS_POLL_SECONDS = 0.01
45
+ _CLEANUP_GRACE_SECONDS = 30.0
46
+ _CAPTURE_QUEUE_CHUNKS = 16
47
+
48
+ _T = TypeVar("_T")
49
+
50
+
51
+ @runtime_checkable
52
+ class PiCompatCancellation(Protocol):
53
+ """Minimal cancellation surface supplied by the Millforge adapter."""
54
+
55
+ def is_cancelled(self) -> bool: ...
56
+
57
+ async def wait(self) -> None: ...
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class PiCompatShellConfig:
62
+ """One resolved host shell and the arguments preceding its command."""
63
+
64
+ executable: str
65
+ arguments: tuple[str, ...]
66
+
67
+
68
+ class PiCompatShellResolutionError(RuntimeError):
69
+ """Raised when the host has no usable shell for Pi-compatible bash calls."""
70
+
71
+
72
+ class _LaunchInterrupted(RuntimeError):
73
+ """The process was created after its caller had already interrupted launch."""
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class _TailSnapshot:
78
+ content: str
79
+ truncated: bool
80
+ truncated_by: str | None
81
+ total_lines: int
82
+ total_bytes: int
83
+ output_lines: int
84
+ output_bytes: int
85
+ last_line_partial: bool
86
+ last_line_bytes: int
87
+ full_output_path: str | None
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class _CleanupOutcome:
92
+ tree_confirmed: bool
93
+ process_exited: bool
94
+ process_reaped: bool
95
+ diagnostics: tuple[str, ...] = ()
96
+
97
+
98
+ class _OutputAccumulator:
99
+ """Bounded Pi-style tail display plus one owned raw-output writer."""
100
+
101
+ def __init__(self) -> None:
102
+ self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
103
+ self._raw_queue: asyncio.Queue[bytes | None] = asyncio.Queue(
104
+ maxsize=_CAPTURE_QUEUE_CHUNKS
105
+ )
106
+ self._writer_task = asyncio.create_task(
107
+ _write_raw_output(self._raw_queue),
108
+ name="millforge-pi-bash-output-writer",
109
+ )
110
+ self._writer_stop_requested = False
111
+ self._raw_output_path: Path | None = None
112
+ self._tail_text = ""
113
+ self._tail_bytes = 0
114
+ self._tail_starts_at_line_boundary = True
115
+ self._total_decoded_bytes = 0
116
+ self._completed_lines = 0
117
+ self._total_lines = 0
118
+ self._current_line_bytes = 0
119
+ self._has_open_line = False
120
+ self._finished = False
121
+ self._snapshot_result: _TailSnapshot | None = None
122
+ self._keep_raw_output = False
123
+ self._cleanup_diagnostics: list[str] = []
124
+
125
+ @property
126
+ def writer_task(self) -> asyncio.Task[Path]:
127
+ return self._writer_task
128
+
129
+ async def append(self, data: bytes) -> None:
130
+ if self._finished:
131
+ raise RuntimeError("cannot append to a finished output accumulator")
132
+ if not data:
133
+ return
134
+ await self._enqueue_raw_output(data)
135
+ self._append_decoded_text(self._decoder.decode(data, final=False))
136
+
137
+ async def finish(self) -> _TailSnapshot:
138
+ if self._snapshot_result is not None:
139
+ return self._snapshot_result
140
+ if not self._finished:
141
+ self._finished = True
142
+ self._append_decoded_text(self._decoder.decode(b"", final=True))
143
+
144
+ raw_output_path = await self._finish_writer()
145
+ snapshot = self._snapshot()
146
+ if snapshot.truncated:
147
+ self._keep_raw_output = True
148
+ snapshot = _TailSnapshot(
149
+ content=snapshot.content,
150
+ truncated=snapshot.truncated,
151
+ truncated_by=snapshot.truncated_by,
152
+ total_lines=snapshot.total_lines,
153
+ total_bytes=snapshot.total_bytes,
154
+ output_lines=snapshot.output_lines,
155
+ output_bytes=snapshot.output_bytes,
156
+ last_line_partial=snapshot.last_line_partial,
157
+ last_line_bytes=snapshot.last_line_bytes,
158
+ full_output_path=str(raw_output_path),
159
+ )
160
+ self._snapshot_result = snapshot
161
+ return snapshot
162
+
163
+ async def close(self) -> tuple[str, ...]:
164
+ """Finish the writer before removing any unreturned raw-output file."""
165
+
166
+ cancellation: asyncio.CancelledError | None = None
167
+ diagnostics: list[str] = []
168
+ try:
169
+ await self._finish_writer()
170
+ except asyncio.CancelledError as exc:
171
+ cancellation = exc
172
+ except Exception as exc:
173
+ diagnostics.append(_format_cleanup_diagnostic(exc))
174
+
175
+ if not self._keep_raw_output and self._raw_output_path is not None:
176
+ raw_output_path = self._raw_output_path
177
+ self._raw_output_path = None
178
+ try:
179
+ await _run_blocking(lambda: raw_output_path.unlink(missing_ok=True))
180
+ except asyncio.CancelledError as exc:
181
+ cancellation = exc
182
+ except OSError as exc:
183
+ diagnostics.append(_format_cleanup_diagnostic(exc))
184
+ self._cleanup_diagnostics.extend(diagnostics)
185
+ if cancellation is not None:
186
+ raise cancellation
187
+ return tuple(diagnostics)
188
+
189
+ def snapshot_after_failure(self) -> _TailSnapshot:
190
+ """Return the model tail after an already-observed capture failure."""
191
+
192
+ if not self._finished:
193
+ self._finished = True
194
+ self._append_decoded_text(self._decoder.decode(b"", final=True))
195
+ return self._snapshot()
196
+
197
+ async def _enqueue_raw_output(self, data: bytes | None) -> None:
198
+ if self._writer_task.done():
199
+ await _observe_task(self._writer_task)
200
+ raise RuntimeError("raw-output writer completed before capture finished")
201
+ try:
202
+ self._raw_queue.put_nowait(data)
203
+ return
204
+ except asyncio.QueueFull:
205
+ pass
206
+
207
+ put_task = asyncio.create_task(
208
+ self._raw_queue.put(data), name="millforge-pi-bash-output-queue-put"
209
+ )
210
+ try:
211
+ done, _ = await asyncio.wait(
212
+ {put_task, self._writer_task}, return_when=asyncio.FIRST_COMPLETED
213
+ )
214
+ if self._writer_task in done:
215
+ if not put_task.done():
216
+ await _cancel_and_observe((put_task,))
217
+ else:
218
+ await _observe_task(put_task)
219
+ await _observe_task(self._writer_task)
220
+ raise RuntimeError(
221
+ "raw-output writer completed before capture finished"
222
+ )
223
+ await _observe_task(put_task)
224
+ finally:
225
+ if not put_task.done():
226
+ await _cancel_and_observe((put_task,))
227
+
228
+ async def _finish_writer(self) -> Path:
229
+ if not self._writer_stop_requested:
230
+ self._writer_stop_requested = True
231
+ await self._enqueue_raw_output(None)
232
+ raw_output_path = await _await_owned_task(self._writer_task)
233
+ self._raw_output_path = raw_output_path
234
+ return raw_output_path
235
+
236
+ def _append_decoded_text(self, text: str) -> None:
237
+ if not text:
238
+ return
239
+
240
+ text_bytes = len(text.encode("utf-8"))
241
+ self._total_decoded_bytes += text_bytes
242
+ self._tail_text += text
243
+ self._tail_bytes += text_bytes
244
+ if self._tail_bytes > _DEFAULT_MAX_BYTES * 4:
245
+ self._trim_tail()
246
+
247
+ newline_count = text.count("\n")
248
+ if newline_count == 0:
249
+ self._current_line_bytes += text_bytes
250
+ self._has_open_line = True
251
+ else:
252
+ self._completed_lines += newline_count
253
+ trailing_text = text.rsplit("\n", maxsplit=1)[1]
254
+ self._current_line_bytes = len(trailing_text.encode("utf-8"))
255
+ self._has_open_line = bool(trailing_text)
256
+ self._total_lines = self._completed_lines + int(self._has_open_line)
257
+
258
+ def _trim_tail(self) -> None:
259
+ encoded_tail = self._tail_text.encode("utf-8")
260
+ max_rolling_bytes = _DEFAULT_MAX_BYTES * 2
261
+ if len(encoded_tail) <= max_rolling_bytes:
262
+ self._tail_bytes = len(encoded_tail)
263
+ return
264
+
265
+ start = len(encoded_tail) - max_rolling_bytes
266
+ while start < len(encoded_tail) and encoded_tail[start] & 0xC0 == 0x80:
267
+ start += 1
268
+ self._tail_starts_at_line_boundary = (
269
+ self._tail_starts_at_line_boundary
270
+ if start == 0
271
+ else encoded_tail[start - 1] == 0x0A
272
+ )
273
+ self._tail_text = encoded_tail[start:].decode("utf-8", errors="replace")
274
+ self._tail_bytes = len(self._tail_text.encode("utf-8"))
275
+
276
+ def _snapshot(self) -> _TailSnapshot:
277
+ display_tail = self._tail_text
278
+ if not self._tail_starts_at_line_boundary:
279
+ first_newline = display_tail.find("\n")
280
+ display_tail = (
281
+ display_tail
282
+ if first_newline == -1
283
+ else display_tail[first_newline + 1 :]
284
+ )
285
+
286
+ content, truncated_by, output_lines, output_bytes, last_line_partial = (
287
+ _truncate_tail(display_tail)
288
+ )
289
+ truncated = (
290
+ self._total_lines > _DEFAULT_MAX_LINES
291
+ or self._total_decoded_bytes > _DEFAULT_MAX_BYTES
292
+ )
293
+ if truncated and truncated_by is None:
294
+ truncated_by = (
295
+ "bytes" if self._total_decoded_bytes > _DEFAULT_MAX_BYTES else "lines"
296
+ )
297
+ return _TailSnapshot(
298
+ content=content,
299
+ truncated=truncated,
300
+ truncated_by=truncated_by if truncated else None,
301
+ total_lines=self._total_lines,
302
+ total_bytes=self._total_decoded_bytes,
303
+ output_lines=output_lines,
304
+ output_bytes=output_bytes,
305
+ last_line_partial=last_line_partial,
306
+ last_line_bytes=self._current_line_bytes,
307
+ full_output_path=None,
308
+ )
309
+
310
+
311
+ def resolve_pi_compat_shell() -> PiCompatShellConfig:
312
+ """Resolve Pi's Bash-first shell policy using the host-native fallback."""
313
+
314
+ if os.name == "nt":
315
+ comspec = os.environ.get("COMSPEC")
316
+ candidate = comspec if comspec and comspec.strip() else "cmd.exe"
317
+ return PiCompatShellConfig(
318
+ executable=_resolve_shell_executable(candidate),
319
+ arguments=("/d", "/s", "/c"),
320
+ )
321
+
322
+ bash_path = Path("/bin/bash")
323
+ if bash_path.is_file():
324
+ return PiCompatShellConfig(executable=str(bash_path), arguments=("-c",))
325
+
326
+ bash_from_path = shutil.which("bash")
327
+ if bash_from_path is not None:
328
+ return PiCompatShellConfig(
329
+ executable=_resolve_shell_executable(bash_from_path), arguments=("-c",)
330
+ )
331
+
332
+ return PiCompatShellConfig(
333
+ executable=_resolve_shell_executable("sh"), arguments=("-c",)
334
+ )
335
+
336
+
337
+ async def execute_bash(
338
+ *,
339
+ cwd: Path,
340
+ command: str,
341
+ timeout_seconds: float,
342
+ cancellation: PiCompatCancellation,
343
+ shell_config: PiCompatShellConfig,
344
+ ) -> PiCompatOperationResult:
345
+ """Execute one shell command with Pi-compatible capture and result mapping."""
346
+
347
+ loop = asyncio.get_running_loop()
348
+ operation_started = loop.time()
349
+ validation_error = _validate_arguments(
350
+ cwd=cwd, command=command, timeout_seconds=timeout_seconds
351
+ )
352
+ if validation_error is not None:
353
+ return _result(
354
+ text=validation_error,
355
+ error_kind=PiCompatErrorKind.INVALID_ARGUMENTS,
356
+ side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
357
+ )
358
+
359
+ deadline = operation_started + float(timeout_seconds)
360
+ if cancellation.is_cancelled():
361
+ return _result(
362
+ text="Command aborted",
363
+ error_kind=PiCompatErrorKind.CANCELLED,
364
+ side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
365
+ )
366
+
367
+ accumulator = _OutputAccumulator()
368
+ cancellation_task = asyncio.create_task(
369
+ cancellation.wait(), name="millforge-pi-bash-cancellation-wait"
370
+ )
371
+ timeout_task = asyncio.create_task(
372
+ _sleep_until(deadline), name="millforge-pi-bash-timeout"
373
+ )
374
+ launch_task = asyncio.create_task(
375
+ _launch_process(
376
+ cwd=cwd,
377
+ command=command,
378
+ shell_config=shell_config,
379
+ ),
380
+ name="millforge-pi-bash-launch",
381
+ )
382
+ process: asyncio.subprocess.Process | None = None
383
+ reader_task: asyncio.Task[None] | None = None
384
+ process_wait_task: asyncio.Task[int] | None = None
385
+ exit_task: asyncio.Task[int] | None = None
386
+ output_activity = asyncio.Event()
387
+ cleanup_task: asyncio.Task[_CleanupOutcome] | None = None
388
+ cleanup_diagnostics: list[str] = []
389
+
390
+ def start_interruption_cleanup() -> asyncio.Task[_CleanupOutcome]:
391
+ """Transfer launch/process ownership to one shielded cleanup task."""
392
+
393
+ nonlocal cleanup_task
394
+ if cleanup_task is not None:
395
+ return cleanup_task
396
+ if (
397
+ process is not None
398
+ and reader_task is not None
399
+ and process_wait_task is not None
400
+ and exit_task is not None
401
+ ):
402
+ cleanup = _finish_interrupted_process(
403
+ process=process,
404
+ exit_task=exit_task,
405
+ process_wait_task=process_wait_task,
406
+ reader_task=reader_task,
407
+ output_activity=output_activity,
408
+ )
409
+ else:
410
+ cleanup = _recover_interrupted_launch(
411
+ launch_task=launch_task,
412
+ accumulator=accumulator,
413
+ output_activity=output_activity,
414
+ )
415
+ cleanup_task = asyncio.create_task(
416
+ cleanup, name="millforge-pi-bash-interruption-cleanup"
417
+ )
418
+ return cleanup_task
419
+
420
+ async def finish_interruption_cleanup() -> _CleanupOutcome:
421
+ outcome = await _await_owned_task(start_interruption_cleanup())
422
+ cleanup_diagnostics.extend(outcome.diagnostics)
423
+ return outcome
424
+
425
+ try:
426
+ done, _ = await asyncio.wait(
427
+ {launch_task, cancellation_task, timeout_task},
428
+ return_when=asyncio.FIRST_COMPLETED,
429
+ )
430
+ if cancellation_task in done or cancellation.is_cancelled():
431
+ await finish_interruption_cleanup()
432
+ return await _interruption_result(
433
+ accumulator=accumulator,
434
+ status="Command aborted",
435
+ error_kind=PiCompatErrorKind.CANCELLED,
436
+ )
437
+ if timeout_task in done:
438
+ await finish_interruption_cleanup()
439
+ return await _interruption_result(
440
+ accumulator=accumulator,
441
+ status=(
442
+ "Command timed out after "
443
+ f"{_format_timeout_seconds(timeout_seconds)} seconds"
444
+ ),
445
+ error_kind=PiCompatErrorKind.PROCESS_TIMEOUT,
446
+ )
447
+
448
+ try:
449
+ process = await _observe_task(launch_task)
450
+ except OSError as exc:
451
+ return _result(
452
+ text=str(exc),
453
+ error_kind=PiCompatErrorKind.PROCESS_LAUNCH_ERROR,
454
+ side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
455
+ )
456
+ except _LaunchInterrupted as exc:
457
+ return _result(
458
+ text=str(exc),
459
+ error_kind=PiCompatErrorKind.PROCESS_LAUNCH_ERROR,
460
+ side_effect_state=PiCompatSideEffectState.NOT_ATTEMPTED,
461
+ )
462
+
463
+ reader_task, process_wait_task, exit_task = _start_process_tasks(
464
+ process, accumulator, output_activity
465
+ )
466
+ reader_observed = False
467
+ while True:
468
+ wait_set = {
469
+ cancellation_task,
470
+ timeout_task,
471
+ exit_task,
472
+ accumulator.writer_task,
473
+ }
474
+ if not reader_observed:
475
+ wait_set.add(reader_task)
476
+ # Typeshed requires one task result type although asyncio.wait
477
+ # supports heterogeneous futures and does not inspect results here.
478
+ done, _ = await asyncio.wait(
479
+ wait_set, # type: ignore[arg-type]
480
+ return_when=asyncio.FIRST_COMPLETED,
481
+ )
482
+
483
+ if cancellation_task in done or cancellation.is_cancelled():
484
+ await finish_interruption_cleanup()
485
+ return await _interruption_result(
486
+ accumulator=accumulator,
487
+ status="Command aborted",
488
+ error_kind=PiCompatErrorKind.CANCELLED,
489
+ )
490
+ if timeout_task in done:
491
+ await finish_interruption_cleanup()
492
+ return await _interruption_result(
493
+ accumulator=accumulator,
494
+ status=(
495
+ "Command timed out after "
496
+ f"{_format_timeout_seconds(timeout_seconds)} seconds"
497
+ ),
498
+ error_kind=PiCompatErrorKind.PROCESS_TIMEOUT,
499
+ )
500
+ if reader_task in done:
501
+ await _observe_task(reader_task)
502
+ reader_observed = True
503
+ continue
504
+ if accumulator.writer_task in done:
505
+ await _observe_task(accumulator.writer_task)
506
+ raise RuntimeError("raw-output writer completed before process exit")
507
+
508
+ exit_code = await _observe_task(exit_task)
509
+ await _finish_post_exit_output(
510
+ process=process,
511
+ process_wait_task=process_wait_task,
512
+ reader_task=reader_task,
513
+ output_activity=output_activity,
514
+ )
515
+ snapshot = await accumulator.finish()
516
+ text = _format_output(snapshot, empty_text="(no output)")
517
+ if exit_code != 0:
518
+ return _result(
519
+ text=_append_status(text, f"Command exited with code {exit_code}"),
520
+ truncated=snapshot.truncated,
521
+ error_kind=PiCompatErrorKind.PROCESS_EXIT_NONZERO,
522
+ exit_code=exit_code,
523
+ side_effect_state=PiCompatSideEffectState.CONFIRMED_COMPLETE,
524
+ )
525
+ return _result(
526
+ text=text,
527
+ truncated=snapshot.truncated,
528
+ exit_code=exit_code,
529
+ side_effect_state=PiCompatSideEffectState.CONFIRMED_COMPLETE,
530
+ )
531
+ except asyncio.CancelledError:
532
+ await finish_interruption_cleanup()
533
+ raise
534
+ except Exception as exc:
535
+ confirmed_complete = process is not None and (
536
+ process.returncode is not None or _exit_is_established(exit_task)
537
+ )
538
+ if (
539
+ process is not None
540
+ and reader_task is not None
541
+ and process_wait_task is not None
542
+ and exit_task is not None
543
+ ):
544
+ if confirmed_complete:
545
+ cleanup_diagnostics.extend(
546
+ await _finish_after_exit_failure(
547
+ process=process,
548
+ process_wait_task=process_wait_task,
549
+ reader_task=reader_task,
550
+ )
551
+ )
552
+ else:
553
+ await finish_interruption_cleanup()
554
+ return _io_error_result(
555
+ accumulator=accumulator,
556
+ error=exc,
557
+ confirmed_complete=confirmed_complete,
558
+ )
559
+ finally:
560
+ if cleanup_task is not None and not cleanup_task.done():
561
+ try:
562
+ outcome = await _await_owned_task(cleanup_task)
563
+ cleanup_diagnostics.extend(outcome.diagnostics)
564
+ except Exception as exc:
565
+ cleanup_diagnostics.append(_format_cleanup_diagnostic(exc))
566
+ try:
567
+ await _cancel_and_observe(
568
+ task
569
+ for task in (
570
+ cancellation_task,
571
+ timeout_task,
572
+ launch_task if cleanup_task is None else None,
573
+ exit_task,
574
+ process_wait_task,
575
+ reader_task,
576
+ )
577
+ if task is not None and not task.done()
578
+ )
579
+ except Exception as exc:
580
+ cleanup_diagnostics.append(_format_cleanup_diagnostic(exc))
581
+ try:
582
+ cleanup_diagnostics.extend(await accumulator.close())
583
+ except Exception as exc:
584
+ cleanup_diagnostics.append(_format_cleanup_diagnostic(exc))
585
+
586
+
587
+ def _resolve_shell_executable(candidate: str) -> str:
588
+ candidate_path = Path(candidate)
589
+ if candidate_path.is_absolute():
590
+ if candidate_path.is_file():
591
+ return str(candidate_path.resolve())
592
+ raise PiCompatShellResolutionError(
593
+ f"shell executable is not a file: {candidate}"
594
+ )
595
+
596
+ resolved = shutil.which(candidate)
597
+ if resolved is None:
598
+ raise PiCompatShellResolutionError(
599
+ f"shell executable was not found: {candidate}"
600
+ )
601
+ return str(Path(resolved).resolve())
602
+
603
+
604
+ def _validate_arguments(
605
+ *, cwd: Path, command: str, timeout_seconds: float
606
+ ) -> str | None:
607
+ try:
608
+ cwd_text = os.fspath(cwd)
609
+ except TypeError:
610
+ return "cwd must be a filesystem path"
611
+ if "\x00" in cwd_text:
612
+ return "cwd must not contain a NUL character"
613
+ try:
614
+ cwd_text.encode(sys.getfilesystemencoding(), "strict")
615
+ except UnicodeEncodeError:
616
+ return "cwd must be filesystem-encodable"
617
+ if not _is_absolute_cwd(cwd):
618
+ return "cwd must be an absolute path"
619
+ if not isinstance(command, str):
620
+ return "command must be a string"
621
+ if (
622
+ isinstance(timeout_seconds, bool)
623
+ or not isinstance(timeout_seconds, (int, float))
624
+ or not math.isfinite(timeout_seconds)
625
+ or timeout_seconds <= 0
626
+ ):
627
+ return "timeout_seconds must be a strictly positive finite number"
628
+ return None
629
+
630
+
631
+ async def _launch_process(
632
+ *,
633
+ cwd: Path,
634
+ command: str,
635
+ shell_config: PiCompatShellConfig,
636
+ ) -> asyncio.subprocess.Process:
637
+ """Materialize a process and hand it to the caller without cleanup work."""
638
+
639
+ return await _create_process(cwd=cwd, command=command, shell_config=shell_config)
640
+
641
+
642
+ async def _create_process(
643
+ *, cwd: Path, command: str, shell_config: PiCompatShellConfig
644
+ ) -> asyncio.subprocess.Process:
645
+ if os.name == "nt":
646
+ return await asyncio.create_subprocess_exec(
647
+ shell_config.executable,
648
+ *shell_config.arguments,
649
+ command,
650
+ cwd=str(cwd),
651
+ stdin=subprocess.DEVNULL,
652
+ stdout=subprocess.PIPE,
653
+ # One kernel pipe gives a single ordered delivery boundary for
654
+ # bursty stdout/stderr instead of racing independent stream readers.
655
+ stderr=asyncio.subprocess.STDOUT,
656
+ creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
657
+ )
658
+ return await asyncio.create_subprocess_exec(
659
+ shell_config.executable,
660
+ *shell_config.arguments,
661
+ command,
662
+ cwd=str(cwd),
663
+ stdin=subprocess.DEVNULL,
664
+ stdout=subprocess.PIPE,
665
+ stderr=asyncio.subprocess.STDOUT,
666
+ start_new_session=True,
667
+ )
668
+
669
+
670
+ def _start_process_tasks(
671
+ process: asyncio.subprocess.Process,
672
+ accumulator: _OutputAccumulator,
673
+ output_activity: asyncio.Event,
674
+ ) -> tuple[asyncio.Task[None], asyncio.Task[int], asyncio.Task[int]]:
675
+ assert process.stdout is not None
676
+ reader_task = asyncio.create_task(
677
+ _read_output(process.stdout, accumulator, output_activity),
678
+ name="millforge-pi-bash-output-reader",
679
+ )
680
+ process_wait_task = asyncio.create_task(
681
+ process.wait(), name="millforge-pi-bash-process-wait"
682
+ )
683
+ exit_task = asyncio.create_task(
684
+ _wait_for_process_exit(process), name="millforge-pi-bash-exit-watch"
685
+ )
686
+ return reader_task, process_wait_task, exit_task
687
+
688
+
689
+ async def _settle_interrupted_launch(
690
+ launch_task: asyncio.Task[asyncio.subprocess.Process],
691
+ cleanup_deadline: float,
692
+ ) -> tuple[asyncio.subprocess.Process | None, tuple[str, ...]]:
693
+ """Bound and observe launch handoff before an interruption result is emitted."""
694
+
695
+ diagnostics: list[str] = []
696
+ if not launch_task.done():
697
+ await _wait_for_task(launch_task, _cleanup_time_remaining(cleanup_deadline))
698
+ if not launch_task.done():
699
+ launch_task.cancel()
700
+ try:
701
+ await _await_owned_task(launch_task)
702
+ except asyncio.CancelledError:
703
+ diagnostics.append("launch did not hand off a process before cleanup grace")
704
+ return None, tuple(diagnostics)
705
+ except Exception as exc:
706
+ diagnostics.append(_format_cleanup_diagnostic(exc))
707
+ return None, tuple(diagnostics)
708
+ try:
709
+ return await _observe_task(launch_task), tuple(diagnostics)
710
+ except _LaunchInterrupted as exc:
711
+ diagnostics.append(_format_cleanup_diagnostic(exc))
712
+ except asyncio.CancelledError:
713
+ diagnostics.append("launch was cancelled before process handoff")
714
+ except Exception as exc:
715
+ diagnostics.append(_format_cleanup_diagnostic(exc))
716
+ return None, tuple(diagnostics)
717
+
718
+
719
+ async def _recover_interrupted_launch(
720
+ *,
721
+ launch_task: asyncio.Task[asyncio.subprocess.Process],
722
+ accumulator: _OutputAccumulator,
723
+ output_activity: asyncio.Event,
724
+ ) -> _CleanupOutcome:
725
+ """Own a racing launch until its process is reaped or handoff is bounded."""
726
+
727
+ cleanup_deadline = _cleanup_deadline()
728
+ process, diagnostics = await _settle_interrupted_launch(
729
+ launch_task, cleanup_deadline
730
+ )
731
+ if process is None:
732
+ return _CleanupOutcome(
733
+ tree_confirmed=False,
734
+ process_exited=False,
735
+ process_reaped=False,
736
+ diagnostics=diagnostics,
737
+ )
738
+ reader_task, process_wait_task, exit_task = _start_process_tasks(
739
+ process, accumulator, output_activity
740
+ )
741
+ outcome = await _finish_interrupted_process(
742
+ process=process,
743
+ exit_task=exit_task,
744
+ process_wait_task=process_wait_task,
745
+ reader_task=reader_task,
746
+ output_activity=output_activity,
747
+ cleanup_deadline=cleanup_deadline,
748
+ )
749
+ return _CleanupOutcome(
750
+ tree_confirmed=outcome.tree_confirmed,
751
+ process_exited=outcome.process_exited,
752
+ process_reaped=outcome.process_reaped,
753
+ diagnostics=diagnostics + outcome.diagnostics,
754
+ )
755
+
756
+
757
+ def _cleanup_deadline() -> float:
758
+ return asyncio.get_running_loop().time() + _CLEANUP_GRACE_SECONDS
759
+
760
+
761
+ def _cleanup_time_remaining(deadline: float) -> float:
762
+ return max(0.0, deadline - asyncio.get_running_loop().time())
763
+
764
+
765
+ async def _sleep_until(deadline: float) -> None:
766
+ await asyncio.sleep(max(0.0, deadline - asyncio.get_running_loop().time()))
767
+
768
+
769
+ def _result(
770
+ *,
771
+ text: str,
772
+ truncated: bool = False,
773
+ error_kind: PiCompatErrorKind | None = None,
774
+ exit_code: int | None = None,
775
+ side_effect_state: PiCompatSideEffectState,
776
+ ) -> PiCompatOperationResult:
777
+ return PiCompatOperationResult(
778
+ model_text=text,
779
+ truncated=truncated,
780
+ error_kind=error_kind,
781
+ exit_code=exit_code,
782
+ changed_path=None,
783
+ side_effect_state=side_effect_state,
784
+ )
785
+
786
+
787
+ async def _read_output(
788
+ stream: asyncio.StreamReader,
789
+ accumulator: _OutputAccumulator,
790
+ output_activity: asyncio.Event,
791
+ ) -> None:
792
+ while chunk := await stream.read(4_096):
793
+ await accumulator.append(chunk)
794
+ output_activity.set()
795
+
796
+
797
+ async def _write_raw_output(queue: asyncio.Queue[bytes | None]) -> Path:
798
+ """Write raw output off-loop and remove a partial destination on failure."""
799
+
800
+ output_path: Path | None = None
801
+ output_file: BinaryIO | None = None
802
+ try:
803
+ output_path = await _run_blocking(_create_raw_output_path)
804
+ output_file = await _run_blocking(lambda: output_path.open("wb"))
805
+ while True:
806
+ data = await queue.get()
807
+ if data is None:
808
+ break
809
+ assert output_file is not None
810
+ chunk_output_file: BinaryIO = output_file
811
+ await _run_blocking(lambda: _write_raw_chunk(chunk_output_file, data))
812
+ assert output_file is not None
813
+ await _run_blocking(output_file.flush)
814
+ await _run_blocking(output_file.close)
815
+ output_file = None
816
+ return output_path
817
+ except BaseException:
818
+ if output_file is not None:
819
+ try:
820
+ await _run_blocking(output_file.close)
821
+ except OSError:
822
+ pass
823
+ if output_path is not None:
824
+ try:
825
+ await _run_blocking(lambda: output_path.unlink(missing_ok=True))
826
+ except OSError:
827
+ pass
828
+ raise
829
+
830
+
831
+ def _create_raw_output_path() -> Path:
832
+ descriptor, path = tempfile.mkstemp(prefix="pi-bash-", suffix=".log")
833
+ os.close(descriptor)
834
+ return Path(path)
835
+
836
+
837
+ def _write_raw_chunk(output_file: BinaryIO, data: bytes) -> None:
838
+ output_file.write(data)
839
+
840
+
841
+ async def _run_blocking(operation: Callable[[], _T]) -> _T:
842
+ """Await one thread-bound file operation before its handle may be closed."""
843
+
844
+ operation_task = asyncio.create_task(
845
+ asyncio.to_thread(operation), name="millforge-pi-bash-output-thread"
846
+ )
847
+ cancellation: asyncio.CancelledError | None = None
848
+ while not operation_task.done():
849
+ try:
850
+ await asyncio.shield(operation_task)
851
+ except asyncio.CancelledError as exc:
852
+ cancellation = exc
853
+ try:
854
+ result = operation_task.result()
855
+ except BaseException:
856
+ if cancellation is not None:
857
+ raise cancellation
858
+ raise
859
+ if cancellation is not None:
860
+ raise cancellation
861
+ return result
862
+
863
+
864
+ async def _wait_for_process_exit(process: asyncio.subprocess.Process) -> int:
865
+ while process.returncode is None:
866
+ await asyncio.sleep(_PROCESS_POLL_SECONDS)
867
+ return process.returncode
868
+
869
+
870
+ async def _wait_for_process_exit_with_grace(
871
+ process: asyncio.subprocess.Process,
872
+ exit_task: asyncio.Task[int],
873
+ cleanup_deadline: float,
874
+ ) -> bool:
875
+ if process.returncode is not None:
876
+ return True
877
+ await _wait_for_task(exit_task, _cleanup_time_remaining(cleanup_deadline))
878
+ return process.returncode is not None
879
+
880
+
881
+ async def _finish_interrupted_process(
882
+ *,
883
+ process: asyncio.subprocess.Process,
884
+ exit_task: asyncio.Task[int],
885
+ process_wait_task: asyncio.Task[int],
886
+ reader_task: asyncio.Task[None],
887
+ output_activity: asyncio.Event,
888
+ cleanup_deadline: float | None = None,
889
+ ) -> _CleanupOutcome:
890
+ cleanup_deadline = cleanup_deadline or _cleanup_deadline()
891
+ outcome = await _terminate_process_tree(
892
+ process, exit_task, process_wait_task, cleanup_deadline
893
+ )
894
+ diagnostics = list(outcome.diagnostics)
895
+ if outcome.process_reaped:
896
+ try:
897
+ await _finish_post_exit_output(
898
+ process=process,
899
+ process_wait_task=process_wait_task,
900
+ reader_task=reader_task,
901
+ output_activity=output_activity,
902
+ cleanup_deadline=cleanup_deadline,
903
+ )
904
+ except Exception as exc:
905
+ diagnostics.append(_format_cleanup_diagnostic(exc))
906
+ else:
907
+ _close_subprocess_pipes(process)
908
+ try:
909
+ await _cancel_and_observe((reader_task, process_wait_task, exit_task))
910
+ except Exception as exc:
911
+ diagnostics.append(_format_cleanup_diagnostic(exc))
912
+ return _CleanupOutcome(
913
+ tree_confirmed=outcome.tree_confirmed,
914
+ process_exited=outcome.process_exited,
915
+ process_reaped=outcome.process_reaped,
916
+ diagnostics=tuple(diagnostics),
917
+ )
918
+
919
+
920
+ async def _terminate_process_tree(
921
+ process: asyncio.subprocess.Process,
922
+ exit_task: asyncio.Task[int],
923
+ process_wait_task: asyncio.Task[int],
924
+ cleanup_deadline: float,
925
+ ) -> _CleanupOutcome:
926
+ """Request tree termination, then contain the direct shell on failure."""
927
+
928
+ diagnostics: list[str] = []
929
+ pid = process.pid
930
+ if pid is None:
931
+ return _CleanupOutcome(
932
+ tree_confirmed=False,
933
+ process_exited=process.returncode is not None,
934
+ process_reaped=False,
935
+ diagnostics=("process did not expose a PID for tree cleanup",),
936
+ )
937
+
938
+ tree_operation = (
939
+ _run_taskkill(pid) if os.name == "nt" else _kill_posix_process_group(pid)
940
+ )
941
+ tree_task = asyncio.create_task(
942
+ tree_operation, name="millforge-pi-bash-tree-cleanup"
943
+ )
944
+ try:
945
+ if await _wait_for_task(tree_task, _cleanup_time_remaining(cleanup_deadline)):
946
+ tree_confirmed = await _observe_task(tree_task)
947
+ else:
948
+ tree_confirmed = False
949
+ diagnostics.append("tree cleanup did not finish before cleanup grace")
950
+ await _cancel_and_observe((tree_task,))
951
+ except OSError as exc:
952
+ tree_confirmed = False
953
+ diagnostics.append(_format_cleanup_diagnostic(exc))
954
+ finally:
955
+ if not tree_task.done():
956
+ await _cancel_and_observe((tree_task,))
957
+
958
+ process_exited = await _wait_for_process_exit_with_grace(
959
+ process, exit_task, cleanup_deadline
960
+ )
961
+ if process_exited:
962
+ _close_subprocess_pipes(process)
963
+ process_reaped, wait_diagnostics = await _settle_process_wait(
964
+ process, process_wait_task, cleanup_deadline
965
+ )
966
+ diagnostics.extend(wait_diagnostics)
967
+ if tree_confirmed and process_reaped:
968
+ return _CleanupOutcome(
969
+ tree_confirmed=True,
970
+ process_exited=True,
971
+ process_reaped=True,
972
+ diagnostics=tuple(diagnostics),
973
+ )
974
+
975
+ direct_exited, direct_reaped, direct_diagnostics = await _terminate_direct_child(
976
+ process, exit_task, process_wait_task, cleanup_deadline
977
+ )
978
+ diagnostics.extend(direct_diagnostics)
979
+ return _CleanupOutcome(
980
+ tree_confirmed=tree_confirmed,
981
+ process_exited=direct_exited,
982
+ process_reaped=direct_reaped,
983
+ diagnostics=tuple(diagnostics),
984
+ )
985
+
986
+
987
+ async def _kill_posix_process_group(pid: int) -> bool:
988
+ try:
989
+ os.killpg(pid, signal.SIGKILL)
990
+ except ProcessLookupError:
991
+ return not _posix_process_group_exists(pid)
992
+ except OSError:
993
+ return False
994
+ return await _wait_for_posix_process_group_death(pid)
995
+
996
+
997
+ def _posix_process_group_exists(pid: int) -> bool:
998
+ try:
999
+ os.killpg(pid, 0)
1000
+ except ProcessLookupError:
1001
+ return False
1002
+ except OSError:
1003
+ return True
1004
+ return True
1005
+
1006
+
1007
+ async def _wait_for_posix_process_group_death(pid: int) -> bool:
1008
+ deadline = asyncio.get_running_loop().time() + _CLEANUP_GRACE_SECONDS
1009
+ while _posix_process_group_exists(pid):
1010
+ if asyncio.get_running_loop().time() >= deadline:
1011
+ return False
1012
+ await asyncio.sleep(_PROCESS_POLL_SECONDS)
1013
+ return True
1014
+
1015
+
1016
+ async def _run_taskkill(pid: int) -> bool:
1017
+ try:
1018
+ taskkill = await asyncio.create_subprocess_exec(
1019
+ "taskkill",
1020
+ "/F",
1021
+ "/T",
1022
+ "/PID",
1023
+ str(pid),
1024
+ stdin=subprocess.DEVNULL,
1025
+ stdout=subprocess.DEVNULL,
1026
+ stderr=subprocess.DEVNULL,
1027
+ )
1028
+ except OSError:
1029
+ return False
1030
+
1031
+ wait_task = asyncio.create_task(
1032
+ taskkill.wait(), name="millforge-pi-bash-taskkill-wait"
1033
+ )
1034
+ try:
1035
+ if not await _wait_for_task(wait_task, _CLEANUP_GRACE_SECONDS):
1036
+ try:
1037
+ taskkill.kill()
1038
+ except (OSError, ProcessLookupError):
1039
+ pass
1040
+ if await _wait_for_task(wait_task, _CLEANUP_GRACE_SECONDS):
1041
+ await _observe_task(wait_task)
1042
+ else:
1043
+ await _cancel_and_observe((wait_task,))
1044
+ return False
1045
+ return await _observe_task(wait_task) == 0
1046
+ finally:
1047
+ await _cancel_and_observe(task for task in (wait_task,) if not task.done())
1048
+
1049
+
1050
+ async def _terminate_direct_child(
1051
+ process: asyncio.subprocess.Process,
1052
+ exit_task: asyncio.Task[int],
1053
+ process_wait_task: asyncio.Task[int],
1054
+ cleanup_deadline: float,
1055
+ ) -> tuple[bool, bool, tuple[str, ...]]:
1056
+ diagnostics: list[str] = []
1057
+ if process.returncode is None:
1058
+ try:
1059
+ process.kill()
1060
+ except (OSError, ProcessLookupError) as exc:
1061
+ diagnostics.append(_format_cleanup_diagnostic(exc))
1062
+ process_exited = await _wait_for_process_exit_with_grace(
1063
+ process, exit_task, cleanup_deadline
1064
+ )
1065
+ _close_subprocess_pipes(process)
1066
+ process_reaped, wait_diagnostics = await _settle_process_wait(
1067
+ process, process_wait_task, cleanup_deadline
1068
+ )
1069
+ diagnostics.extend(wait_diagnostics)
1070
+ return process_exited, process_reaped, tuple(diagnostics)
1071
+
1072
+
1073
+ async def _finish_post_exit_output(
1074
+ *,
1075
+ process: asyncio.subprocess.Process,
1076
+ process_wait_task: asyncio.Task[int],
1077
+ reader_task: asyncio.Task[None],
1078
+ output_activity: asyncio.Event,
1079
+ cleanup_deadline: float | None = None,
1080
+ ) -> None:
1081
+ reader_error: Exception | None = None
1082
+ try:
1083
+ reader_finished = await _drain_post_exit_output(reader_task, output_activity)
1084
+ if not reader_finished:
1085
+ _close_subprocess_pipes(process)
1086
+ await _cancel_and_observe((reader_task,))
1087
+ else:
1088
+ await _observe_task(reader_task)
1089
+ except Exception as exc:
1090
+ reader_error = exc
1091
+ finally:
1092
+ await _settle_process_wait(
1093
+ process,
1094
+ process_wait_task,
1095
+ cleanup_deadline or _cleanup_deadline(),
1096
+ )
1097
+ if reader_error is not None:
1098
+ raise reader_error
1099
+
1100
+
1101
+ async def _finish_after_exit_failure(
1102
+ *,
1103
+ process: asyncio.subprocess.Process,
1104
+ process_wait_task: asyncio.Task[int],
1105
+ reader_task: asyncio.Task[None],
1106
+ ) -> tuple[str, ...]:
1107
+ diagnostics: list[str] = []
1108
+ cleanup_deadline = _cleanup_deadline()
1109
+ _close_subprocess_pipes(process)
1110
+ try:
1111
+ await _cancel_and_observe((reader_task,))
1112
+ except Exception as exc:
1113
+ diagnostics.append(_format_cleanup_diagnostic(exc))
1114
+ _, wait_diagnostics = await _settle_process_wait(
1115
+ process, process_wait_task, cleanup_deadline
1116
+ )
1117
+ diagnostics.extend(wait_diagnostics)
1118
+ return tuple(diagnostics)
1119
+
1120
+
1121
+ async def _settle_process_wait(
1122
+ process: asyncio.subprocess.Process,
1123
+ process_wait_task: asyncio.Task[int],
1124
+ cleanup_deadline: float,
1125
+ ) -> tuple[bool, tuple[str, ...]]:
1126
+ diagnostics: list[str] = []
1127
+ if await _wait_for_task(
1128
+ process_wait_task, _cleanup_time_remaining(cleanup_deadline)
1129
+ ):
1130
+ try:
1131
+ await _observe_task(process_wait_task)
1132
+ except Exception as exc:
1133
+ diagnostics.append(_format_cleanup_diagnostic(exc))
1134
+ return False, tuple(diagnostics)
1135
+ return True, tuple(diagnostics)
1136
+ _close_subprocess_pipes(process)
1137
+ if await _wait_for_task(
1138
+ process_wait_task, _cleanup_time_remaining(cleanup_deadline)
1139
+ ):
1140
+ try:
1141
+ await _observe_task(process_wait_task)
1142
+ except Exception as exc:
1143
+ diagnostics.append(_format_cleanup_diagnostic(exc))
1144
+ return False, tuple(diagnostics)
1145
+ return True, tuple(diagnostics)
1146
+ try:
1147
+ await _cancel_and_observe((process_wait_task,))
1148
+ except Exception as exc:
1149
+ diagnostics.append(_format_cleanup_diagnostic(exc))
1150
+ return False, tuple(diagnostics)
1151
+
1152
+
1153
+ async def _drain_post_exit_output(
1154
+ reader_task: asyncio.Task[None], output_activity: asyncio.Event
1155
+ ) -> bool:
1156
+ """Drain output until EOF or Pi's re-armed 100-ms idle window expires."""
1157
+
1158
+ loop = asyncio.get_running_loop()
1159
+ idle_deadline = loop.time() + _POST_EXIT_IDLE_SECONDS
1160
+ output_activity.clear()
1161
+ while not reader_task.done():
1162
+ remaining = idle_deadline - loop.time()
1163
+ if remaining <= 0:
1164
+ return False
1165
+ activity_task = asyncio.create_task(
1166
+ output_activity.wait(), name="millforge-pi-bash-post-exit-activity"
1167
+ )
1168
+ try:
1169
+ done, _ = await asyncio.wait(
1170
+ {reader_task, activity_task},
1171
+ timeout=remaining,
1172
+ return_when=asyncio.FIRST_COMPLETED,
1173
+ )
1174
+ if reader_task.done():
1175
+ return True
1176
+ if activity_task in done:
1177
+ await _observe_task(activity_task)
1178
+ output_activity.clear()
1179
+ idle_deadline = loop.time() + _POST_EXIT_IDLE_SECONDS
1180
+ finally:
1181
+ if not activity_task.done():
1182
+ await _cancel_and_observe((activity_task,))
1183
+ return True
1184
+
1185
+
1186
+ def _close_subprocess_pipes(process: asyncio.subprocess.Process) -> None:
1187
+ """Close inherited pipe transports once Pi's idle window has elapsed."""
1188
+
1189
+ transport = getattr(process, "_transport", None)
1190
+ if transport is None:
1191
+ return
1192
+ pipe_transport = transport.get_pipe_transport(1)
1193
+ if pipe_transport is not None:
1194
+ pipe_transport.close()
1195
+
1196
+
1197
+ async def _wait_for_task(task: asyncio.Task[object], timeout: float) -> bool:
1198
+ done, _ = await asyncio.wait({task}, timeout=timeout)
1199
+ return task in done
1200
+
1201
+
1202
+ async def _observe_task(task: asyncio.Task[_T]) -> _T:
1203
+ return await task
1204
+
1205
+
1206
+ async def _await_owned_task(task: asyncio.Task[_T]) -> _T:
1207
+ """Observe a task even if its parent receives cancellation mid-cleanup."""
1208
+
1209
+ cancellation: asyncio.CancelledError | None = None
1210
+ while not task.done():
1211
+ try:
1212
+ await asyncio.shield(task)
1213
+ except asyncio.CancelledError as exc:
1214
+ cancellation = exc
1215
+ try:
1216
+ result = task.result()
1217
+ except BaseException:
1218
+ if cancellation is not None:
1219
+ raise cancellation
1220
+ raise
1221
+ if cancellation is not None:
1222
+ raise cancellation
1223
+ return result
1224
+
1225
+
1226
+ async def _cancel_and_observe(tasks: Iterable[asyncio.Task[object]]) -> None:
1227
+ pending = tuple(tasks)
1228
+ for task in pending:
1229
+ if not task.done():
1230
+ task.cancel()
1231
+ error: Exception | None = None
1232
+ for task in pending:
1233
+ if not task.done() and not await _wait_for_task(task, _CLEANUP_GRACE_SECONDS):
1234
+ continue
1235
+ try:
1236
+ task.result()
1237
+ except asyncio.CancelledError:
1238
+ pass
1239
+ except Exception as exc:
1240
+ if error is None:
1241
+ error = exc
1242
+ if error is not None:
1243
+ raise error
1244
+
1245
+
1246
+ async def _interruption_result(
1247
+ *,
1248
+ accumulator: _OutputAccumulator,
1249
+ status: str,
1250
+ error_kind: PiCompatErrorKind,
1251
+ ) -> PiCompatOperationResult:
1252
+ try:
1253
+ snapshot = await accumulator.finish()
1254
+ except Exception:
1255
+ snapshot = accumulator.snapshot_after_failure()
1256
+ return _result(
1257
+ text=_append_status(_format_output(snapshot, empty_text=""), status),
1258
+ truncated=snapshot.truncated,
1259
+ error_kind=error_kind,
1260
+ side_effect_state=PiCompatSideEffectState.COMPLETION_UNKNOWN,
1261
+ )
1262
+
1263
+
1264
+ def _io_error_result(
1265
+ *,
1266
+ accumulator: _OutputAccumulator,
1267
+ error: Exception,
1268
+ confirmed_complete: bool,
1269
+ ) -> PiCompatOperationResult:
1270
+ snapshot = accumulator.snapshot_after_failure()
1271
+ return _result(
1272
+ text=_append_status(_format_output(snapshot, empty_text=""), str(error)),
1273
+ truncated=snapshot.truncated,
1274
+ error_kind=PiCompatErrorKind.IO_ERROR,
1275
+ side_effect_state=(
1276
+ PiCompatSideEffectState.CONFIRMED_COMPLETE
1277
+ if confirmed_complete
1278
+ else PiCompatSideEffectState.COMPLETION_UNKNOWN
1279
+ ),
1280
+ )
1281
+
1282
+
1283
+ def _exit_is_established(exit_task: asyncio.Task[int] | None) -> bool:
1284
+ if exit_task is None or not exit_task.done() or exit_task.cancelled():
1285
+ return False
1286
+ return exit_task.exception() is None
1287
+
1288
+
1289
+ def _format_cleanup_diagnostic(error: Exception) -> str:
1290
+ message = str(error).replace("\x00", "\\0")
1291
+ return f"{type(error).__name__}: {message}"
1292
+
1293
+
1294
+ def _format_timeout_seconds(timeout_seconds: float) -> str:
1295
+ """Render a finite IEEE-754 value using JavaScript Number#toString rules."""
1296
+
1297
+ timeout = float(timeout_seconds)
1298
+ if timeout == 0:
1299
+ return "0"
1300
+
1301
+ text = repr(timeout).lower()
1302
+ magnitude = abs(timeout)
1303
+ if 1e-6 <= magnitude < 1e21:
1304
+ return _scientific_to_decimal(text) if "e" in text else text.removesuffix(".0")
1305
+
1306
+ if "e" not in text:
1307
+ return text.removesuffix(".0")
1308
+ mantissa, exponent_text = text.split("e", maxsplit=1)
1309
+ mantissa = mantissa.removesuffix(".0")
1310
+ exponent = int(exponent_text)
1311
+ sign = "+" if exponent >= 0 else "-"
1312
+ return f"{mantissa}e{sign}{abs(exponent)}"
1313
+
1314
+
1315
+ def _scientific_to_decimal(text: str) -> str:
1316
+ mantissa, exponent_text = text.split("e", maxsplit=1)
1317
+ exponent = int(exponent_text)
1318
+ sign = ""
1319
+ if mantissa.startswith("-"):
1320
+ sign, mantissa = "-", mantissa[1:]
1321
+ whole, _, fraction = mantissa.partition(".")
1322
+ digits = whole + fraction
1323
+ decimal_index = len(whole) + exponent
1324
+ if decimal_index <= 0:
1325
+ return f"{sign}0.{('0' * -decimal_index)}{digits}"
1326
+ if decimal_index >= len(digits):
1327
+ return f"{sign}{digits}{('0' * (decimal_index - len(digits)))}"
1328
+ return f"{sign}{digits[:decimal_index]}.{digits[decimal_index:]}"
1329
+
1330
+
1331
+ def _format_output(snapshot: _TailSnapshot, *, empty_text: str) -> str:
1332
+ text = snapshot.content or empty_text
1333
+ if not snapshot.truncated or snapshot.full_output_path is None:
1334
+ return text
1335
+
1336
+ start_line = snapshot.total_lines - snapshot.output_lines + 1
1337
+ end_line = snapshot.total_lines
1338
+ if snapshot.last_line_partial:
1339
+ last_line_size = _format_size(snapshot.last_line_bytes)
1340
+ return _append_status(
1341
+ text,
1342
+ f"[Showing last {_format_size(snapshot.output_bytes)} of line {end_line} "
1343
+ f"(line is {last_line_size}). Full output: {snapshot.full_output_path}]",
1344
+ )
1345
+ if snapshot.truncated_by == "lines":
1346
+ notice = f"[Showing lines {start_line}-{end_line} of {snapshot.total_lines}. Full output: {snapshot.full_output_path}]"
1347
+ else:
1348
+ notice = (
1349
+ f"[Showing lines {start_line}-{end_line} of {snapshot.total_lines} "
1350
+ f"({_format_size(_DEFAULT_MAX_BYTES)} limit). Full output: {snapshot.full_output_path}]"
1351
+ )
1352
+ return _append_status(text, notice)
1353
+
1354
+
1355
+ def _append_status(text: str, status: str) -> str:
1356
+ return f"{text}\n\n{status}" if text else status
1357
+
1358
+
1359
+ def _truncate_tail(content: str) -> tuple[str, str | None, int, int, bool]:
1360
+ truncation = truncate_tail(
1361
+ content,
1362
+ max_lines=_DEFAULT_MAX_LINES,
1363
+ max_bytes=_DEFAULT_MAX_BYTES,
1364
+ )
1365
+ return (
1366
+ truncation.content,
1367
+ truncation.truncated_by,
1368
+ truncation.output_lines,
1369
+ truncation.output_bytes,
1370
+ truncation.last_line_partial,
1371
+ )
1372
+
1373
+
1374
+ def _format_size(byte_count: int) -> str:
1375
+ return format_size(byte_count)