codex-autorunner 0.1.2__py3-none-any.whl → 1.0.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 (189) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import IO, Literal, Optional, Tuple
11
+
12
+ _WORKER_METADATA_FILENAME = "worker.json"
13
+
14
+
15
+ @dataclass
16
+ class FlowWorkerHealth:
17
+ status: Literal["absent", "alive", "dead", "invalid", "mismatch"]
18
+ pid: Optional[int]
19
+ cmdline: list[str]
20
+ artifact_path: Path
21
+ message: Optional[str] = None
22
+
23
+ @property
24
+ def is_alive(self) -> bool:
25
+ return self.status == "alive"
26
+
27
+
28
+ def _normalized_run_id(run_id: str) -> str:
29
+ return str(uuid.UUID(str(run_id)))
30
+
31
+
32
+ def _worker_artifacts_dir(
33
+ repo_root: Path, run_id: str, artifacts_root: Optional[Path] = None
34
+ ) -> Path:
35
+ repo_root = repo_root.resolve()
36
+ base_artifacts = (
37
+ artifacts_root
38
+ if artifacts_root is not None
39
+ else repo_root / ".codex-autorunner" / "flows"
40
+ )
41
+ artifacts_dir = base_artifacts / _normalized_run_id(run_id)
42
+ artifacts_dir.mkdir(parents=True, exist_ok=True)
43
+ return artifacts_dir
44
+
45
+
46
+ def _worker_metadata_path(artifacts_dir: Path) -> Path:
47
+ return artifacts_dir / _WORKER_METADATA_FILENAME
48
+
49
+
50
+ def _build_worker_cmd(entrypoint: str, run_id: str) -> list[str]:
51
+ normalized_run_id = _normalized_run_id(run_id)
52
+ return [
53
+ sys.executable,
54
+ "-m",
55
+ entrypoint,
56
+ "flow",
57
+ "worker",
58
+ "--run-id",
59
+ normalized_run_id,
60
+ ]
61
+
62
+
63
+ def _pid_is_running(pid: int) -> bool:
64
+ try:
65
+ os.kill(pid, 0)
66
+ except ProcessLookupError:
67
+ return False
68
+ except PermissionError:
69
+ # Process exists but we may not own it.
70
+ return True
71
+ except OSError:
72
+ return False
73
+ return True
74
+
75
+
76
+ def _read_process_cmdline(pid: int) -> list[str] | None:
77
+ proc_path = Path(f"/proc/{pid}/cmdline")
78
+ if proc_path.exists():
79
+ try:
80
+ raw = proc_path.read_bytes()
81
+ return [part for part in raw.decode().split("\0") if part]
82
+ except Exception:
83
+ pass
84
+
85
+ try:
86
+ out = subprocess.check_output(
87
+ ["ps", "-p", str(pid), "-o", "command="],
88
+ stderr=subprocess.DEVNULL,
89
+ )
90
+ cmd = out.decode().strip()
91
+ if cmd:
92
+ return cmd.split()
93
+ except Exception:
94
+ return None
95
+ return None
96
+
97
+
98
+ def _cmdline_matches(expected: list[str], actual: list[str]) -> bool:
99
+ if not expected or not actual:
100
+ return False
101
+ if len(actual) >= len(expected) and actual[-len(expected) :] == expected:
102
+ return True
103
+ expected_str = " ".join(expected)
104
+ actual_str = " ".join(actual)
105
+ return expected_str in actual_str
106
+
107
+
108
+ def _write_worker_metadata(path: Path, pid: int, cmd: list[str]) -> None:
109
+ data = {
110
+ "pid": pid,
111
+ "cmd": cmd,
112
+ "cwd": os.getcwd(),
113
+ }
114
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
115
+ # Also emit a plain PID file for quick inspection.
116
+ pid_path = path.with_suffix(".pid")
117
+ pid_path.write_text(str(pid), encoding="utf-8")
118
+
119
+
120
+ def clear_worker_metadata(artifacts_dir: Path) -> None:
121
+ for name in (
122
+ _WORKER_METADATA_FILENAME,
123
+ f"{Path(_WORKER_METADATA_FILENAME).stem}.pid",
124
+ ):
125
+ try:
126
+ (artifacts_dir / name).unlink()
127
+ except FileNotFoundError:
128
+ pass
129
+ except Exception:
130
+ pass
131
+
132
+
133
+ def check_worker_health(
134
+ repo_root: Path,
135
+ run_id: str,
136
+ *,
137
+ artifacts_root: Optional[Path] = None,
138
+ entrypoint: str = "codex_autorunner",
139
+ ) -> FlowWorkerHealth:
140
+ artifacts_dir = _worker_artifacts_dir(repo_root, run_id, artifacts_root)
141
+ metadata_path = _worker_metadata_path(artifacts_dir)
142
+
143
+ if not metadata_path.exists():
144
+ return FlowWorkerHealth(
145
+ status="absent",
146
+ pid=None,
147
+ cmdline=[],
148
+ artifact_path=metadata_path,
149
+ message="worker metadata missing",
150
+ )
151
+
152
+ try:
153
+ data = json.loads(metadata_path.read_text(encoding="utf-8"))
154
+ pid = int(data.get("pid")) if data.get("pid") is not None else None
155
+ cmd = data.get("cmd") or []
156
+ except Exception:
157
+ return FlowWorkerHealth(
158
+ status="invalid",
159
+ pid=None,
160
+ cmdline=[],
161
+ artifact_path=metadata_path,
162
+ message="worker metadata unreadable",
163
+ )
164
+
165
+ if not pid or pid <= 0:
166
+ return FlowWorkerHealth(
167
+ status="invalid",
168
+ pid=pid,
169
+ cmdline=cmd if isinstance(cmd, list) else [],
170
+ artifact_path=metadata_path,
171
+ message="missing or invalid PID",
172
+ )
173
+
174
+ if not _pid_is_running(pid):
175
+ return FlowWorkerHealth(
176
+ status="dead",
177
+ pid=pid,
178
+ cmdline=cmd if isinstance(cmd, list) else [],
179
+ artifact_path=metadata_path,
180
+ message="worker PID not running",
181
+ )
182
+
183
+ expected_cmd = _build_worker_cmd(entrypoint, run_id)
184
+ actual_cmd = _read_process_cmdline(pid)
185
+ if actual_cmd is None:
186
+ # Can't inspect cmdline; trust the PID check.
187
+ return FlowWorkerHealth(
188
+ status="alive",
189
+ pid=pid,
190
+ cmdline=cmd if isinstance(cmd, list) else [],
191
+ artifact_path=metadata_path,
192
+ message="worker running (cmdline unknown)",
193
+ )
194
+
195
+ if not _cmdline_matches(expected_cmd, actual_cmd):
196
+ return FlowWorkerHealth(
197
+ status="mismatch",
198
+ pid=pid,
199
+ cmdline=actual_cmd,
200
+ artifact_path=metadata_path,
201
+ message="worker PID command does not match expected",
202
+ )
203
+
204
+ return FlowWorkerHealth(
205
+ status="alive",
206
+ pid=pid,
207
+ cmdline=actual_cmd,
208
+ artifact_path=metadata_path,
209
+ message="worker running",
210
+ )
211
+
212
+
213
+ def spawn_flow_worker(
214
+ repo_root: Path,
215
+ run_id: str,
216
+ *,
217
+ artifacts_root: Optional[Path] = None,
218
+ entrypoint: str = "codex_autorunner",
219
+ ) -> Tuple[subprocess.Popen, IO[bytes], IO[bytes]]:
220
+ """Spawn a detached flow worker with consistent artifacts/log layout."""
221
+
222
+ normalized_run_id = _normalized_run_id(run_id)
223
+ repo_root = repo_root.resolve()
224
+ artifacts_dir = _worker_artifacts_dir(repo_root, normalized_run_id, artifacts_root)
225
+
226
+ stdout_path = artifacts_dir / "worker.out.log"
227
+ stderr_path = artifacts_dir / "worker.err.log"
228
+
229
+ stdout_handle = stdout_path.open("ab")
230
+ stderr_handle = stderr_path.open("ab")
231
+
232
+ cmd = _build_worker_cmd(entrypoint, normalized_run_id)
233
+
234
+ proc = subprocess.Popen(
235
+ cmd,
236
+ cwd=repo_root,
237
+ stdout=stdout_handle,
238
+ stderr=stderr_handle,
239
+ )
240
+
241
+ _write_worker_metadata(_worker_metadata_path(artifacts_dir), proc.pid, cmd)
242
+ return proc, stdout_handle, stderr_handle
@@ -137,7 +137,8 @@ def load_hub_state(state_path: Path, hub_root: Path) -> HubState:
137
137
  import json
