codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -1,40 +1,41 @@
1
- from __future__ import annotations
2
-
3
1
  import threading
4
- from collections.abc import Callable
2
+ from typing import Callable, Optional
5
3
 
6
- from .engine import Engine, LockError
7
4
  from .locks import DEFAULT_RUNNER_CMD_HINTS, assess_lock, process_alive, read_lock_info
8
5
  from .runner_process import build_runner_cmd, spawn_detached
6
+ from .runtime import LockError, RuntimeContext
9
7
  from .state import RunnerState, load_state, now_iso, save_state, state_lock
10
8
 
11
- SpawnRunnerFn = Callable[[list[str], Engine], object]
9
+ SpawnRunnerFn = Callable[[list[str], RuntimeContext], object]
12
10
 
13
11
 
14
- def _spawn_detached(cmd: list[str], engine: Engine) -> object:
15
- return spawn_detached(cmd, cwd=engine.repo_root)
12
+ def _spawn_detached(cmd: list[str], ctx: RuntimeContext) -> object:
13
+ return spawn_detached(cmd, cwd=ctx.repo_root)
16
14
 
17
15
 
18
16
  class ProcessRunnerController:
19
- def __init__(self, engine: Engine, *, spawn_fn: SpawnRunnerFn | None = None):
20
- self.engine = engine
17
+ def __init__(
18
+ self, ctx: RuntimeContext, *, spawn_fn: Optional[SpawnRunnerFn] = None
19
+ ):
20
+ self.ctx = ctx
21
21
  self._spawn_fn = spawn_fn or _spawn_detached
22
22
  self._lock = threading.Lock()
23
23
 
24
24
  @property
25
25
  def running(self) -> bool:
26
- return self.engine.runner_pid() is not None
26
+ return self.ctx.runner_pid() is not None
27
27
 
28
28
  def reconcile(self) -> None:
29
29
  lock_pid = None
30
- if self.engine.lock_path.exists():
31
- info = read_lock_info(self.engine.lock_path)
30
+ if self.ctx.lock_path.exists():
31
+ info = read_lock_info(self.ctx.lock_path)
32
32
  lock_pid = info.pid if info.pid and process_alive(info.pid) else None
33
33
  if not lock_pid:
34
- self.engine.lock_path.unlink(missing_ok=True)
34
+ self.ctx.lock_path.unlink(missing_ok=True)
35
35
 
36
- with state_lock(self.engine.state_path):
37
- state = load_state(self.engine.state_path)
36
+ durable = self.ctx.config.durable_writes
37
+ with state_lock(self.ctx.state_path):
38
+ state = load_state(self.ctx.state_path, durable=durable)
38
39
  if lock_pid:
39
40
  if state.runner_pid != lock_pid or state.status != "running":
40
41
  new_state = RunnerState(
@@ -53,8 +54,8 @@ class ProcessRunnerController:
53
54
  sessions=state.sessions,
54
55
  repo_to_session=state.repo_to_session,
55
56
  )
56
- save_state(self.engine.state_path, new_state)
57
- self.engine.reconcile_run_index()
57
+ save_state(self.ctx.state_path, new_state, durable=durable)
58
+ self.ctx.reconcile_run_index()
58
59
  return
59
60
 
60
61
  pid = state.runner_pid
@@ -84,17 +85,17 @@ class ProcessRunnerController:
84
85
  sessions=state.sessions,
85
86
  repo_to_session=state.repo_to_session,
86
87
  )
87
- save_state(self.engine.state_path, new_state)
88
+ save_state(self.ctx.state_path, new_state, durable=durable)
88
89
 
89
- self.engine.reconcile_run_index()
90
+ self.ctx.reconcile_run_index()
90
91
 
91
92
  def _ensure_unlocked(self) -> None:
92
- if not self.engine.lock_path.exists():
93
+ if not self.ctx.lock_path.exists():
93
94
  return
94
95
  assessment = self._clear_freeable_lock()
95
96
  if assessment.freeable:
96
97
  return
97
- info = read_lock_info(self.engine.lock_path)
98
+ info = read_lock_info(self.ctx.lock_path)
98
99
  pid = info.pid
99
100
  if pid and process_alive(pid):
