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.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/runtime.py +59 -18
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +36 -7
- codex_autorunner/bootstrap.py +226 -4
- codex_autorunner/cli.py +5 -1174
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +20 -0
- codex_autorunner/core/about_car.py +119 -1
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_threads.py +17 -2
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +6 -2
- codex_autorunner/core/config.py +433 -4
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/drafts.py +58 -4
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +96 -2
- codex_autorunner/core/flows/models.py +13 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +134 -0
- codex_autorunner/core/flows/runtime.py +57 -4
- codex_autorunner/core/flows/store.py +142 -7
- codex_autorunner/core/flows/transition.py +27 -15
- codex_autorunner/core/flows/ux_helpers.py +272 -0
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +291 -20
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +496 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +62 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +218 -0
- codex_autorunner/core/ticket_manager_cli.py +494 -0
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/update.py +4 -5
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +125 -15
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
- codex_autorunner/flows/ticket_flow/definition.py +52 -3
- codex_autorunner/integrations/agents/__init__.py +11 -19
- codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +177 -25
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +305 -32
- codex_autorunner/integrations/agents/runner.py +86 -0
- codex_autorunner/integrations/agents/wiring.py +279 -0
- codex_autorunner/integrations/app_server/client.py +7 -60
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/telegram/adapter.py +65 -0
- codex_autorunner/integrations/telegram/config.py +46 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
- codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +22 -1
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +45 -10
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
- codex_autorunner/integrations/telegram/transport.py +13 -4
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/routes/__init__.py +37 -76
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +2 -238
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -596
- codex_autorunner/routes/file_chat.py +4 -833
- codex_autorunner/routes/flows.py +4 -977
- codex_autorunner/routes/messages.py +4 -456
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -193
- codex_autorunner/routes/usage.py +2 -86
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +2 -270
- codex_autorunner/server.py +4 -4
- codex_autorunner/static/agentControls.js +61 -16
- codex_autorunner/static/app.js +126 -14
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +7 -7
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/dashboard.js +224 -171
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +114 -131
- codex_autorunner/static/index.html +375 -49
- codex_autorunner/static/messages.js +568 -87
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +128 -6
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9798 -6143
- codex_autorunner/static/tabs.js +152 -11
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminal.js +18 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +137 -15
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +821 -98
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +39 -0
- codex_autorunner/static/workspace.js +389 -82
- codex_autorunner/static/workspaceFileBrowser.js +15 -13
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +2534 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2223 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +82 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +284 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +1354 -0
- codex_autorunner/surfaces/web/routes/messages.py +490 -0
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +277 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +469 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +8 -1
- codex_autorunner/tickets/agent_pool.py +53 -4
- codex_autorunner/tickets/files.py +37 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +6 -1
- codex_autorunner/tickets/outbox.py +50 -2
- codex_autorunner/tickets/runner.py +396 -57
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1949
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -586
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -376
- codex_autorunner/web/static_assets.py +4 -441
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/paths.py +49 -33
- codex_autorunner-1.2.0.dist-info/METADATA +150 -0
- codex_autorunner-1.2.0.dist-info/RECORD +339 -0
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -2653
- codex_autorunner/core/static_assets.py +0 -55
- codex_autorunner-1.0.0.dist-info/METADATA +0 -246
- codex_autorunner-1.0.0.dist-info/RECORD +0 -251
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.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
|
+
]
|