138
138
 
139
139
  payload = json.loads(data)
140
- except Exception:
140
+ except Exception as exc:
141
+ logger.warning("Failed to parse hub state from %s: %s", state_path, exc)
141
142
  return HubState(last_scan_at=None, repos=[])
142
143
  last_scan_at = payload.get("last_scan_at")
143
144
  repos_payload = payload.get("repos") or []
@@ -168,7 +169,13 @@ def load_hub_state(state_path: Path, hub_root: Path) -> HubState:
168
169
  runner_pid=entry.get("runner_pid"),
169
170
  )
170
171
  repos.append(repo)
171
- except Exception:
172
+ except Exception as exc:
173
+ repo_id = entry.get("id", "unknown")
174
+ logger.warning(
175
+ "Failed to load repo snapshot for id=%s from hub state: %s",
176
+ repo_id,
177
+ exc,
178
+ )
172
179
  continue
173
180
  return HubState(last_scan_at=last_scan_at, repos=repos)
174
181
 
@@ -759,10 +766,10 @@ class HubSupervisor:
759
766
  if not repo:
760
767
  raise ValueError(f"Repo {repo_id} not found in manifest")
761
768
  repo_root = (self.hub_config.root / repo.path).resolve()
762
- state_path = repo_root / ".codex-autorunner" / "state.sqlite3"
763
- if not allow_uninitialized and not state_path.exists():
769
+ tickets_dir = repo_root / ".codex-autorunner" / "tickets"
770
+ if not allow_uninitialized and not tickets_dir.exists():
764
771
  raise ValueError(f"Repo {repo_id} is not initialized")
