codex-autorunner 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.
- codex_autorunner/__init__.py +3 -0
- codex_autorunner/bootstrap.py +151 -0
- codex_autorunner/cli.py +886 -0
- codex_autorunner/codex_cli.py +79 -0
- codex_autorunner/codex_runner.py +17 -0
- codex_autorunner/core/__init__.py +1 -0
- codex_autorunner/core/about_car.py +125 -0
- codex_autorunner/core/codex_runner.py +100 -0
- codex_autorunner/core/config.py +1465 -0
- codex_autorunner/core/doc_chat.py +547 -0
- codex_autorunner/core/docs.py +37 -0
- codex_autorunner/core/engine.py +720 -0
- codex_autorunner/core/git_utils.py +206 -0
- codex_autorunner/core/hub.py +756 -0
- codex_autorunner/core/injected_context.py +9 -0
- codex_autorunner/core/locks.py +57 -0
- codex_autorunner/core/logging_utils.py +158 -0
- codex_autorunner/core/notifications.py +465 -0
- codex_autorunner/core/optional_dependencies.py +41 -0
- codex_autorunner/core/prompt.py +107 -0
- codex_autorunner/core/prompts.py +275 -0
- codex_autorunner/core/request_context.py +21 -0
- codex_autorunner/core/runner_controller.py +116 -0
- codex_autorunner/core/runner_process.py +29 -0
- codex_autorunner/core/snapshot.py +576 -0
- codex_autorunner/core/state.py +156 -0
- codex_autorunner/core/update.py +567 -0
- codex_autorunner/core/update_runner.py +44 -0
- codex_autorunner/core/usage.py +1221 -0
- codex_autorunner/core/utils.py +108 -0
- codex_autorunner/discovery.py +102 -0
- codex_autorunner/housekeeping.py +423 -0
- codex_autorunner/integrations/__init__.py +1 -0
- codex_autorunner/integrations/app_server/__init__.py +6 -0
- codex_autorunner/integrations/app_server/client.py +1386 -0
- codex_autorunner/integrations/app_server/supervisor.py +206 -0
- codex_autorunner/integrations/github/__init__.py +10 -0
- codex_autorunner/integrations/github/service.py +889 -0
- codex_autorunner/integrations/telegram/__init__.py +1 -0
- codex_autorunner/integrations/telegram/adapter.py +1401 -0
- codex_autorunner/integrations/telegram/commands_registry.py +104 -0
- codex_autorunner/integrations/telegram/config.py +450 -0
- codex_autorunner/integrations/telegram/constants.py +154 -0
- codex_autorunner/integrations/telegram/dispatch.py +162 -0
- codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
- codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
- codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
- codex_autorunner/integrations/telegram/helpers.py +2084 -0
- codex_autorunner/integrations/telegram/notifications.py +164 -0
- codex_autorunner/integrations/telegram/outbox.py +174 -0
- codex_autorunner/integrations/telegram/rendering.py +102 -0
- codex_autorunner/integrations/telegram/retry.py +37 -0
- codex_autorunner/integrations/telegram/runtime.py +270 -0
- codex_autorunner/integrations/telegram/service.py +921 -0
- codex_autorunner/integrations/telegram/state.py +1223 -0
- codex_autorunner/integrations/telegram/transport.py +318 -0
- codex_autorunner/integrations/telegram/types.py +57 -0
- codex_autorunner/integrations/telegram/voice.py +413 -0
- codex_autorunner/manifest.py +150 -0
- codex_autorunner/routes/__init__.py +53 -0
- codex_autorunner/routes/base.py +470 -0
- codex_autorunner/routes/docs.py +275 -0
- codex_autorunner/routes/github.py +197 -0
- codex_autorunner/routes/repos.py +121 -0
- codex_autorunner/routes/sessions.py +137 -0
- codex_autorunner/routes/shared.py +137 -0
- codex_autorunner/routes/system.py +175 -0
- codex_autorunner/routes/terminal_images.py +107 -0
- codex_autorunner/routes/voice.py +128 -0
- codex_autorunner/server.py +23 -0
- codex_autorunner/spec_ingest.py +113 -0
- codex_autorunner/static/app.js +95 -0
- codex_autorunner/static/autoRefresh.js +209 -0
- codex_autorunner/static/bootstrap.js +105 -0
- codex_autorunner/static/bus.js +23 -0
- codex_autorunner/static/cache.js +52 -0
- codex_autorunner/static/constants.js +48 -0
- codex_autorunner/static/dashboard.js +795 -0
- codex_autorunner/static/docs.js +1514 -0
- codex_autorunner/static/env.js +99 -0
- codex_autorunner/static/github.js +168 -0
- codex_autorunner/static/hub.js +1511 -0
- codex_autorunner/static/index.html +622 -0
- codex_autorunner/static/loader.js +28 -0
- codex_autorunner/static/logs.js +690 -0
- codex_autorunner/static/mobileCompact.js +300 -0
- codex_autorunner/static/snapshot.js +116 -0
- codex_autorunner/static/state.js +87 -0
- codex_autorunner/static/styles.css +4966 -0
- codex_autorunner/static/tabs.js +50 -0
- codex_autorunner/static/terminal.js +21 -0
- codex_autorunner/static/terminalManager.js +3535 -0
- codex_autorunner/static/todoPreview.js +25 -0
- codex_autorunner/static/types.d.ts +8 -0
- codex_autorunner/static/utils.js +597 -0
- codex_autorunner/static/vendor/LICENSE.xterm +24 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
- codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
- codex_autorunner/static/vendor/xterm.css +209 -0
- codex_autorunner/static/vendor/xterm.js +2 -0
- codex_autorunner/static/voice.js +591 -0
- codex_autorunner/voice/__init__.py +39 -0
- codex_autorunner/voice/capture.py +349 -0
- codex_autorunner/voice/config.py +167 -0
- codex_autorunner/voice/provider.py +66 -0
- codex_autorunner/voice/providers/__init__.py +7 -0
- codex_autorunner/voice/providers/openai_whisper.py +345 -0
- codex_autorunner/voice/resolver.py +36 -0
- codex_autorunner/voice/service.py +210 -0
- codex_autorunner/web/__init__.py +1 -0
- codex_autorunner/web/app.py +1037 -0
- codex_autorunner/web/hub_jobs.py +181 -0
- codex_autorunner/web/middleware.py +552 -0
- codex_autorunner/web/pty_session.py +357 -0
- codex_autorunner/web/runner_manager.py +25 -0
- codex_autorunner/web/schemas.py +253 -0
- codex_autorunner/web/static_assets.py +430 -0
- codex_autorunner/web/terminal_sessions.py +78 -0
- codex_autorunner/workspace.py +16 -0
- codex_autorunner-0.1.0.dist-info/METADATA +240 -0
- codex_autorunner-0.1.0.dist-info/RECORD +147 -0
- codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
- codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
- codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import traceback
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from logging.handlers import RotatingFileHandler
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import IO, Iterator, Optional
|
|
14
|
+
|
|
15
|
+
from .about_car import ensure_about_car_file
|
|
16
|
+
from .codex_runner import run_codex_streaming
|
|
17
|
+
from .config import ConfigError, RepoConfig, load_config
|
|
18
|
+
from .docs import DocsManager
|
|
19
|
+
from .locks import process_alive, read_lock_info, write_lock_info
|
|
20
|
+
from .notifications import NotificationManager
|
|
21
|
+
from .prompt import build_final_summary_prompt, build_prompt
|
|
22
|
+
from .state import RunnerState, load_state, now_iso, save_state, state_lock
|
|
23
|
+
from .utils import (
|
|
24
|
+
atomic_write,
|
|
25
|
+
ensure_executable,
|
|
26
|
+
find_repo_root,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LockError(Exception):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def timestamp() -> str:
|
|
35
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
SUMMARY_FINALIZED_MARKER = "CAR:SUMMARY_FINALIZED"
|
|
39
|
+
SUMMARY_FINALIZED_MARKER_PREFIX = f"<!-- {SUMMARY_FINALIZED_MARKER}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Engine:
|
|
43
|
+
def __init__(self, repo_root: Path):
|
|
44
|
+
config = load_config(repo_root)
|
|
45
|
+
if not isinstance(config, RepoConfig):
|
|
46
|
+
raise ConfigError("Engine requires repo mode configuration")
|
|
47
|
+
self.config: RepoConfig = config
|
|
48
|
+
self.repo_root = self.config.root
|
|
49
|
+
self.docs = DocsManager(self.config)
|
|
50
|
+
self.notifier = NotificationManager(self.config)
|
|
51
|
+
self.state_path = self.repo_root / ".codex-autorunner" / "state.json"
|
|
52
|
+
self.log_path = self.config.log.path
|
|
53
|
+
self.run_index_path = self.repo_root / ".codex-autorunner" / "run_index.json"
|
|
54
|
+
self.lock_path = self.repo_root / ".codex-autorunner" / "lock"
|
|
55
|
+
self.stop_path = self.repo_root / ".codex-autorunner" / "stop"
|
|
56
|
+
self._active_global_handler: Optional[RotatingFileHandler] = None
|
|
57
|
+
self._active_run_log: Optional[IO[str]] = None
|
|
58
|
+
# Ensure the interactive TUI briefing doc exists (for web Terminal "New").
|
|
59
|
+
try:
|
|
60
|
+
ensure_about_car_file(self.config)
|
|
61
|
+
except Exception:
|
|
62
|
+
# Never fail Engine creation due to a best-effort helper doc.
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def from_cwd(repo: Optional[Path] = None) -> "Engine":
|
|
67
|
+
root = find_repo_root(repo or Path.cwd())
|
|
68
|
+
return Engine(root)
|
|
69
|
+
|
|
70
|
+
def acquire_lock(self, force: bool = False) -> None:
|
|
71
|
+
if self.lock_path.exists():
|
|
72
|
+
info = read_lock_info(self.lock_path)
|
|
73
|
+
pid = info.pid
|
|
74
|
+
if pid and process_alive(pid):
|
|
75
|
+
if not force:
|
|
76
|
+
raise LockError(
|
|
77
|
+
f"Another autorunner is active (pid={pid}); use --force to override"
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
self.lock_path.unlink(missing_ok=True)
|
|
81
|
+
write_lock_info(self.lock_path, os.getpid(), started_at=now_iso())
|
|
82
|
+
|
|
83
|
+
def release_lock(self) -> None:
|
|
84
|
+
if self.lock_path.exists():
|
|
85
|
+
self.lock_path.unlink()
|
|
86
|
+
|
|
87
|
+
def request_stop(self) -> None:
|
|
88
|
+
self.stop_path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
atomic_write(self.stop_path, f"{now_iso()}\n")
|
|
90
|
+
|
|
91
|
+
def clear_stop_request(self) -> None:
|
|
92
|
+
self.stop_path.unlink(missing_ok=True)
|
|
93
|
+
|
|
94
|
+
def stop_requested(self) -> bool:
|
|
95
|
+
return self.stop_path.exists()
|
|
96
|
+
|
|
97
|
+
def _should_stop(self, external_stop_flag: Optional[threading.Event]) -> bool:
|
|
98
|
+
if external_stop_flag and external_stop_flag.is_set():
|
|
99
|
+
return True
|
|
100
|
+
return self.stop_requested()
|
|
101
|
+
|
|
102
|
+
def kill_running_process(self) -> Optional[int]:
|
|
103
|
+
"""Force-kill the process holding the lock, if any. Returns pid if killed."""
|
|
104
|
+
if not self.lock_path.exists():
|
|
105
|
+
return None
|
|
106
|
+
info = read_lock_info(self.lock_path)
|
|
107
|
+
pid = info.pid
|
|
108
|
+
if pid and process_alive(pid):
|
|
109
|
+
try:
|
|
110
|
+
os.kill(pid, signal.SIGTERM)
|
|
111
|
+
return pid
|
|
112
|
+
except OSError:
|
|
113
|
+
return None
|
|
114
|
+
# stale lock
|
|
115
|
+
self.lock_path.unlink(missing_ok=True)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def runner_pid(self) -> Optional[int]:
|
|
119
|
+
state = load_state(self.state_path)
|
|
120
|
+
pid = state.runner_pid
|
|
121
|
+
if pid and process_alive(pid):
|
|
122
|
+
return pid
|
|
123
|
+
info = read_lock_info(self.lock_path)
|
|
124
|
+
if info.pid and process_alive(info.pid):
|
|
125
|
+
return info.pid
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def todos_done(self) -> bool:
|
|
129
|
+
return self.docs.todos_done()
|
|
130
|
+
|
|
131
|
+
def summary_finalized(self) -> bool:
|
|
132
|
+
"""Return True if SUMMARY.md contains the finalization marker."""
|
|
133
|
+
try:
|
|
134
|
+
text = self.docs.read_doc("summary")
|
|
135
|
+
except Exception:
|
|
136
|
+
return False
|
|
137
|
+
return SUMMARY_FINALIZED_MARKER in (text or "")
|
|
138
|
+
|
|
139
|
+
def _stamp_summary_finalized(self, run_id: int) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Append an idempotency marker to SUMMARY.md so the final summary job runs only once.
|
|
142
|
+
Users may remove the marker to force regeneration.
|
|
143
|
+
"""
|
|
144
|
+
path = self.config.doc_path("summary")
|
|
145
|
+
try:
|
|
146
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
147
|
+
except Exception:
|
|
148
|
+
existing = ""
|
|
149
|
+
if SUMMARY_FINALIZED_MARKER in existing:
|
|
150
|
+
return
|
|
151
|
+
stamp = f"{SUMMARY_FINALIZED_MARKER_PREFIX} run_id={int(run_id)} -->\n"
|
|
152
|
+
new_text = existing
|
|
153
|
+
if new_text and not new_text.endswith("\n"):
|
|
154
|
+
new_text += "\n"
|
|
155
|
+
# Keep a blank line before the marker for readability.
|
|
156
|
+
if new_text and not new_text.endswith("\n\n"):
|
|
157
|
+
new_text += "\n"
|
|
158
|
+
new_text += stamp
|
|
159
|
+
atomic_write(path, new_text)
|
|
160
|
+
|
|
161
|
+
def _execute_run_step(self, prompt: str, run_id: int) -> int:
|
|
162
|
+
"""
|
|
163
|
+
Execute a single run step:
|
|
164
|
+
1. Update state to 'running'
|
|
165
|
+
2. Log start
|
|
166
|
+
3. Run Codex CLI
|
|
167
|
+
4. Log end
|
|
168
|
+
5. Update state to 'idle' or 'error'
|
|
169
|
+
6. Commit if successful and auto-commit is enabled
|
|
170
|
+
"""
|
|
171
|
+
self._update_state("running", run_id, None, started=True)
|
|
172
|
+
with self._run_log_context(run_id):
|
|
173
|
+
self._write_run_marker(run_id, "start")
|
|
174
|
+
exit_code = self.run_codex_cli(prompt, run_id)
|
|
175
|
+
self._write_run_marker(run_id, "end", exit_code=exit_code)
|
|
176
|
+
|
|
177
|
+
self._update_state(
|
|
178
|
+
"error" if exit_code != 0 else "idle",
|
|
179
|
+
run_id,
|
|
180
|
+
exit_code,
|
|
181
|
+
finished=True,
|
|
182
|
+
)
|
|
183
|
+
if exit_code != 0:
|
|
184
|
+
self.notifier.notify_run_finished(run_id=run_id, exit_code=exit_code)
|
|
185
|
+
|
|
186
|
+
if exit_code == 0 and self.config.git_auto_commit:
|
|
187
|
+
self.maybe_git_commit(run_id)
|
|
188
|
+
|
|
189
|
+
return exit_code
|
|
190
|
+
|
|
191
|
+
def _run_final_summary_job(self, run_id: int) -> int:
|
|
192
|
+
"""
|
|
193
|
+
Run a dedicated Codex invocation that produces/updates SUMMARY.md as the final user report.
|
|
194
|
+
"""
|
|
195
|
+
prev_output = self.extract_prev_output(run_id - 1)
|
|
196
|
+
prompt = build_final_summary_prompt(self.config, self.docs, prev_output)
|
|
197
|
+
|
|
198
|
+
exit_code = self._execute_run_step(prompt, run_id)
|
|
199
|
+
|
|
200
|
+
if exit_code == 0:
|
|
201
|
+
self._stamp_summary_finalized(run_id)
|
|
202
|
+
self.notifier.notify_run_finished(run_id=run_id, exit_code=exit_code)
|
|
203
|
+
# Commit is already handled by _execute_run_step if auto-commit is enabled.
|
|
204
|
+
return exit_code
|
|
205
|
+
|
|
206
|
+
def extract_prev_output(self, run_id: int) -> Optional[str]:
|
|
207
|
+
if run_id <= 0:
|
|
208
|
+
return None
|
|
209
|
+
run_log = self._run_log_path(run_id)
|
|
210
|
+
if run_log.exists():
|
|
211
|
+
try:
|
|
212
|
+
text = run_log.read_text(encoding="utf-8")
|
|
213
|
+
except Exception:
|
|
214
|
+
text = ""
|
|
215
|
+
if text:
|
|
216
|
+
lines = [
|
|
217
|
+
line
|
|
218
|
+
for line in text.splitlines()
|
|
219
|
+
if not line.startswith("=== run ")
|
|
220
|
+
]
|
|
221
|
+
text = _strip_log_prefixes("\n".join(lines))
|
|
222
|
+
max_chars = self.config.prompt_prev_run_max_chars
|
|
223
|
+
return text[-max_chars:] if text else None
|
|
224
|
+
if not self.log_path.exists():
|
|
225
|
+
return None
|
|
226
|
+
start = f"=== run {run_id} start ==="
|
|
227
|
+
end = f"=== run {run_id} end"
|
|
228
|
+
# NOTE: do NOT read the full log file into memory. Logs can be very large
|
|
229
|
+
# (especially with verbose Codex output) and this can OOM the server/runner.
|
|
230
|
+
text = _read_tail_text(self.log_path, max_bytes=250_000)
|
|
231
|
+
lines = text.splitlines()
|
|
232
|
+
collecting = False
|
|
233
|
+
collected = []
|
|
234
|
+
for line in lines:
|
|
235
|
+
if line.strip() == start:
|
|
236
|
+
collecting = True
|
|
237
|
+
continue
|
|
238
|
+
if collecting and line.startswith(end):
|
|
239
|
+
break
|
|
240
|
+
if collecting:
|
|
241
|
+
collected.append(line)
|
|
242
|
+
if not collected:
|
|
243
|
+
return None
|
|
244
|
+
text = "\n".join(collected)
|
|
245
|
+
text = _strip_log_prefixes(text)
|
|
246
|
+
max_chars = self.config.prompt_prev_run_max_chars
|
|
247
|
+
return text[-max_chars:]
|
|
248
|
+
|
|
249
|
+
def read_run_block(self, run_id: int) -> Optional[str]:
|
|
250
|
+
"""Return a single run block from the log."""
|
|
251
|
+
index_entry = self._load_run_index().get(str(run_id))
|
|
252
|
+
run_log = None
|
|
253
|
+
if index_entry:
|
|
254
|
+
run_log_raw = index_entry.get("run_log_path")
|
|
255
|
+
if isinstance(run_log_raw, str) and run_log_raw:
|
|
256
|
+
run_log = Path(run_log_raw)
|
|
257
|
+
if run_log is None:
|
|
258
|
+
run_log = self._run_log_path(run_id)
|
|
259
|
+
if run_log.exists():
|
|
260
|
+
try:
|
|
261
|
+
return run_log.read_text(encoding="utf-8")
|
|
262
|
+
except Exception:
|
|
263
|
+
return None
|
|
264
|
+
if index_entry:
|
|
265
|
+
block = self._read_log_range(index_entry)
|
|
266
|
+
if block is not None:
|
|
267
|
+
return block
|
|
268
|
+
if not self.log_path.exists():
|
|
269
|
+
return None
|
|
270
|
+
start = f"=== run {run_id} start"
|
|
271
|
+
end = f"=== run {run_id} end"
|
|
272
|
+
# Avoid reading entire log into memory; prefer tail scan.
|
|
273
|
+
max_bytes = 1_000_000
|
|
274
|
+
text = _read_tail_text(self.log_path, max_bytes=max_bytes)
|
|
275
|
+
lines = text.splitlines()
|
|
276
|
+
buf = []
|
|
277
|
+
printing = False
|
|
278
|
+
for line in lines:
|
|
279
|
+
if line.startswith(start):
|
|
280
|
+
printing = True
|
|
281
|
+
buf.append(line)
|
|
282
|
+
continue
|
|
283
|
+
if printing and line.startswith(end):
|
|
284
|
+
buf.append(line)
|
|
285
|
+
break
|
|
286
|
+
if printing:
|
|
287
|
+
buf.append(line)
|
|
288
|
+
if buf:
|
|
289
|
+
return "\n".join(buf)
|
|
290
|
+
# If file is small, fall back to full read (safe).
|
|
291
|
+
try:
|
|
292
|
+
if self.log_path.stat().st_size <= max_bytes:
|
|
293
|
+
lines = self.log_path.read_text(encoding="utf-8").splitlines()
|
|
294
|
+
buf = []
|
|
295
|
+
printing = False
|
|
296
|
+
for line in lines:
|
|
297
|
+
if line.startswith(start):
|
|
298
|
+
printing = True
|
|
299
|
+
buf.append(line)
|
|
300
|
+
continue
|
|
301
|
+
if printing and line.startswith(end):
|
|
302
|
+
buf.append(line)
|
|
303
|
+
break
|
|
304
|
+
if printing:
|
|
305
|
+
buf.append(line)
|
|
306
|
+
return "\n".join(buf) if buf else None
|
|
307
|
+
except Exception:
|
|
308
|
+
return None
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
def tail_log(self, tail: int) -> str:
|
|
312
|
+
if not self.log_path.exists():
|
|
313
|
+
return ""
|
|
314
|
+
# Bound memory usage: only read a chunk from the end.
|
|
315
|
+
text = _read_tail_text(self.log_path, max_bytes=400_000)
|
|
316
|
+
lines = text.splitlines()
|
|
317
|
+
return "\n".join(lines[-tail:])
|
|
318
|
+
|
|
319
|
+
def log_line(self, run_id: int, message: str) -> None:
|
|
320
|
+
line = f"[{timestamp()}] run={run_id} {message}\n"
|
|
321
|
+
if self._active_global_handler is not None:
|
|
322
|
+
self._emit_global_line(line.rstrip("\n"))
|
|
323
|
+
else:
|
|
324
|
+
self._ensure_log_path()
|
|
325
|
+
with self.log_path.open("a", encoding="utf-8") as f:
|
|
326
|
+
f.write(line)
|
|
327
|
+
if self._active_run_log is not None:
|
|
328
|
+
try:
|
|
329
|
+
self._active_run_log.write(line)
|
|
330
|
+
self._active_run_log.flush()
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
else:
|
|
334
|
+
run_log = self._run_log_path(run_id)
|
|
335
|
+
self._ensure_run_log_dir()
|
|
336
|
+
with run_log.open("a", encoding="utf-8") as f:
|
|
337
|
+
f.write(line)
|
|
338
|
+
|
|
339
|
+
def _ensure_log_path(self) -> None:
|
|
340
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
|
|
342
|
+
def _run_log_path(self, run_id: int) -> Path:
|
|
343
|
+
return self.log_path.parent / "runs" / f"run-{run_id}.log"
|
|
344
|
+
|
|
345
|
+
def _ensure_run_log_dir(self) -> None:
|
|
346
|
+
(self.log_path.parent / "runs").mkdir(parents=True, exist_ok=True)
|
|
347
|
+
|
|
348
|
+
def _write_run_marker(
|
|
349
|
+
self, run_id: int, marker: str, exit_code: Optional[int] = None
|
|
350
|
+
) -> None:
|
|
351
|
+
suffix = ""
|
|
352
|
+
if marker == "end":
|
|
353
|
+
suffix = f" (code {exit_code})"
|
|
354
|
+
text = f"=== run {run_id} {marker}{suffix} ==="
|
|
355
|
+
offset = self._emit_global_line(text)
|
|
356
|
+
if self._active_run_log is not None:
|
|
357
|
+
try:
|
|
358
|
+
self._active_run_log.write(f"{text}\n")
|
|
359
|
+
self._active_run_log.flush()
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
else:
|
|
363
|
+
self._ensure_run_log_dir()
|
|
364
|
+
run_log = self._run_log_path(run_id)
|
|
365
|
+
with run_log.open("a", encoding="utf-8") as f:
|
|
366
|
+
f.write(f"{text}\n")
|
|
367
|
+
self._update_run_index(run_id, marker, offset, exit_code)
|
|
368
|
+
|
|
369
|
+
def _emit_global_line(self, text: str) -> Optional[tuple[int, int]]:
|
|
370
|
+
if self._active_global_handler is None:
|
|
371
|
+
self._ensure_log_path()
|
|
372
|
+
try:
|
|
373
|
+
with self.log_path.open("a", encoding="utf-8") as f:
|
|
374
|
+
start = f.tell()
|
|
375
|
+
f.write(f"{text}\n")
|
|
376
|
+
f.flush()
|
|
377
|
+
return (start, f.tell())
|
|
378
|
+
except Exception:
|
|
379
|
+
return None
|
|
380
|
+
handler = self._active_global_handler
|
|
381
|
+
record = logging.LogRecord(
|
|
382
|
+
name="codex_autorunner.engine",
|
|
383
|
+
level=logging.INFO,
|
|
384
|
+
pathname="",
|
|
385
|
+
lineno=0,
|
|
386
|
+
msg=text,
|
|
387
|
+
args=(),
|
|
388
|
+
exc_info=None,
|
|
389
|
+
)
|
|
390
|
+
handler.acquire()
|
|
391
|
+
try:
|
|
392
|
+
if handler.shouldRollover(record):
|
|
393
|
+
handler.doRollover()
|
|
394
|
+
if handler.stream is None:
|
|
395
|
+
handler.stream = handler._open()
|
|
396
|
+
start_offset = handler.stream.tell()
|
|
397
|
+
logging.FileHandler.emit(handler, record)
|
|
398
|
+
handler.flush()
|
|
399
|
+
end_offset = handler.stream.tell()
|
|
400
|
+
return (start_offset, end_offset)
|
|
401
|
+
except Exception:
|
|
402
|
+
return None
|
|
403
|
+
finally:
|
|
404
|
+
handler.release()
|
|
405
|
+
|
|
406
|
+
@contextlib.contextmanager
|
|
407
|
+
def _run_log_context(self, run_id: int) -> Iterator[None]:
|
|
408
|
+
self._ensure_log_path()
|
|
409
|
+
self._ensure_run_log_dir()
|
|
410
|
+
max_bytes = getattr(self.config.log, "max_bytes", None) or 0
|
|
411
|
+
backup_count = getattr(self.config.log, "backup_count", 0) or 0
|
|
412
|
+
handler = RotatingFileHandler(
|
|
413
|
+
self.log_path,
|
|
414
|
+
maxBytes=max_bytes,
|
|
415
|
+
backupCount=backup_count,
|
|
416
|
+
encoding="utf-8",
|
|
417
|
+
)
|
|
418
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
419
|
+
run_log = self._run_log_path(run_id)
|
|
420
|
+
with run_log.open("a", encoding="utf-8") as run_handle:
|
|
421
|
+
self._active_global_handler = handler
|
|
422
|
+
self._active_run_log = run_handle
|
|
423
|
+
try:
|
|
424
|
+
yield
|
|
425
|
+
finally:
|
|
426
|
+
self._active_global_handler = None
|
|
427
|
+
self._active_run_log = None
|
|
428
|
+
try:
|
|
429
|
+
handler.close()
|
|
430
|
+
except Exception:
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
def _load_run_index(self) -> dict[str, dict]:
|
|
434
|
+
if not self.run_index_path.exists():
|
|
435
|
+
return {}
|
|
436
|
+
try:
|
|
437
|
+
raw = self.run_index_path.read_text(encoding="utf-8")
|
|
438
|
+
data = json.loads(raw)
|
|
439
|
+
if isinstance(data, dict):
|
|
440
|
+
return data
|
|
441
|
+
except Exception:
|
|
442
|
+
return {}
|
|
443
|
+
return {}
|
|
444
|
+
|
|
445
|
+
def _save_run_index(self, index: dict[str, dict]) -> None:
|
|
446
|
+
try:
|
|
447
|
+
self.run_index_path.parent.mkdir(parents=True, exist_ok=True)
|
|
448
|
+
payload = json.dumps(index, ensure_ascii=True, indent=2)
|
|
449
|
+
atomic_write(self.run_index_path, f"{payload}\n")
|
|
450
|
+
except Exception:
|
|
451
|
+
pass
|
|
452
|
+
|
|
453
|
+
def _update_run_index(
|
|
454
|
+
self,
|
|
455
|
+
run_id: int,
|
|
456
|
+
marker: str,
|
|
457
|
+
offset: Optional[tuple[int, int]],
|
|
458
|
+
exit_code: Optional[int],
|
|
459
|
+
) -> None:
|
|
460
|
+
index = self._load_run_index()
|
|
461
|
+
key = str(run_id)
|
|
462
|
+
entry = index.get(key, {})
|
|
463
|
+
if marker == "start":
|
|
464
|
+
entry["start_offset"] = offset[0] if offset else None
|
|
465
|
+
entry["started_at"] = now_iso()
|
|
466
|
+
entry["log_path"] = str(self.log_path)
|
|
467
|
+
entry["run_log_path"] = str(self._run_log_path(run_id))
|
|
468
|
+
elif marker == "end":
|
|
469
|
+
entry["end_offset"] = offset[1] if offset else None
|
|
470
|
+
entry["finished_at"] = now_iso()
|
|
471
|
+
entry["exit_code"] = exit_code
|
|
472
|
+
entry.setdefault("log_path", str(self.log_path))
|
|
473
|
+
entry.setdefault("run_log_path", str(self._run_log_path(run_id)))
|
|
474
|
+
index[key] = entry
|
|
475
|
+
self._save_run_index(index)
|
|
476
|
+
|
|
477
|
+
def _read_log_range(self, entry: dict) -> Optional[str]:
|
|
478
|
+
start = entry.get("start_offset")
|
|
479
|
+
end = entry.get("end_offset")
|
|
480
|
+
if start is None or end is None:
|
|
481
|
+
return None
|
|
482
|
+
try:
|
|
483
|
+
start_offset = int(start)
|
|
484
|
+
end_offset = int(end)
|
|
485
|
+
except (TypeError, ValueError):
|
|
486
|
+
return None
|
|
487
|
+
if end_offset < start_offset:
|
|
488
|
+
return None
|
|
489
|
+
log_path = Path(entry.get("log_path", self.log_path))
|
|
490
|
+
if not log_path.exists():
|
|
491
|
+
return None
|
|
492
|
+
try:
|
|
493
|
+
size = log_path.stat().st_size
|
|
494
|
+
if size < end_offset:
|
|
495
|
+
return None
|
|
496
|
+
with log_path.open("rb") as f:
|
|
497
|
+
f.seek(start_offset)
|
|
498
|
+
data = f.read(end_offset - start_offset)
|
|
499
|
+
return data.decode("utf-8", errors="replace")
|
|
500
|
+
except Exception:
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
def run_codex_cli(self, prompt: str, run_id: int) -> int:
|
|
504
|
+
def _log_stdout(line: str) -> None:
|
|
505
|
+
self.log_line(run_id, f"stdout: {line}" if line else "stdout: ")
|
|
506
|
+
|
|
507
|
+
return run_codex_streaming(
|
|
508
|
+
self.config,
|
|
509
|
+
self.repo_root,
|
|
510
|
+
prompt,
|
|
511
|
+
on_stdout_line=_log_stdout,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
def maybe_git_commit(self, run_id: int) -> None:
|
|
515
|
+
msg = self.config.git_commit_message_template.replace(
|
|
516
|
+
"{run_id}", str(run_id)
|
|
517
|
+
).replace("#{run_id}", str(run_id))
|
|
518
|
+
paths = [
|
|
519
|
+
self.config.doc_path("todo"),
|
|
520
|
+
self.config.doc_path("progress"),
|
|
521
|
+
self.config.doc_path("opinions"),
|
|
522
|
+
self.config.doc_path("spec"),
|
|
523
|
+
self.config.doc_path("summary"),
|
|
524
|
+
]
|
|
525
|
+
add_cmd = ["git", "add"] + [
|
|
526
|
+
str(p.relative_to(self.repo_root)) for p in paths if p.exists()
|
|
527
|
+
]
|
|
528
|
+
subprocess.run(add_cmd, cwd=self.repo_root, check=False)
|
|
529
|
+
subprocess.run(["git", "commit", "-m", msg], cwd=self.repo_root, check=False)
|
|
530
|
+
|
|
531
|
+
def run_loop(
|
|
532
|
+
self,
|
|
533
|
+
stop_after_runs: Optional[int] = None,
|
|
534
|
+
external_stop_flag: Optional[threading.Event] = None,
|
|
535
|
+
) -> None:
|
|
536
|
+
state = load_state(self.state_path)
|
|
537
|
+
run_id = (state.last_run_id or 0) + 1
|
|
538
|
+
last_exit_code: Optional[int] = state.last_exit_code
|
|
539
|
+
start_wallclock = time.time()
|
|
540
|
+
target_runs = (
|
|
541
|
+
stop_after_runs
|
|
542
|
+
if stop_after_runs is not None
|
|
543
|
+
else self.config.runner_stop_after_runs
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
while True:
|
|
548
|
+
if self._should_stop(external_stop_flag):
|
|
549
|
+
self.clear_stop_request()
|
|
550
|
+
self._update_state(
|
|
551
|
+
"idle", run_id - 1, last_exit_code, finished=True
|
|
552
|
+
)
|
|
553
|
+
break
|
|
554
|
+
if self.config.runner_max_wallclock_seconds is not None:
|
|
555
|
+
if (
|
|
556
|
+
time.time() - start_wallclock
|
|
557
|
+
> self.config.runner_max_wallclock_seconds
|
|
558
|
+
):
|
|
559
|
+
self._update_state(
|
|
560
|
+
"idle", run_id - 1, state.last_exit_code, finished=True
|
|
561
|
+
)
|
|
562
|
+
break
|
|
563
|
+
|
|
564
|
+
if self.todos_done():
|
|
565
|
+
if not self.summary_finalized():
|
|
566
|
+
exit_code = self._run_final_summary_job(run_id)
|
|
567
|
+
last_exit_code = exit_code
|
|
568
|
+
else:
|
|
569
|
+
current = load_state(self.state_path)
|
|
570
|
+
last_exit_code = current.last_exit_code
|
|
571
|
+
self._update_state(
|
|
572
|
+
"idle", run_id - 1, last_exit_code, finished=True
|
|
573
|
+
)
|
|
574
|
+
break
|
|
575
|
+
|
|
576
|
+
prev_output = self.extract_prev_output(run_id - 1)
|
|
577
|
+
prompt = build_prompt(self.config, self.docs, prev_output)
|
|
578
|
+
|
|
579
|
+
exit_code = self._execute_run_step(prompt, run_id)
|
|
580
|
+
last_exit_code = exit_code
|
|
581
|
+
|
|
582
|
+
if exit_code != 0:
|
|
583
|
+
break
|
|
584
|
+
|
|
585
|
+
# If TODO is now complete, run the final report job once and stop.
|
|
586
|
+
if self.todos_done() and not self.summary_finalized():
|
|
587
|
+
exit_code = self._run_final_summary_job(run_id + 1)
|
|
588
|
+
last_exit_code = exit_code
|
|
589
|
+
break
|
|
590
|
+
|
|
591
|
+
if target_runs is not None and run_id >= target_runs:
|
|
592
|
+
break
|
|
593
|
+
|
|
594
|
+
run_id += 1
|
|
595
|
+
if self._should_stop(external_stop_flag):
|
|
596
|
+
self.clear_stop_request()
|
|
597
|
+
self._update_state("idle", run_id - 1, exit_code, finished=True)
|
|
598
|
+
break
|
|
599
|
+
time.sleep(self.config.runner_sleep_seconds)
|
|
600
|
+
except Exception as exc:
|
|
601
|
+
# Never silently die: persist the reason to the agent log and surface in state.
|
|
602
|
+
try:
|
|
603
|
+
self.log_line(run_id, f"FATAL: run_loop crashed: {exc!r}")
|
|
604
|
+
tb = traceback.format_exc()
|
|
605
|
+
for line in tb.splitlines():
|
|
606
|
+
self.log_line(run_id, f"traceback: {line}")
|
|
607
|
+
except Exception:
|
|
608
|
+
pass
|
|
609
|
+
try:
|
|
610
|
+
self._update_state("error", run_id, 1, finished=True)
|
|
611
|
+
except Exception:
|
|
612
|
+
pass
|
|
613
|
+
# IMPORTANT: lock ownership is managed by the caller (CLI/Hub/Server runner).
|
|
614
|
+
# Engine.run_loop must never unconditionally mutate the lock file.
|
|
615
|
+
|
|
616
|
+
def run_once(self) -> None:
|
|
617
|
+
self.run_loop(stop_after_runs=1)
|
|
618
|
+
|
|
619
|
+
def _update_state(
|
|
620
|
+
self,
|
|
621
|
+
status: str,
|
|
622
|
+
run_id: int,
|
|
623
|
+
exit_code: Optional[int],
|
|
624
|
+
*,
|
|
625
|
+
started: bool = False,
|
|
626
|
+
finished: bool = False,
|
|
627
|
+
) -> None:
|
|
628
|
+
with state_lock(self.state_path):
|
|
629
|
+
current = load_state(self.state_path)
|
|
630
|
+
last_run_started_at = current.last_run_started_at
|
|
631
|
+
last_run_finished_at = current.last_run_finished_at
|
|
632
|
+
runner_pid = current.runner_pid
|
|
633
|
+
if started:
|
|
634
|
+
last_run_started_at = now_iso()
|
|
635
|
+
last_run_finished_at = None
|
|
636
|
+
runner_pid = os.getpid()
|
|
637
|
+
if finished:
|
|
638
|
+
last_run_finished_at = now_iso()
|
|
639
|
+
runner_pid = None
|
|
640
|
+
new_state = RunnerState(
|
|
641
|
+
last_run_id=run_id,
|
|
642
|
+
status=status,
|
|
643
|
+
last_exit_code=exit_code,
|
|
644
|
+
last_run_started_at=last_run_started_at,
|
|
645
|
+
last_run_finished_at=last_run_finished_at,
|
|
646
|
+
runner_pid=runner_pid,
|
|
647
|
+
sessions=current.sessions,
|
|
648
|
+
repo_to_session=current.repo_to_session,
|
|
649
|
+
)
|
|
650
|
+
save_state(self.state_path, new_state)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def clear_stale_lock(lock_path: Path) -> None:
|
|
654
|
+
if lock_path.exists():
|
|
655
|
+
info = read_lock_info(lock_path)
|
|
656
|
+
pid = info.pid
|
|
657
|
+
if not pid or not process_alive(pid):
|
|
658
|
+
lock_path.unlink(missing_ok=True)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _strip_log_prefixes(text: str) -> str:
|
|
662
|
+
"""Strip log prefixes and clip to content after token-usage marker if present."""
|
|
663
|
+
lines = text.splitlines()
|
|
664
|
+
cleaned_lines = []
|
|
665
|
+
token_marker_idx = None
|
|
666
|
+
for idx, line in enumerate(lines):
|
|
667
|
+
if "stdout: tokens used" in line:
|
|
668
|
+
token_marker_idx = idx
|
|
669
|
+
break
|
|
670
|
+
if token_marker_idx is not None:
|
|
671
|
+
lines = lines[token_marker_idx + 1 :]
|
|
672
|
+
|
|
673
|
+
for line in lines:
|
|
674
|
+
if "] run=" in line and "stdout:" in line:
|
|
675
|
+
try:
|
|
676
|
+
_, remainder = line.split("stdout:", 1)
|
|
677
|
+
cleaned_lines.append(remainder.strip())
|
|
678
|
+
continue
|
|
679
|
+
except ValueError:
|
|
680
|
+
pass
|
|
681
|
+
cleaned_lines.append(line)
|
|
682
|
+
return "\n".join(cleaned_lines).strip()
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _read_tail_text(path: Path, *, max_bytes: int) -> str:
|
|
686
|
+
"""
|
|
687
|
+
Read at most the last `max_bytes` bytes from a UTF-8-ish text file.
|
|
688
|
+
Returns decoded text with errors replaced.
|
|
689
|
+
"""
|
|
690
|
+
try:
|
|
691
|
+
size = path.stat().st_size
|
|
692
|
+
except OSError:
|
|
693
|
+
return ""
|
|
694
|
+
if size <= 0:
|
|
695
|
+
return ""
|
|
696
|
+
try:
|
|
697
|
+
with path.open("rb") as f:
|
|
698
|
+
if size > max_bytes:
|
|
699
|
+
f.seek(-max_bytes, os.SEEK_END)
|
|
700
|
+
data = f.read()
|
|
701
|
+
return data.decode("utf-8", errors="replace")
|
|
702
|
+
except Exception:
|
|
703
|
+
return ""
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def doctor(repo_root: Path) -> None:
|
|
707
|
+
root = find_repo_root(repo_root)
|
|
708
|
+
config = load_config(root)
|
|
709
|
+
if not isinstance(config, RepoConfig):
|
|
710
|
+
raise ConfigError("Doctor requires repo mode configuration")
|
|
711
|
+
missing = []
|
|
712
|
+
for key in ("todo", "progress", "opinions"):
|
|
713
|
+
path = config.doc_path(key)
|
|
714
|
+
if not path.exists():
|
|
715
|
+
missing.append(path)
|
|
716
|
+
if missing:
|
|
717
|
+
names = ", ".join(str(p) for p in missing)
|
|
718
|
+
raise ConfigError(f"Missing doc files: {names}")
|
|
719
|
+
if not ensure_executable(config.codex_binary):
|
|
720
|
+
raise ConfigError(f"Codex binary not found in PATH: {config.codex_binary}")
|