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,11 +1,13 @@
1
+ import asyncio
1
2
  import dataclasses
2
3
  import enum
3
4
  import logging
4
5
  import re
5
6
  import shutil
7
+ import threading
6
8
  import time
7
9
  from pathlib import Path
8
- from typing import Dict, List, Optional, Tuple
10
+ from typing import Any, Callable, Dict, List, Optional, Tuple
9
11
 
10
12
  from ..bootstrap import seed_repo_files
11
13
  from ..discovery import DiscoveryRecord, discover_and_init
@@ -16,23 +18,35 @@ from ..manifest import (
16
18
  sanitize_repo_id,
17
19
  save_manifest,
18
20
  )
21
+ from .archive import archive_worktree_snapshot, build_snapshot_id
19
22
  from .config import HubConfig, RepoConfig, derive_repo_config, load_hub_config
20
- from .engine import Engine
21
23
  from .git_utils import (
22
24
  GitError,
23
25
  git_available,
26
+ git_branch,
24
27
  git_default_branch,
28
+ git_head_sha,
25
29
  git_is_clean,
26
30
  git_upstream_status,
27
31
  run_git,
28
32
  )
33
+ from .lifecycle_events import LifecycleEvent, LifecycleEventEmitter, LifecycleEventStore
29
34
  from .locks import DEFAULT_RUNNER_CMD_HINTS, assess_lock, process_alive
35
+ from .ports.backend_orchestrator import (
36
+ BackendOrchestrator as BackendOrchestratorProtocol,
37
+ )
30
38
  from .runner_controller import ProcessRunnerController, SpawnRunnerFn
39
+ from .runtime import RuntimeContext
31
40
  from .state import RunnerState, load_state, now_iso
41
+ from .types import AppServerSupervisorFactory, BackendFactory
32
42
  from .utils import atomic_write
33
43
 
34
44
  logger = logging.getLogger("codex_autorunner.hub")
35
45
 
46
+ BackendFactoryBuilder = Callable[[Path, RepoConfig], BackendFactory]
47
+ AppServerSupervisorFactoryBuilder = Callable[[RepoConfig], AppServerSupervisorFactory]
48
+ BackendOrchestratorBuilder = Callable[[Path, RepoConfig], BackendOrchestratorProtocol]
49
+
36
50
 
37
51
  def _git_failure_detail(proc) -> str:
38
52
  return (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}"
@@ -195,10 +209,29 @@ class RepoRunner:
195
209
  *,
196
210
  repo_config: RepoConfig,
197
211
  spawn_fn: Optional[SpawnRunnerFn] = None,
212
+ backend_factory_builder: Optional[BackendFactoryBuilder] = None,
213
+ app_server_supervisor_factory_builder: Optional[
214
+ AppServerSupervisorFactoryBuilder
215
+ ] = None,
216
+ backend_orchestrator_builder: Optional[BackendOrchestratorBuilder] = None,
217
+ agent_id_validator: Optional[Callable[[str], str]] = None,
198
218
  ):
199
219
  self.repo_id = repo_id
200
- self._engine = Engine(repo_root, config=repo_config)
201
- self._controller = ProcessRunnerController(self._engine, spawn_fn=spawn_fn)
220
+ backend_orchestrator = (
221
+ backend_orchestrator_builder(repo_root, repo_config)
222
+ if backend_orchestrator_builder is not None
223
+ else None
224
+ )
225
+ if backend_orchestrator is None:
226
+ raise ValueError(
227
+ "backend_orchestrator_builder is required for HubSupervisor"
228
+ )
229
+ self._ctx = RuntimeContext(
230
+ repo_root=repo_root,
231
+ config=repo_config,
232
+ backend_orchestrator=backend_orchestrator,
233
+ )
234
+ self._controller = ProcessRunnerController(self._ctx, spawn_fn=spawn_fn)
202
235
 
203
236
  @property
204
237
  def running(self) -> bool:
@@ -219,21 +252,60 @@ class RepoRunner:
219
252
 
220
253
  class HubSupervisor:
