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.
Files changed (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. 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}")