codex-autorunner 1.1.0__py3-none-any.whl → 1.2.1__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 (134) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +124 -11
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +238 -3
  9. codex_autorunner/core/context_awareness.py +39 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +683 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/adapter.py +1 -1
  58. codex_autorunner/integrations/telegram/config.py +1 -1
  59. codex_autorunner/integrations/telegram/doctor.py +228 -6
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  63. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  66. codex_autorunner/integrations/telegram/handlers/messages.py +34 -3
  67. codex_autorunner/integrations/telegram/helpers.py +1 -3
  68. codex_autorunner/integrations/telegram/runtime.py +9 -4
  69. codex_autorunner/integrations/telegram/service.py +30 -0
  70. codex_autorunner/integrations/telegram/state.py +38 -0
  71. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  72. codex_autorunner/integrations/telegram/transport.py +10 -3
  73. codex_autorunner/integrations/templates/__init__.py +27 -0
  74. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  75. codex_autorunner/server.py +2 -2
  76. codex_autorunner/static/agentControls.js +21 -5
  77. codex_autorunner/static/app.js +115 -11
  78. codex_autorunner/static/archive.js +274 -81
  79. codex_autorunner/static/archiveApi.js +21 -0
  80. codex_autorunner/static/chatUploads.js +137 -0
  81. codex_autorunner/static/constants.js +1 -1
  82. codex_autorunner/static/docChatCore.js +185 -13
  83. codex_autorunner/static/fileChat.js +68 -40
  84. codex_autorunner/static/fileboxUi.js +159 -0
  85. codex_autorunner/static/hub.js +46 -81
  86. codex_autorunner/static/index.html +303 -24
  87. codex_autorunner/static/messages.js +82 -4
  88. codex_autorunner/static/notifications.js +288 -0
  89. codex_autorunner/static/pma.js +1167 -0
  90. codex_autorunner/static/settings.js +3 -0
  91. codex_autorunner/static/streamUtils.js +57 -0
  92. codex_autorunner/static/styles.css +9141 -6742
  93. codex_autorunner/static/templateReposSettings.js +225 -0
  94. codex_autorunner/static/terminalManager.js +22 -3
  95. codex_autorunner/static/ticketChatActions.js +165 -3
  96. codex_autorunner/static/ticketChatStream.js +17 -119
  97. codex_autorunner/static/ticketEditor.js +41 -13
  98. codex_autorunner/static/ticketTemplates.js +798 -0
  99. codex_autorunner/static/tickets.js +69 -19
  100. codex_autorunner/static/turnEvents.js +27 -0
  101. codex_autorunner/static/turnResume.js +33 -0
  102. codex_autorunner/static/utils.js +28 -0
  103. codex_autorunner/static/workspace.js +258 -44
  104. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  105. codex_autorunner/surfaces/cli/cli.py +1465 -155
  106. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  107. codex_autorunner/surfaces/web/app.py +253 -49
  108. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  109. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  110. codex_autorunner/surfaces/web/routes/archive.py +197 -0
  111. codex_autorunner/surfaces/web/routes/file_chat.py +297 -36
  112. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  113. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  114. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  115. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  116. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  117. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  118. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  119. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  120. codex_autorunner/surfaces/web/schemas.py +81 -18
  121. codex_autorunner/tickets/agent_pool.py +27 -0
  122. codex_autorunner/tickets/files.py +33 -16
  123. codex_autorunner/tickets/lint.py +50 -0
  124. codex_autorunner/tickets/models.py +3 -0
  125. codex_autorunner/tickets/outbox.py +41 -5
  126. codex_autorunner/tickets/runner.py +350 -69
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/METADATA +15 -19
  128. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/RECORD +132 -101
  129. codex_autorunner/core/adapter_utils.py +0 -21
  130. codex_autorunner/core/engine.py +0 -3302
  131. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/WHEEL +0 -0
  132. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/entry_points.txt +0 -0
  133. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/licenses/LICENSE +0 -0
  134. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,829 @@
