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.
Files changed (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. 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