765
- if not state_path.exists():
772
+ if not tickets_dir.exists():
766
773
  return None
767
774
  repo_config = derive_repo_config(self.hub_config, repo_root, load_env=False)
768
775
  runner = RepoRunner(
@@ -781,7 +788,7 @@ class HubSupervisor:
781
788
  records: List[DiscoveryRecord] = []
782
789
  for entry in manifest.repos:
783
790
  repo_path = (self.hub_config.root / entry.path).resolve()
784
- initialized = (repo_path / ".codex-autorunner" / "state.sqlite3").exists()
791
+ initialized = (repo_path / ".codex-autorunner" / "tickets").exists()
785
792
  records.append(
786
793
  DiscoveryRecord(
787
794
  repo=entry,
@@ -821,9 +828,8 @@ class HubSupervisor:
821
828
  lock_status = read_lock_status(lock_path)
822
829
 
823
830
  runner_state: Optional[RunnerState] = None
824
- state_path = repo_path / ".codex-autorunner" / "state.sqlite3"
825
- if record.initialized and state_path.exists():
826
- runner_state = load_state(state_path)
831
+ if record.initialized:
832
+ runner_state = load_state(repo_path / ".codex-autorunner" / "state.sqlite3")
827
833
 
828
834
  is_clean: Optional[bool] = None
829
835
  if record.exists_on_disk and git_available(repo_path):
@@ -1,5 +1,6 @@
1
1
  import errno
2
2
  import json
3
+ import logging
3
4
  import os
4
5
  import socket
5
6
  import subprocess
@@ -27,6 +28,7 @@ class LockAssessment:
27
28
 
28
29
 
29
30
  DEFAULT_RUNNER_CMD_HINTS = ("codex_autorunner.cli", "codex-autorunner", "car ")
31
+ logger = logging.getLogger(__name__)
30
32
 
31
33
 
32
34
  def process_alive(pid: int) -> bool:
@@ -48,6 +50,7 @@ def process_is_zombie(pid: int) -> bool:
48
50
  check=False,
49
51
  )
50
52
  except Exception:
53
+ logger.debug("Failed to check process status for pid %s", pid, exc_info=True)
51
54
  return False
52
55
  if result.returncode != 0:
53
56
  return False
@@ -65,6 +68,7 @@ def process_command(pid: int) -> Optional[str]:
65
68
  check=False,
66
69
  )
67
70
  except Exception:
71
+ logger.debug("Failed to inspect process command for pid %s", pid, exc_info=True)
68
72
  return None
69
73
  if result.returncode != 0:
70
74
  return None
@@ -15,12 +15,20 @@ def _display_path(root: Path, path: Path) -> str:
15
15
 
16
16
 
17
17
  def build_doc_paths(config: Config) -> Mapping[str, str]:
18
+ def _safe_path(*keys: str) -> str:
19
+ for key in keys:
20
+ try:
21
+ return _display_path(config.root, config.doc_path(key))
22
+ except KeyError:
23
+ continue
24
+ return ""
25
+
18
26
  return {
19
- "todo": _display_path(config.root, config.doc_path("todo")),
20
- "progress": _display_path(config.root, config.doc_path("progress")),
21
- "opinions": _display_path(config.root, config.doc_path("opinions")),
22
- "spec": _display_path(config.root, config.doc_path("spec")),
23
- "summary": _display_path(config.root, config.doc_path("summary")),
27
+ "todo": _safe_path("todo", "active_context"),
28
+ "progress": _safe_path("progress", "decisions"),
29
+ "opinions": _safe_path("opinions"),
30
+ "spec": _safe_path("spec"),
31
+ "summary": _safe_path("summary"),
24
32
  }
25
33
 
26
34
 
@@ -64,8 +72,8 @@ def build_final_summary_prompt(
64
72
 
65
73
  doc_paths = build_doc_paths(config)
66
74
  doc_contents = {
67
- "todo": docs.read_doc("todo"),
68
- "progress": docs.read_doc("progress"),
75
+ "todo": docs.read_doc("todo") or docs.read_doc("active_context"),
76
+ "progress": docs.read_doc("progress") or docs.read_doc("decisions"),
69
77
  "opinions": docs.read_doc("opinions"),
70
78
  "spec": docs.read_doc("spec"),
71
79
  "summary": docs.read_doc("summary"),
@@ -0,0 +1,29 @@
1
+ import re
2
+ from typing import List, Tuple
3
+
4
+ _REDACTIONS: List[Tuple[re.Pattern[str], str]] = [
5
+ # OpenAI-like keys.
6
+ (re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"), "sk-[REDACTED]"),
7
+ # GitHub personal access tokens.
8
+ (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"), "gh_[REDACTED]"),
9
+ # AWS access key ids (best-effort).
10
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AKIA[REDACTED]"),
11
+ # JWT-ish blobs.
12
+ (
13
+ re.compile(
14
+ r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"
15
+ ),
16
+ "[JWT_REDACTED]",
17
+ ),
18
+ ]
19
+
20
+
21
+ def redact_text(text: str) -> str:
22
+ redacted = text
23
+ for pattern, replacement in _REDACTIONS:
24
+ redacted = pattern.sub(replacement, redacted)
25
+ return redacted
26
+
27
+
28
+ def get_redaction_patterns() -> List[str]:
29
+ return [pattern.pattern for pattern, _ in _REDACTIONS]
@@ -97,13 +97,10 @@ def build_spec_progress_review_context(
97
97
  remaining = 0
98
98
 
99
99
  def doc_label(name: str) -> str:
100
- mapping = {
101
- "spec": "SPEC.md",
102
- "progress": "PROGRESS.md",
103
- "todo": "TODO.md",
104
- "summary": "SUMMARY.md",
105
- }
106
- return mapping.get(name.lower(), name)
100
+ try:
101
+ return engine.config.doc_path(name).relative_to(engine.repo_root).as_posix()
102
+ except Exception:
103
+ return name
107
104
 
108
105
  def read_doc(name: str) -> str:
109
106
  try:
@@ -122,7 +119,7 @@ def build_spec_progress_review_context(
122
119
 
123
120
  primary_list = [doc for doc in primary_docs if isinstance(doc, str)] or [
124
121
  "spec",
125
- "progress",
122
+ "active_context",
126
123
  ]
127
124
  primary_set = {doc.lower() for doc in primary_list}
128
125
 
@@ -197,6 +197,8 @@ class RunIndexStore:
197
197
  *,
198
198
  log_path: str,
199
199
  run_log_path: str,
200
+ actor: Optional[dict[str, Any]] = None,
201
+ mode: Optional[dict[str, Any]] = None,
200
202
  ) -> dict[str, Any]:
201
203
  with open_sqlite(self._path) as conn:
202
204
  self._ensure_schema(conn)
@@ -207,6 +209,10 @@ class RunIndexStore:
207
209
  entry["started_at"] = now_iso()
208
210
  entry["log_path"] = log_path
209
211
  entry["run_log_path"] = run_log_path
212
+ if actor is not None:
213
+ entry["actor"] = actor
214
+ if mode is not None:
215
+ entry["mode"] = mode
210
216
  elif marker == "end":
211
217
  entry["end_offset"] = offset[1] if offset else None
212
218
  entry["finished_at"] = now_iso()
@@ -1,12 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import atexit
4
+ import logging
4
5
  import subprocess
5
6
  import sys
6
7
  from pathlib import Path
7
8
  from typing import Set
8
9
 
9
10
  _process_registry: Set[subprocess.Popen] = set()
11
+ logger = logging.getLogger(__name__)
10
12
 
11
13
 
12
14
  def build_runner_cmd(repo_root: Path, *, action: str, once: bool = False) -> list[str]:
@@ -41,14 +43,15 @@ def cleanup_processes() -> None:
41
43
  try:
42
44
  proc.terminate()
43
45
  except Exception:
44
- pass
46
+ logger.debug("Failed to terminate runner process", exc_info=True)
45
47
  try:
46
48
  proc.wait(timeout=5)
47
49
  except Exception:
50
+ logger.debug("Runner process wait timed out", exc_info=True)
48
51
  try:
49
52
  proc.kill()
50
53
  except Exception:
51
- pass
54
+ logger.debug("Failed to kill runner process", exc_info=True)
52
55
  _process_registry.clear()
53
56
 
54
57
 
@@ -1,6 +1,5 @@
1
1
  import dataclasses
2
2
  import json
3
- import logging
4
3
  from contextlib import contextmanager
5
4
  from datetime import datetime, timezone
6
5
  from pathlib import Path
@@ -9,8 +8,6 @@ from typing import Any, Iterator, Optional
9
8
  from .locks import file_lock
10
9
  from .sqlite_utils import open_sqlite
11
10
 
12
- _logger = logging.getLogger(__name__)
13
-
14
11
 
15
12
  @dataclasses.dataclass
16
13
  class RunnerState:
@@ -100,75 +97,6 @@ def now_iso() -> str:
100
97
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
101
98
 
102
99
 
103
- def _coerce_int(value: Any) -> Optional[int]:
104
- if isinstance(value, int) and not isinstance(value, bool):
105
- return value
106
- return None
107
-
108
-
109
- def _coerce_str(value: Any) -> Optional[str]:
110
- if isinstance(value, str) and value:
111
- return value
112
- return None
113
-
114
-
115
- def _load_legacy_state_json(path: Path) -> Optional[RunnerState]:
116
- try:
117
- payload = json.loads(path.read_text(encoding="utf-8"))
118
- except (OSError, json.JSONDecodeError):
119
- return None
120
- if not isinstance(payload, dict):
121
- return None
122
- state = RunnerState(
123
- last_run_id=_coerce_int(payload.get("last_run_id")),
124
- status=_coerce_str(payload.get("status")) or "idle",
125
- last_exit_code=_coerce_int(payload.get("last_exit_code")),
126
- last_run_started_at=_coerce_str(payload.get("last_run_started_at")),
127
- last_run_finished_at=_coerce_str(payload.get("last_run_finished_at")),
128
- autorunner_agent_override=_coerce_str(payload.get("autorunner_agent_override")),
129
- autorunner_model_override=_coerce_str(payload.get("autorunner_model_override")),
130
- autorunner_effort_override=_coerce_str(
131
- payload.get("autorunner_effort_override")
132
- ),
133
- autorunner_approval_policy=_coerce_str(
134
- payload.get("autorunner_approval_policy")
135
- ),
136
- autorunner_sandbox_mode=_coerce_str(payload.get("autorunner_sandbox_mode")),
137
- autorunner_workspace_write_network=(
138
- payload.get("autorunner_workspace_write_network")
139
- if isinstance(payload.get("autorunner_workspace_write_network"), bool)
140
- else None
141
- ),
142
- runner_stop_after_runs=_coerce_int(payload.get("runner_stop_after_runs")),
143
- runner_pid=_coerce_int(payload.get("runner_pid")),
144
- )
145
- sessions: dict[str, SessionRecord] = {}
146
- sessions_payload = payload.get("sessions")
147
- if isinstance(sessions_payload, dict):
148
- for session_id, record_payload in sessions_payload.items():
149
- if not isinstance(session_id, str) or not session_id:
150
- continue
151
- record = (
152
- SessionRecord.from_dict(record_payload)
153
- if isinstance(record_payload, dict)
154
- else None
155
- )
156
- if record is not None:
157
- sessions[session_id] = record
158
- repo_to_session: dict[str, str] = {}
159
- repo_payload = payload.get("repo_to_session")
160
- if isinstance(repo_payload, dict):
161
- for repo_key, session_id in repo_payload.items():
162
- if not isinstance(repo_key, str) or not repo_key:
163
- continue
164
- if not isinstance(session_id, str) or not session_id:
165
- continue
166
- repo_to_session[repo_key] = session_id
167
- state.sessions = sessions
168
- state.repo_to_session = repo_to_session
169
- return state
170
-
171
-
172
100
  def _ensure_state_schema(conn) -> None:
173
101
  with conn:
174
102
  conn.execute(
@@ -270,22 +198,6 @@ def _apply_overrides(state: RunnerState, raw: Optional[str]) -> None:
270
198
 
271
199
 
272
200
  def load_state(state_path: Path) -> RunnerState:
273
- legacy_path = state_path.with_name("state.json")
274
- # Legacy JSON migration (remove after old state.json is retired).
275
- if not state_path.exists() and legacy_path.exists():
276
- migrated = _load_legacy_state_json(legacy_path)
277
- if migrated is not None:
278
- try:
279
- save_state(state_path, migrated)
280
- return migrated
281
- except Exception:
282
- _logger.warning(
283
- "Failed to migrate legacy state from %s to %s. The original JSON file is preserved.",
284
- legacy_path,
285
- state_path,
286
- exc_info=True,
287
- )
288
- raise
289
201
  with open_sqlite(state_path) as conn:
290
202
  _ensure_state_schema(conn)
291
203
  row = conn.execute(
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import ExitStack
4
+ from importlib import resources
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ # Keep the required asset list close to the core boundary so core modules do not
9
+ # import from codex_autorunner.web.*
10
+ _REQUIRED_STATIC_ASSETS = (
11
+ "index.html",
12
+ "styles.css",
13
+ "bootstrap.js",
14
+ "loader.js",
15
+ "app.js",
16
+ "vendor/xterm.js",
17
+ "vendor/xterm-addon-fit.js",
18
+ "vendor/xterm.css",
19
+ )
20
+
21
+
22
+ def missing_static_assets(static_dir: Path) -> list[str]:
23
+ missing: list[str] = []
24
+ for rel_path in _REQUIRED_STATIC_ASSETS:
25
+ try:
26
+ if not (static_dir / rel_path).exists():
27
+ missing.append(rel_path)
28
+ except OSError:
29
+ missing.append(rel_path)
30
+ return missing
31
+
32
+
33
+ def resolve_static_dir() -> tuple[Path, Optional[ExitStack]]:
34
+ """Locate packaged static assets without importing codex_autorunner.web."""
35
+
36
+ static_root = resources.files("codex_autorunner").joinpath("static")
37
+ if isinstance(static_root, Path):
38
+ if static_root.exists():
39
+ return static_root, None
40
+ fallback = Path(__file__).resolve().parent.parent / "static"
41
+ return fallback, None
42
+
43
+ stack = ExitStack()
44
+ try:
45
+ static_path = stack.enter_context(resources.as_file(static_root))
46
+ except Exception:
47
+ stack.close()
48
+ fallback = Path(__file__).resolve().parent.parent / "static"
49
+ return fallback, None
50
+ if static_path.exists():
51
+ return static_path, stack
52
+
53
+ stack.close()
54
+ fallback = Path(__file__).resolve().parent.parent / "static"
55
+ return fallback, None