codex-autorunner 0.1.1__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 (226) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/__init__.py +20 -0
  3. codex_autorunner/agents/base.py +2 -2
  4. codex_autorunner/agents/codex/harness.py +1 -1
  5. codex_autorunner/agents/opencode/__init__.py +4 -0
  6. codex_autorunner/agents/opencode/agent_config.py +104 -0
  7. codex_autorunner/agents/opencode/client.py +305 -28
  8. codex_autorunner/agents/opencode/harness.py +71 -20
  9. codex_autorunner/agents/opencode/logging.py +225 -0
  10. codex_autorunner/agents/opencode/run_prompt.py +261 -0
  11. codex_autorunner/agents/opencode/runtime.py +1202 -132
  12. codex_autorunner/agents/opencode/supervisor.py +194 -68
  13. codex_autorunner/agents/registry.py +258 -0
  14. codex_autorunner/agents/types.py +2 -2
  15. codex_autorunner/api.py +25 -0
  16. codex_autorunner/bootstrap.py +19 -40
  17. codex_autorunner/cli.py +234 -151
  18. codex_autorunner/core/about_car.py +44 -32
  19. codex_autorunner/core/adapter_utils.py +21 -0
  20. codex_autorunner/core/app_server_events.py +15 -6
  21. codex_autorunner/core/app_server_logging.py +55 -15
  22. codex_autorunner/core/app_server_prompts.py +28 -259
  23. codex_autorunner/core/app_server_threads.py +15 -26
  24. codex_autorunner/core/circuit_breaker.py +183 -0
  25. codex_autorunner/core/codex_runner.py +6 -0
  26. codex_autorunner/core/config.py +555 -133
  27. codex_autorunner/core/docs.py +54 -9
  28. codex_autorunner/core/drafts.py +82 -0
  29. codex_autorunner/core/engine.py +828 -274
  30. codex_autorunner/core/exceptions.py +60 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +178 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +75 -0
  35. codex_autorunner/core/flows/runtime.py +351 -0
  36. codex_autorunner/core/flows/store.py +485 -0
  37. codex_autorunner/core/flows/transition.py +133 -0
  38. codex_autorunner/core/flows/worker_process.py +242 -0
  39. codex_autorunner/core/hub.py +21 -13
  40. codex_autorunner/core/locks.py +118 -1
  41. codex_autorunner/core/logging_utils.py +9 -6
  42. codex_autorunner/core/path_utils.py +123 -0
  43. codex_autorunner/core/prompt.py +15 -7
  44. codex_autorunner/core/redaction.py +29 -0
  45. codex_autorunner/core/retry.py +61 -0
  46. codex_autorunner/core/review.py +888 -0
  47. codex_autorunner/core/review_context.py +161 -0
  48. codex_autorunner/core/run_index.py +223 -0
  49. codex_autorunner/core/runner_controller.py +44 -1
  50. codex_autorunner/core/runner_process.py +30 -1
  51. codex_autorunner/core/sqlite_utils.py +32 -0
  52. codex_autorunner/core/state.py +273 -44
  53. codex_autorunner/core/static_assets.py +55 -0
  54. codex_autorunner/core/supervisor_utils.py +67 -0
  55. codex_autorunner/core/text_delta_coalescer.py +43 -0
  56. codex_autorunner/core/update.py +20 -11
  57. codex_autorunner/core/update_runner.py +2 -0
  58. codex_autorunner/core/usage.py +107 -75
  59. codex_autorunner/core/utils.py +167 -3
  60. codex_autorunner/discovery.py +3 -3
  61. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  62. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  63. codex_autorunner/integrations/agents/__init__.py +27 -0
  64. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  65. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  66. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  67. codex_autorunner/integrations/agents/run_event.py +71 -0
  68. codex_autorunner/integrations/app_server/client.py +708 -153
  69. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  70. codex_autorunner/integrations/telegram/adapter.py +474 -185
  71. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  72. codex_autorunner/integrations/telegram/config.py +239 -1
  73. codex_autorunner/integrations/telegram/constants.py +19 -1
  74. codex_autorunner/integrations/telegram/dispatch.py +44 -8
  75. codex_autorunner/integrations/telegram/doctor.py +47 -0
  76. codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
  77. codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
  78. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
  79. codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
  80. codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
  81. codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
  82. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  83. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
  84. codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
  85. codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
  86. codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
  87. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
  88. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
  89. codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
  90. codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
  91. codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
  92. codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
  93. codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
  94. codex_autorunner/integrations/telegram/helpers.py +90 -18
  95. codex_autorunner/integrations/telegram/notifications.py +126 -35
  96. codex_autorunner/integrations/telegram/outbox.py +214 -43
  97. codex_autorunner/integrations/telegram/progress_stream.py +42 -19
  98. codex_autorunner/integrations/telegram/runtime.py +24 -13
  99. codex_autorunner/integrations/telegram/service.py +500 -129
  100. codex_autorunner/integrations/telegram/state.py +1278 -330
  101. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  102. codex_autorunner/integrations/telegram/transport.py +37 -4
  103. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  104. codex_autorunner/integrations/telegram/types.py +22 -2
  105. codex_autorunner/integrations/telegram/voice.py +14 -15
  106. codex_autorunner/manifest.py +2 -0
  107. codex_autorunner/plugin_api.py +22 -0
  108. codex_autorunner/routes/__init__.py +25 -14
  109. codex_autorunner/routes/agents.py +18 -78
  110. codex_autorunner/routes/analytics.py +239 -0
  111. codex_autorunner/routes/base.py +142 -113
  112. codex_autorunner/routes/file_chat.py +836 -0
  113. codex_autorunner/routes/flows.py +980 -0
  114. codex_autorunner/routes/messages.py +459 -0
  115. codex_autorunner/routes/repos.py +17 -0
  116. codex_autorunner/routes/review.py +148 -0
  117. codex_autorunner/routes/sessions.py +16 -8
  118. codex_autorunner/routes/settings.py +22 -0
  119. codex_autorunner/routes/shared.py +33 -3
  120. codex_autorunner/routes/system.py +22 -1
  121. codex_autorunner/routes/usage.py +87 -0
  122. codex_autorunner/routes/voice.py +5 -13
  123. codex_autorunner/routes/workspace.py +271 -0
  124. codex_autorunner/server.py +2 -1
  125. codex_autorunner/static/agentControls.js +9 -1
  126. codex_autorunner/static/agentEvents.js +248 -0
  127. codex_autorunner/static/app.js +27 -22
  128. codex_autorunner/static/autoRefresh.js +29 -1
  129. codex_autorunner/static/bootstrap.js +1 -0
  130. codex_autorunner/static/bus.js +1 -0
  131. codex_autorunner/static/cache.js +1 -0
  132. codex_autorunner/static/constants.js +20 -4
  133. codex_autorunner/static/dashboard.js +162 -150
  134. codex_autorunner/static/diffRenderer.js +37 -0
  135. codex_autorunner/static/docChatCore.js +324 -0
  136. codex_autorunner/static/docChatStorage.js +65 -0
  137. codex_autorunner/static/docChatVoice.js +65 -0
  138. codex_autorunner/static/docEditor.js +133 -0
  139. codex_autorunner/static/env.js +1 -0
  140. codex_autorunner/static/eventSummarizer.js +166 -0
  141. codex_autorunner/static/fileChat.js +182 -0
  142. codex_autorunner/static/health.js +155 -0
  143. codex_autorunner/static/hub.js +67 -126
  144. codex_autorunner/static/index.html +788 -807
  145. codex_autorunner/static/liveUpdates.js +59 -0
  146. codex_autorunner/static/loader.js +1 -0
  147. codex_autorunner/static/messages.js +470 -0
  148. codex_autorunner/static/mobileCompact.js +2 -1
  149. codex_autorunner/static/settings.js +24 -205
  150. codex_autorunner/static/styles.css +7577 -3758
  151. codex_autorunner/static/tabs.js +28 -5
  152. codex_autorunner/static/terminal.js +14 -0
  153. codex_autorunner/static/terminalManager.js +53 -59
  154. codex_autorunner/static/ticketChatActions.js +333 -0
  155. codex_autorunner/static/ticketChatEvents.js +16 -0
  156. codex_autorunner/static/ticketChatStorage.js +16 -0
  157. codex_autorunner/static/ticketChatStream.js +264 -0
  158. codex_autorunner/static/ticketEditor.js +750 -0
  159. codex_autorunner/static/ticketVoice.js +9 -0
  160. codex_autorunner/static/tickets.js +1315 -0
  161. codex_autorunner/static/utils.js +32 -3
  162. codex_autorunner/static/voice.js +21 -7
  163. codex_autorunner/static/workspace.js +672 -0
  164. codex_autorunner/static/workspaceApi.js +53 -0
  165. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  166. codex_autorunner/tickets/__init__.py +20 -0
  167. codex_autorunner/tickets/agent_pool.py +377 -0
  168. codex_autorunner/tickets/files.py +85 -0
  169. codex_autorunner/tickets/frontmatter.py +55 -0
  170. codex_autorunner/tickets/lint.py +102 -0
  171. codex_autorunner/tickets/models.py +95 -0
  172. codex_autorunner/tickets/outbox.py +232 -0
  173. codex_autorunner/tickets/replies.py +179 -0
  174. codex_autorunner/tickets/runner.py +823 -0
  175. codex_autorunner/tickets/spec_ingest.py +77 -0
  176. codex_autorunner/voice/capture.py +7 -7
  177. codex_autorunner/voice/service.py +51 -9
  178. codex_autorunner/web/app.py +419 -199
  179. codex_autorunner/web/hub_jobs.py +13 -2
  180. codex_autorunner/web/middleware.py +47 -13
  181. codex_autorunner/web/pty_session.py +26 -13
  182. codex_autorunner/web/schemas.py +114 -109
  183. codex_autorunner/web/static_assets.py +55 -42
  184. codex_autorunner/web/static_refresh.py +86 -0
  185. codex_autorunner/workspace/__init__.py +40 -0
  186. codex_autorunner/workspace/paths.py +319 -0
  187. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
  188. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  189. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  190. codex_autorunner/core/doc_chat.py +0 -1415
  191. codex_autorunner/core/snapshot.py +0 -580
  192. codex_autorunner/integrations/github/chatops.py +0 -268
  193. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  194. codex_autorunner/routes/docs.py +0 -381
  195. codex_autorunner/routes/github.py +0 -327
  196. codex_autorunner/routes/runs.py +0 -118
  197. codex_autorunner/spec_ingest.py +0 -788
  198. codex_autorunner/static/docChatActions.js +0 -279
  199. codex_autorunner/static/docChatEvents.js +0 -300
  200. codex_autorunner/static/docChatRender.js +0 -205
  201. codex_autorunner/static/docChatStream.js +0 -361
  202. codex_autorunner/static/docs.js +0 -20
  203. codex_autorunner/static/docsClipboard.js +0 -69
  204. codex_autorunner/static/docsCrud.js +0 -257
  205. codex_autorunner/static/docsDocUpdates.js +0 -62
  206. codex_autorunner/static/docsDrafts.js +0 -16
  207. codex_autorunner/static/docsElements.js +0 -69
  208. codex_autorunner/static/docsInit.js +0 -274
  209. codex_autorunner/static/docsParse.js +0 -160
  210. codex_autorunner/static/docsSnapshot.js +0 -87
  211. codex_autorunner/static/docsSpecIngest.js +0 -263
  212. codex_autorunner/static/docsState.js +0 -127
  213. codex_autorunner/static/docsThreadRegistry.js +0 -44
  214. codex_autorunner/static/docsUi.js +0 -153
  215. codex_autorunner/static/docsVoice.js +0 -56
  216. codex_autorunner/static/github.js +0 -442
  217. codex_autorunner/static/logs.js +0 -640
  218. codex_autorunner/static/runs.js +0 -409
  219. codex_autorunner/static/snapshot.js +0 -124
  220. codex_autorunner/static/state.js +0 -86
  221. codex_autorunner/static/todoPreview.js +0 -27
  222. codex_autorunner/workspace.py +0 -16
  223. codex_autorunner-0.1.1.dist-info/RECORD +0 -191
  224. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  225. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  226. {codex_autorunner-0.1.1.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
@@ -26,7 +26,7 @@ from .git_utils import (
26
26
  git_upstream_status,
27
27
  run_git,
28
28
  )
29
- from .locks import process_alive, read_lock_info
29
+ from .locks import DEFAULT_RUNNER_CMD_HINTS, assess_lock, process_alive
30
30
  from .runner_controller import ProcessRunnerController, SpawnRunnerFn
31
31
  from .state import RunnerState, load_state, now_iso
32
32
  from .utils import atomic_write
@@ -120,9 +120,11 @@ class HubState:
120
120
  def read_lock_status(lock_path: Path) -> LockStatus:
121
121
  if not lock_path.exists():
122
122
  return LockStatus.UNLOCKED
123
- info = read_lock_info(lock_path)
124
- pid = info.pid
125
- if pid and process_alive(pid):
123
+ assessment = assess_lock(
124
+ lock_path,
125
+ expected_cmd_substrings=DEFAULT_RUNNER_CMD_HINTS,
126
+ )
127
+ if not assessment.freeable and assessment.pid and process_alive(assessment.pid):
126
128
  return LockStatus.LOCKED_ALIVE
127
129
  return LockStatus.LOCKED_STALE
128
130
 
@@ -135,7 +137,8 @@ def load_hub_state(state_path: Path, hub_root: Path) -> HubState:
135
137
  import json
136
138
 
137
139
  payload = json.loads(data)
138
- except Exception:
140
+ except Exception as exc:
141
+ logger.warning("Failed to parse hub state from %s: %s", state_path, exc)
139
142
  return HubState(last_scan_at=None, repos=[])
140
143
  last_scan_at = payload.get("last_scan_at")
141
144
  repos_payload = payload.get("repos") or []
@@ -166,7 +169,13 @@ def load_hub_state(state_path: Path, hub_root: Path) -> HubState:
166
169
  runner_pid=entry.get("runner_pid"),
167
170
  )