221
254
  def __init__(
222
- self, hub_config: HubConfig, *, spawn_fn: Optional[SpawnRunnerFn] = None
255
+ self,
256
+ hub_config: HubConfig,
257
+ *,
258
+ spawn_fn: Optional[SpawnRunnerFn] = None,
259
+ backend_factory_builder: Optional[BackendFactoryBuilder] = None,
260
+ app_server_supervisor_factory_builder: Optional[
261
+ AppServerSupervisorFactoryBuilder
262
+ ] = None,
263
+ backend_orchestrator_builder: Optional[BackendOrchestratorBuilder] = None,
264
+ agent_id_validator: Optional[Callable[[str], str]] = None,
223
265
  ):
224
266
  self.hub_config = hub_config
225
267
  self.state_path = hub_config.root / ".codex-autorunner" / "hub_state.json"
226
268
  self._runners: Dict[str, RepoRunner] = {}
227
269
  self._spawn_fn = spawn_fn
270
+ self._backend_factory_builder = backend_factory_builder
271
+ self._app_server_supervisor_factory_builder = (
272
+ app_server_supervisor_factory_builder
273
+ )
274
+ self._backend_orchestrator_builder = backend_orchestrator_builder
275
+ self._agent_id_validator = agent_id_validator
228
276
  self.state = load_hub_state(self.state_path, self.hub_config.root)
229
277
  self._list_cache_at: Optional[float] = None
230
278
  self._list_cache: Optional[List[RepoSnapshot]] = None
279
+ self._list_lock = threading.Lock()
280
+ self._lifecycle_emitter = LifecycleEventEmitter(hub_config.root)
281
+ self._lifecycle_task_lock = threading.Lock()
282
+ self._lifecycle_stop_event = threading.Event()
283
+ self._lifecycle_thread: Optional[threading.Thread] = None
284
+ self._dispatch_interceptor_task: Optional[asyncio.Task] = None
285
+ self._dispatch_interceptor_stop_event: Optional[threading.Event] = None
286
+ self._dispatch_interceptor_thread: Optional[threading.Thread] = None
231
287
  self._reconcile_startup()
288
+ self._start_lifecycle_event_processor()
289
+ self._start_dispatch_interceptor()
232
290
 
233
291
  @classmethod
234
- def from_path(cls, path: Path) -> "HubSupervisor":
292
+ def from_path(
293
+ cls,
294
+ path: Path,
295
+ *,
296
+ backend_factory_builder: Optional[BackendFactoryBuilder] = None,
297
+ app_server_supervisor_factory_builder: Optional[
298
+ AppServerSupervisorFactoryBuilder
299
+ ] = None,
300
+ backend_orchestrator_builder: Optional[BackendOrchestratorBuilder] = None,
301
+ ) -> "HubSupervisor":
235
302
  config = load_hub_config(path)
236
- return cls(config)
303
+ return cls(
304
+ config,
305
+ backend_factory_builder=backend_factory_builder,
306
+ app_server_supervisor_factory_builder=app_server_supervisor_factory_builder,
307
+ backend_orchestrator_builder=backend_orchestrator_builder,
308
+ )
237
309
 
238
310
  def scan(self) -> List[RepoSnapshot]:
239
311
  self._invalidate_list_cache()
@@ -244,16 +316,17 @@ class HubSupervisor:
244
316
  return snapshots
245
317
 
246
318
  def list_repos(self, *, use_cache: bool = True) -> List[RepoSnapshot]:
247
- if use_cache and self._list_cache and self._list_cache_at is not None:
248
- if time.monotonic() - self._list_cache_at < 2.0:
249
- return self._list_cache
250
- manifest, records = self._manifest_records(manifest_only=True)
251
- snapshots = self._build_snapshots(records)
252
- self.state = HubState(last_scan_at=self.state.last_scan_at, repos=snapshots)
253
- save_hub_state(self.state_path, self.state, self.hub_config.root)
254
- self._list_cache = snapshots
255
- self._list_cache_at = time.monotonic()
256
- return snapshots
319
+ with self._list_lock:
320
+ if use_cache and self._list_cache and self._list_cache_at is not None:
321
+ if time.monotonic() - self._list_cache_at < 2.0:
322
+ return self._list_cache
323
+ manifest, records = self._manifest_records(manifest_only=True)
324
+ snapshots = self._build_snapshots(records)
325
+ self.state = HubState(last_scan_at=self.state.last_scan_at, repos=snapshots)
326
+ save_hub_state(self.state_path, self.state, self.hub_config.root)
327
+ self._list_cache = snapshots
328
+ self._list_cache_at = time.monotonic()
329
+ return snapshots
257
330
 