1
+ """Runtime context module.
2
+
3
+ Provides RuntimeContext as a minimal runtime helper for ticket flows.
4
+ This replaces Engine as the runtime authority while preserving utility functions.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ from datetime import datetime, timedelta, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Optional, Union
12
+
13
+ from ..manifest import load_manifest
14
+ from .config import HubConfig, RepoConfig, load_repo_config
15
+ from .locks import DEFAULT_RUNNER_CMD_HINTS, assess_lock
16
+ from .notifications import NotificationManager
17
+ from .run_index import RunIndexStore
18
+ from .runner_state import LockError, RunnerStateManager
19
+ from .state import now_iso
20
+ from .utils import RepoNotFoundError, find_repo_root
21
+
22
+ _logger = logging.getLogger(__name__)
23
+
24
+ PMA_STATE_FILE = ".codex-autorunner/pma/state.json"
25
+ PMA_QUEUE_DIR = ".codex-autorunner/pma/queue"
26
+ STUCK_LANE_THRESHOLD_MINUTES = 60
27
+
28
+
29
+ class DoctorCheck:
30
+ """Health check result."""
31
+
32
+ def __init__(
33
+ self,
34
+ name: str,
35
+ passed: bool,
36
+ message: str,
37
+ severity: str = "error",
38
+ check_id: Optional[str] = None,
39
+ fix: Optional[str] = None,
40
+ ):
41
+ self.name = name
42
+ self.passed = passed
43
+ self.message = message
44
+ self.severity = severity
45
+ self.check_id = check_id
46
+ self.status = "ok" if passed else "error"
47
+ self.fix = fix
48
+
49
+ def __repr__(self) -> str:
50
+ status = "✓" if self.passed else "✗"
51
+ return f"{status} {self.name}: {self.message}"
52
+
53
+ def to_dict(self) -> dict:
54
+ return {
55
+ "name": self.name,
56
+ "passed": self.passed,
57
+ "message": self.message,
58
+ "severity": self.severity,
59
+ "check_id": self.check_id,
60
+ "status": self.status,
61
+ "fix": self.fix,
62
+ }
63
+
64
+
65
+ class DoctorReport:
66
+ """Report from running health checks."""
67
+
68
+ def __init__(self, checks: list[DoctorCheck]):
69
+ self.checks = checks
70
+
71
+ @property
72
+ def all_passed(self) -> bool:
73
+ return all(check.passed for check in self.checks)
74
+
75
+ def has_errors(self) -> bool:
76
+ return any(check.status == "error" for check in self.checks)
77
+
78
+ def to_dict(self) -> dict:
79
+ return {
80
+ "ok": sum(1 for check in self.checks if check.status == "ok"),
81
+ "warnings": sum(1 for check in self.checks if check.status == "warning"),
82
+ "errors": sum(1 for check in self.checks if check.status == "error"),
83
+ "checks": [check.to_dict() for check in self.checks],
84
+ }
85
+
86
+ def print_report(self) -> None:
87
+ for check in self.checks:
88
+ if check.severity == "error":
89
+ print(check)
90
+ for check in self.checks:
91
+ if check.severity == "warning":
92
+ print(check)
93
+ for check in self.checks:
94
+ if check.passed and check.severity != "info":
95
+ print(check)
96
+
97
+
98
+ def doctor(
99
+ repo_root: Path,
100
+ backend_orchestrator: Optional[Any] = None,
101
+ check_id: Optional[str] = None,
102
+ ) -> DoctorReport:
103
+ """Run health checks on the repository.
104
+
105
+ Args:
106
+ repo_root: Repository root path.
107
+ backend_orchestrator: Optional backend orchestrator for agent checks.
108
+ check_id: Optional ID for specific check.
109
+
110
+ Returns:
111
+ DoctorReport with check results.
112
+ """
113
+ checks: list[DoctorCheck] = []
114
+
115
+ # Check if in git repo
116
+ try:
117
+ from .git_utils import run_git
118
+
119
+ result = run_git(["rev-parse", "--is-inside-work-tree"], repo_root, check=False)
120
+ if result.returncode != 0:
121
+ checks.append(
122
+ DoctorCheck(
123
+ name="Git repository",
124
+ passed=False,
125
+ message="Not a git repository",
126
+ check_id=check_id,
127
+ )
128
+ )
129
+ else:
130
+ checks.append(
131
+ DoctorCheck(
132
+ name="Git repository",
133
+ passed=True,
134
+ message="OK",
135
+ severity="info",
136
+ check_id=check_id,
137
+ )
138
+ )
139
+ except Exception as e:
140
+ checks.append(
141
+ DoctorCheck(
142
+ name="Git repository",
143
+ passed=False,
144
+ message=f"Failed to check git: {e}",
145
+ check_id=check_id,
146
+ )
147
+ )
148
+
149
+ # Check config file
150
+ config_path = repo_root / "codex-autorunner.yml"
151
+ if not config_path.exists():
152
+ checks.append(
153
+ DoctorCheck(
154
+ name="Config file",
155
+ passed=False,
156
+ message=f"Config file not found: {config_path}",
157
+ check_id=check_id,
158
+ )
159
+ )
160
+ else:
161
+ try:
162
+ load_repo_config(repo_root)
163
+ checks.append(
164
+ DoctorCheck(
165
+ name="Config file",
166
+ passed=True,
167
+ message="OK",
168
+ severity="info",
169
+ check_id=check_id,
170
+ )
171
+ )
172
+ except Exception as e:
173
+ checks.append(
174
+ DoctorCheck(
175
+ name="Config file",
176
+ passed=False,
177
+ message=f"Failed to load: {e}",
178
+ check_id=check_id,
179
+ )
180
+ )
181
+
182
+ # Check state directory
183
+ state_root = repo_root / ".codex-autorunner"
184
+ if not state_root.exists():
185
+ checks.append(
186
+ DoctorCheck(
187
+ name="State directory",
188
+ passed=False,
189
+ message=f"State directory not found: {state_root}",
190
+ severity="warning",
191
+ check_id=check_id,
192
+ )
193
+ )
194
+ else:
195
+ checks.append(
196
+ DoctorCheck(
197
+ name="State directory",
198
+ passed=True,
199
+ message="OK",
200
+ severity="info",
201
+ check_id=check_id,
202
+ )
203
+ )
204
+
205
+ # Check for stale locks
206
+ lock_path = state_root / "lock"
207
+ if lock_path.exists():
208
+ assessment = assess_lock(
209
+ lock_path, expected_cmd_substrings=DEFAULT_RUNNER_CMD_HINTS
210
+ )
211
+ if assessment.freeable:
212
+ checks.append(
213
+ DoctorCheck(
214
+ name="Runner lock",
215
+ passed=False,
216
+ message="Stale lock detected; run `car clear-stale-lock`",
217
+ severity="warning",
218
+ check_id=check_id,
219
+ )
220
+ )
221
+ elif assessment.pid:
222
+ checks.append(
223
+ DoctorCheck(
224
+ name="Runner lock",
225
+ passed=True,
226
+ message=f"Active (pid={assessment.pid})",
227
+ severity="info",
228
+ check_id=check_id,
229
+ )
230
+ )
231
+ else:
232
+ checks.append(
233
+ DoctorCheck(
234
+ name="Runner lock",
235
+ passed=True,
236
+ message="OK",
237
+ severity="info",
238
+ check_id=check_id,
239
+ )
240
+ )
241
+
242
+ return DoctorReport(checks)
243
+
244
+
245
+ def clear_stale_lock(repo_root: Path) -> bool:
246
+ """Clear stale runner lock if present.
247
+
248
+ Returns:
249
+ True if lock was cleared, False if lock was active or absent.
250
+ """
251
+ lock_path = repo_root / ".codex-autorunner" / "lock"
252
+ if not lock_path.exists():
253
+ return False
254
+
255
+ assessment = assess_lock(
256
+ lock_path, expected_cmd_substrings=DEFAULT_RUNNER_CMD_HINTS
257
+ )
258
+ if not assessment.freeable:
259
+ return False
260
+
261
+ lock_path.unlink(missing_ok=True)
262
+ return True
263
+
264
+
265
+ def pma_doctor_checks(
266
+ config: Union[HubConfig, RepoConfig, dict[str, Any]],
267
+ repo_root: Optional[Path] = None,
268
+ ) -> list[DoctorCheck]:
269
+ """Run PMA-specific doctor checks.
270
+
271
+ Returns a list of DoctorCheck objects for PMA integration.
272
+ Works with HubConfig, RepoConfig, or raw dict.
273
+
274
+ Args:
275
+ config: HubConfig, RepoConfig, or raw dict
276
+ repo_root: Optional repo root path for state and queue checks
277
+ """
278
+ checks: list[DoctorCheck] = []
279
+
280
+ pma_cfg = None
281
+ if isinstance(config, dict):
282
+ pma_cfg = config.get("pma")
283
+ elif hasattr(config, "raw"):
284
+ pma_cfg = config.raw.get("pma") if isinstance(config.raw, dict) else None
285
+
286
+ if not isinstance(pma_cfg, dict):
287
+ checks.append(
288
+ DoctorCheck(
289
+ name="PMA config",
290
+ passed=False,
291
+ message="PMA configuration not found",
292
+ check_id="pma.config",
293
+ severity="info",
294
+ )
295
+ )
296
+ return checks
297
+
298
+ enabled = pma_cfg.get("enabled", True)
299
+ if not enabled:
300
+ checks.append(
301
+ DoctorCheck(
302
+ name="PMA enabled",
303
+ passed=True,
304
+ message="PMA is disabled in config",
305
+ check_id="pma.enabled",
306
+ severity="info",
307
+ )
308
+ )
309
+ return checks
310
+
311
+ checks.append(
312
+ DoctorCheck(
313
+ name="PMA enabled",
314
+ passed=True,
315
+ message="PMA is enabled",
316
+ check_id="pma.enabled",
317
+ severity="info",
318
+ )
319
+ )
320
+
321
+ default_agent = pma_cfg.get("default_agent", "codex")
322
+ if default_agent not in ("codex", "opencode"):
323
+ checks.append(
324
+ DoctorCheck(
325
+ name="PMA default agent",
326
+ passed=False,
327
+ message=f"Invalid PMA default_agent: {default_agent}",
328
+ check_id="pma.default_agent",
329
+ fix="Set pma.default_agent to 'codex' or 'opencode' in config.",
330
+ )
331
+ )
332
+ else:
333
+ checks.append(
334
+ DoctorCheck(
335
+ name="PMA default agent",
336
+ passed=True,
337
+ message=f"Default agent: {default_agent}",
338
+ check_id="pma.default_agent",
339
+ severity="info",
340
+ )
341
+ )
342
+
343
+ model = pma_cfg.get("model")
344
+ if model:
345
+ checks.append(
346
+ DoctorCheck(
347
+ name="PMA model",
348
+ passed=True,
349
+ message=f"Model configured: {model}",
350
+ check_id="pma.model",
351
+ severity="info",
352
+ )
353
+ )
354
+ else:
355
+ checks.append(
356
+ DoctorCheck(
357
+ name="PMA model",
358
+ passed=True,
359
+ message="Using default model (none specified)",
360
+ check_id="pma.model",
361
+ severity="info",
362
+ )
363
+ )
364
+
365
+ if repo_root:
366
+ _check_pma_state_file(checks, repo_root)
367
+ _check_pma_queue(checks, repo_root)
368
+ _check_pma_artifacts(checks, repo_root)
369
+
370
+ return checks
371
+
372
+
373
+ def hub_worktree_doctor_checks(hub_config: HubConfig) -> list[DoctorCheck]:
374
+ """Check for unregistered worktrees under the hub worktrees root."""
375
+ checks: list[DoctorCheck] = []
376
+ worktrees_root = hub_config.worktrees_root
377
+ manifest = load_manifest(hub_config.manifest_path, hub_config.root)
378
+ manifest_paths = {
379
+ (hub_config.root / repo.path).resolve() for repo in manifest.repos
380
+ }
381
+
382
+ orphans: list[Path] = []
383
+ if worktrees_root.exists():
384
+ try:
385
+ entries = list(worktrees_root.iterdir())
386
+ except OSError:
387
+ entries = []
388
+ for entry in entries:
389
+ if not entry.is_dir() or entry.is_symlink():
390
+ continue
391
+ if not (entry / ".git").exists():
392
+ continue
393
+ resolved = entry.resolve()
394
+ if resolved not in manifest_paths:
395
+ orphans.append(resolved)
396
+
397
+ if orphans:
398
+ checks.append(
399
+ DoctorCheck(
400
+ name="Hub worktrees registered",
401
+ passed=False,
402
+ message=(
403
+ f"{len(orphans)} worktree(s) exist under {worktrees_root} "
404
+ "but are not in the hub manifest"
405
+ ),
406
+ severity="warning",
407
+ fix=f"Run: car hub scan --path {hub_config.root}",
408
+ )
409
+ )
410
+ else:
411
+ checks.append(
412
+ DoctorCheck(
413
+ name="Hub worktrees registered",
414
+ passed=True,
415
+ message="OK",
416
+ severity="warning",
417
+ )
418
+ )
419
+ return checks
420
+
421
+
422
+ def _check_pma_state_file(checks: list[DoctorCheck], repo_root: Path) -> None:
423
+ """Check PMA state file."""
424
+ state_path = repo_root / PMA_STATE_FILE
425
+ if not state_path.exists():
426
+ checks.append(
427
+ DoctorCheck(
428
+ name="PMA state file",
429
+ passed=False,
430
+ message=f"PMA state file not found: {state_path}",
431
+ check_id="pma.state_file",
432
+ severity="warning",
433
+ fix="Run a PMA command to initialize state file.",
434
+ )
435
+ )
436
+ return
437
+
438
+ try:
439
+ with open(state_path, "r", encoding="utf-8") as f:
440
+ state = json.load(f)
441
+
442
+ if not isinstance(state, dict):
443
+ checks.append(
444
+ DoctorCheck(
445
+ name="PMA state file",
446
+ passed=False,
447
+ message=f"PMA state file is not a valid JSON object: {state_path}",
448
+ check_id="pma.state_file",
449
+ fix="Delete corrupt state file and reinitialize.",
450
+ )
451
+ )
452
+ return
453
+
454
+ version = state.get("version")
455
+ active = state.get("active", False)
456
+ updated_at = state.get("updated_at")
457
+
458
+ checks.append(
459
+ DoctorCheck(
460
+ name="PMA state file",
461
+ passed=True,
462
+ message=f"State file OK (version={version}, active={active})",
463
+ check_id="pma.state_file",
464
+ severity="info",
465
+ )
466
+ )
467
+
468
+ if active and updated_at:
469
+ try:
470
+ updated_dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
471
+ if updated_dt.tzinfo is None:
472
+ updated_dt = updated_dt.replace(tzinfo=timezone.utc)
473
+ age = datetime.now(timezone.utc) - updated_dt
474
+ if age > timedelta(minutes=STUCK_LANE_THRESHOLD_MINUTES):
475
+ checks.append(
476
+ DoctorCheck(
477
+ name="PMA activity",
478
+ passed=False,
479
+ message=f"PMA appears stuck (last update {age.total_seconds() / 60:.0f}m ago)",
480
+ check_id="pma.activity",
481
+ fix="Check PMA logs and consider running a reset command.",
482
+ )
483
+ )
484
+ except (ValueError, TypeError):
485
+ pass
486
+ except (json.JSONDecodeError, OSError) as exc:
487
+ checks.append(
488
+ DoctorCheck(
489
+ name="PMA state file",
490
+ passed=False,
491
+ message=f"Failed to read PMA state file: {exc}",
492
+ check_id="pma.state_file",
493
+ severity="error",
494
+ fix="Check file permissions or delete corrupt state file.",
495
+ )
496
+ )
497
+
498
+
499
+ def _check_pma_queue(checks: list[DoctorCheck], repo_root: Path) -> None:
500
+ """Check PMA queue for stuck items."""
501
+ queue_dir = repo_root / PMA_QUEUE_DIR
502
+ if not queue_dir.exists():
503
+ checks.append(
504
+ DoctorCheck(
505
+ name="PMA queue",
506
+ passed=True,
507
+ message="PMA queue directory not created yet",
508
+ check_id="pma.queue",
509
+ severity="info",
510
+ )
511
+ )
512
+ return
513
+
514
+ try:
515
+ lane_files = list(queue_dir.glob("*.jsonl"))
516
+ total_lanes = len(lane_files)
517
+
518
+ if total_lanes == 0:
519
+ checks.append(
520
+ DoctorCheck(
521
+ name="PMA queue",
522
+ passed=True,
523
+ message="No active PMA lanes",
524
+ check_id="pma.queue",
525
+ severity="info",
526
+ )
527
+ )
528
+ return
529
+
530
+ threshold = datetime.now(timezone.utc) - timedelta(
531
+ minutes=STUCK_LANE_THRESHOLD_MINUTES
532
+ )
533
+ stuck_lanes = []
534
+
535
+ for lane_file in lane_files:
536
+ try:
537
+ with open(lane_file, "r", encoding="utf-8") as f:
538
+ for line in f:
539
+ line = line.strip()
540
+ if not line:
541
+ continue
542
+ try:
543
+ item = json.loads(line)
544
+ state = item.get("state")
545
+ started_at = item.get("started_at")
546
+ if state == "running" and started_at:
547
+ try:
548
+ started_dt = datetime.fromisoformat(
549
+ started_at.replace("Z", "+00:00")
550
+ )
551
+ if started_dt.tzinfo is None:
552
+ started_dt = started_dt.replace(
553
+ tzinfo=timezone.utc
554
+ )
555
+ if started_dt < threshold:
556
+ lane_id = item.get("lane_id", "unknown")
557
+ stuck_lanes.append(lane_id)
558
+ break
559
+ except (ValueError, TypeError):
560
+ continue
561
+ except (json.JSONDecodeError, TypeError):
562
+ continue
563
+ except OSError:
564
+ continue
565
+
566
+ if stuck_lanes:
567
+ checks.append(
568
+ DoctorCheck(
569
+ name="PMA queue",
570
+ passed=False,
571
+ message=f"Found {len(stuck_lanes)} stuck lane(s): {', '.join(stuck_lanes)}",
572
+ check_id="pma.queue",
573
+ fix=f"Run 'car pma stop' for stuck lanes or check logs at {queue_dir}",
574
+ )
575
+ )
576
+ else:
577
+ checks.append(
578
+ DoctorCheck(
579
+ name="PMA queue",
580
+ passed=True,
581
+ message=f"PMA queue OK ({total_lanes} active lane(s))",
582
+ check_id="pma.queue",
583
+ severity="info",
584
+ )
585
+ )
586
+ except OSError as exc:
587
+ checks.append(
588
+ DoctorCheck(
589
+ name="PMA queue",
590
+ passed=False,
591
+ message=f"Failed to check PMA queue: {exc}",
592
+ check_id="pma.queue",
593
+ severity="warning",
594
+ fix="Check permissions on queue directory.",
595
+ )
596
+ )
597
+
598
+
599
+ def _check_pma_artifacts(checks: list[DoctorCheck], repo_root: Path) -> None:
600
+ """Check PMA artifact integrity."""
601
+ pma_dir = repo_root / ".codex-autorunner" / "pma"
602
+ if not pma_dir.exists():
603
+ checks.append(
604
+ DoctorCheck(
605
+ name="PMA artifacts",
606
+ passed=True,
607
+ message="PMA directory not created yet",
608
+ check_id="pma.artifacts",
609
+ severity="info",
610
+ )
611
+ )
612
+ return
613
+
614
+ state_file = pma_dir / "state.json"
615
+ queue_dir = pma_dir / "queue"
616
+ lifecycle_dir = pma_dir / "lifecycle"
617
+
618
+ artifacts_ok = True
619
+ missing = []
620
+
621
+ if not state_file.exists():
622
+ missing.append("state.json")
623
+ artifacts_ok = False
624
+
625
+ if not queue_dir.exists():
626
+ missing.append("queue/")
627
+ artifacts_ok = False
628
+
629
+ if not lifecycle_dir.exists():
630
+ missing.append("lifecycle/")
631
+ artifacts_ok = False
632
+
633
+ if artifacts_ok:
634
+ checks.append(
635
+ DoctorCheck(
636
+ name="PMA artifacts",
637
+ passed=True,
638
+ message=f"PMA artifacts OK at {pma_dir}",
639
+ check_id="pma.artifacts",
640
+ severity="info",
641
+ )
642
+ )
643
+ else:
644
+ checks.append(
645
+ DoctorCheck(
646
+ name="PMA artifacts",
647
+ passed=False,
648
+ message=f"Missing PMA artifacts: {', '.join(missing)}",
649
+ check_id="pma.artifacts",
650
+ fix="Run a PMA command to initialize artifacts.",
651
+ )
652
+ )
653
+
654
+
655
+ class RuntimeContext:
656
+ """Minimal runtime context for ticket flows.
657
+
658
+ Provides config, state paths, logging, and lock management utilities.
659
+ Does NOT include orchestration logic (use ticket_flow/TicketRunner instead).
660
+ """
661
+
662
+ def __init__(
663
+ self,
664
+ repo_root: Path,
665
+ config: Optional[RepoConfig] = None,
666
+ backend_orchestrator: Optional[Any] = None,
667
+ ):
668
+ self._config = config or load_repo_config(repo_root)
669
+ self.repo_root = self._config.root
670
+ self._backend_orchestrator = backend_orchestrator
671
+
672
+ # Paths
673
+ self.state_root = repo_root / ".codex-autorunner"
674
+ self.state_path = self.state_root / "state.sqlite3"
675
+ self.log_path = self.state_root / "codex-autorunner.log"
676
+ self.lock_path = self.state_root / "lock"
677
+
678
+ # Managers
679
+ self._state_manager = RunnerStateManager(
680
+ repo_root=self.repo_root,
681
+ lock_path=self.lock_path,
682
+ state_path=self.state_path,
683
+ )
684
+
685
+ # Run index store
686
+ self._run_index_store: Optional[RunIndexStore] = None
687
+
688
+ # Notification manager (for run-level events)
689
+ self._notifier: Optional[NotificationManager] = None
690
+
691
+ @classmethod
692
+ def from_cwd(
693
+ cls, repo: Optional[Path] = None, *, backend_orchestrator: Optional[Any] = None
694
+ ) -> "RuntimeContext":
695
+ """Create RuntimeContext from current working directory or given repo."""
696
+ if repo is None:
697
+ repo = find_repo_root()
698
+ if not repo or not repo.exists():
699
+ raise RepoNotFoundError(f"Repository not found: {repo}")
700
+ return cls(repo_root=repo, backend_orchestrator=backend_orchestrator)
701
+
702
+ @property
703
+ def config(self) -> RepoConfig:
704
+ """Get repository config."""
705
+ return self._config
706
+
707
+ @property
708
+ def run_index_store(self) -> RunIndexStore:
709
+ """Get run index store."""
710
+ if self._run_index_store is None:
711
+ self._run_index_store = RunIndexStore(self.state_path)
712
+ return self._run_index_store
713
+
714
+ @property
715
+ def notifier(self) -> NotificationManager:
716
+ """Get notification manager."""
717
+ if self._notifier is None:
718
+ self._notifier = NotificationManager(self._config)
719
+ return self._notifier
720
+
721
+ # Delegate to state manager
722
+ def acquire_lock(self, force: bool = False) -> None:
723
+ """Acquire runner lock."""
724
+ self._state_manager.acquire_lock(force=force)
725
+
726
+ def release_lock(self) -> None:
727
+ """Release runner lock."""
728
+ self._state_manager.release_lock()
729
+
730
+ def repo_busy_reason(self) -> Optional[str]:
731
+ """Return a reason why the repo is busy, or None if not busy."""
732
+ return self._state_manager.repo_busy_reason()
733
+
734
+ def request_stop(self) -> None:
735
+ """Request a stop by writing to the stop path."""
736
+ self._state_manager.request_stop()
737
+
738
+ def clear_stop_request(self) -> None:
739
+ """Clear a stop request."""
740
+ self._state_manager.clear_stop_request()
741
+
742
+ def stop_requested(self) -> bool:
743
+ """Check if a stop has been requested."""
744
+ return self._state_manager.stop_requested()
745
+
746
+ def kill_running_process(self) -> Optional[int]:
747
+ """Force-kill process holding the lock, if any. Returns pid if killed."""
748
+ return self._state_manager.kill_running_process()
749
+
750
+ def runner_pid(self) -> Optional[int]:
751
+ """Get PID of the running runner."""
752
+ return self._state_manager.runner_pid()
753
+
754
+ # Logging utilities
755
+ def tail_log(self, tail: int = 50) -> str:
756
+ """Tail the log file."""
757
+ if not self.log_path.exists():
758
+ return ""
759
+ try:
760
+ with open(self.log_path, "r", encoding="utf-8", errors="replace") as f:
761
+ lines = f.readlines()
762
+ return "".join(lines[-tail:])
763
+ except Exception:
764
+ return ""
765
+
766
+ def log_line(self, run_id: int, message: str) -> None:
767
+ """Append a line to the run log."""
768
+ run_log_path = self._run_log_path(run_id)
769
+ run_log_path.parent.mkdir(parents=True, exist_ok=True)
770
+ timestamp = now_iso()
771
+ with open(run_log_path, "a", encoding="utf-8") as f:
772
+ f.write(f"[{timestamp}] {message}\n")
773
+
774
+ def _run_log_path(self, run_id: int) -> Path:
775
+ """Get path to run log file."""
776
+ return self.state_root / "runs" / str(run_id) / "run.log"
777
+
778
+ def read_run_block(self, run_id: int) -> Optional[str]:
779
+ """Read the run log block for a given run ID."""
780
+ run_log_path = self._run_log_path(run_id)
781
+ if not run_log_path.exists():
782
+ return None
783
+ try:
784
+ with open(run_log_path, "r", encoding="utf-8", errors="replace") as f:
785
+ return f.read()
786
+ except Exception:
787
+ return None
788
+
789
+ def reconcile_run_index(self) -> None:
790
+ """Reconcile run index with run directories."""
791
+ runs_dir = self.state_root / "runs"
792
+ if not runs_dir.exists():
793
+ return
794
+ # Historical runs are stored under numeric directories like `runs/123/`.
795
+ # Be defensive: other artifacts (UUID directories, stray files) can exist and
796
+ # should not break reconciliation.
797
+ parsed: list[tuple[int, Path]] = []
798
+ try:
799
+ entries = list(runs_dir.iterdir())
800
+ except OSError:
801
+ return
802
+ for entry in entries:
803
+ try:
804
+ run_id = int(entry.name)
805
+ except ValueError:
806
+ continue
807
+ parsed.append((run_id, entry))
808
+ for run_id, _ in sorted(parsed, key=lambda pair: pair[0]):
809
+ self._merge_run_index_entry(run_id, {})
810
+
811
+ def _merge_run_index_entry(self, run_id: int, extra: dict[str, Any]) -> None:
812
+ """Merge extra data into run index entry."""
813
+ # Ensure timestamp if missing
814
+ if "timestamp" not in extra:
815
+ extra["timestamp"] = now_iso()
816
+
817
+ self.run_index_store.merge_entry(run_id, extra)
818
+
819
+
820
+ __all__ = [
821
+ "RuntimeContext",
822
+ "LockError",
823
+ "doctor",
824
+ "DoctorCheck",
825
+ "DoctorReport",
826
+ "clear_stale_lock",
827
+ "hub_worktree_doctor_checks",
828
+ "pma_doctor_checks",
829
+ ]