100
101
  raise LockError(
@@ -104,14 +105,15 @@ class ProcessRunnerController:
104
105
 
105
106
  def _clear_freeable_lock(self):
106
107
  assessment = assess_lock(
107
- self.engine.lock_path,
108
+ self.ctx.lock_path,
108
109
  expected_cmd_substrings=DEFAULT_RUNNER_CMD_HINTS,
109
110
  )
110
111
  if not assessment.freeable:
111
112
  return assessment
112
- self.engine.lock_path.unlink(missing_ok=True)
113
- with state_lock(self.engine.state_path):
114
- state = load_state(self.engine.state_path)
113
+ self.ctx.lock_path.unlink(missing_ok=True)
114
+ durable = self.ctx.config.durable_writes
115
+ with state_lock(self.ctx.state_path):
116
+ state = load_state(self.ctx.state_path, durable=durable)
115
117
  if state.status == "running" or state.runner_pid:
116
118
  exit_code = state.last_exit_code
117
119
  if exit_code is None:
@@ -132,7 +134,7 @@ class ProcessRunnerController:
132
134
  sessions=state.sessions,
133
135
  repo_to_session=state.repo_to_session,
134
136
  )
135
- save_state(self.engine.state_path, new_state)
137
+ save_state(self.ctx.state_path, new_state, durable=durable)
136
138
  return assessment
137
139
 
138
140
  def clear_freeable_lock(self):
@@ -141,17 +143,17 @@ class ProcessRunnerController:
141
143
 
142
144
  def _spawn_runner(self, *, action: str, once: bool = False) -> None:
143
145
  cmd = build_runner_cmd(
144
- self.engine.repo_root,
146
+ self.ctx.repo_root,
145
147
  action=action,
146
148
  once=once,
147
149
  )
148
- self._spawn_fn(cmd, self.engine)
150
+ self._spawn_fn(cmd, self.ctx)
149
151
 
150
152
  def start(self, once: bool = False) -> None:
151
153
  with self._lock:
152
154
  self.reconcile()
153
155
  self._ensure_unlocked()
154
- self.engine.clear_stop_request()
156
+ self.ctx.clear_stop_request()
155
157
  action = "once" if once else "run"
156
158
  self._spawn_runner(action=action)
157
159
 
@@ -159,13 +161,13 @@ class ProcessRunnerController:
159
161
  with self._lock:
160
162
  self.reconcile()
161
163
  self._ensure_unlocked()
162
- self.engine.clear_stop_request()
164
+ self.ctx.clear_stop_request()
163
165
  self._spawn_runner(action="resume", once=once)
164
166
 
165
167
  def stop(self) -> None:
166
168
  with self._lock:
167
- self.engine.request_stop()
169
+ self.ctx.request_stop()
168
170
 
169
- def kill(self) -> int | None:
171
+ def kill(self) -> Optional[int]:
170
172
  with self._lock:
171
- return self.engine.kill_running_process()
173
+ return self.ctx.kill_running_process()
@@ -0,0 +1,147 @@
1
+ """Runner state and lock management utilities.
2
+
3
+ This module provides runner state operations extracted from Engine.
4
+ """
5
+
6
+ import os
7
+ import signal
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from .locks import (
12
+ DEFAULT_RUNNER_CMD_HINTS,
13
+ FileLock,
14
+ FileLockBusy,
15
+ assess_lock,
16
+ process_alive,
17
+ read_lock_info,
18
+ write_lock_info,
19
+ )
20
+ from .state import load_state, now_iso
21
+
22
+
23
+ class LockError(Exception):
24
+ """Raised when a runner lock cannot be acquired."""
25
+
26
+
27
+ def _timestamp() -> str:
28
+ return now_iso()
29
+
30
+
31
+ class RunnerStateManager:
32
+ """Manages runner state and locks for ticket flows."""
33
+
34
+ def __init__(
35
+ self,
36
+ repo_root: Path,
37
+ lock_path: Optional[Path] = None,
38
+ state_path: Optional[Path] = None,
39
+ ):
40
+ self.repo_root = repo_root
41
+ self.lock_path = lock_path or (repo_root / ".codex-autorunner" / "lock")
42
+ self.state_path = state_path or (
43
+ repo_root / ".codex-autorunner" / "state.sqlite3"
44
+ )
45
+ self.stop_path = repo_root / ".codex-autorunner" / "stop"
46
+ self._lock_handle: Optional[FileLock] = None
47
+
48
+ def acquire_lock(self, force: bool = False) -> None:
49
+ """Acquire the runner lock."""
50
+ self._lock_handle = FileLock(self.lock_path)
51
+ try:
52
+ self._lock_handle.acquire(blocking=False)
53
+ except FileLockBusy as exc:
54
+ info = read_lock_info(self.lock_path)
55
+ pid = info.pid
56
+ if pid and process_alive(pid):
57
+ raise LockError(
58
+ f"Another autorunner is active (pid={pid}); stop it before continuing"
59
+ ) from exc
60
+ raise LockError(
61
+ "Another autorunner is active; stop it before continuing"
62
+ ) from exc
63
+
64
+ info = read_lock_info(self.lock_path)
65
+ pid = info.pid
66
+ if pid and process_alive(pid) and not force:
67
+ self._lock_handle.release()
68
+ self._lock_handle = None
69
+ raise LockError(
70
+ f"Another autorunner is active (pid={pid}); use --force to override"
71
+ )
72
+ write_lock_info(
73
+ self.lock_path,
74
+ os.getpid(),
75
+ started_at=_timestamp(),
76
+ lock_file=self._lock_handle.file,
77
+ )
78
+
79
+ def release_lock(self) -> None:
80
+ """Release the runner lock."""
81
+ if self._lock_handle is not None:
82
+ self._lock_handle.release()
83
+ self._lock_handle = None
84
+ if self.lock_path.exists():
85
+ self.lock_path.unlink()
86
+
87
+ def repo_busy_reason(self) -> Optional[str]:
88
+ """Return a reason why the repo is busy, or None if not busy."""
89
+ if self.lock_path.exists():
90
+ assessment = assess_lock(
91
+ self.lock_path,
92
+ expected_cmd_substrings=DEFAULT_RUNNER_CMD_HINTS,
93
+ )
94
+ if assessment.freeable:
95
+ return "Autorunner lock is stale; clear it before continuing."
96
+ pid = assessment.pid
97
+ if pid and process_alive(pid):
98
+ host = f" on {assessment.host}" if assessment.host else ""
99
+ return f"Autorunner is running (pid={pid}{host}); try again later."
100
+ return "Autorunner lock present; clear or resume before continuing."
101
+
102
+ state = load_state(self.state_path)
103
+ if state.status == "running":
104
+ if state.runner_pid and process_alive(state.runner_pid):
105
+ return f"Autorunner is currently running (pid={state.runner_pid}); try again later."
106
+ return "Autorunner state is stale; use 'car resume' to continue."
107
+ return None
108
+
109
+ def request_stop(self) -> None:
110
+ """Request a stop by writing to the stop path."""
111
+ self.stop_path.parent.mkdir(parents=True, exist_ok=True)
112
+ self.stop_path.write_text(f"{_timestamp()}\n")
113
+
114
+ def clear_stop_request(self) -> None:
115
+ """Clear a stop request."""
116
+ self.stop_path.unlink(missing_ok=True)
117
+
118
+ def stop_requested(self) -> bool:
119
+ """Check if a stop has been requested."""
120
+ return self.stop_path.exists()
121
+
122
+ def kill_running_process(self) -> Optional[int]:
123
+ """Force-kill the process holding the lock, if any. Returns pid if killed."""
124
+ if not self.lock_path.exists():
125
+ return None
126
+ info = read_lock_info(self.lock_path)
127
+ pid = info.pid
128
+ if pid and process_alive(pid):
129
+ try:
130
+ os.kill(pid, signal.SIGTERM)
131
+ return pid
132
+ except OSError:
133
+ return None
134
+ # stale lock
135
+ self.lock_path.unlink(missing_ok=True)
136
+ return None
137
+
138
+ def runner_pid(self) -> Optional[int]:
139
+ """Get the PID of the running runner."""
140
+ state = load_state(self.state_path)
141
+ pid = state.runner_pid
142
+ if pid and process_alive(pid):
143
+ return pid
144
+ info = read_lock_info(self.lock_path)
145
+ if info.pid and process_alive(info.pid):
146
+ return info.pid
147
+ return None