syncade 0.6.2__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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- syncade-0.6.2.dist-info/top_level.txt +1 -0
syncade/process.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""Structured subprocess helper for reviewer-adapter invocations.
|
|
2
|
+
|
|
3
|
+
:func:`run_subprocess` wraps :func:`subprocess.Popen` with timeout
|
|
4
|
+
handling that kills the entire process group (not just the immediate
|
|
5
|
+
child), captures stdout and stderr separately, and surfaces the three
|
|
6
|
+
failure modes a reviewer dispatcher needs to distinguish:
|
|
7
|
+
|
|
8
|
+
- The binary at ``argv[0]`` isn't on ``PATH`` → :class:`SubprocessNotFoundError`
|
|
9
|
+
- The subprocess ran but exceeded its timeout → :class:`SubprocessTimeoutError`
|
|
10
|
+
- Anything else went wrong launching the process → :class:`SubprocessError`
|
|
11
|
+
|
|
12
|
+
The return code itself is never treated as a failure — callers decide
|
|
13
|
+
based on the exit code. A non-zero ``returncode`` in :class:`SubprocessResult`
|
|
14
|
+
is normal output, not an exception.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import signal
|
|
21
|
+
import subprocess
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import IO, Final
|
|
28
|
+
|
|
29
|
+
_TIMEOUT_DRAIN_SECONDS: Final = 0.25
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class SubprocessResult:
|
|
34
|
+
"""The outcome of a successfully-launched subprocess.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
returncode: The process's exit code. Callers interpret this —
|
|
38
|
+
``run_subprocess`` itself never raises on non-zero.
|
|
39
|
+
stdout: Captured standard output, decoded as text.
|
|
40
|
+
stderr: Captured standard error, decoded as text.
|
|
41
|
+
duration_seconds: Wall-clock time from launch to completion,
|
|
42
|
+
measured via :func:`time.monotonic`.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
returncode: int
|
|
46
|
+
stdout: str
|
|
47
|
+
stderr: str
|
|
48
|
+
duration_seconds: float
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SubprocessError(Exception):
|
|
52
|
+
"""Base class for failures from :func:`run_subprocess`.
|
|
53
|
+
|
|
54
|
+
A non-zero ``returncode`` does NOT raise this — that's normal output.
|
|
55
|
+
This is raised only when the subprocess couldn't be launched, was
|
|
56
|
+
killed for timing out, or hit some other OS-level failure.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SubprocessTimeoutError(SubprocessError):
|
|
61
|
+
"""Raised when the subprocess exceeded its timeout.
|
|
62
|
+
|
|
63
|
+
The process group is killed (SIGKILL) before this exception is
|
|
64
|
+
raised. Any output captured before the kill is attached as
|
|
65
|
+
``.stdout`` / ``.stderr`` so callers can surface partial reviewer
|
|
66
|
+
output even on timeout.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
stdout: Partial captured stdout, if any.
|
|
70
|
+
stderr: Partial captured stderr, if any.
|
|
71
|
+
timeout: The timeout value (seconds) that fired.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
message: str,
|
|
77
|
+
*,
|
|
78
|
+
stdout: str,
|
|
79
|
+
stderr: str,
|
|
80
|
+
timeout: float,
|
|
81
|
+
) -> None:
|
|
82
|
+
super().__init__(message)
|
|
83
|
+
self.stdout = stdout
|
|
84
|
+
self.stderr = stderr
|
|
85
|
+
self.timeout = timeout
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SubprocessNotFoundError(SubprocessError):
|
|
89
|
+
"""Raised when ``argv[0]`` is not on ``PATH`` (or otherwise can't
|
|
90
|
+
be exec'd as an executable).
|
|
91
|
+
|
|
92
|
+
Attribute ``.binary`` names the missing executable so the CLI
|
|
93
|
+
surface can produce a useful error like "claude is not installed"
|
|
94
|
+
rather than a generic "no such file or directory".
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, binary: str) -> None:
|
|
98
|
+
super().__init__(f"executable not found on PATH: {binary!r}")
|
|
99
|
+
self.binary = binary
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _decode_timeout_output(output: str | bytes | None) -> str:
|
|
103
|
+
match output:
|
|
104
|
+
case str():
|
|
105
|
+
return output
|
|
106
|
+
case bytes():
|
|
107
|
+
return output.decode("utf-8", errors="replace")
|
|
108
|
+
case None:
|
|
109
|
+
return ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _kill_direct_process_if_running(proc: subprocess.Popen[bytes]) -> None:
|
|
113
|
+
if proc.poll() is None:
|
|
114
|
+
try:
|
|
115
|
+
proc.kill()
|
|
116
|
+
except (ProcessLookupError, PermissionError):
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _kill_pgid(pgid: int, proc: subprocess.Popen[bytes]) -> None:
|
|
121
|
+
"""SIGKILL ``pgid`` using a pgid captured at spawn time, then kill the direct child.
|
|
122
|
+
|
|
123
|
+
Using a pre-captured pgid rather than calling ``os.getpgid(proc.pid)`` here is
|
|
124
|
+
load-bearing: ``proc.poll()`` calls ``waitpid(WNOHANG)`` which REAPS the zombie,
|
|
125
|
+
removing its pid from the process table. After that, ``os.getpgid(proc.pid)``
|
|
126
|
+
raises ``ProcessLookupError`` and the descendants survive — exactly the failure
|
|
127
|
+
mode that left pump threads blocked past the timeout.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
131
|
+
except (ProcessLookupError, PermissionError):
|
|
132
|
+
pass
|
|
133
|
+
_kill_direct_process_if_running(proc)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Registry of in-flight child process groups. Reviewers dispatch in a
|
|
137
|
+
# ThreadPoolExecutor, so a signal landing in the MAIN thread while worker threads
|
|
138
|
+
# are blocked in communicate() cannot trigger their own except-BaseException
|
|
139
|
+
# cleanup. terminate_active_child_groups() lets the dispatcher's interrupt path
|
|
140
|
+
# kill those groups so each communicate() returns at once instead of the executor
|
|
141
|
+
# hanging in shutdown(wait=True) until the reviewer timeout.
|
|
142
|
+
# Maps proc → pgid captured immediately at spawn, before any poll() can reap the child.
|
|
143
|
+
_active_procs: dict[subprocess.Popen[bytes], int] = {}
|
|
144
|
+
_active_procs_lock = threading.Lock()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _register_proc(proc: subprocess.Popen[bytes], pgid: int) -> None:
|
|
148
|
+
with _active_procs_lock:
|
|
149
|
+
_active_procs[proc] = pgid
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _unregister_proc(proc: subprocess.Popen[bytes]) -> None:
|
|
153
|
+
with _active_procs_lock:
|
|
154
|
+
_active_procs.pop(proc, None)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def terminate_active_child_groups() -> int:
|
|
158
|
+
"""SIGKILL every in-flight child process group; return the count killed.
|
|
159
|
+
|
|
160
|
+
Safe to call from any thread. The reviewer dispatcher calls this on interrupt
|
|
161
|
+
so a signal during parallel dispatch tears the reviewers down promptly rather
|
|
162
|
+
than orphaning them (their own killpg cleanup only fires when the interrupted
|
|
163
|
+
thread IS the one in communicate() — true for synth/producer, false for the
|
|
164
|
+
threaded reviewer phase)."""
|
|
165
|
+
with _active_procs_lock:
|
|
166
|
+
items = list(_active_procs.items())
|
|
167
|
+
for proc, pgid in items:
|
|
168
|
+
_kill_pgid(pgid, proc)
|
|
169
|
+
return len(items)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _pump(fd: int, sink: IO[bytes]) -> None:
|
|
173
|
+
"""Copy one stream to disk as the child produces it, until EOF.
|
|
174
|
+
|
|
175
|
+
``os.read`` returns whatever has arrived rather than waiting to fill a buffer, and each
|
|
176
|
+
chunk is flushed, so the bytes are on disk before the parent has finished reading them.
|
|
177
|
+
That is the durability half. The EOF half is what ENDS this loop: the read returns empty
|
|
178
|
+
only when every holder of the write end has closed it — the direct child and any
|
|
179
|
+
descendant that inherited fd 1/2 — which is the exact guarantee ``communicate()`` gave.
|
|
180
|
+
"""
|
|
181
|
+
while True:
|
|
182
|
+
try:
|
|
183
|
+
chunk = os.read(fd, 65536)
|
|
184
|
+
except OSError:
|
|
185
|
+
return
|
|
186
|
+
if not chunk:
|
|
187
|
+
return
|
|
188
|
+
try:
|
|
189
|
+
sink.write(chunk)
|
|
190
|
+
sink.flush()
|
|
191
|
+
except OSError:
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _write_stdin(pipe: IO[bytes], payload: bytes | None) -> None:
|
|
196
|
+
"""Feed the child its prompt on a thread, then close.
|
|
197
|
+
|
|
198
|
+
On a thread because the prompt can be ~1 MB (PR-h-field-01) — larger than a pipe buffer —
|
|
199
|
+
so a synchronous write would block until the child drained it, while the child may be
|
|
200
|
+
blocked writing output we have not started reading. That is the classic deadlock
|
|
201
|
+
``communicate()`` uses threads to avoid, and teeing means we have to avoid it ourselves.
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
if payload:
|
|
205
|
+
pipe.write(payload)
|
|
206
|
+
except OSError:
|
|
207
|
+
pass # child exited early; its returncode is the real signal
|
|
208
|
+
finally:
|
|
209
|
+
try:
|
|
210
|
+
pipe.close()
|
|
211
|
+
except OSError:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _join_until(threads: list[threading.Thread], deadline: float | None) -> bool:
|
|
216
|
+
"""Wait for the pump threads to finish (i.e. for EOF). False if the deadline passed."""
|
|
217
|
+
for thread in threads:
|
|
218
|
+
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
219
|
+
thread.join(timeout=remaining)
|
|
220
|
+
if thread.is_alive():
|
|
221
|
+
return False
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _reap(proc: subprocess.Popen[bytes]) -> None:
|
|
226
|
+
"""Collect an already-killed child so it does not linger as a zombie.
|
|
227
|
+
|
|
228
|
+
Bounded, and best-effort twice: a descendant that escaped the killed process group can
|
|
229
|
+
keep the leader unreapable, and blocking a review on that is worse than a stray process.
|
|
230
|
+
"""
|
|
231
|
+
for _ in range(2):
|
|
232
|
+
try:
|
|
233
|
+
proc.wait(timeout=_TIMEOUT_DRAIN_SECONDS)
|
|
234
|
+
return
|
|
235
|
+
except subprocess.TimeoutExpired:
|
|
236
|
+
_kill_direct_process_if_running(proc)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _drain_after_timeout(
|
|
240
|
+
proc: subprocess.Popen[bytes],
|
|
241
|
+
timeout_exc: subprocess.TimeoutExpired,
|
|
242
|
+
) -> tuple[str, str]:
|
|
243
|
+
initial_stdout = _decode_timeout_output(timeout_exc.stdout)
|
|
244
|
+
initial_stderr = _decode_timeout_output(timeout_exc.stderr)
|
|
245
|
+
try:
|
|
246
|
+
stdout, stderr = proc.communicate(timeout=_TIMEOUT_DRAIN_SECONDS)
|
|
247
|
+
except subprocess.TimeoutExpired as drain_exc:
|
|
248
|
+
_kill_direct_process_if_running(proc)
|
|
249
|
+
return (
|
|
250
|
+
_decode_timeout_output(drain_exc.stdout) or initial_stdout,
|
|
251
|
+
_decode_timeout_output(drain_exc.stderr) or initial_stderr,
|
|
252
|
+
)
|
|
253
|
+
return (
|
|
254
|
+
_decode_timeout_output(stdout) or initial_stdout,
|
|
255
|
+
_decode_timeout_output(stderr) or initial_stderr,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _stream_paths(prefix: Path) -> tuple[Path, Path]:
|
|
260
|
+
"""The two files a streamed child writes.
|
|
261
|
+
|
|
262
|
+
Appends ``.stdout``/``.stderr`` to the name rather than using ``with_suffix``, which
|
|
263
|
+
replaces the last suffix. A reviewer named ``team.a`` must produce ``team.a.stdout`` and
|
|
264
|
+
``team.a.stderr``; ``with_suffix`` collapses it to ``team.stdout`` — the same path
|
|
265
|
+
``team.b`` would produce, losing one reviewer's output entirely.
|
|
266
|
+
"""
|
|
267
|
+
name = prefix.name
|
|
268
|
+
return prefix.parent / (name + ".stdout"), prefix.parent / (name + ".stderr")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _open_stream_files(prefix: Path | None) -> tuple[IO[bytes], IO[bytes]] | None:
|
|
272
|
+
"""Open the child's stdout/stderr sinks, or ``None`` to use pipes.
|
|
273
|
+
|
|
274
|
+
Binary mode, so no newline translation can rewrite a lone ``\r`` (PR-h-field-01). The
|
|
275
|
+
pump threads write each chunk here as it arrives and flush, so the bytes are on disk while
|
|
276
|
+
the child is still running — the entire point, since a pipe alone leaves the child's output
|
|
277
|
+
in THIS process's memory where a SIGKILL destroys it.
|
|
278
|
+
|
|
279
|
+
Opened EAGERLY, before the child is spawned, for two reasons the pumps cannot provide: a
|
|
280
|
+
bad path fails loudly here instead of dying silently on a worker thread, and the traversal
|
|
281
|
+
guard below runs before anything is created.
|
|
282
|
+
|
|
283
|
+
A failure to open is raised, not swallowed. Falling back to pipes would leave the caller
|
|
284
|
+
believing its output is protected when it is not — and the only caller passing a prefix has
|
|
285
|
+
a round directory that was written to moments earlier, so a failure here means the artifact
|
|
286
|
+
directory is already broken and the run would fail at persistence anyway, after the spend.
|
|
287
|
+
"""
|
|
288
|
+
if prefix is None:
|
|
289
|
+
return None
|
|
290
|
+
# Reject path traversal. The persistence layer validates reviewer names, but streaming
|
|
291
|
+
# opens the files BEFORE that check runs, so a ".." component would write outside the
|
|
292
|
+
# capture directory before any guard fires.
|
|
293
|
+
if ".." in prefix.parts:
|
|
294
|
+
raise SubprocessError(f"capture_prefix must not contain '..': {prefix}")
|
|
295
|
+
out_path, err_path = _stream_paths(prefix)
|
|
296
|
+
try:
|
|
297
|
+
out = open(out_path, "wb")
|
|
298
|
+
try:
|
|
299
|
+
err = open(err_path, "wb")
|
|
300
|
+
except OSError:
|
|
301
|
+
out.close()
|
|
302
|
+
raise
|
|
303
|
+
except OSError as exc:
|
|
304
|
+
raise SubprocessError(f"cannot open capture file for {prefix}: {exc}") from exc
|
|
305
|
+
return out, err
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _close_stream_files(files: tuple[IO[bytes], IO[bytes]] | None) -> None:
|
|
309
|
+
for handle in files or ():
|
|
310
|
+
try:
|
|
311
|
+
handle.close()
|
|
312
|
+
except OSError:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _read_streamed(prefix: Path) -> tuple[str, str]:
|
|
317
|
+
"""Read back what the child wrote, decoded exactly as the pipe path decodes it.
|
|
318
|
+
|
|
319
|
+
Same ``errors="replace"`` contract as the pipe path (PR-h-field-01): a non-UTF-8 locale
|
|
320
|
+
cannot raise, and a SIGKILL that truncates a multi-byte sequence still yields the partial
|
|
321
|
+
output the timeout contract promises. Bytes on the way in, bytes on the way out — no
|
|
322
|
+
newline translation anywhere, so a lone ``\\r`` inside a binary payload survives to
|
|
323
|
+
``diff_filter`` instead of being rewritten into a forged ``diff --git`` boundary.
|
|
324
|
+
|
|
325
|
+
A missing file decodes to ``""``: the child can die before either sink is touched.
|
|
326
|
+
"""
|
|
327
|
+
texts = []
|
|
328
|
+
for path in _stream_paths(prefix):
|
|
329
|
+
try:
|
|
330
|
+
texts.append(path.read_bytes().decode("utf-8", errors="replace"))
|
|
331
|
+
except OSError:
|
|
332
|
+
texts.append("")
|
|
333
|
+
return texts[0], texts[1]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _git_hardened_env(argv: list[str], env: dict[str, str] | None) -> dict[str, str] | None:
|
|
337
|
+
"""Force ``GIT_NO_REPLACE_OBJECTS=1`` on every ``git`` child.
|
|
338
|
+
|
|
339
|
+
``refs/replace/*`` lives in the shared ``.git`` common dir and is
|
|
340
|
+
PRODUCER-WRITABLE, so a replacement object silently substitutes itself in
|
|
341
|
+
any git command that READS a commit. Reproduced, each against plain git
|
|
342
|
+
first so the fixture was known-sensitive:
|
|
343
|
+
|
|
344
|
+
- ``git merge-base --is-ancestor`` returned 0 for a non-descendant, which
|
|
345
|
+
bypassed the fast-forward-only branch-advance invariant and landed the
|
|
346
|
+
operator's branch on an unrelated commit.
|
|
347
|
+
- ``git log -1 --pretty=%s <sha>`` returned an attacker-chosen subject, so
|
|
348
|
+
an operator-facing commit summary can be a lie.
|
|
349
|
+
- ``git reset --hard <sha>`` restored an attacker's tree, so a producer
|
|
350
|
+
retry resumes from code nobody wrote.
|
|
351
|
+
|
|
352
|
+
(``git rev-parse HEAD`` is NOT affected — it resolves a ref name without
|
|
353
|
+
reading the object. Measured, not assumed.)
|
|
354
|
+
|
|
355
|
+
Pinning ``--no-replace-objects`` per call site failed three dogfood rounds
|
|
356
|
+
running: each round the blind panel found the next unflagged invocation,
|
|
357
|
+
and two were still open when this landed. Setting it HERE makes every
|
|
358
|
+
current and future git invocation safe by default — the per-call flags
|
|
359
|
+
that remain are belt-and-braces, not the load-bearing defense.
|
|
360
|
+
|
|
361
|
+
Non-git children are returned unchanged: the test/check legs share this
|
|
362
|
+
helper and must keep the environment the operator configured.
|
|
363
|
+
"""
|
|
364
|
+
if os.path.basename(argv[0]) != "git":
|
|
365
|
+
return env
|
|
366
|
+
# env=None means "inherit"; materialize it so the var can be added.
|
|
367
|
+
return {**(os.environ if env is None else env), "GIT_NO_REPLACE_OBJECTS": "1"}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def run_subprocess(
|
|
371
|
+
argv: list[str],
|
|
372
|
+
*,
|
|
373
|
+
cwd: Path | None = None,
|
|
374
|
+
env: dict[str, str] | None = None,
|
|
375
|
+
timeout: float | None = None,
|
|
376
|
+
input_text: str | None = None,
|
|
377
|
+
capture_prefix: Path | None = None,
|
|
378
|
+
on_spawn: Callable[[int], None] | None = None,
|
|
379
|
+
) -> SubprocessResult:
|
|
380
|
+
"""Run a subprocess and return a structured :class:`SubprocessResult`.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
argv: The argument vector. Must be a non-empty list. Passed
|
|
384
|
+
directly to :func:`subprocess.Popen` with ``shell=False``.
|
|
385
|
+
cwd: Working directory for the child. ``None`` inherits the
|
|
386
|
+
caller's cwd.
|
|
387
|
+
env: Environment for the child. ``None`` inherits the caller's
|
|
388
|
+
environment. Pass an explicit dict to scope it.
|
|
389
|
+
timeout: Maximum wall-clock seconds to wait. ``None`` waits
|
|
390
|
+
indefinitely. On timeout the process group is SIGKILL'd
|
|
391
|
+
and :class:`SubprocessTimeoutError` is raised with any
|
|
392
|
+
partial output.
|
|
393
|
+
input_text: If supplied, written to the child's stdin and stdin
|
|
394
|
+
is then closed. ``None`` closes stdin without writing.
|
|
395
|
+
capture_prefix: When given, the child's stdout/stderr are TEE'd
|
|
396
|
+
to ``<prefix>.stdout`` / ``<prefix>.stderr`` — still read from
|
|
397
|
+
pipes (that is where the EOF that means "every writer has
|
|
398
|
+
closed" comes from), but written to disk chunk by chunk as
|
|
399
|
+
they arrive rather than held until the child exits. So the
|
|
400
|
+
output survives what happens to this process: SIGKILL, OOM, a
|
|
401
|
+
closed laptop. The guarantee is "everything the pump has
|
|
402
|
+
written", not "every byte the child produced" — a chunk sitting
|
|
403
|
+
in the pipe when the parent dies is still lost. That window is
|
|
404
|
+
one read, not a whole run. ``None`` (the
|
|
405
|
+
default) keeps the pipe path: output lives in this process's
|
|
406
|
+
memory until the child exits, which is right for the ~50
|
|
407
|
+
sub-second ``git`` calls whose output is a SHA nobody needs
|
|
408
|
+
after a crash. ``SubprocessResult`` is identical either way.
|
|
409
|
+
on_spawn: Called with the child's pid the moment it EXISTS, which
|
|
410
|
+
is the only moment that pid is knowable and the only place
|
|
411
|
+
that knows it. Purely diagnostic — this module stays a leaf,
|
|
412
|
+
so a caller that wants the pid durable supplies a callback
|
|
413
|
+
rather than this module reaching for persistence. Exceptions
|
|
414
|
+
from it are swallowed: a diagnostic must never fail a review.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
:class:`SubprocessResult` on normal completion (regardless of
|
|
418
|
+
``returncode``).
|
|
419
|
+
|
|
420
|
+
Raises:
|
|
421
|
+
SubprocessNotFoundError: If ``argv[0]`` is not on ``PATH``.
|
|
422
|
+
SubprocessTimeoutError: If the subprocess exceeded ``timeout``.
|
|
423
|
+
SubprocessError: On any other launch / OS failure, or if
|
|
424
|
+
``argv`` is empty.
|
|
425
|
+
"""
|
|
426
|
+
if not argv:
|
|
427
|
+
raise SubprocessError("argv must be a non-empty list")
|
|
428
|
+
|
|
429
|
+
# Pre-check cwd so a missing/invalid cwd surfaces as
|
|
430
|
+
# SubprocessError, not SubprocessNotFoundError. Without this guard,
|
|
431
|
+
# subprocess.Popen raises FileNotFoundError for BOTH "argv[0] not
|
|
432
|
+
# on PATH" and "cwd doesn't exist", and we can't reliably tell
|
|
433
|
+
# which one happened from the exception alone — falsely classifying
|
|
434
|
+
# a missing cwd as "executable not found" sends callers down the
|
|
435
|
+
# wrong remediation path (install `claude` vs. fix the worktree
|
|
436
|
+
# path).
|
|
437
|
+
if cwd is not None:
|
|
438
|
+
if not cwd.exists():
|
|
439
|
+
raise SubprocessError(f"cwd does not exist: {cwd}")
|
|
440
|
+
if not cwd.is_dir():
|
|
441
|
+
raise SubprocessError(f"cwd exists but is not a directory: {cwd}")
|
|
442
|
+
|
|
443
|
+
start = time.monotonic()
|
|
444
|
+
|
|
445
|
+
stream_files = _open_stream_files(capture_prefix)
|
|
446
|
+
try:
|
|
447
|
+
proc = subprocess.Popen(
|
|
448
|
+
argv,
|
|
449
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
450
|
+
env=_git_hardened_env(argv, env),
|
|
451
|
+
stdin=subprocess.PIPE,
|
|
452
|
+
stdout=subprocess.PIPE,
|
|
453
|
+
stderr=subprocess.PIPE,
|
|
454
|
+
# Capture as bytes rather than text. text=True enables Python's
|
|
455
|
+
# universal newline translation (\r and \r\n → \n), which converts
|
|
456
|
+
# carriage-return bytes inside binary payload hunks before
|
|
457
|
+
# diff_filter sees the text. A binary containing \r followed by
|
|
458
|
+
# "diff --git ..." produces a fake section boundary after that
|
|
459
|
+
# normalization, letting non-NUL bytes escape elision or triggering
|
|
460
|
+
# a false diff_malformed refusal. Manual decode below preserves \r.
|
|
461
|
+
# The two concerns that originally drove text=True are handled at
|
|
462
|
+
# decode time: (1) non-UTF-8 locale → errors="replace"; (2)
|
|
463
|
+
# SIGKILL-on-timeout truncates a multi-byte sequence at a buffer
|
|
464
|
+
# boundary → errors="replace" still never raises.
|
|
465
|
+
# New session → new process group on POSIX, so we can kill
|
|
466
|
+
# the whole tree on timeout instead of orphaning grandchildren.
|
|
467
|
+
start_new_session=True,
|
|
468
|
+
)
|
|
469
|
+
except FileNotFoundError as exc:
|
|
470
|
+
# cwd was pre-validated above, so a FileNotFoundError here is
|
|
471
|
+
# genuinely "argv[0] not on PATH" — safe to classify as
|
|
472
|
+
# SubprocessNotFoundError.
|
|
473
|
+
_close_stream_files(stream_files)
|
|
474
|
+
raise SubprocessNotFoundError(argv[0]) from exc
|
|
475
|
+
except OSError as exc:
|
|
476
|
+
_close_stream_files(stream_files)
|
|
477
|
+
raise SubprocessError(f"failed to launch {argv[0]!r}: {exc}") from exc
|
|
478
|
+
|
|
479
|
+
# Capture pgid NOW, while the child is alive and in the process table. Any later
|
|
480
|
+
# proc.poll() call may reap the zombie (waitpid WNOHANG removes the pid entry), after
|
|
481
|
+
# which os.getpgid(proc.pid) raises ProcessLookupError and descendants survive the kill.
|
|
482
|
+
try:
|
|
483
|
+
pgid = os.getpgid(proc.pid)
|
|
484
|
+
except (ProcessLookupError, PermissionError):
|
|
485
|
+
pgid = proc.pid # best-effort fallback; spawn just succeeded so this is very unlikely
|
|
486
|
+
_register_proc(proc, pgid)
|
|
487
|
+
if on_spawn is not None:
|
|
488
|
+
# Swallowing is deliberate and narrow: this is a post-mortem breadcrumb, and losing it
|
|
489
|
+
# is strictly better than failing a review that is otherwise about to succeed.
|
|
490
|
+
try:
|
|
491
|
+
on_spawn(proc.pid)
|
|
492
|
+
except Exception: # noqa: BLE001 - a diagnostic must never fail a run
|
|
493
|
+
pass
|
|
494
|
+
input_bytes = input_text.encode("utf-8") if input_text is not None else None
|
|
495
|
+
deadline = (start + timeout) if timeout is not None else None
|
|
496
|
+
pumps: list[threading.Thread] = []
|
|
497
|
+
try:
|
|
498
|
+
try:
|
|
499
|
+
if capture_prefix is not None:
|
|
500
|
+
# TEE rather than redirect. An earlier cut pointed Popen straight at the files,
|
|
501
|
+
# which put the bytes on disk but DESTROYED the completion signal: a file has
|
|
502
|
+
# no EOF, so the parent returned when the direct child exited while a
|
|
503
|
+
# descendant holding fd 1/2 was still writing. Three proxies for that signal
|
|
504
|
+
# were tried and rejected in review. Keeping the pipes keeps the signal itself
|
|
505
|
+
# — EOF still means "every writer has closed", descendants included, because
|
|
506
|
+
# they inherit fd 1/2 like any other child. (A separate sentinel fd cannot
|
|
507
|
+
# substitute: Python closes fds above 2 in the grandchild, measured.)
|
|
508
|
+
assert stream_files is not None
|
|
509
|
+
for pipe, sink in ((proc.stdout, stream_files[0]), (proc.stderr, stream_files[1])):
|
|
510
|
+
if pipe is None:
|
|
511
|
+
continue
|
|
512
|
+
pump = threading.Thread(target=_pump, args=(pipe.fileno(), sink), daemon=True)
|
|
513
|
+
pump.start()
|
|
514
|
+
pumps.append(pump)
|
|
515
|
+
if proc.stdin is not None:
|
|
516
|
+
feeder = threading.Thread(
|
|
517
|
+
target=_write_stdin, args=(proc.stdin, input_bytes), daemon=True
|
|
518
|
+
)
|
|
519
|
+
feeder.start()
|
|
520
|
+
pumps.append(feeder)
|
|
521
|
+
if not _join_until(pumps, deadline):
|
|
522
|
+
raise subprocess.TimeoutExpired(argv, timeout or 0.0)
|
|
523
|
+
# EOF is not exit. A child may legitimately close fd 1/2 and keep working —
|
|
524
|
+
# and it is entitled to the REST of its budget to do so, exactly as the pipe
|
|
525
|
+
# path allowed (communicate() waits for EOF *and* exit under one timeout).
|
|
526
|
+
# Waiting a fixed drain here instead killed such a child at 0.27s of a 5s
|
|
527
|
+
# budget and reported a SubprocessTimeoutError over a run that exited 7
|
|
528
|
+
# cleanly — a false timeout that discards the real return code.
|
|
529
|
+
left = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
530
|
+
proc.wait(timeout=left)
|
|
531
|
+
stdout, stderr = _read_streamed(capture_prefix)
|
|
532
|
+
else:
|
|
533
|
+
stdout_b, stderr_b = proc.communicate(input=input_bytes, timeout=timeout)
|
|
534
|
+
stdout = stdout_b.decode("utf-8", errors="replace") if stdout_b else ""
|
|
535
|
+
stderr = stderr_b.decode("utf-8", errors="replace") if stderr_b else ""
|
|
536
|
+
except subprocess.TimeoutExpired as timeout_exc:
|
|
537
|
+
# Kill the whole process group so descendants die too. SIGKILL
|
|
538
|
+
# is non-negotiable here — we already decided the child has
|
|
539
|
+
# gone too long.
|
|
540
|
+
_kill_pgid(pgid, proc)
|
|
541
|
+
if capture_prefix is not None:
|
|
542
|
+
# Everything the child emitted is already on disk; the pumps put it there as
|
|
543
|
+
# it arrived. The SIGKILL above closes the write ends, so they reach EOF and
|
|
544
|
+
# exit on their own — joined briefly so the files are closed before we read.
|
|
545
|
+
#
|
|
546
|
+
# The killed child must still be REAPED: the pipe path gets that for free
|
|
547
|
+
# because its drain calls communicate() again, and skipping it leaves a
|
|
548
|
+
# zombie whose Popen.__del__ raises a ResourceWarning that `pytest -W error`
|
|
549
|
+
# promotes to a failure (which is how this was found).
|
|
550
|
+
_join_until(pumps, time.monotonic() + _TIMEOUT_DRAIN_SECONDS)
|
|
551
|
+
_reap(proc)
|
|
552
|
+
stdout, stderr = _read_streamed(capture_prefix)
|
|
553
|
+
else:
|
|
554
|
+
# Drain partial output. We pass no input here — that pipe is
|
|
555
|
+
# already closed from the prior communicate call. The drain is
|
|
556
|
+
# bounded: a descendant can escape the killed process group with
|
|
557
|
+
# stdout/stderr still open, which would otherwise block forever.
|
|
558
|
+
stdout, stderr = _drain_after_timeout(proc, timeout_exc)
|
|
559
|
+
duration = time.monotonic() - start
|
|
560
|
+
raise SubprocessTimeoutError(
|
|
561
|
+
f"{argv[0]!r} exceeded timeout of {timeout}s (killed after {duration:.2f}s)",
|
|
562
|
+
stdout=stdout or "",
|
|
563
|
+
stderr=stderr or "",
|
|
564
|
+
timeout=timeout if timeout is not None else 0.0,
|
|
565
|
+
) from timeout_exc
|
|
566
|
+
except BaseException:
|
|
567
|
+
# KeyboardInterrupt/SystemExit can arrive while communicate() is
|
|
568
|
+
# waiting. The child is in its own process group, so it will not
|
|
569
|
+
# receive the parent's signal unless we explicitly clean it up.
|
|
570
|
+
_kill_pgid(pgid, proc)
|
|
571
|
+
try:
|
|
572
|
+
proc.communicate(timeout=_TIMEOUT_DRAIN_SECONDS)
|
|
573
|
+
except subprocess.TimeoutExpired:
|
|
574
|
+
_kill_direct_process_if_running(proc)
|
|
575
|
+
raise
|
|
576
|
+
|
|
577
|
+
duration = time.monotonic() - start
|
|
578
|
+
return SubprocessResult(
|
|
579
|
+
returncode=proc.returncode,
|
|
580
|
+
stdout=stdout or "",
|
|
581
|
+
stderr=stderr or "",
|
|
582
|
+
duration_seconds=duration,
|
|
583
|
+
)
|
|
584
|
+
finally:
|
|
585
|
+
_close_stream_files(stream_files)
|
|
586
|
+
_unregister_proc(proc)
|
|
587
|
+
# communicate() leaves the stdout/stderr pipes OPEN when it raises
|
|
588
|
+
# TimeoutExpired. When the bounded drain ALSO times out (a descendant
|
|
589
|
+
# escaped the killed group still holding the pipe), those fds would
|
|
590
|
+
# otherwise leak until GC and surface as ResourceWarning — which
|
|
591
|
+
# `pytest -W error` promotes to an error. Close them here: a single
|
|
592
|
+
# chokepoint covering every exit (success, timeout, KeyboardInterrupt
|
|
593
|
+
# re-raise). close() is idempotent, so the happy path (already closed
|
|
594
|
+
# by communicate) is a no-op.
|
|
595
|
+
for stream in (proc.stdin, proc.stdout, proc.stderr):
|
|
596
|
+
if stream is not None:
|
|
597
|
+
try:
|
|
598
|
+
stream.close()
|
|
599
|
+
except OSError:
|
|
600
|
+
pass
|