258
331
  def _reconcile_startup(self) -> None:
259
332
  try:
@@ -268,8 +341,19 @@ class HubSupervisor:
268
341
  repo_config = derive_repo_config(
269
342
  self.hub_config, record.absolute_path, load_env=False
270
343
  )
344
+ backend_orchestrator = (
345
+ self._backend_orchestrator_builder(
346
+ record.absolute_path, repo_config
347
+ )
348
+ if self._backend_orchestrator_builder is not None
349
+ else None
350
+ )
271
351
  controller = ProcessRunnerController(
272
- Engine(record.absolute_path, config=repo_config)
352
+ RuntimeContext(
353
+ repo_root=record.absolute_path,
354
+ config=repo_config,
355
+ backend_orchestrator=backend_orchestrator,
356
+ )
273
357
  )
274
358
  controller.reconcile()
275
359
  except Exception as exc:
@@ -593,6 +677,9 @@ class HubSupervisor:
593
677
  worktree_repo_id: str,
594
678
  delete_branch: bool = False,
595
679
  delete_remote: bool = False,
680
+ archive: bool = True,
681
+ force_archive: bool = False,
682
+ archive_note: Optional[str] = None,
596
683
  ) -> None:
597
684
  self._invalidate_list_cache()
598
685
  manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
@@ -613,6 +700,44 @@ class HubSupervisor:
613
700
  if runner:
614
701
  runner.stop()
615
702
 
703
+ if archive:
704
+ branch_name = entry.branch or git_branch(worktree_path) or "unknown"
705
+ head_sha = git_head_sha(worktree_path) or "unknown"
706
+ snapshot_id = build_snapshot_id(branch_name, head_sha)
707
+ logger.info(
708
+ "Hub archive worktree start id=%s snapshot_id=%s",
709
+ worktree_repo_id,
710
+ snapshot_id,
711
+ )
712
+ try:
713
+ result = archive_worktree_snapshot(
714
+ base_repo_root=base_path,
715
+ base_repo_id=base.id,
716
+ worktree_repo_root=worktree_path,
717
+ worktree_repo_id=worktree_repo_id,
718
+ branch=branch_name,
719
+ worktree_of=entry.worktree_of,
720
+ note=archive_note,
721
+ snapshot_id=snapshot_id,
722
+ head_sha=head_sha,
723
+ source_path=entry.path,
724
+ )
725
+ except Exception as exc:
726
+ logger.exception(
727
+ "Hub archive worktree failed id=%s snapshot_id=%s",
728
+ worktree_repo_id,
729
+ snapshot_id,
730
+ )
731
+ if not force_archive:
732
+ raise ValueError(f"Worktree archive failed: {exc}") from exc
733
+ else:
734
+ logger.info(
735
+ "Hub archive worktree complete id=%s snapshot_id=%s status=%s",
736
+ worktree_repo_id,
737
+ result.snapshot_id,
738
+ result.status,
739
+ )
740
+
616
741
  # Remove worktree from base repo.
617
742
  try:
618
743
  proc = run_git(
@@ -777,6 +902,12 @@ class HubSupervisor:
777
902
  repo_root,
778
903
  repo_config=repo_config,
779
904
  spawn_fn=self._spawn_fn,
905
+ backend_factory_builder=self._backend_factory_builder,
906
+ app_server_supervisor_factory_builder=(
907
+ self._app_server_supervisor_factory_builder
908
+ ),
909
+ backend_orchestrator_builder=self._backend_orchestrator_builder,
910
+ agent_id_validator=self._agent_id_validator,
780
911
  )
781
912
  self._runners[repo_id] = runner
782
913
  return runner
@@ -819,8 +950,148 @@ class HubSupervisor:
819
950
  return snapshot
820
951
 
821
952
  def _invalidate_list_cache(self) -> None:
822
- self._list_cache = None
823
- self._list_cache_at = None
953
+ with self._list_lock:
954
+ self._list_cache = None
955
+ self._list_cache_at = None
956
+
957
+ @property
958
+ def lifecycle_emitter(self) -> LifecycleEventEmitter:
959
+ return self._lifecycle_emitter
960
+
961
+ @property
962
+ def lifecycle_store(self) -> LifecycleEventStore:
963
+ return self._lifecycle_emitter._store
964
+
965
+ def trigger_pma_from_lifecycle_event(self, event: LifecycleEvent) -> None:
966
+ if event.processed:
967
+ return
968
+ event_id = event.event_id
969
+ if event_id is None:
970
+ return
971
+ self.lifecycle_store.mark_processed(event_id)
972
+ self.lifecycle_store.prune_processed(keep_last=50)
973
+ logger.info(
974
+ "PMA wakeup triggered by lifecycle event: type=%s repo_id=%s run_id=%s",
975
+ event.event_type.value,
976
+ event.repo_id,
977
+ event.run_id,
978
+ )
979
+
980
+ def process_lifecycle_events(self) -> None:
981
+ events = self.lifecycle_store.get_unprocessed(limit=100)
982
+ if not events:
983
+ return
984
+ for event in events:
985
+ try:
986
+ self.trigger_pma_from_lifecycle_event(event)
987
+ except Exception as exc:
988
+ logger.exception(
989
+ "Failed to process lifecycle event %s: %s", event.event_id, exc
990
+ )
991
+
992
+ def _start_lifecycle_event_processor(self) -> None:
993
+ if self._lifecycle_thread is not None:
994
+ return
995
+
996
+ def _process_loop():
997
+ while not self._lifecycle_stop_event.wait(5.0):
998
+ try:
999
+ self.process_lifecycle_events()
1000
+ except Exception:
1001
+ logger.exception("Error in lifecycle event processor")
1002
+
1003
+ self._lifecycle_thread = threading.Thread(
1004
+ target=_process_loop, daemon=True, name="lifecycle-event-processor"
1005
+ )
1006
+ self._lifecycle_thread.start()
1007
+
1008
+ def _stop_lifecycle_event_processor(self) -> None:
1009
+ if self._lifecycle_thread is None:
1010
+ return
1011
+ self._lifecycle_stop_event.set()
1012
+ self._lifecycle_thread.join(timeout=2.0)
1013
+ self._lifecycle_thread = None
1014
+
1015
+ def shutdown(self) -> None:
1016
+ self._stop_lifecycle_event_processor()
1017
+ self._stop_dispatch_interceptor()
1018
+
1019
+ def _start_dispatch_interceptor(self) -> None:
1020
+ if not self.hub_config.pma.enabled:
1021
+ return
1022
+ if not self.hub_config.pma.dispatch_interception_enabled:
1023
+ return
1024
+ if self._dispatch_interceptor_thread is not None:
1025
+ return
1026
+
1027
+ import asyncio
1028
+ from typing import TYPE_CHECKING
1029
+
1030
+ if TYPE_CHECKING:
1031
+ pass
1032
+
1033
+ def _run_interceptor():
1034
+ loop = asyncio.new_event_loop()
1035
+ asyncio.set_event_loop(loop)
1036
+
1037
+ from .pma_dispatch_interceptor import run_dispatch_interceptor
1038
+
1039
+ stop_event = threading.Event()
1040
+ self._dispatch_interceptor_stop_event = stop_event
1041
+
1042
+ async def run_until_stop():
1043
+ task = None
1044
+ try:
1045
+ task = await run_dispatch_interceptor(
1046
+ hub_root=self.hub_config.root,
1047
+ supervisor=self,
1048
+ interval_seconds=5.0,
1049
+ on_intercept=self._on_dispatch_intercept,
1050
+ )
1051
+ while not stop_event.is_set():
1052
+ await asyncio.sleep(0.1)
1053
+ except asyncio.CancelledError:
1054
+ pass
1055
+ finally:
1056
+ if task is not None and not task.done():
1057
+ task.cancel()
1058
+ if task is not None:
1059
+ try:
1060
+ await task
1061
+ except (asyncio.CancelledError, Exception):
1062
+ pass
1063
+
1064
+ loop.run_until_complete(run_until_stop())
1065
+ loop.close()
1066
+
1067
+ self._dispatch_interceptor_thread = threading.Thread(
1068
+ target=_run_interceptor, daemon=True, name="pma-dispatch-interceptor"
1069
+ )
1070
+ self._dispatch_interceptor_thread.start()
1071
+
1072
+ def _stop_dispatch_interceptor(self) -> None:
1073
+ if self._dispatch_interceptor_stop_event is not None:
1074
+ self._dispatch_interceptor_stop_event.set()
1075
+ if self._dispatch_interceptor_thread is not None:
1076
+ self._dispatch_interceptor_thread.join(timeout=2.0)
1077
+ self._dispatch_interceptor_thread = None
1078
+ self._dispatch_interceptor_stop_event = None
1079
+
1080
+ def _on_dispatch_intercept(self, event_id: str, result: Any) -> None:
1081
+ logger.info(
1082
+ "Dispatch intercepted: event_id=%s action=%s reason=%s",
1083
+ event_id,
1084
+ (
1085
+ result.get("action")
1086
+ if isinstance(result, dict)
1087
+ else getattr(result, "action", None)
1088
+ ),
1089
+ (
1090
+ result.get("reason")
1091
+ if isinstance(result, dict)
1092
+ else getattr(result, "reason", None)
1093
+ ),
1094
+ )
824
1095
 
