continuous-refactoring 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.
- continuous_refactoring/__init__.py +74 -0
- continuous_refactoring/__main__.py +8 -0
- continuous_refactoring/agent.py +733 -0
- continuous_refactoring/artifacts.py +431 -0
- continuous_refactoring/cli.py +687 -0
- continuous_refactoring/commit_messages.py +68 -0
- continuous_refactoring/config.py +377 -0
- continuous_refactoring/decisions.py +197 -0
- continuous_refactoring/effort.py +159 -0
- continuous_refactoring/failure_report.py +329 -0
- continuous_refactoring/git.py +134 -0
- continuous_refactoring/loop.py +1137 -0
- continuous_refactoring/migration_manifest_codec.py +190 -0
- continuous_refactoring/migration_tick.py +468 -0
- continuous_refactoring/migrations.py +251 -0
- continuous_refactoring/phases.py +690 -0
- continuous_refactoring/planning.py +588 -0
- continuous_refactoring/prompts.py +900 -0
- continuous_refactoring/refactor_attempts.py +424 -0
- continuous_refactoring/review_cli.py +136 -0
- continuous_refactoring/routing.py +133 -0
- continuous_refactoring/routing_pipeline.py +313 -0
- continuous_refactoring/scope_candidates.py +421 -0
- continuous_refactoring/scope_expansion.py +219 -0
- continuous_refactoring/targeting.py +274 -0
- continuous_refactoring-0.1.0.dist-info/METADATA +272 -0
- continuous_refactoring-0.1.0.dist-info/RECORD +30 -0
- continuous_refactoring-0.1.0.dist-info/WHEEL +4 -0
- continuous_refactoring-0.1.0.dist-info/entry_points.txt +2 -0
- continuous_refactoring-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import signal
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from shutil import which
|
|
14
|
+
from typing import TYPE_CHECKING, TextIO
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import termios
|
|
18
|
+
except ImportError: # pragma: no cover - termios is unavailable on Windows.
|
|
19
|
+
termios = None
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"build_command",
|
|
26
|
+
"maybe_run_agent",
|
|
27
|
+
"run_agent_interactive",
|
|
28
|
+
"run_agent_interactive_until_settled",
|
|
29
|
+
"run_observed_command",
|
|
30
|
+
"run_tests",
|
|
31
|
+
"summarize_output",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
from continuous_refactoring.artifacts import (
|
|
35
|
+
CommandCapture,
|
|
36
|
+
ContinuousRefactorError,
|
|
37
|
+
iso_timestamp,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _require_supported_agent(agent: str) -> None:
|
|
42
|
+
if agent not in {"codex", "claude"}:
|
|
43
|
+
raise ContinuousRefactorError(f"Unsupported agent backend: {agent}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_claude_command(
|
|
47
|
+
model: str,
|
|
48
|
+
effort: str,
|
|
49
|
+
prompt: str,
|
|
50
|
+
) -> list[str]:
|
|
51
|
+
return [
|
|
52
|
+
"claude",
|
|
53
|
+
"--print",
|
|
54
|
+
"--model",
|
|
55
|
+
model,
|
|
56
|
+
"--effort",
|
|
57
|
+
effort,
|
|
58
|
+
"--permission-mode",
|
|
59
|
+
"bypassPermissions",
|
|
60
|
+
"--verbose",
|
|
61
|
+
"--output-format",
|
|
62
|
+
"stream-json",
|
|
63
|
+
"--include-partial-messages",
|
|
64
|
+
prompt,
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_claude_final_text(raw: str) -> str:
|
|
69
|
+
"""Pull plain-text output from claude's ``--output-format stream-json`` stream.
|
|
70
|
+
|
|
71
|
+
Claude emits NDJSON events. Prefer the last valid top-level ``result``
|
|
72
|
+
string; otherwise join assistant text blocks; otherwise return ``raw``
|
|
73
|
+
unchanged so upstream errors like "produced no output" stay meaningful.
|
|
74
|
+
"""
|
|
75
|
+
last_result: str | None = None
|
|
76
|
+
assistant_messages: list[str] = []
|
|
77
|
+
for line in raw.splitlines():
|
|
78
|
+
if not line.lstrip().startswith("{"):
|
|
79
|
+
continue
|
|
80
|
+
try:
|
|
81
|
+
event = json.loads(line)
|
|
82
|
+
except ValueError:
|
|
83
|
+
continue
|
|
84
|
+
if not isinstance(event, dict):
|
|
85
|
+
continue
|
|
86
|
+
if event.get("type") == "result":
|
|
87
|
+
if event.get("is_error") is True:
|
|
88
|
+
continue
|
|
89
|
+
result = event.get("result")
|
|
90
|
+
if isinstance(result, str) and result:
|
|
91
|
+
last_result = result
|
|
92
|
+
continue
|
|
93
|
+
if event.get("type") != "assistant":
|
|
94
|
+
continue
|
|
95
|
+
message = event.get("message")
|
|
96
|
+
if not isinstance(message, dict):
|
|
97
|
+
continue
|
|
98
|
+
content = message.get("content")
|
|
99
|
+
if not isinstance(content, list):
|
|
100
|
+
continue
|
|
101
|
+
parts = [
|
|
102
|
+
text
|
|
103
|
+
for block in content
|
|
104
|
+
if isinstance(block, dict)
|
|
105
|
+
and block.get("type") == "text"
|
|
106
|
+
and isinstance(text := block.get("text"), str)
|
|
107
|
+
and text
|
|
108
|
+
]
|
|
109
|
+
if parts:
|
|
110
|
+
assistant_messages.append("".join(parts))
|
|
111
|
+
if last_result is not None:
|
|
112
|
+
return last_result
|
|
113
|
+
if assistant_messages:
|
|
114
|
+
return "\n".join(assistant_messages)
|
|
115
|
+
return raw
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _require_agent_on_path(agent: str) -> None:
|
|
119
|
+
_require_supported_agent(agent)
|
|
120
|
+
if which(agent) is None:
|
|
121
|
+
raise ContinuousRefactorError(f"Required command not found in PATH: {agent}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _raise_process_launch_error(
|
|
125
|
+
command: Sequence[str],
|
|
126
|
+
cwd: Path,
|
|
127
|
+
exc: OSError,
|
|
128
|
+
) -> None:
|
|
129
|
+
command_name = _command_display_name(command)
|
|
130
|
+
raise ContinuousRefactorError(
|
|
131
|
+
f"Failed to start {command_name} in {cwd}: {exc}"
|
|
132
|
+
) from exc
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _build_interactive_command(
|
|
136
|
+
agent: str,
|
|
137
|
+
model: str,
|
|
138
|
+
effort: str,
|
|
139
|
+
prompt: str,
|
|
140
|
+
repo_root: Path,
|
|
141
|
+
) -> list[str]:
|
|
142
|
+
_require_supported_agent(agent)
|
|
143
|
+
if agent == "codex":
|
|
144
|
+
return _build_codex_interactive_command(model, effort, prompt, repo_root)
|
|
145
|
+
return _build_claude_interactive_command(model, effort, prompt)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_codex_command(
|
|
149
|
+
model: str,
|
|
150
|
+
effort: str,
|
|
151
|
+
prompt: str,
|
|
152
|
+
repo_root: Path,
|
|
153
|
+
*,
|
|
154
|
+
last_message_path: Path,
|
|
155
|
+
) -> list[str]:
|
|
156
|
+
return [
|
|
157
|
+
"codex",
|
|
158
|
+
"exec",
|
|
159
|
+
"--model",
|
|
160
|
+
model,
|
|
161
|
+
"--config",
|
|
162
|
+
f"model_reasoning_effort={effort}",
|
|
163
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
164
|
+
"--output-last-message",
|
|
165
|
+
str(last_message_path),
|
|
166
|
+
"--cd",
|
|
167
|
+
str(repo_root),
|
|
168
|
+
prompt,
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_command(
|
|
173
|
+
agent: str,
|
|
174
|
+
model: str,
|
|
175
|
+
effort: str,
|
|
176
|
+
prompt: str,
|
|
177
|
+
repo_root: Path,
|
|
178
|
+
*,
|
|
179
|
+
last_message_path: Path | None = None,
|
|
180
|
+
) -> list[str]:
|
|
181
|
+
_require_supported_agent(agent)
|
|
182
|
+
if agent == "codex":
|
|
183
|
+
if last_message_path is None:
|
|
184
|
+
raise ContinuousRefactorError(
|
|
185
|
+
"Codex runs require a last-message artifact path."
|
|
186
|
+
)
|
|
187
|
+
return _build_codex_command(
|
|
188
|
+
model=model,
|
|
189
|
+
effort=effort,
|
|
190
|
+
prompt=prompt,
|
|
191
|
+
repo_root=repo_root,
|
|
192
|
+
last_message_path=last_message_path,
|
|
193
|
+
)
|
|
194
|
+
return _build_claude_command(model, effort, prompt)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _build_codex_interactive_command(
|
|
198
|
+
model: str,
|
|
199
|
+
effort: str,
|
|
200
|
+
prompt: str,
|
|
201
|
+
repo_root: Path,
|
|
202
|
+
) -> list[str]:
|
|
203
|
+
return [
|
|
204
|
+
"codex",
|
|
205
|
+
"--model",
|
|
206
|
+
model,
|
|
207
|
+
"--config",
|
|
208
|
+
f"model_reasoning_effort={effort}",
|
|
209
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
210
|
+
"--cd",
|
|
211
|
+
str(repo_root),
|
|
212
|
+
prompt,
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _build_claude_interactive_command(
|
|
217
|
+
model: str,
|
|
218
|
+
effort: str,
|
|
219
|
+
prompt: str,
|
|
220
|
+
) -> list[str]:
|
|
221
|
+
return [
|
|
222
|
+
"claude",
|
|
223
|
+
"--model",
|
|
224
|
+
model,
|
|
225
|
+
"--effort",
|
|
226
|
+
effort,
|
|
227
|
+
"--permission-mode",
|
|
228
|
+
"bypassPermissions",
|
|
229
|
+
prompt,
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def run_agent_interactive(
|
|
234
|
+
agent: str,
|
|
235
|
+
model: str,
|
|
236
|
+
effort: str,
|
|
237
|
+
prompt: str,
|
|
238
|
+
repo_root: Path,
|
|
239
|
+
) -> int:
|
|
240
|
+
"""Exec the agent attached to the user's terminal. Returns exit code."""
|
|
241
|
+
_require_agent_on_path(agent)
|
|
242
|
+
command = _build_interactive_command(agent, model, effort, prompt, repo_root)
|
|
243
|
+
terminal_fd = _terminal_control_fd()
|
|
244
|
+
terminal_state = _capture_terminal_state(terminal_fd)
|
|
245
|
+
try:
|
|
246
|
+
try:
|
|
247
|
+
return subprocess.call(command, cwd=repo_root)
|
|
248
|
+
except OSError as exc:
|
|
249
|
+
_raise_process_launch_error(command, repo_root, exc)
|
|
250
|
+
finally:
|
|
251
|
+
_restore_terminal_state(terminal_fd, terminal_state)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _read_sha256(path: Path) -> str | None:
|
|
255
|
+
try:
|
|
256
|
+
data = path.read_bytes()
|
|
257
|
+
except OSError:
|
|
258
|
+
return None
|
|
259
|
+
return hashlib.sha256(data).hexdigest()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _read_settle_digest(path: Path) -> str | None:
|
|
263
|
+
try:
|
|
264
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
265
|
+
except OSError:
|
|
266
|
+
return None
|
|
267
|
+
prefix = "sha256:"
|
|
268
|
+
if not text.startswith(prefix):
|
|
269
|
+
return None
|
|
270
|
+
digest = text[len(prefix):].strip().lower()
|
|
271
|
+
if len(digest) != 64:
|
|
272
|
+
return None
|
|
273
|
+
if any(char not in "0123456789abcdef" for char in digest):
|
|
274
|
+
return None
|
|
275
|
+
return digest
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _interactive_settle_fingerprint(
|
|
279
|
+
content_path: Path,
|
|
280
|
+
settle_path: Path,
|
|
281
|
+
) -> tuple[str, int, int, int, int] | None:
|
|
282
|
+
expected_digest = _read_settle_digest(settle_path)
|
|
283
|
+
if expected_digest is None:
|
|
284
|
+
return None
|
|
285
|
+
actual_digest = _read_sha256(content_path)
|
|
286
|
+
if actual_digest != expected_digest:
|
|
287
|
+
return None
|
|
288
|
+
try:
|
|
289
|
+
content_stat = content_path.stat()
|
|
290
|
+
settle_stat = settle_path.stat()
|
|
291
|
+
except OSError:
|
|
292
|
+
return None
|
|
293
|
+
return (
|
|
294
|
+
actual_digest,
|
|
295
|
+
content_stat.st_size,
|
|
296
|
+
content_stat.st_mtime_ns,
|
|
297
|
+
settle_stat.st_size,
|
|
298
|
+
settle_stat.st_mtime_ns,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _send_signal_and_wait_for_exit(
|
|
303
|
+
process: subprocess.Popen[object],
|
|
304
|
+
signal_to_send: int,
|
|
305
|
+
*,
|
|
306
|
+
timeout: float,
|
|
307
|
+
) -> bool:
|
|
308
|
+
if process.poll() is not None:
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
process.send_signal(signal_to_send)
|
|
313
|
+
except (OSError, ValueError):
|
|
314
|
+
return process.poll() is not None
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
process.wait(timeout=timeout)
|
|
318
|
+
except subprocess.TimeoutExpired:
|
|
319
|
+
return process.poll() is not None
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _terminal_control_fd() -> int | None:
|
|
324
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
325
|
+
try:
|
|
326
|
+
if stream.isatty():
|
|
327
|
+
return stream.fileno()
|
|
328
|
+
except (AttributeError, OSError, ValueError):
|
|
329
|
+
continue
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _capture_terminal_state(fd: int | None) -> object | None:
|
|
334
|
+
if fd is None or termios is None:
|
|
335
|
+
return None
|
|
336
|
+
try:
|
|
337
|
+
return termios.tcgetattr(fd)
|
|
338
|
+
except (termios.error, OSError, ValueError):
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _restore_terminal_state(fd: int | None, state: object | None) -> None:
|
|
343
|
+
if fd is None or state is None or termios is None:
|
|
344
|
+
return
|
|
345
|
+
try:
|
|
346
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, state)
|
|
347
|
+
except (termios.error, OSError, ValueError):
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
_FORCED_CODEX_TERMINAL_RESET = (
|
|
352
|
+
b"\x1b[<u" # Pop keyboard enhancement flags.
|
|
353
|
+
b"\x1b[?2004l" # Disable bracketed paste.
|
|
354
|
+
b"\x1b[?1004l" # Disable focus reporting.
|
|
355
|
+
b"\x1b[>4m" # Disable modifyOtherKeys.
|
|
356
|
+
b"\x1b[?25h" # Show cursor.
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _restore_codex_terminal_modes_after_forced_stop() -> None:
|
|
361
|
+
for stream in (sys.stdout, sys.stderr):
|
|
362
|
+
try:
|
|
363
|
+
if not stream.isatty():
|
|
364
|
+
continue
|
|
365
|
+
buffer = getattr(stream, "buffer", None)
|
|
366
|
+
if buffer is not None:
|
|
367
|
+
buffer.write(_FORCED_CODEX_TERMINAL_RESET)
|
|
368
|
+
buffer.flush()
|
|
369
|
+
return
|
|
370
|
+
stream.write(_FORCED_CODEX_TERMINAL_RESET.decode("ascii"))
|
|
371
|
+
stream.flush()
|
|
372
|
+
return
|
|
373
|
+
except (AttributeError, OSError, ValueError):
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
with open("/dev/tty", "wb", buffering=0) as tty:
|
|
378
|
+
tty.write(_FORCED_CODEX_TERMINAL_RESET)
|
|
379
|
+
except OSError:
|
|
380
|
+
pass
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _flush_terminal_input(fd: int | None) -> None:
|
|
384
|
+
if fd is None or termios is None:
|
|
385
|
+
return
|
|
386
|
+
try:
|
|
387
|
+
termios.tcflush(fd, termios.TCIFLUSH)
|
|
388
|
+
except (termios.error, OSError, ValueError):
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _gracefully_stop_interactive_process(
|
|
393
|
+
process: subprocess.Popen[object],
|
|
394
|
+
*,
|
|
395
|
+
interrupt_timeout: float = 1.0,
|
|
396
|
+
terminate_timeout: float = 2.0,
|
|
397
|
+
) -> None:
|
|
398
|
+
if _send_signal_and_wait_for_exit(
|
|
399
|
+
process,
|
|
400
|
+
signal.SIGINT,
|
|
401
|
+
timeout=interrupt_timeout,
|
|
402
|
+
):
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
if _send_signal_and_wait_for_exit(
|
|
406
|
+
process,
|
|
407
|
+
signal.SIGTERM,
|
|
408
|
+
timeout=terminate_timeout,
|
|
409
|
+
):
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
process.kill()
|
|
414
|
+
except OSError:
|
|
415
|
+
pass
|
|
416
|
+
try:
|
|
417
|
+
process.wait()
|
|
418
|
+
except OSError:
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def run_agent_interactive_until_settled(
|
|
423
|
+
agent: str,
|
|
424
|
+
model: str,
|
|
425
|
+
effort: str,
|
|
426
|
+
prompt: str,
|
|
427
|
+
repo_root: Path,
|
|
428
|
+
*,
|
|
429
|
+
content_path: Path,
|
|
430
|
+
settle_path: Path,
|
|
431
|
+
settle_window_seconds: float = 2.0,
|
|
432
|
+
poll_interval_seconds: float = 0.1,
|
|
433
|
+
) -> int:
|
|
434
|
+
_require_agent_on_path(agent)
|
|
435
|
+
|
|
436
|
+
settle_path.parent.mkdir(parents=True, exist_ok=True)
|
|
437
|
+
if settle_path.exists():
|
|
438
|
+
if settle_path.is_dir():
|
|
439
|
+
raise ContinuousRefactorError(f"Settle path is a directory: {settle_path}")
|
|
440
|
+
settle_path.unlink()
|
|
441
|
+
|
|
442
|
+
command = _build_interactive_command(agent, model, effort, prompt, repo_root)
|
|
443
|
+
terminal_fd = _terminal_control_fd()
|
|
444
|
+
terminal_state = _capture_terminal_state(terminal_fd)
|
|
445
|
+
try:
|
|
446
|
+
process = subprocess.Popen(command, cwd=repo_root)
|
|
447
|
+
except OSError as exc:
|
|
448
|
+
_raise_process_launch_error(command, repo_root, exc)
|
|
449
|
+
settled_since: float | None = None
|
|
450
|
+
last_fingerprint: tuple[str, int, int, int, int] | None = None
|
|
451
|
+
forced_codex_stop = False
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
while True:
|
|
455
|
+
fingerprint = _interactive_settle_fingerprint(content_path, settle_path)
|
|
456
|
+
if fingerprint is None:
|
|
457
|
+
settled_since = None
|
|
458
|
+
last_fingerprint = None
|
|
459
|
+
elif fingerprint != last_fingerprint:
|
|
460
|
+
last_fingerprint = fingerprint
|
|
461
|
+
settled_since = time.monotonic()
|
|
462
|
+
elif settled_since is not None:
|
|
463
|
+
elapsed = time.monotonic() - settled_since
|
|
464
|
+
if elapsed >= settle_window_seconds:
|
|
465
|
+
returncode = process.poll()
|
|
466
|
+
if returncode is not None:
|
|
467
|
+
return returncode
|
|
468
|
+
forced_codex_stop = agent == "codex"
|
|
469
|
+
_gracefully_stop_interactive_process(process)
|
|
470
|
+
return 0
|
|
471
|
+
|
|
472
|
+
returncode = process.poll()
|
|
473
|
+
if returncode is not None:
|
|
474
|
+
if fingerprint is not None:
|
|
475
|
+
return returncode
|
|
476
|
+
raise ContinuousRefactorError(
|
|
477
|
+
"interactive agent exited before the settled write was confirmed"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
time.sleep(poll_interval_seconds)
|
|
481
|
+
finally:
|
|
482
|
+
if forced_codex_stop:
|
|
483
|
+
_restore_codex_terminal_modes_after_forced_stop()
|
|
484
|
+
_restore_terminal_state(terminal_fd, terminal_state)
|
|
485
|
+
if forced_codex_stop:
|
|
486
|
+
_flush_terminal_input(terminal_fd)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def maybe_run_agent(
|
|
490
|
+
agent: str,
|
|
491
|
+
model: str,
|
|
492
|
+
effort: str,
|
|
493
|
+
prompt: str,
|
|
494
|
+
repo_root: Path,
|
|
495
|
+
*,
|
|
496
|
+
stdout_path: Path,
|
|
497
|
+
stderr_path: Path,
|
|
498
|
+
last_message_path: Path | None = None,
|
|
499
|
+
mirror_to_terminal: bool = True,
|
|
500
|
+
timeout: int | None = None,
|
|
501
|
+
) -> CommandCapture:
|
|
502
|
+
_require_agent_on_path(agent)
|
|
503
|
+
command = build_command(
|
|
504
|
+
agent=agent,
|
|
505
|
+
model=model,
|
|
506
|
+
effort=effort,
|
|
507
|
+
prompt=prompt,
|
|
508
|
+
repo_root=repo_root,
|
|
509
|
+
last_message_path=last_message_path,
|
|
510
|
+
)
|
|
511
|
+
capture = run_observed_command(
|
|
512
|
+
command,
|
|
513
|
+
cwd=repo_root,
|
|
514
|
+
stdout_path=stdout_path,
|
|
515
|
+
stderr_path=stderr_path,
|
|
516
|
+
mirror_to_terminal=mirror_to_terminal,
|
|
517
|
+
timeout=timeout,
|
|
518
|
+
)
|
|
519
|
+
if agent == "claude":
|
|
520
|
+
return replace(capture, stdout=_extract_claude_final_text(capture.stdout))
|
|
521
|
+
return capture
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _write_timestamped_line(handle: TextIO, line: str) -> None:
|
|
525
|
+
suffix = "" if line.endswith("\n") else "\n"
|
|
526
|
+
handle.write(f"[{iso_timestamp()}] {line}{suffix}")
|
|
527
|
+
handle.flush()
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _stream_pipe(
|
|
531
|
+
pipe: TextIO,
|
|
532
|
+
sink: TextIO,
|
|
533
|
+
mirror: TextIO | None,
|
|
534
|
+
chunks: list[str],
|
|
535
|
+
) -> None:
|
|
536
|
+
for line in pipe:
|
|
537
|
+
chunks.append(line)
|
|
538
|
+
_write_timestamped_line(sink, line)
|
|
539
|
+
if mirror is not None:
|
|
540
|
+
mirror.write(line)
|
|
541
|
+
mirror.flush()
|
|
542
|
+
pipe.close()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _terminate_process(process: subprocess.Popen[str]) -> None:
|
|
546
|
+
"""SIGTERM then SIGKILL if the process doesn't exit within 5 seconds."""
|
|
547
|
+
try:
|
|
548
|
+
process.terminate()
|
|
549
|
+
try:
|
|
550
|
+
process.wait(timeout=5)
|
|
551
|
+
except subprocess.TimeoutExpired:
|
|
552
|
+
process.kill()
|
|
553
|
+
except OSError:
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _command_display_name(command: Sequence[str]) -> str:
|
|
558
|
+
return Path(command[0]).name or str(command[0])
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
@dataclass(frozen=True)
|
|
562
|
+
class _ObservedCommandOutcome:
|
|
563
|
+
returncode: int
|
|
564
|
+
timed_out: bool
|
|
565
|
+
was_stuck: bool
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _wait_for_observed_command(
|
|
569
|
+
process: subprocess.Popen[str],
|
|
570
|
+
*,
|
|
571
|
+
timeout: int | None,
|
|
572
|
+
stdout_thread: threading.Thread,
|
|
573
|
+
stderr_thread: threading.Thread,
|
|
574
|
+
stop_watchdog: threading.Event,
|
|
575
|
+
watchdog_thread: threading.Thread,
|
|
576
|
+
stuck_detected: threading.Event,
|
|
577
|
+
) -> _ObservedCommandOutcome:
|
|
578
|
+
timed_out = False
|
|
579
|
+
try:
|
|
580
|
+
returncode = process.wait(timeout=timeout)
|
|
581
|
+
except subprocess.TimeoutExpired:
|
|
582
|
+
timed_out = True
|
|
583
|
+
_terminate_process(process)
|
|
584
|
+
returncode = process.wait()
|
|
585
|
+
|
|
586
|
+
stdout_thread.join()
|
|
587
|
+
stderr_thread.join()
|
|
588
|
+
stop_watchdog.set()
|
|
589
|
+
watchdog_thread.join(timeout=10)
|
|
590
|
+
|
|
591
|
+
return _ObservedCommandOutcome(
|
|
592
|
+
returncode=returncode,
|
|
593
|
+
timed_out=timed_out,
|
|
594
|
+
was_stuck=stuck_detected.is_set(),
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def run_observed_command(
|
|
599
|
+
command: Sequence[str],
|
|
600
|
+
cwd: Path,
|
|
601
|
+
*,
|
|
602
|
+
stdout_path: Path,
|
|
603
|
+
stderr_path: Path,
|
|
604
|
+
mirror_to_terminal: bool,
|
|
605
|
+
timeout: int | None = None,
|
|
606
|
+
stuck_interval: int = 30,
|
|
607
|
+
stuck_timeout: int = 300,
|
|
608
|
+
) -> CommandCapture:
|
|
609
|
+
stdout_path.parent.mkdir(parents=True, exist_ok=True)
|
|
610
|
+
stderr_path.parent.mkdir(parents=True, exist_ok=True)
|
|
611
|
+
try:
|
|
612
|
+
process = subprocess.Popen(
|
|
613
|
+
command,
|
|
614
|
+
cwd=cwd,
|
|
615
|
+
text=True,
|
|
616
|
+
shell=False,
|
|
617
|
+
stdout=subprocess.PIPE,
|
|
618
|
+
stderr=subprocess.PIPE,
|
|
619
|
+
bufsize=1,
|
|
620
|
+
)
|
|
621
|
+
except OSError as exc:
|
|
622
|
+
_raise_process_launch_error(command, cwd, exc)
|
|
623
|
+
if process.stdout is None or process.stderr is None:
|
|
624
|
+
command_name = _command_display_name(command)
|
|
625
|
+
raise ContinuousRefactorError(
|
|
626
|
+
f"Failed to capture process output for {command_name}"
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
stdout_chunks: list[str] = []
|
|
630
|
+
stderr_chunks: list[str] = []
|
|
631
|
+
stop_watchdog = threading.Event()
|
|
632
|
+
stuck_detected = threading.Event()
|
|
633
|
+
|
|
634
|
+
def watchdog() -> None:
|
|
635
|
+
last_count = 0
|
|
636
|
+
stale_since: float | None = None
|
|
637
|
+
while not stop_watchdog.wait(timeout=stuck_interval):
|
|
638
|
+
if process.poll() is not None:
|
|
639
|
+
return
|
|
640
|
+
current_count = len(stdout_chunks) + len(stderr_chunks)
|
|
641
|
+
if current_count != last_count:
|
|
642
|
+
last_count = current_count
|
|
643
|
+
stale_since = None
|
|
644
|
+
else:
|
|
645
|
+
if stale_since is None:
|
|
646
|
+
stale_since = time.monotonic()
|
|
647
|
+
elif time.monotonic() - stale_since >= stuck_timeout:
|
|
648
|
+
_terminate_process(process)
|
|
649
|
+
stuck_detected.set()
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
with (
|
|
653
|
+
stdout_path.open("w", encoding="utf-8") as stdout_handle,
|
|
654
|
+
stderr_path.open("w", encoding="utf-8") as stderr_handle,
|
|
655
|
+
):
|
|
656
|
+
stdout_thread = threading.Thread(
|
|
657
|
+
target=_stream_pipe,
|
|
658
|
+
args=(
|
|
659
|
+
process.stdout,
|
|
660
|
+
stdout_handle,
|
|
661
|
+
sys.stdout if mirror_to_terminal else None,
|
|
662
|
+
stdout_chunks,
|
|
663
|
+
),
|
|
664
|
+
)
|
|
665
|
+
stderr_thread = threading.Thread(
|
|
666
|
+
target=_stream_pipe,
|
|
667
|
+
args=(
|
|
668
|
+
process.stderr,
|
|
669
|
+
stderr_handle,
|
|
670
|
+
sys.stderr if mirror_to_terminal else None,
|
|
671
|
+
stderr_chunks,
|
|
672
|
+
),
|
|
673
|
+
)
|
|
674
|
+
stdout_thread.start()
|
|
675
|
+
stderr_thread.start()
|
|
676
|
+
|
|
677
|
+
watchdog_thread = threading.Thread(target=watchdog, daemon=True)
|
|
678
|
+
watchdog_thread.start()
|
|
679
|
+
|
|
680
|
+
outcome = _wait_for_observed_command(
|
|
681
|
+
process,
|
|
682
|
+
timeout=timeout,
|
|
683
|
+
stdout_thread=stdout_thread,
|
|
684
|
+
stderr_thread=stderr_thread,
|
|
685
|
+
stop_watchdog=stop_watchdog,
|
|
686
|
+
watchdog_thread=watchdog_thread,
|
|
687
|
+
stuck_detected=stuck_detected,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
if not stdout_chunks:
|
|
691
|
+
_write_timestamped_line(stdout_handle, "<no output>")
|
|
692
|
+
if not stderr_chunks:
|
|
693
|
+
_write_timestamped_line(stderr_handle, "<no output>")
|
|
694
|
+
|
|
695
|
+
if outcome.timed_out:
|
|
696
|
+
command_name = _command_display_name(command)
|
|
697
|
+
raise ContinuousRefactorError(f"{command_name} timed out after {timeout}s")
|
|
698
|
+
if outcome.was_stuck:
|
|
699
|
+
command_name = _command_display_name(command)
|
|
700
|
+
raise ContinuousRefactorError(
|
|
701
|
+
f"{command_name} produced no output for {stuck_timeout}s"
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
return CommandCapture(
|
|
705
|
+
command=tuple(command),
|
|
706
|
+
returncode=outcome.returncode,
|
|
707
|
+
stdout="".join(stdout_chunks),
|
|
708
|
+
stderr="".join(stderr_chunks),
|
|
709
|
+
stdout_path=stdout_path,
|
|
710
|
+
stderr_path=stderr_path,
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def run_tests(
|
|
715
|
+
test_command: str,
|
|
716
|
+
repo_root: Path,
|
|
717
|
+
stdout_path: Path,
|
|
718
|
+
stderr_path: Path,
|
|
719
|
+
*,
|
|
720
|
+
mirror_to_terminal: bool = False,
|
|
721
|
+
) -> CommandCapture:
|
|
722
|
+
return run_observed_command(
|
|
723
|
+
shlex.split(test_command),
|
|
724
|
+
cwd=repo_root,
|
|
725
|
+
stdout_path=stdout_path,
|
|
726
|
+
stderr_path=stderr_path,
|
|
727
|
+
mirror_to_terminal=mirror_to_terminal,
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def summarize_output(result: CommandCapture) -> str:
|
|
732
|
+
lines = (result.stdout + result.stderr).splitlines()
|
|
733
|
+
return "\n".join(lines[-40:])
|