codex-autorunner 0.1.1__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/__init__.py +20 -0
- codex_autorunner/agents/base.py +2 -2
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/__init__.py +4 -0
- codex_autorunner/agents/opencode/agent_config.py +104 -0
- codex_autorunner/agents/opencode/client.py +305 -28
- codex_autorunner/agents/opencode/harness.py +71 -20
- codex_autorunner/agents/opencode/logging.py +225 -0
- codex_autorunner/agents/opencode/run_prompt.py +261 -0
- codex_autorunner/agents/opencode/runtime.py +1202 -132
- codex_autorunner/agents/opencode/supervisor.py +194 -68
- codex_autorunner/agents/registry.py +258 -0
- codex_autorunner/agents/types.py +2 -2
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +19 -40
- codex_autorunner/cli.py +234 -151
- codex_autorunner/core/about_car.py +44 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_events.py +15 -6
- codex_autorunner/core/app_server_logging.py +55 -15
- codex_autorunner/core/app_server_prompts.py +28 -259
- codex_autorunner/core/app_server_threads.py +15 -26
- codex_autorunner/core/circuit_breaker.py +183 -0
- codex_autorunner/core/codex_runner.py +6 -0
- codex_autorunner/core/config.py +555 -133
- codex_autorunner/core/docs.py +54 -9
- codex_autorunner/core/drafts.py +82 -0
- codex_autorunner/core/engine.py +828 -274
- codex_autorunner/core/exceptions.py +60 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +178 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +75 -0
- codex_autorunner/core/flows/runtime.py +351 -0
- codex_autorunner/core/flows/store.py +485 -0
- codex_autorunner/core/flows/transition.py +133 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/hub.py +21 -13
- codex_autorunner/core/locks.py +118 -1
- codex_autorunner/core/logging_utils.py +9 -6
- codex_autorunner/core/path_utils.py +123 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/retry.py +61 -0
- codex_autorunner/core/review.py +888 -0
- codex_autorunner/core/review_context.py +161 -0
- codex_autorunner/core/run_index.py +223 -0
- codex_autorunner/core/runner_controller.py +44 -1
- codex_autorunner/core/runner_process.py +30 -1
- codex_autorunner/core/sqlite_utils.py +32 -0
- codex_autorunner/core/state.py +273 -44
- codex_autorunner/core/static_assets.py +55 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +43 -0
- codex_autorunner/core/update.py +20 -11
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +107 -75
- codex_autorunner/core/utils.py +167 -3
- codex_autorunner/discovery.py +3 -3
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +91 -0
- codex_autorunner/integrations/agents/__init__.py +27 -0
- codex_autorunner/integrations/agents/agent_backend.py +142 -0
- codex_autorunner/integrations/agents/codex_backend.py +307 -0
- codex_autorunner/integrations/agents/opencode_backend.py +325 -0
- codex_autorunner/integrations/agents/run_event.py +71 -0
- codex_autorunner/integrations/app_server/client.py +708 -153
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +474 -185
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +239 -1
- codex_autorunner/integrations/telegram/constants.py +19 -1
- codex_autorunner/integrations/telegram/dispatch.py +44 -8
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
- codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
- codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
- codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
- codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
- codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
- codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
- codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
- codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
- codex_autorunner/integrations/telegram/helpers.py +90 -18
- codex_autorunner/integrations/telegram/notifications.py +126 -35
- codex_autorunner/integrations/telegram/outbox.py +214 -43
- codex_autorunner/integrations/telegram/progress_stream.py +42 -19
- codex_autorunner/integrations/telegram/runtime.py +24 -13
- codex_autorunner/integrations/telegram/service.py +500 -129
- codex_autorunner/integrations/telegram/state.py +1278 -330
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
- codex_autorunner/integrations/telegram/transport.py +37 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/integrations/telegram/types.py +22 -2
- codex_autorunner/integrations/telegram/voice.py +14 -15
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +25 -14
- codex_autorunner/routes/agents.py +18 -78
- codex_autorunner/routes/analytics.py +239 -0
- codex_autorunner/routes/base.py +142 -113
- codex_autorunner/routes/file_chat.py +836 -0
- codex_autorunner/routes/flows.py +980 -0
- codex_autorunner/routes/messages.py +459 -0
- codex_autorunner/routes/repos.py +17 -0
- codex_autorunner/routes/review.py +148 -0
- codex_autorunner/routes/sessions.py +16 -8
- codex_autorunner/routes/settings.py +22 -0
- codex_autorunner/routes/shared.py +33 -3
- codex_autorunner/routes/system.py +22 -1
- codex_autorunner/routes/usage.py +87 -0
- codex_autorunner/routes/voice.py +5 -13
- codex_autorunner/routes/workspace.py +271 -0
- codex_autorunner/server.py +2 -1
- codex_autorunner/static/agentControls.js +9 -1
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +27 -22
- codex_autorunner/static/autoRefresh.js +29 -1
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +162 -150
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +67 -126
- codex_autorunner/static/index.html +788 -807
- codex_autorunner/static/liveUpdates.js +59 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +470 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/settings.js +24 -205
- codex_autorunner/static/styles.css +7577 -3758
- codex_autorunner/static/tabs.js +28 -5
- codex_autorunner/static/terminal.js +14 -0
- codex_autorunner/static/terminalManager.js +53 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +750 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1315 -0
- codex_autorunner/static/utils.js +32 -3
- codex_autorunner/static/voice.js +21 -7
- codex_autorunner/static/workspace.js +672 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/tickets/__init__.py +20 -0
- codex_autorunner/tickets/agent_pool.py +377 -0
- codex_autorunner/tickets/files.py +85 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +95 -0
- codex_autorunner/tickets/outbox.py +232 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +823 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/voice/capture.py +7 -7
- codex_autorunner/voice/service.py +51 -9
- codex_autorunner/web/app.py +419 -199
- codex_autorunner/web/hub_jobs.py +13 -2
- codex_autorunner/web/middleware.py +47 -13
- codex_autorunner/web/pty_session.py +26 -13
- codex_autorunner/web/schemas.py +114 -109
- codex_autorunner/web/static_assets.py +55 -42
- codex_autorunner/web/static_refresh.py +86 -0
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +319 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
- codex_autorunner-1.0.0.dist-info/RECORD +251 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
- codex_autorunner/core/doc_chat.py +0 -1415
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -118
- codex_autorunner/spec_ingest.py +0 -788
- codex_autorunner/static/docChatActions.js +0 -279
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -274
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -442
- codex_autorunner/static/logs.js +0 -640
- codex_autorunner/static/runs.js +0 -409
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -86
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.1.dist-info/RECORD +0 -191
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -2,19 +2,21 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
-
import os
|
|
6
5
|
import re
|
|
7
6
|
import time
|
|
8
7
|
from dataclasses import dataclass
|
|
9
8
|
from pathlib import Path
|
|
10
|
-
from typing import Optional, Sequence
|
|
9
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
11
12
|
|
|
12
13
|
from ...core.logging_utils import log_event
|
|
13
|
-
from ...core.
|
|
14
|
+
from ...core.supervisor_utils import evict_lru_handle_locked, pop_idle_handles_locked
|
|
15
|
+
from ...core.utils import infer_home_from_workspace, subprocess_env
|
|
14
16
|
from ...workspace import canonical_workspace_root, workspace_id_for_path
|
|
15
17
|
from .client import OpenCodeClient
|
|
16
18
|
|
|
17
|
-
_LISTENING_RE = re.compile(r"listening on (
|
|
19
|
+
_LISTENING_RE = re.compile(r"listening on (https?://[^\s]+)")
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
class OpenCodeSupervisorError(Exception):
|
|
@@ -28,6 +30,9 @@ class OpenCodeHandle:
|
|
|
28
30
|
process: Optional[asyncio.subprocess.Process]
|
|
29
31
|
client: Optional[OpenCodeClient]
|
|
30
32
|
base_url: Optional[str]
|
|
33
|
+
health_info: Optional[dict[str, Any]]
|
|
34
|
+
version: Optional[str]
|
|
35
|
+
openapi_spec: Optional[dict[str, Any]]
|
|
31
36
|
start_lock: asyncio.Lock
|
|
32
37
|
stdout_task: Optional[asyncio.Task[None]] = None
|
|
33
38
|
started: bool = False
|
|
@@ -46,15 +51,31 @@ class OpenCodeSupervisor:
|
|
|
46
51
|
idle_ttl_seconds: Optional[float] = None,
|
|
47
52
|
username: Optional[str] = None,
|
|
48
53
|
password: Optional[str] = None,
|
|
54
|
+
base_env: Optional[Mapping[str, str]] = None,
|
|
55
|
+
base_url: Optional[str] = None,
|
|
56
|
+
subagent_models: Optional[Mapping[str, str]] = None,
|
|
57
|
+
session_stall_timeout_seconds: Optional[float] = None,
|
|
49
58
|
) -> None:
|
|
50
59
|
self._command = [str(arg) for arg in command]
|
|
51
60
|
self._logger = logger or logging.getLogger(__name__)
|
|
52
61
|
self._request_timeout = request_timeout
|
|
53
62
|
self._max_handles = max_handles
|
|
54
63
|
self._idle_ttl_seconds = idle_ttl_seconds
|
|
55
|
-
self.
|
|
64
|
+
self._session_stall_timeout_seconds = session_stall_timeout_seconds
|
|
65
|
+
if password and not username:
|
|
66
|
+
username = "opencode"
|
|
67
|
+
self._auth: Optional[tuple[str, str]] = (
|
|
68
|
+
(username, password) if password and username else None
|
|
69
|
+
)
|
|
70
|
+
self._base_env = base_env
|
|
71
|
+
self._base_url = base_url
|
|
72
|
+
self._subagent_models = subagent_models or {}
|
|
56
73
|
self._handles: dict[str, OpenCodeHandle] = {}
|
|
57
|
-
self._lock
|
|
74
|
+
self._lock: Optional[asyncio.Lock] = None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def session_stall_timeout_seconds(self) -> Optional[float]:
|
|
78
|
+
return self._session_stall_timeout_seconds
|
|
58
79
|
|
|
59
80
|
async def get_client(self, workspace_root: Path) -> OpenCodeClient:
|
|
60
81
|
canonical_root = canonical_workspace_root(workspace_root)
|
|
@@ -67,7 +88,7 @@ class OpenCodeSupervisor:
|
|
|
67
88
|
return handle.client
|
|
68
89
|
|
|
69
90
|
async def close_all(self) -> None:
|
|
70
|
-
async with self.
|
|
91
|
+
async with self._get_lock():
|
|
71
92
|
handles = list(self._handles.values())
|
|
72
93
|
self._handles = {}
|
|
73
94
|
for handle in handles:
|
|
@@ -86,7 +107,7 @@ class OpenCodeSupervisor:
|
|
|
86
107
|
async def mark_turn_started(self, workspace_root: Path) -> None:
|
|
87
108
|
canonical_root = canonical_workspace_root(workspace_root)
|
|
88
109
|
workspace_id = workspace_id_for_path(canonical_root)
|
|
89
|
-
async with self.
|
|
110
|
+
async with self._get_lock():
|
|
90
111
|
handle = self._handles.get(workspace_id)
|
|
91
112
|
if handle is None:
|
|
92
113
|
return
|
|
@@ -96,7 +117,7 @@ class OpenCodeSupervisor:
|
|
|
96
117
|
async def mark_turn_finished(self, workspace_root: Path) -> None:
|
|
97
118
|
canonical_root = canonical_workspace_root(workspace_root)
|
|
98
119
|
workspace_id = workspace_id_for_path(canonical_root)
|
|
99
|
-
async with self.
|
|
120
|
+
async with self._get_lock():
|
|
100
121
|
handle = self._handles.get(workspace_id)
|
|
101
122
|
if handle is None:
|
|
102
123
|
return
|
|
@@ -104,6 +125,34 @@ class OpenCodeSupervisor:
|
|
|
104
125
|
handle.active_turns -= 1
|
|
105
126
|
handle.last_used_at = time.monotonic()
|
|
106
127
|
|
|
128
|
+
async def ensure_subagent_config(
|
|
129
|
+
self,
|
|
130
|
+
workspace_root: Path,
|
|
131
|
+
agent_id: str,
|
|
132
|
+
model: Optional[str] = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Ensure subagent agent config file exists with correct model.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
workspace_root: Path to workspace root
|
|
138
|
+
agent_id: Agent ID to configure (e.g., "subagent")
|
|
139
|
+
model: Optional model override (defaults to subagent_models if not provided)
|
|
140
|
+
"""
|
|
141
|
+
if model is None:
|
|
142
|
+
model = self._subagent_models.get(agent_id)
|
|
143
|
+
if not model:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
from .agent_config import ensure_agent_config
|
|
147
|
+
|
|
148
|
+
await ensure_agent_config(
|
|
149
|
+
workspace_root=workspace_root,
|
|
150
|
+
agent_id=agent_id,
|
|
151
|
+
model=model,
|
|
152
|
+
title=agent_id,
|
|
153
|
+
description=f"Subagent for {agent_id} tasks",
|
|
154
|
+
)
|
|
155
|
+
|
|
107
156
|
async def _close_handle(self, handle: OpenCodeHandle, *, reason: str) -> None:
|
|
108
157
|
try:
|
|
109
158
|
idle_seconds = None
|
|
@@ -147,7 +196,7 @@ class OpenCodeSupervisor:
|
|
|
147
196
|
) -> OpenCodeHandle:
|
|
148
197
|
handles_to_close: list[OpenCodeHandle] = []
|
|
149
198
|
evicted_id: Optional[str] = None
|
|
150
|
-
async with self.
|
|
199
|
+
async with self._get_lock():
|
|
151
200
|
existing = self._handles.get(workspace_id)
|
|
152
201
|
if existing is not None:
|
|
153
202
|
existing.last_used_at = time.monotonic()
|
|
@@ -163,6 +212,9 @@ class OpenCodeSupervisor:
|
|
|
163
212
|
process=None,
|
|
164
213
|
client=None,
|
|
165
214
|
base_url=None,
|
|
215
|
+
health_info=None,
|
|
216
|
+
version=None,
|
|
217
|
+
openapi_spec=None,
|
|
166
218
|
start_lock=asyncio.Lock(),
|
|
167
219
|
stdout_task=None,
|
|
168
220
|
last_used_at=time.monotonic(),
|
|
@@ -181,9 +233,97 @@ class OpenCodeSupervisor:
|
|
|
181
233
|
async with handle.start_lock:
|
|
182
234
|
if handle.started and handle.process and handle.process.returncode is None:
|
|
183
235
|
return
|
|
184
|
-
|
|
236
|
+
if self._base_url:
|
|
237
|
+
await self._ensure_started_base_url(handle)
|
|
238
|
+
else:
|
|
239
|
+
await self._start_process(handle)
|
|
240
|
+
|
|
241
|
+
async def _ensure_started_base_url(self, handle: OpenCodeHandle) -> None:
|
|
242
|
+
base_url = self._base_url
|
|
243
|
+
handle.health_info = None
|
|
244
|
+
handle.version = None
|
|
245
|
+
|
|
246
|
+
if not base_url:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
health_url = f"{base_url.rstrip('/')}/global/health"
|
|
251
|
+
async with httpx.AsyncClient(
|
|
252
|
+
timeout=self._request_timeout or 10.0
|
|
253
|
+
) as client:
|
|
254
|
+
response = await client.get(health_url)
|
|
255
|
+
response.raise_for_status()
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
handle.health_info = response.json() if response.content else {}
|
|
259
|
+
except Exception:
|
|
260
|
+
handle.health_info = {}
|
|
261
|
+
|
|
262
|
+
handle.version = str(handle.health_info.get("version", "unknown"))
|
|
263
|
+
|
|
264
|
+
log_event(
|
|
265
|
+
self._logger,
|
|
266
|
+
logging.INFO,
|
|
267
|
+
"opencode.health_check",
|
|
268
|
+
base_url=base_url,
|
|
269
|
+
version=handle.version,
|
|
270
|
+
health_info=bool(handle.health_info),
|
|
271
|
+
exc=None,
|
|
272
|
+
)
|
|
273
|
+
handle.base_url = base_url
|
|
274
|
+
handle.client = OpenCodeClient(
|
|
275
|
+
base_url,
|
|
276
|
+
auth=self._auth,
|
|
277
|
+
timeout=self._request_timeout,
|
|
278
|
+
logger=self._logger,
|
|
279
|
+
)
|
|
280
|
+
try:
|
|
281
|
+
handle.openapi_spec = await handle.client.fetch_openapi_spec()
|
|
282
|
+
log_event(
|
|
283
|
+
self._logger,
|
|
284
|
+
logging.INFO,
|
|
285
|
+
"opencode.openapi.fetched",
|
|
286
|
+
base_url=base_url,
|
|
287
|
+
endpoints=(
|
|
288
|
+
len(handle.openapi_spec.get("paths", {}))
|
|
289
|
+
if isinstance(handle.openapi_spec, dict)
|
|
290
|
+
else 0
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
except Exception as exc:
|
|
294
|
+
log_event(
|
|
295
|
+
self._logger,
|
|
296
|
+
logging.WARNING,
|
|
297
|
+
"opencode.openapi.fetch_failed",
|
|
298
|
+
base_url=base_url,
|
|
299
|
+
exc=exc,
|
|
300
|
+
)
|
|
301
|
+
handle.openapi_spec = {}
|
|
302
|
+
handle.started = True
|
|
303
|
+
except Exception as exc:
|
|
304
|
+
log_event(
|
|
305
|
+
self._logger,
|
|
306
|
+
logging.WARNING,
|
|
307
|
+
"opencode.health_check.failed",
|
|
308
|
+
base_url=base_url,
|
|
309
|
+
exc=exc,
|
|
310
|
+
)
|
|
311
|
+
raise OpenCodeSupervisorError(
|
|
312
|
+
f"OpenCode health check failed: {exc}"
|
|
313
|
+
) from exc
|
|
185
314
|
|
|
186
315
|
async def _start_process(self, handle: OpenCodeHandle) -> None:
|
|
316
|
+
if self._base_url:
|
|
317
|
+
handle.health_info = {}
|
|
318
|
+
handle.version = "external"
|
|
319
|
+
log_event(
|
|
320
|
+
self._logger,
|
|
321
|
+
logging.INFO,
|
|
322
|
+
"opencode.external_mode",
|
|
323
|
+
base_url=self._base_url,
|
|
324
|
+
)
|
|
325
|
+
return
|
|
326
|
+
|
|
187
327
|
env = self._build_opencode_env(handle.workspace_root)
|
|
188
328
|
process = await asyncio.create_subprocess_exec(
|
|
189
329
|
*self._command,
|
|
@@ -206,6 +346,28 @@ class OpenCodeSupervisor:
|
|
|
206
346
|
timeout=self._request_timeout,
|
|
207
347
|
logger=self._logger,
|
|
208
348
|
)
|
|
349
|
+
try:
|
|
350
|
+
handle.openapi_spec = await handle.client.fetch_openapi_spec()
|
|
351
|
+
log_event(
|
|
352
|
+
self._logger,
|
|
353
|
+
logging.INFO,
|
|
354
|
+
"opencode.openapi.fetched",
|
|
355
|
+
base_url=base_url,
|
|
356
|
+
endpoints=(
|
|
357
|
+
len(handle.openapi_spec.get("paths", {}))
|
|
358
|
+
if isinstance(handle.openapi_spec, dict)
|
|
359
|
+
else 0
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
except Exception as exc:
|
|
363
|
+
log_event(
|
|
364
|
+
self._logger,
|
|
365
|
+
logging.WARNING,
|
|
366
|
+
"opencode.openapi.fetch_failed",
|
|
367
|
+
base_url=base_url,
|
|
368
|
+
exc=exc,
|
|
369
|
+
)
|
|
370
|
+
handle.openapi_spec = {}
|
|
209
371
|
self._start_stdout_drain(handle)
|
|
210
372
|
handle.started = True
|
|
211
373
|
except Exception:
|
|
@@ -219,8 +381,8 @@ class OpenCodeSupervisor:
|
|
|
219
381
|
raise
|
|
220
382
|
|
|
221
383
|
def _build_opencode_env(self, workspace_root: Path) -> dict[str, str]:
|
|
222
|
-
env = subprocess_env()
|
|
223
|
-
inferred_home =
|
|
384
|
+
env = subprocess_env(base_env=self._base_env)
|
|
385
|
+
inferred_home = infer_home_from_workspace(workspace_root)
|
|
224
386
|
if inferred_home is None:
|
|
225
387
|
return env
|
|
226
388
|
inferred_auth = inferred_home / ".local" / "share" / "opencode" / "auth.json"
|
|
@@ -241,21 +403,6 @@ class OpenCodeSupervisor:
|
|
|
241
403
|
)
|
|
242
404
|
return env
|
|
243
405
|
|
|
244
|
-
def _infer_home_from_workspace(self, workspace_root: Path) -> Optional[Path]:
|
|
245
|
-
resolved = workspace_root.resolve()
|
|
246
|
-
parts = resolved.parts
|
|
247
|
-
if (
|
|
248
|
-
len(parts) >= 3
|
|
249
|
-
and parts[0] == os.path.sep
|
|
250
|
-
and parts[1]
|
|
251
|
-
in (
|
|
252
|
-
"Users",
|
|
253
|
-
"home",
|
|
254
|
-
)
|
|
255
|
-
):
|
|
256
|
-
return Path(parts[0]) / parts[1] / parts[2]
|
|
257
|
-
return None
|
|
258
|
-
|
|
259
406
|
def _opencode_auth_path_for_env(self, env: dict[str, str]) -> Optional[Path]:
|
|
260
407
|
data_home = env.get("XDG_DATA_HOME")
|
|
261
408
|
if not data_home:
|
|
@@ -330,53 +477,32 @@ class OpenCodeSupervisor:
|
|
|
330
477
|
return match.group(1)
|
|
331
478
|
|
|
332
479
|
async def _pop_idle_handles(self) -> list[OpenCodeHandle]:
|
|
333
|
-
async with self.
|
|
480
|
+
async with self._get_lock():
|
|
334
481
|
return self._pop_idle_handles_locked()
|
|
335
482
|
|
|
483
|
+
def _get_lock(self) -> asyncio.Lock:
|
|
484
|
+
if self._lock is None:
|
|
485
|
+
self._lock = asyncio.Lock()
|
|
486
|
+
return self._lock
|
|
487
|
+
|
|
336
488
|
def _pop_idle_handles_locked(self) -> list[OpenCodeHandle]:
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
logging.INFO,
|
|
346
|
-
"opencode.handle.prune.skipped",
|
|
347
|
-
reason="active_turns",
|
|
348
|
-
workspace_id=handle.workspace_id,
|
|
349
|
-
workspace_root=str(handle.workspace_root),
|
|
350
|
-
active_turns=handle.active_turns,
|
|
351
|
-
)
|
|
352
|
-
continue
|
|
353
|
-
if handle.last_used_at and handle.last_used_at < cutoff:
|
|
354
|
-
self._handles.pop(handle.workspace_id, None)
|
|
355
|
-
stale.append(handle)
|
|
356
|
-
return stale
|
|
489
|
+
return pop_idle_handles_locked(
|
|
490
|
+
self._handles,
|
|
491
|
+
self._idle_ttl_seconds,
|
|
492
|
+
self._logger,
|
|
493
|
+
"opencode",
|
|
494
|
+
last_used_at_getter=lambda h: h.last_used_at,
|
|
495
|
+
should_skip_prune=lambda h: h.active_turns > 0,
|
|
496
|
+
)
|
|
357
497
|
|
|
358
498
|
def _evict_lru_handle_locked(self) -> Optional[OpenCodeHandle]:
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
return None
|
|
363
|
-
lru_handle = min(
|
|
364
|
-
self._handles.values(),
|
|
365
|
-
key=lambda handle: handle.last_used_at or 0.0,
|
|
366
|
-
)
|
|
367
|
-
log_event(
|
|
499
|
+
return evict_lru_handle_locked(
|
|
500
|
+
self._handles,
|
|
501
|
+
self._max_handles,
|
|
368
502
|
self._logger,
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
reason="max_handles",
|
|
372
|
-
workspace_id=lru_handle.workspace_id,
|
|
373
|
-
workspace_root=str(lru_handle.workspace_root),
|
|
374
|
-
max_handles=self._max_handles,
|
|
375
|
-
handle_count=len(self._handles),
|
|
376
|
-
last_used_at=lru_handle.last_used_at,
|
|
503
|
+
"opencode",
|
|
504
|
+
last_used_at_getter=lambda h: h.last_used_at or 0.0,
|
|
377
505
|
)
|
|
378
|
-
self._handles.pop(lru_handle.workspace_id, None)
|
|
379
|
-
return lru_handle
|
|
380
506
|
|
|
381
507
|
|
|
382
508
|
__all__ = ["OpenCodeHandle", "OpenCodeSupervisor", "OpenCodeSupervisorError"]
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable, Iterable, Literal, Optional
|
|
7
|
+
|
|
8
|
+
from ..plugin_api import CAR_AGENT_ENTRYPOINT_GROUP, CAR_PLUGIN_API_VERSION
|
|
9
|
+
from .base import AgentHarness
|
|
10
|
+
from .codex.harness import CodexHarness
|
|
11
|
+
from .opencode.harness import OpenCodeHarness
|
|
12
|
+
|
|
13
|
+
_logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
AgentCapability = Literal[
|
|
16
|
+
"threads",
|
|
17
|
+
"turns",
|
|
18
|
+
"review",
|
|
19
|
+
"model_listing",
|
|
20
|
+
"event_streaming",
|
|
21
|
+
"approvals",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class AgentDescriptor:
|
|
27
|
+
"""A registered agent backend.
|
|
28
|
+
|
|
29
|
+
Built-in backends live in `_BUILTIN_AGENTS`. Additional backends MAY be loaded
|
|
30
|
+
via Python entry points (see `CAR_AGENT_ENTRYPOINT_GROUP`).
|
|
31
|
+
|
|
32
|
+
Plugins SHOULD set `plugin_api_version` to `CAR_PLUGIN_API_VERSION`.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
id: str
|
|
36
|
+
name: str
|
|
37
|
+
capabilities: frozenset[AgentCapability]
|
|
38
|
+
make_harness: Callable[[Any], AgentHarness]
|
|
39
|
+
healthcheck: Optional[Callable[[Any], bool]] = None
|
|
40
|
+
plugin_api_version: int = CAR_PLUGIN_API_VERSION
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _make_codex_harness(ctx: Any) -> AgentHarness:
|
|
44
|
+
supervisor = ctx.app_server_supervisor
|
|
45
|
+
events = ctx.app_server_events
|
|
46
|
+
if supervisor is None or events is None:
|
|
47
|
+
raise RuntimeError("Codex harness unavailable: supervisor or events missing")
|
|
48
|
+
return CodexHarness(supervisor, events)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _make_opencode_harness(ctx: Any) -> AgentHarness:
|
|
52
|
+
supervisor = ctx.opencode_supervisor
|
|
53
|
+
if supervisor is None:
|
|
54
|
+
raise RuntimeError("OpenCode harness unavailable: supervisor missing")
|
|
55
|
+
return OpenCodeHarness(supervisor)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _check_codex_health(ctx: Any) -> bool:
|
|
59
|
+
supervisor = ctx.app_server_supervisor
|
|
60
|
+
return supervisor is not None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_opencode_health(ctx: Any) -> bool:
|
|
64
|
+
supervisor = ctx.opencode_supervisor
|
|
65
|
+
return supervisor is not None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_BUILTIN_AGENTS: dict[str, AgentDescriptor] = {
|
|
69
|
+
"codex": AgentDescriptor(
|
|
70
|
+
id="codex",
|
|
71
|
+
name="Codex",
|
|
72
|
+
capabilities=frozenset(
|
|
73
|
+
[
|
|
74
|
+
"threads",
|
|
75
|
+
"turns",
|
|
76
|
+
"review",
|
|
77
|
+
"model_listing",
|
|
78
|
+
"event_streaming",
|
|
79
|
+
"approvals",
|
|
80
|
+
]
|
|
81
|
+
),
|
|
82
|
+
make_harness=_make_codex_harness,
|
|
83
|
+
healthcheck=_check_codex_health,
|
|
84
|
+
),
|
|
85
|
+
"opencode": AgentDescriptor(
|
|
86
|
+
id="opencode",
|
|
87
|
+
name="OpenCode",
|
|
88
|
+
capabilities=frozenset(
|
|
89
|
+
[
|
|
90
|
+
"threads",
|
|
91
|
+
"turns",
|
|
92
|
+
"review",
|
|
93
|
+
"model_listing",
|
|
94
|
+
"event_streaming",
|
|
95
|
+
]
|
|
96
|
+
),
|
|
97
|
+
make_harness=_make_opencode_harness,
|
|
98
|
+
healthcheck=_check_opencode_health,
|
|
99
|
+
),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Lazy-loaded cache of built-in + plugin agents.
|
|
103
|
+
_AGENT_CACHE: Optional[dict[str, AgentDescriptor]] = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _select_entry_points(group: str) -> Iterable[importlib.metadata.EntryPoint]:
|
|
107
|
+
"""Compatibility wrapper for `importlib.metadata.entry_points()` across py versions."""
|
|
108
|
+
|
|
109
|
+
eps = importlib.metadata.entry_points()
|
|
110
|
+
# Python 3.9: may return a dict
|
|
111
|
+
if isinstance(eps, dict):
|
|
112
|
+
return eps.get(group, [])
|
|
113
|
+
if hasattr(eps, "select"):
|
|
114
|
+
return list(eps.select(group=group))
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _load_agent_plugins() -> dict[str, AgentDescriptor]:
|
|
119
|
+
loaded: dict[str, AgentDescriptor] = {}
|
|
120
|
+
for ep in _select_entry_points(CAR_AGENT_ENTRYPOINT_GROUP):
|
|
121
|
+
try:
|
|
122
|
+
obj = ep.load()
|
|
123
|
+
except Exception as exc: # noqa: BLE001
|
|
124
|
+
_logger.warning(
|
|
125
|
+
"Failed to load agent plugin entry point %s:%s: %s",
|
|
126
|
+
ep.group,
|
|
127
|
+
ep.name,
|
|
128
|
+
exc,
|
|
129
|
+
)
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
descriptor: Optional[AgentDescriptor] = None
|
|
133
|
+
if isinstance(obj, AgentDescriptor):
|
|
134
|
+
descriptor = obj
|
|
135
|
+
elif callable(obj):
|
|
136
|
+
try:
|
|
137
|
+
maybe = obj()
|
|
138
|
+
except Exception as exc: # noqa: BLE001
|
|
139
|
+
_logger.warning(
|
|
140
|
+
"Agent plugin entry point %s:%s factory failed: %s",
|
|
141
|
+
ep.group,
|
|
142
|
+
ep.name,
|
|
143
|
+
exc,
|
|
144
|
+
)
|
|
145
|
+
continue
|
|
146
|
+
if isinstance(maybe, AgentDescriptor):
|
|
147
|
+
descriptor = maybe
|
|
148
|
+
|
|
149
|
+
if descriptor is None:
|
|
150
|
+
_logger.warning(
|
|
151
|
+
"Ignoring agent plugin entry point %s:%s: expected AgentDescriptor or factory",
|
|
152
|
+
ep.group,
|
|
153
|
+
ep.name,
|
|
154
|
+
)
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
agent_id = (descriptor.id or "").strip().lower()
|
|
158
|
+
if not agent_id:
|
|
159
|
+
_logger.warning(
|
|
160
|
+
"Ignoring agent plugin entry point %s:%s: missing id",
|
|
161
|
+
ep.group,
|
|
162
|
+
ep.name,
|
|
163
|
+
)
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
if descriptor.plugin_api_version != CAR_PLUGIN_API_VERSION:
|
|
167
|
+
_logger.warning(
|
|
168
|
+
"Ignoring agent plugin %s (api_version=%s): expected %s",
|
|
169
|
+
agent_id,
|
|
170
|
+
descriptor.plugin_api_version,
|
|
171
|
+
CAR_PLUGIN_API_VERSION,
|
|
172
|
+
)
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
if agent_id in _BUILTIN_AGENTS:
|
|
176
|
+
_logger.warning(
|
|
177
|
+
"Ignoring agent plugin %s: conflicts with built-in agent id",
|
|
178
|
+
agent_id,
|
|
179
|
+
)
|
|
180
|
+
continue
|
|
181
|
+
if agent_id in loaded:
|
|
182
|
+
_logger.warning(
|
|
183
|
+
"Ignoring duplicate agent plugin id %s from entry point %s:%s",
|
|
184
|
+
agent_id,
|
|
185
|
+
ep.group,
|
|
186
|
+
ep.name,
|
|
187
|
+
)
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
loaded[agent_id] = descriptor
|
|
191
|
+
_logger.info("Loaded agent plugin: %s (%s)", agent_id, descriptor.name)
|
|
192
|
+
|
|
193
|
+
return loaded
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _all_agents() -> dict[str, AgentDescriptor]:
|
|
197
|
+
global _AGENT_CACHE
|
|
198
|
+
if _AGENT_CACHE is None:
|
|
199
|
+
agents = _BUILTIN_AGENTS.copy()
|
|
200
|
+
agents.update(_load_agent_plugins())
|
|
201
|
+
_AGENT_CACHE = agents
|
|
202
|
+
return _AGENT_CACHE
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def reload_agents() -> dict[str, AgentDescriptor]:
|
|
206
|
+
"""Clear the plugin cache and reload agent backends.
|
|
207
|
+
|
|
208
|
+
This is primarily useful for tests and local development.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
global _AGENT_CACHE
|
|
212
|
+
_AGENT_CACHE = None
|
|
213
|
+
return get_registered_agents()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_registered_agents() -> dict[str, AgentDescriptor]:
|
|
217
|
+
return _all_agents().copy()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_available_agents(app_ctx: Any) -> dict[str, AgentDescriptor]:
|
|
221
|
+
available: dict[str, AgentDescriptor] = {}
|
|
222
|
+
for agent_id, descriptor in _all_agents().items():
|
|
223
|
+
if descriptor.healthcheck is None or descriptor.healthcheck(app_ctx):
|
|
224
|
+
available[agent_id] = descriptor
|
|
225
|
+
return available
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_agent_descriptor(agent_id: str) -> Optional[AgentDescriptor]:
|
|
229
|
+
normalized = (agent_id or "").strip().lower()
|
|
230
|
+
return _all_agents().get(normalized)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def validate_agent_id(agent_id: str) -> str:
|
|
234
|
+
normalized = (agent_id or "").strip().lower()
|
|
235
|
+
if normalized not in _all_agents():
|
|
236
|
+
raise ValueError(f"Unknown agent: {agent_id!r}")
|
|
237
|
+
return normalized
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def has_capability(agent_id: str, capability: AgentCapability) -> bool:
|
|
241
|
+
descriptor = get_agent_descriptor(agent_id)
|
|
242
|
+
if descriptor is None:
|
|
243
|
+
return False
|
|
244
|
+
return capability in descriptor.capabilities
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
__all__ = [
|
|
248
|
+
"AgentCapability",
|
|
249
|
+
"AgentDescriptor",
|
|
250
|
+
"CAR_PLUGIN_API_VERSION",
|
|
251
|
+
"CAR_AGENT_ENTRYPOINT_GROUP",
|
|
252
|
+
"get_registered_agents",
|
|
253
|
+
"get_available_agents",
|
|
254
|
+
"get_agent_descriptor",
|
|
255
|
+
"validate_agent_id",
|
|
256
|
+
"has_capability",
|
|
257
|
+
"reload_agents",
|
|
258
|
+
]
|
codex_autorunner/agents/types.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from typing import
|
|
4
|
+
from typing import NewType
|
|
5
5
|
|
|
6
6
|
# When adding agents, update core/config.py agents defaults + validation (config-driven).
|
|
7
|
-
AgentId =
|
|
7
|
+
AgentId = NewType("AgentId", str)
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@dataclass(frozen=True)
|
codex_autorunner/api.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Stable public API for Codex Autorunner plugins.
|
|
2
|
+
|
|
3
|
+
Everything else in the codebase should be treated as internal unless documented.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .agents.base import AgentHarness
|
|
9
|
+
from .agents.registry import AgentCapability, AgentDescriptor, reload_agents
|
|
10
|
+
from .agents.types import AgentId, ConversationRef, ModelCatalog, ModelSpec, TurnRef
|
|
11
|
+
from .plugin_api import CAR_AGENT_ENTRYPOINT_GROUP, CAR_PLUGIN_API_VERSION
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AgentCapability",
|
|
15
|
+
"AgentDescriptor",
|
|
16
|
+
"AgentHarness",
|
|
17
|
+
"AgentId",
|
|
18
|
+
"ConversationRef",
|
|
19
|
+
"ModelCatalog",
|
|
20
|
+
"ModelSpec",
|
|
21
|
+
"TurnRef",
|
|
22
|
+
"CAR_AGENT_ENTRYPOINT_GROUP",
|
|
23
|
+
"CAR_PLUGIN_API_VERSION",
|
|
24
|
+
"reload_agents",
|
|
25
|
+
]
|