825
1096
  def _snapshot_from_record(self, record: DiscoveryRecord) -> RepoSnapshot:
826
1097
  repo_path = record.absolute_path
@@ -0,0 +1,253 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Optional
10
+
11
+ from .locks import file_lock
12
+ from .utils import atomic_write
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ LIFECYCLE_EVENTS_FILENAME = "lifecycle_events.json"
17
+ LIFECYCLE_EVENTS_LOCK_SUFFIX = ".lock"
18
+
19
+
20
+ class LifecycleEventType(str, Enum):
21
+ FLOW_PAUSED = "flow_paused"
22
+ FLOW_COMPLETED = "flow_completed"
23
+ FLOW_FAILED = "flow_failed"
24
+ FLOW_STOPPED = "flow_stopped"
25
+ DISPATCH_CREATED = "dispatch_created"
26
+
27
+
28
+ @dataclass
29
+ class LifecycleEvent:
30
+ event_type: LifecycleEventType
31
+ repo_id: str
32
+ run_id: str
33
+ data: dict[str, Any] = field(default_factory=dict)
34
+ timestamp: str = field(
35
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
36
+ )
37
+ processed: bool = False
38
+ event_id: str = ""
39
+
40
+ def __post_init__(self):
41
+ if not self.event_id:
42
+ import uuid
43
+
44
+ object.__setattr__(self, "event_id", str(uuid.uuid4()))
45
+
46
+
47
+ def default_lifecycle_events_path(hub_root: Path) -> Path:
48
+ return hub_root / ".codex-autorunner" / LIFECYCLE_EVENTS_FILENAME
49
+
50
+
51
+ class LifecycleEventStore:
52
+ def __init__(self, hub_root: Path) -> None:
53
+ self._path = default_lifecycle_events_path(hub_root)
54
+
55
+ @property
56
+ def path(self) -> Path:
57
+ return self._path
58
+
59
+ def _lock_path(self) -> Path:
60
+ return self._path.with_suffix(LIFECYCLE_EVENTS_LOCK_SUFFIX)
61
+
62
+ def load(self, *, ensure_exists: bool = True) -> list[LifecycleEvent]:
63
+ with file_lock(self._lock_path()):
64
+ if not self._path.exists():
65
+ return []
66
+ try:
67
+ raw = self._path.read_text(encoding="utf-8")
68
+ except OSError as exc:
69
+ logger.warning(
70
+ "Failed to read lifecycle events at %s: %s", self._path, exc
71
+ )
72
+ return []
73
+ try:
74
+ data = json.loads(raw)
75
+ except json.JSONDecodeError as exc:
76
+ logger.warning(
77
+ "Failed to parse lifecycle events at %s: %s", self._path, exc
78
+ )
79
+ return []
80
+ if not isinstance(data, list):
81
+ logger.warning("Lifecycle events data is not a list: %s", self._path)
82
+ return []
83
+ events: list[LifecycleEvent] = []
84
+ for entry in data:
85
+ try:
86
+ if not isinstance(entry, dict):
87
+ continue
88
+ event_type_str = entry.get("event_type")
89
+ if not isinstance(event_type_str, str):
90
+ continue
91
+ try:
92
+ event_type = LifecycleEventType(event_type_str)
93
+ except ValueError:
94
+ continue
95
+ event_id_raw = entry.get("event_id")
96
+ event_id = (
97
+ str(event_id_raw) if isinstance(event_id_raw, str) else ""
98
+ )
99
+ if not event_id:
100
+ import uuid
101
+
102
+ event_id = str(uuid.uuid4())
103
+ event = LifecycleEvent(
104
+ event_type=event_type,
105
+ repo_id=str(entry.get("repo_id", "")),
106
+ run_id=str(entry.get("run_id", "")),
107
+ data=dict(entry.get("data", {})),
108
+ timestamp=str(entry.get("timestamp", "")),
109
+ processed=bool(entry.get("processed", False)),
110
+ event_id=event_id,
111
+ )
112
+ events.append(event)
113
+ except Exception as exc:
114
+ logger.debug("Failed to parse lifecycle event entry: %s", exc)
115
+ continue
116
+ return events
117
+
118
+ def save(self, events: list[LifecycleEvent]) -> None:
119
+ with file_lock(self._lock_path()):
120
+ self._save_unlocked(events)
121
+
122
+ def _save_unlocked(self, events: list[LifecycleEvent]) -> None:
123
+ self._path.parent.mkdir(parents=True, exist_ok=True)
124
+ data = [
125
+ {
126
+ "event_id": event.event_id,
127
+ "event_type": event.event_type.value,
128
+ "repo_id": event.repo_id,
129
+ "run_id": event.run_id,
130
+ "data": event.data,
131
+ "timestamp": event.timestamp,
132
+ "processed": event.processed,
133
+ }
134
+ for event in events
135
+ ]
136
+ atomic_write(self._path, json.dumps(data, indent=2) + "\n")
137
+
138
+ def append(self, event: LifecycleEvent) -> None:
139
+ events = self.load(ensure_exists=False)
140
+ events.append(event)
141
+ self.save(events)
142
+
143
+ def mark_processed(self, event_id: str) -> Optional[LifecycleEvent]:
144
+ if not event_id:
145
+ return None
146
+ events = self.load(ensure_exists=False)
147
+ updated = None
148
+ for event in events:
149
+ if event.event_id == event_id:
150
+ event.processed = True
151
+ updated = event
152
+ break
153
+ if updated:
154
+ self.save(events)
155
+ return updated
156
+
157
+ def get_unprocessed(self, *, limit: int = 100) -> list[LifecycleEvent]:
158
+ events = self.load(ensure_exists=False)
159
+ unprocessed = [e for e in events if not e.processed]
160
+ return unprocessed[:limit]
161
+
162
+ def prune_processed(self, *, keep_last: int = 100) -> None:
163
+ events = self.load(ensure_exists=False)
164
+ unprocessed = [e for e in events if not e.processed]
165
+ processed = [e for e in events if e.processed]
166
+ if len(processed) > keep_last:
167
+ processed = processed[-keep_last:]
168
+ self.save(unprocessed + processed)
169
+
170
+
171
+ class LifecycleEventEmitter:
172
+ def __init__(self, hub_root: Path) -> None:
173
+ self._store = LifecycleEventStore(hub_root)
174
+ self._listeners: list[Callable[[LifecycleEvent], None]] = []
175
+
176
+ def emit(self, event: LifecycleEvent) -> str:
177
+ self._store.append(event)
178
+ for listener in self._listeners:
179
+ try:
180
+ listener(event)
181
+ except Exception as exc:
182
+ logger.exception("Error in lifecycle event listener: %s", exc)
183
+ return event.event_id
184
+
185
+ def emit_flow_paused(
186
+ self, repo_id: str, run_id: str, *, data: Optional[dict[str, Any]] = None
187
+ ) -> str:
188
+ event = LifecycleEvent(
189
+ event_type=LifecycleEventType.FLOW_PAUSED,
190
+ repo_id=repo_id,
191
+ run_id=run_id,
192
+ data=data or {},
193
+ )
194
+ return self.emit(event)
195
+
196
+ def emit_flow_completed(
197
+ self, repo_id: str, run_id: str, *, data: Optional[dict[str, Any]] = None
198
+ ) -> str:
199
+ event = LifecycleEvent(
200
+ event_type=LifecycleEventType.FLOW_COMPLETED,
201
+ repo_id=repo_id,
202
+ run_id=run_id,
203
+ data=data or {},
204
+ )
205
+ return self.emit(event)
206
+
207
+ def emit_flow_failed(
208
+ self, repo_id: str, run_id: str, *, data: Optional[dict[str, Any]] = None
209
+ ) -> str:
210
+ event = LifecycleEvent(
211
+ event_type=LifecycleEventType.FLOW_FAILED,
212
+ repo_id=repo_id,
213
+ run_id=run_id,
214
+ data=data or {},
215
+ )
216
+ return self.emit(event)
217
+
218
+ def emit_flow_stopped(
219
+ self, repo_id: str, run_id: str, *, data: Optional[dict[str, Any]] = None
220
+ ) -> str:
221
+ event = LifecycleEvent(
222
+ event_type=LifecycleEventType.FLOW_STOPPED,
223
+ repo_id=repo_id,
224
+ run_id=run_id,
225
+ data=data or {},
226
+ )
227
+ return self.emit(event)
228
+
229
+ def emit_dispatch_created(
230
+ self, repo_id: str, run_id: str, *, data: Optional[dict[str, Any]] = None
231
+ ) -> str:
232
+ event = LifecycleEvent(
233
+ event_type=LifecycleEventType.DISPATCH_CREATED,
234
+ repo_id=repo_id,
235
+ run_id=run_id,
236
+ data=data or {},
237
+ )
238
+ return self.emit(event)
239
+
240
+ def add_listener(self, listener: Callable[[LifecycleEvent], None]) -> None:
241
+ self._listeners.append(listener)
242
+
243
+ def remove_listener(self, listener: Callable[[LifecycleEvent], None]) -> None:
244
+ self._listeners = [lst for lst in self._listeners if lst != listener]
245
+
246
+
247
+ __all__ = [
248
+ "LifecycleEventType",
249
+ "LifecycleEvent",
250
+ "LifecycleEventStore",
251
+ "LifecycleEventEmitter",
252
+ "default_lifecycle_events_path",
253
+ ]
@@ -23,6 +23,18 @@ class NotificationManager:
23
23
  self._warned_missing: set[str] = set()