168
171
  repos.append(repo)
169
- 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
+ )
170
179
  continue
171
180
  return HubState(last_scan_at=last_scan_at, repos=repos)
172
181
 
@@ -757,10 +766,10 @@ class HubSupervisor:
757
766
  if not repo:
758
767
  raise ValueError(f"Repo {repo_id} not found in manifest")
759
768
  repo_root = (self.hub_config.root / repo.path).resolve()
760
- state_path = repo_root / ".codex-autorunner" / "state.json"
761
- 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():
762
771
  raise ValueError(f"Repo {repo_id} is not initialized")
763
- if not state_path.exists():
772
+ if not tickets_dir.exists():
764
773
  return None
765
774
  repo_config = derive_repo_config(self.hub_config, repo_root, load_env=False)
766
775
  runner = RepoRunner(
@@ -779,7 +788,7 @@ class HubSupervisor:
779
788
  records: List[DiscoveryRecord] = []
780
789
  for entry in manifest.repos:
781
790
  repo_path = (self.hub_config.root / entry.path).resolve()
782
- initialized = (repo_path / ".codex-autorunner" / "state.json").exists()
791
+ initialized = (repo_path / ".codex-autorunner" / "tickets").exists()
783
792
  records.append(
784
793
  DiscoveryRecord(
785
794
  repo=entry,
@@ -819,9 +828,8 @@ class HubSupervisor:
819
828
  lock_status = read_lock_status(lock_path)
820
829
 
821
830
  runner_state: Optional[RunnerState] = None
822
- state_path = repo_path / ".codex-autorunner" / "state.json"
823
- if record.initialized and state_path.exists():
824
- runner_state = load_state(state_path)
831
+ if record.initialized:
832
+ runner_state = load_state(repo_path / ".codex-autorunner" / "state.sqlite3")
825
833
 
826
834
  is_clean: Optional[bool] = None
827
835
  if record.exists_on_disk and git_available(repo_path):
@@ -1,11 +1,13 @@
1
1
  import errno
2
2
  import json
3
+ import logging
3
4
  import os
4
5
  import socket
6
+ import subprocess
5
7
  from contextlib import contextmanager
6
8
  from dataclasses import dataclass
7
9
  from pathlib import Path
8
- from typing import IO, Iterator, Optional
10
+ from typing import IO, Iterable, Iterator, Optional
9
11
 
10
12
  from .utils import atomic_write
11
13
 
@@ -17,6 +19,18 @@ class LockInfo:
17
19
  host: Optional[str]
18
20
 
19
21
 
22
+ @dataclass
23
+ class LockAssessment:
24
+ freeable: bool
25
+ reason: Optional[str]
26
+ pid: Optional[int]
27
+ host: Optional[str]
28
+
29
+
30
+ DEFAULT_RUNNER_CMD_HINTS = ("codex_autorunner.cli", "codex-autorunner", "car ")
31
+ logger = logging.getLogger(__name__)
32
+
33
+
20
34
  def process_alive(pid: int) -> bool:
21
35
  try:
22
36
  os.kill(pid, 0)
@@ -25,6 +39,109 @@ def process_alive(pid: int) -> bool:
25
39
  return True
26
40
 
27
41
 
42
+ def process_is_zombie(pid: int) -> bool:
43
+ if os.name == "nt":
44
+ return False
45
+ try:
46
+ result = subprocess.run(
47
+ ["ps", "-o", "stat=", "-p", str(pid)],
48
+ capture_output=True,
49
+ text=True,
50
+ check=False,
51
+ )
52
+ except Exception:
53
+ logger.debug("Failed to check process status for pid %s", pid, exc_info=True)
54
+ return False
55
+ if result.returncode != 0:
56
+ return False
57
+ return "Z" in result.stdout.strip()
58
+
59
+
60
+ def process_command(pid: int) -> Optional[str]:
61
+ if os.name == "nt":
62
+ return None
63
+ try:
64
+ result = subprocess.run(
65
+ ["ps", "-o", "command=", "-p", str(pid)],
66
+ capture_output=True,
67
+ text=True,
68
+ check=False,
69
+ )
70
+ except Exception:
71
+ logger.debug("Failed to inspect process command for pid %s", pid, exc_info=True)
72
+ return None
73
+ if result.returncode != 0:
74
+ return None
75
+ command = result.stdout.strip()
76
+ return command or None
77
+
78
+
79
+ def process_is_active(pid: int) -> bool:
80
+ return process_alive(pid) and not process_is_zombie(pid)
81
+
82
+
83
+ def assess_lock(
84
+ lock_path: Path,
85
+ *,
86
+ expected_cmd_substrings: Optional[Iterable[str]] = None,
87
+ require_host_match: bool = True,
88
+ ) -> LockAssessment:
89
+ if not lock_path.exists():
90
+ return LockAssessment(freeable=False, reason=None, pid=None, host=None)
91
+ info = read_lock_info(lock_path)
92
+ pid = info.pid
93
+ if not pid:
94
+ return LockAssessment(
95
+ freeable=True,
96
+ reason="Lock has no pid; safe to clear.",
97
+ pid=None,
98
+ host=info.host,
99
+ )
100
+ if not process_alive(pid):
101
+ return LockAssessment(
102
+ freeable=True,
103
+ reason="Lock pid is not running; safe to clear.",
104
+ pid=pid,
105
+ host=info.host,
106
+ )
107
+ if process_is_zombie(pid):
108
+ return LockAssessment(
109
+ freeable=True,
110
+ reason="Lock pid is a zombie process; safe to clear.",
111
+ pid=pid,
112
+ host=info.host,
113
+ )
114
+ if require_host_match and info.host and info.host != socket.gethostname():
115
+ return LockAssessment(
116
+ freeable=False,
117
+ reason="Lock belongs to another host.",
118
+ pid=pid,
119
+ host=info.host,
120
+ )
121
+ if expected_cmd_substrings:
122
+ command = process_command(pid)
123
+ if command is None:
124
+ return LockAssessment(
125
+ freeable=False,
126
+ reason="Unable to inspect lock pid command.",
127
+ pid=pid,
128
+ host=info.host,
129
+ )
130
+ if not any(fragment in command for fragment in expected_cmd_substrings):
131
+ return LockAssessment(
132
+ freeable=True,
133
+ reason="Lock pid command does not match autorunner; safe to clear.",
134
+ pid=pid,
135
+ host=info.host,
136
+ )
137
+ return LockAssessment(
138
+ freeable=False,
139
+ reason=None,
140
+ pid=pid,
141
+ host=info.host,
142
+ )
143
+
144
+
28
145
  try:
29
146
  import fcntl
30
147
  except ImportError: # pragma: no cover - Windows fallback
@@ -8,6 +8,8 @@ from typing import Any, Mapping, Optional, OrderedDict
8
8
  from .config import LogConfig
9
9
  from .request_context import get_conversation_id, get_request_id
10
10
 
11
+ logger = logging.getLogger("codex_autorunner.core.logging_utils")
12
+
11
13
  _MAX_CACHED_LOGGERS = 64
12
14
  _LOGGER_CACHE: "OrderedDict[str, logging.Logger]" = collections.OrderedDict()
13
15
  _REDACTED_VALUE = "<redacted>"
@@ -60,10 +62,10 @@ def setup_rotating_logger(name: str, log_config: LogConfig) -> logging.Logger:
60
62
  for h in list(evicted.handlers):
61
63
  try:
62
64
  h.close()
63
- except Exception:
65
+ except (OSError, ValueError):
64
66
  pass
65
67
  evicted.handlers.clear()
66
- except Exception:
68
+ except (OSError, ValueError, RuntimeError):
67
69
  pass
68
70
  return logger
69
71
 
@@ -74,18 +76,19 @@ def safe_log(
74
76
  message: str,
75
77
  *args,
76
78
  exc: Optional[Exception] = None,
79
+ exc_info: bool = False,
77
80
  ) -> None:
78
81
  try:
79
82
  formatted = message
80
83
  if args:
81
84
  try:
82
85
  formatted = message % args
83
- except Exception:
86
+ except (TypeError, ValueError):
84
87
  formatted = f"{message} {' '.join(str(arg) for arg in args)}"
85
88
  if exc is not None:
86
89
  formatted = f"{formatted}: {exc}"
87
- logger.log(level, formatted)
88
- except Exception:
90
+ logger.log(level, formatted, exc_info=exc_info)
91
+ except (OSError, TypeError, ValueError, RuntimeError):
89
92
  pass
90
93
 
91
94
 
@@ -114,7 +117,7 @@ def log_event(
114
117
  try:
115
118
  message = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
116
119
  logger.log(level, message)
117
- except Exception:
120
+ except (TypeError, ValueError, OverflowError, RuntimeError):
118
121
  pass
119
122
 
120
123
 
@@ -0,0 +1,123 @@
1
+ from pathlib import Path
2
+ from typing import Optional, Union
3
+
4
+ PathLike = Union[str, Path]
5
+
6
+
7
+ class ConfigPathError(Exception):
8
+ """Raised when a config path is invalid."""
9
+
10
+ def __init__(
11
+ self,
12
+ message: str,
13
+ *,
14
+ path: Optional[str] = None,
15
+ resolved: Optional[Path] = None,
16
+ scope: Optional[str] = None,
17
+ ) -> None:
18
+ super().__init__(message)
19
+ self.path = path
20
+ self.resolved = resolved
21
+ self.scope = scope
22
+
23
+ def __str__(self) -> str:
24
+ msg = super().__str__()
25
+ if self.scope:
26
+ msg = f"{self.scope}: {msg}"
27
+ if self.path:
28
+ msg = f"{msg} (path: {self.path})"
29
+ if self.resolved:
30
+ msg = f"{msg} (resolved: {self.resolved})"
31
+ return msg
32
+
33
+
34
+ def resolve_config_path(
35
+ value: PathLike,
36
+ repo_root: Path,
37
+ *,
38
+ allow_absolute: bool = False,
39
+ allow_home: bool = False,
40
+ allow_dotdot: bool = False,
41
+ scope: Optional[str] = None,
42
+ ) -> Path:
43
+ """
44
+ Resolve a config path according to standard rules.
45
+
46
+ Rules:
47
+ 1. If value starts with '/' and allow_absolute=True, use as-is
48
+ 2. If value starts with '~', expand to home directory
49
+ 3. Otherwise, resolve relative to repo_root
50
+ 4. Reject '..' segments unless allow_dotdot=True
51
+ 5. Reject paths escaping repo_root (except home expansion)
52
+
53
+ Args:
54
+ value: Path string or Path object
55
+ repo_root: Repository root directory
56
+ allow_absolute: Allow absolute paths (default False)
57
+ allow_home: Allow home directory expansion with ~ (default False)
58
+ allow_dotdot: Allow '..' segments (default False, for security)
59
+ scope: Config section name for error messages (e.g., 'docs.todo')
60
+
61
+ Returns:
62
+ Resolved Path object
63
+
64
+ Raises:
65
+ ConfigPathError: If path is invalid
66
+ """
67
+ value_str = str(value)
68
+
69
+ if not value_str:
70
+ raise ConfigPathError("Path cannot be empty", path=value_str, scope=scope)
71
+
72
+ if value_str.strip() == "":
73
+ raise ConfigPathError(
74
+ "Path cannot be whitespace only", path=value_str, scope=scope
75
+ )
76
+
77
+ value_str = value_str.strip()
78
+
79
+ path = Path(value_str)
80
+
81
+ if path.is_absolute():
82
+ if allow_absolute:
83
+ return path.resolve()
84
+ raise ConfigPathError(
85
+ "Absolute paths are not allowed",
86
+ path=value_str,
87
+ scope=scope,
88
+ )
89
+
90
+ if str(path).startswith("~"):
91
+ if not allow_home:
92
+ raise ConfigPathError(
93
+ "Home directory expansion (~) is not allowed",
94
+ path=value_str,
95
+ scope=scope,
96
+ )
97
+ if not allow_dotdot and ".." in path.parts:
98
+ raise ConfigPathError(
99
+ "Path contains '..' segments",
100
+ path=value_str,
101
+ scope=scope,
102
+ )
103
+ resolved = path.expanduser().resolve()
104
+ return resolved
105
+
106
+ if not allow_dotdot and ".." in path.parts:
107
+ raise ConfigPathError(
108
+ "Path contains '..' segments",
109
+ path=value_str,
110
+ scope=scope,
111
+ )
112
+
113
+ resolved = (repo_root / path).resolve()
114
+
115
+ if not allow_home and not allow_dotdot and not resolved.is_relative_to(repo_root):
116
+ raise ConfigPathError(
117
+ "Path resolves outside repo root",
118
+ path=value_str,
119
+ resolved=resolved,
120
+ scope=scope,
121
+ )
122
+
123
+ return resolved
@@ -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"),