24
24
  self._enabled_mode = self._parse_enabled(self._cfg.get("enabled"))
25
25
  self._events = self._normalize_events(self._cfg.get("events"))
26
+ timeout_raw = self._cfg.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
27
+ try:
28
+ timeout_seconds = (
29
+ float(timeout_raw)
30
+ if timeout_raw is not None
31
+ else DEFAULT_TIMEOUT_SECONDS
32
+ )
33
+ except (TypeError, ValueError):
34
+ timeout_seconds = DEFAULT_TIMEOUT_SECONDS
35
+ if timeout_seconds <= 0:
36
+ timeout_seconds = DEFAULT_TIMEOUT_SECONDS
37
+ self._timeout_seconds = timeout_seconds
26
38
  self._warn_unknown_events(self._events)
27
39
  discord_cfg = self._cfg.get("discord")
28
40
  self._discord: Dict[str, Any] = (
@@ -202,7 +214,7 @@ class NotificationManager:
202
214
  if not targets:
203
215
  return
204
216
  try:
205
- with httpx.Client(timeout=DEFAULT_TIMEOUT_SECONDS) as client:
217
+ with httpx.Client(timeout=self._timeout_seconds) as client:
206
218
  self._send_sync(client, targets, message)
207
219
  except Exception as exc:
208
220
  self._log_warning("Notification delivery failed", exc)
@@ -216,7 +228,7 @@ class NotificationManager:
216
228
  if not targets:
217
229
  return
218
230
  try:
219
- async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT_SECONDS) as client:
231
+ async with httpx.AsyncClient(timeout=self._timeout_seconds) as client:
220
232
  await self._send_async(client, targets, message)
221
233
  except Exception as exc:
222
234
  self._log_warning("Notification delivery failed", exc)