codetether 1.2.2__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.
- a2a_server/__init__.py +29 -0
- a2a_server/a2a_agent_card.py +365 -0
- a2a_server/a2a_errors.py +1133 -0
- a2a_server/a2a_executor.py +926 -0
- a2a_server/a2a_router.py +1033 -0
- a2a_server/a2a_types.py +344 -0
- a2a_server/agent_card.py +408 -0
- a2a_server/agents_server.py +271 -0
- a2a_server/auth_api.py +349 -0
- a2a_server/billing_api.py +638 -0
- a2a_server/billing_service.py +712 -0
- a2a_server/billing_webhooks.py +501 -0
- a2a_server/config.py +96 -0
- a2a_server/database.py +2165 -0
- a2a_server/email_inbound.py +398 -0
- a2a_server/email_notifications.py +486 -0
- a2a_server/enhanced_agents.py +919 -0
- a2a_server/enhanced_server.py +160 -0
- a2a_server/hosted_worker.py +1049 -0
- a2a_server/integrated_agents_server.py +347 -0
- a2a_server/keycloak_auth.py +750 -0
- a2a_server/livekit_bridge.py +439 -0
- a2a_server/marketing_tools.py +1364 -0
- a2a_server/mcp_client.py +196 -0
- a2a_server/mcp_http_server.py +2256 -0
- a2a_server/mcp_server.py +191 -0
- a2a_server/message_broker.py +725 -0
- a2a_server/mock_mcp.py +273 -0
- a2a_server/models.py +494 -0
- a2a_server/monitor_api.py +5904 -0
- a2a_server/opencode_bridge.py +1594 -0
- a2a_server/redis_task_manager.py +518 -0
- a2a_server/server.py +726 -0
- a2a_server/task_manager.py +668 -0
- a2a_server/task_queue.py +742 -0
- a2a_server/tenant_api.py +333 -0
- a2a_server/tenant_middleware.py +219 -0
- a2a_server/tenant_service.py +760 -0
- a2a_server/user_auth.py +721 -0
- a2a_server/vault_client.py +576 -0
- a2a_server/worker_sse.py +873 -0
- agent_worker/__init__.py +8 -0
- agent_worker/worker.py +4877 -0
- codetether/__init__.py +10 -0
- codetether/__main__.py +4 -0
- codetether/cli.py +112 -0
- codetether/worker_cli.py +57 -0
- codetether-1.2.2.dist-info/METADATA +570 -0
- codetether-1.2.2.dist-info/RECORD +66 -0
- codetether-1.2.2.dist-info/WHEEL +5 -0
- codetether-1.2.2.dist-info/entry_points.txt +4 -0
- codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
- codetether-1.2.2.dist-info/top_level.txt +5 -0
- codetether_voice_agent/__init__.py +6 -0
- codetether_voice_agent/agent.py +445 -0
- codetether_voice_agent/codetether_mcp.py +345 -0
- codetether_voice_agent/config.py +16 -0
- codetether_voice_agent/functiongemma_caller.py +380 -0
- codetether_voice_agent/session_playback.py +247 -0
- codetether_voice_agent/tools/__init__.py +21 -0
- codetether_voice_agent/tools/definitions.py +135 -0
- codetether_voice_agent/tools/handlers.py +380 -0
- run_server.py +314 -0
- ui/monitor-tailwind.html +1790 -0
- ui/monitor.html +1775 -0
- ui/monitor.js +2662 -0
|
@@ -0,0 +1,1594 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenCode Bridge - Integrates OpenCode AI coding agent with A2A Server
|
|
3
|
+
|
|
4
|
+
This module provides a bridge between the A2A protocol server and OpenCode,
|
|
5
|
+
allowing web UI triggers to start AI agents working on registered codebases.
|
|
6
|
+
|
|
7
|
+
Architecture:
|
|
8
|
+
- Workers sync codebases, tasks, and sessions to PostgreSQL (via database.py)
|
|
9
|
+
- Bridge reads from PostgreSQL for a consistent view across replicas
|
|
10
|
+
- No SQLite persistence - all durable storage is in PostgreSQL
|
|
11
|
+
- In-memory caches are used for performance but are not authoritative
|
|
12
|
+
|
|
13
|
+
Production usage:
|
|
14
|
+
- Configure DATABASE_URL environment variable to point to PostgreSQL
|
|
15
|
+
- Workers register codebases and sync session state to PostgreSQL
|
|
16
|
+
- Multiple server replicas can read the same PostgreSQL data
|
|
17
|
+
- Monitor API queries PostgreSQL directly for session listings
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
import uuid
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from enum import Enum
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Dict, List, Optional, Callable
|
|
31
|
+
|
|
32
|
+
import aiohttp
|
|
33
|
+
|
|
34
|
+
# Import PostgreSQL database module
|
|
35
|
+
from . import database as db
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# OpenCode host configuration - allows container to connect to host VM's opencode
|
|
40
|
+
# Use 'host.docker.internal' when running in Docker on Linux/Mac/Windows
|
|
41
|
+
# Use the actual host IP when host.docker.internal is not available
|
|
42
|
+
OPENCODE_HOST = os.environ.get('OPENCODE_HOST', 'localhost')
|
|
43
|
+
OPENCODE_DEFAULT_PORT = int(os.environ.get('OPENCODE_PORT', '9777'))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AgentStatus(str, Enum):
|
|
47
|
+
"""Status of an OpenCode agent instance."""
|
|
48
|
+
|
|
49
|
+
IDLE = 'idle'
|
|
50
|
+
RUNNING = 'running'
|
|
51
|
+
BUSY = 'busy'
|
|
52
|
+
ERROR = 'error'
|
|
53
|
+
STOPPED = 'stopped'
|
|
54
|
+
WATCHING = 'watching' # Agent is watching for tasks
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AgentTaskStatus(str, Enum):
|
|
58
|
+
"""Status of an agent task."""
|
|
59
|
+
|
|
60
|
+
PENDING = 'pending'
|
|
61
|
+
ASSIGNED = 'assigned'
|
|
62
|
+
RUNNING = 'running'
|
|
63
|
+
COMPLETED = 'completed'
|
|
64
|
+
FAILED = 'failed'
|
|
65
|
+
CANCELLED = 'cancelled'
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Model selector mapping: user-friendly names -> provider/model-id
|
|
69
|
+
# This allows users to say "use minimax" instead of "minimax/minimax-m2.1"
|
|
70
|
+
MODEL_SELECTOR = {
|
|
71
|
+
# Anthropic models
|
|
72
|
+
'claude-sonnet': 'anthropic/claude-sonnet-4-20250514',
|
|
73
|
+
'claude-sonnet-4': 'anthropic/claude-sonnet-4-20250514',
|
|
74
|
+
'sonnet': 'anthropic/claude-sonnet-4-20250514',
|
|
75
|
+
'claude-opus': 'anthropic/claude-opus-4-20250514',
|
|
76
|
+
'opus': 'anthropic/claude-opus-4-20250514',
|
|
77
|
+
'claude-haiku': 'anthropic/claude-haiku',
|
|
78
|
+
'haiku': 'anthropic/claude-haiku',
|
|
79
|
+
# Minimax models
|
|
80
|
+
'minimax': 'minimax/minimax-m2.1',
|
|
81
|
+
'minimax-m2': 'minimax/minimax-m2.1',
|
|
82
|
+
'minimax-m2.1': 'minimax/minimax-m2.1',
|
|
83
|
+
'm2.1': 'minimax/minimax-m2.1',
|
|
84
|
+
# OpenAI models
|
|
85
|
+
'gpt-4': 'openai/gpt-4',
|
|
86
|
+
'gpt-4o': 'openai/gpt-4o',
|
|
87
|
+
'gpt-4-turbo': 'openai/gpt-4-turbo',
|
|
88
|
+
'gpt-4.1': 'openai/gpt-4.1',
|
|
89
|
+
'o1': 'openai/o1',
|
|
90
|
+
'o1-mini': 'openai/o1-mini',
|
|
91
|
+
'o3': 'openai/o3',
|
|
92
|
+
'o3-mini': 'openai/o3-mini',
|
|
93
|
+
# Google models
|
|
94
|
+
'gemini': 'google/gemini-2.5-pro',
|
|
95
|
+
'gemini-pro': 'google/gemini-2.5-pro',
|
|
96
|
+
'gemini-2.5-pro': 'google/gemini-2.5-pro',
|
|
97
|
+
'gemini-flash': 'google/gemini-2.5-flash',
|
|
98
|
+
'gemini-2.5-flash': 'google/gemini-2.5-flash',
|
|
99
|
+
# xAI models
|
|
100
|
+
'grok': 'xai/grok-3',
|
|
101
|
+
'grok-3': 'xai/grok-3',
|
|
102
|
+
# Default (empty string or None uses agent default)
|
|
103
|
+
'default': '',
|
|
104
|
+
'': '',
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# List of valid model selector keys for enum validation
|
|
108
|
+
MODEL_SELECTOR_KEYS = list(MODEL_SELECTOR.keys())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_model(model_input: Optional[str]) -> Optional[str]:
|
|
112
|
+
"""
|
|
113
|
+
Resolve a user-friendly model name to the full provider/model-id format.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
model_input: User input like 'minimax', 'claude-sonnet', or full 'provider/model-id'
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Full provider/model-id string, or None if default should be used
|
|
120
|
+
"""
|
|
121
|
+
if not model_input:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
model_lower = model_input.lower().strip()
|
|
125
|
+
|
|
126
|
+
# Check if it's already in provider/model format
|
|
127
|
+
if '/' in model_input:
|
|
128
|
+
return model_input
|
|
129
|
+
|
|
130
|
+
# Look up in selector mapping
|
|
131
|
+
if model_lower in MODEL_SELECTOR:
|
|
132
|
+
resolved = MODEL_SELECTOR[model_lower]
|
|
133
|
+
return resolved if resolved else None
|
|
134
|
+
|
|
135
|
+
# If not found, return as-is (let the worker handle validation)
|
|
136
|
+
logger.warning(
|
|
137
|
+
f"Unknown model selector '{model_input}', passing through as-is"
|
|
138
|
+
)
|
|
139
|
+
return model_input
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class AgentTask:
|
|
144
|
+
"""Represents a task assigned to an agent."""
|
|
145
|
+
|
|
146
|
+
id: str
|
|
147
|
+
codebase_id: str
|
|
148
|
+
title: str
|
|
149
|
+
prompt: str
|
|
150
|
+
agent_type: str = 'build' # build, plan, general, explore
|
|
151
|
+
model: Optional[str] = (
|
|
152
|
+
None # Full provider/model-id (e.g., 'minimax/minimax-m2.1')
|
|
153
|
+
)
|
|
154
|
+
status: AgentTaskStatus = AgentTaskStatus.PENDING
|
|
155
|
+
priority: int = 0 # Higher = more urgent
|
|
156
|
+
created_at: datetime = field(default_factory=datetime.utcnow)
|
|
157
|
+
started_at: Optional[datetime] = None
|
|
158
|
+
completed_at: Optional[datetime] = None
|
|
159
|
+
result: Optional[str] = None
|
|
160
|
+
error: Optional[str] = None
|
|
161
|
+
session_id: Optional[str] = None
|
|
162
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
163
|
+
|
|
164
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
165
|
+
return {
|
|
166
|
+
'id': self.id,
|
|
167
|
+
'codebase_id': self.codebase_id,
|
|
168
|
+
'title': self.title,
|
|
169
|
+
'prompt': self.prompt,
|
|
170
|
+
'agent_type': self.agent_type,
|
|
171
|
+
'model': self.model,
|
|
172
|
+
'status': self.status.value,
|
|
173
|
+
'priority': self.priority,
|
|
174
|
+
'created_at': self.created_at.isoformat(),
|
|
175
|
+
'started_at': self.started_at.isoformat()
|
|
176
|
+
if self.started_at
|
|
177
|
+
else None,
|
|
178
|
+
'completed_at': self.completed_at.isoformat()
|
|
179
|
+
if self.completed_at
|
|
180
|
+
else None,
|
|
181
|
+
'result': self.result,
|
|
182
|
+
'error': self.error,
|
|
183
|
+
'session_id': self.session_id,
|
|
184
|
+
'metadata': self.metadata,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class RegisteredCodebase:
|
|
190
|
+
"""Represents a codebase registered for agent work."""
|
|
191
|
+
|
|
192
|
+
id: str
|
|
193
|
+
name: str
|
|
194
|
+
path: str
|
|
195
|
+
description: str = ''
|
|
196
|
+
registered_at: datetime = field(default_factory=datetime.utcnow)
|
|
197
|
+
agent_config: Dict[str, Any] = field(default_factory=dict)
|
|
198
|
+
last_triggered: Optional[datetime] = None
|
|
199
|
+
status: AgentStatus = AgentStatus.IDLE
|
|
200
|
+
opencode_port: Optional[int] = None
|
|
201
|
+
session_id: Optional[str] = None
|
|
202
|
+
watch_mode: bool = False # Whether agent is in watch mode
|
|
203
|
+
watch_interval: int = 5 # Seconds between task checks
|
|
204
|
+
worker_id: Optional[str] = None # ID of the worker that owns this codebase
|
|
205
|
+
|
|
206
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
207
|
+
return {
|
|
208
|
+
'id': self.id,
|
|
209
|
+
'name': self.name,
|
|
210
|
+
'path': self.path,
|
|
211
|
+
'description': self.description,
|
|
212
|
+
'registered_at': self.registered_at.isoformat(),
|
|
213
|
+
'agent_config': self.agent_config,
|
|
214
|
+
'last_triggered': self.last_triggered.isoformat()
|
|
215
|
+
if self.last_triggered
|
|
216
|
+
else None,
|
|
217
|
+
'status': self.status.value,
|
|
218
|
+
'opencode_port': self.opencode_port,
|
|
219
|
+
'session_id': self.session_id,
|
|
220
|
+
'watch_mode': self.watch_mode,
|
|
221
|
+
'watch_interval': self.watch_interval,
|
|
222
|
+
'worker_id': self.worker_id,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass
|
|
227
|
+
class AgentTriggerRequest:
|
|
228
|
+
"""Request to trigger an agent on a codebase."""
|
|
229
|
+
|
|
230
|
+
codebase_id: str
|
|
231
|
+
prompt: str
|
|
232
|
+
agent: str = 'build' # build, plan, general, explore
|
|
233
|
+
model: Optional[str] = None
|
|
234
|
+
files: List[str] = field(default_factory=list)
|
|
235
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class AgentTriggerResponse:
|
|
240
|
+
"""Response from triggering an agent."""
|
|
241
|
+
|
|
242
|
+
success: bool
|
|
243
|
+
session_id: Optional[str] = None
|
|
244
|
+
message: str = ''
|
|
245
|
+
codebase_id: Optional[str] = None
|
|
246
|
+
agent: Optional[str] = None
|
|
247
|
+
error: Optional[str] = None
|
|
248
|
+
|
|
249
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
250
|
+
return {
|
|
251
|
+
'success': self.success,
|
|
252
|
+
'session_id': self.session_id,
|
|
253
|
+
'message': self.message,
|
|
254
|
+
'codebase_id': self.codebase_id,
|
|
255
|
+
'agent': self.agent,
|
|
256
|
+
'error': self.error,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class OpenCodeBridge:
|
|
261
|
+
"""
|
|
262
|
+
Bridge between A2A Server and OpenCode.
|
|
263
|
+
|
|
264
|
+
Manages codebase registrations, task queues, and triggers OpenCode agents
|
|
265
|
+
through its HTTP API. Supports watch mode where agents poll for tasks.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(
|
|
269
|
+
self,
|
|
270
|
+
opencode_bin: Optional[str] = None,
|
|
271
|
+
default_port: int = None,
|
|
272
|
+
auto_start: bool = True,
|
|
273
|
+
db_path: Optional[str] = None,
|
|
274
|
+
opencode_host: Optional[str] = None,
|
|
275
|
+
):
|
|
276
|
+
"""
|
|
277
|
+
Initialize the OpenCode bridge.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
opencode_bin: Path to opencode binary (auto-detected if None)
|
|
281
|
+
default_port: Default port for OpenCode server
|
|
282
|
+
auto_start: Whether to auto-start OpenCode when triggering
|
|
283
|
+
db_path: DEPRECATED - bridge now uses PostgreSQL from database.py
|
|
284
|
+
opencode_host: Host where OpenCode API is running (for container->host)
|
|
285
|
+
"""
|
|
286
|
+
self.opencode_bin = opencode_bin or self._find_opencode_binary()
|
|
287
|
+
self.default_port = default_port or OPENCODE_DEFAULT_PORT
|
|
288
|
+
self.auto_start = auto_start
|
|
289
|
+
# OpenCode host - allows container to connect to host VM's opencode
|
|
290
|
+
self.opencode_host = opencode_host or OPENCODE_HOST
|
|
291
|
+
|
|
292
|
+
# In-memory caches (populated from PostgreSQL on demand)
|
|
293
|
+
self._codebases: Dict[str, RegisteredCodebase] = {}
|
|
294
|
+
self._tasks: Dict[str, AgentTask] = {} # task_id -> task
|
|
295
|
+
self._codebase_tasks: Dict[
|
|
296
|
+
str, List[str]
|
|
297
|
+
] = {} # codebase_id -> [task_ids]
|
|
298
|
+
|
|
299
|
+
# Watch mode background tasks
|
|
300
|
+
self._watch_tasks: Dict[
|
|
301
|
+
str, asyncio.Task
|
|
302
|
+
] = {} # codebase_id -> asyncio task
|
|
303
|
+
|
|
304
|
+
# Active OpenCode processes
|
|
305
|
+
self._processes: Dict[str, subprocess.Popen] = {}
|
|
306
|
+
|
|
307
|
+
# Port allocations
|
|
308
|
+
self._port_allocations: Dict[str, int] = {}
|
|
309
|
+
self._next_port = self.default_port
|
|
310
|
+
|
|
311
|
+
# Event callbacks
|
|
312
|
+
self._on_status_change: List[Callable] = []
|
|
313
|
+
self._on_message: List[Callable] = []
|
|
314
|
+
self._on_task_update: List[Callable] = []
|
|
315
|
+
|
|
316
|
+
# HTTP session for API calls
|
|
317
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
318
|
+
|
|
319
|
+
logger.info(
|
|
320
|
+
f'OpenCode bridge initialized with binary: {self.opencode_bin}'
|
|
321
|
+
)
|
|
322
|
+
logger.info(f'OpenCode host: {self.opencode_host}:{self.default_port}')
|
|
323
|
+
logger.info(f'Using PostgreSQL database for persistence')
|
|
324
|
+
|
|
325
|
+
def _get_opencode_base_url(self, port: Optional[int] = None) -> str:
|
|
326
|
+
"""
|
|
327
|
+
Get the base URL for OpenCode API.
|
|
328
|
+
|
|
329
|
+
Uses configured opencode_host to allow container->host communication.
|
|
330
|
+
"""
|
|
331
|
+
p = port or self.default_port
|
|
332
|
+
return f'http://{self.opencode_host}:{p}'
|
|
333
|
+
|
|
334
|
+
async def _save_codebase(self, codebase: RegisteredCodebase):
|
|
335
|
+
"""Save or update a codebase in PostgreSQL."""
|
|
336
|
+
try:
|
|
337
|
+
await db.db_upsert_codebase(
|
|
338
|
+
{
|
|
339
|
+
'id': codebase.id,
|
|
340
|
+
'name': codebase.name,
|
|
341
|
+
'path': codebase.path,
|
|
342
|
+
'description': codebase.description,
|
|
343
|
+
'worker_id': codebase.worker_id,
|
|
344
|
+
'agent_config': codebase.agent_config,
|
|
345
|
+
'created_at': codebase.registered_at.isoformat(),
|
|
346
|
+
'updated_at': datetime.utcnow().isoformat(),
|
|
347
|
+
'status': codebase.status.value,
|
|
348
|
+
'session_id': codebase.session_id,
|
|
349
|
+
'opencode_port': codebase.opencode_port,
|
|
350
|
+
}
|
|
351
|
+
)
|
|
352
|
+
except Exception as e:
|
|
353
|
+
logger.error(f'Failed to save codebase to PostgreSQL: {e}')
|
|
354
|
+
|
|
355
|
+
async def _delete_codebase(self, codebase_id: str):
|
|
356
|
+
"""Delete a codebase from PostgreSQL."""
|
|
357
|
+
try:
|
|
358
|
+
await db.db_delete_codebase(codebase_id)
|
|
359
|
+
except Exception as e:
|
|
360
|
+
logger.error(f'Failed to delete codebase from PostgreSQL: {e}')
|
|
361
|
+
|
|
362
|
+
async def _save_task(self, task: AgentTask):
|
|
363
|
+
"""Save or update a task in PostgreSQL."""
|
|
364
|
+
try:
|
|
365
|
+
await db.db_upsert_task(
|
|
366
|
+
{
|
|
367
|
+
'id': task.id,
|
|
368
|
+
'codebase_id': task.codebase_id,
|
|
369
|
+
'title': task.title,
|
|
370
|
+
'prompt': task.prompt,
|
|
371
|
+
'agent_type': task.agent_type,
|
|
372
|
+
'status': task.status.value,
|
|
373
|
+
'priority': task.priority,
|
|
374
|
+
'worker_id': None, # Will be set by worker when claimed
|
|
375
|
+
'result': task.result,
|
|
376
|
+
'error': task.error,
|
|
377
|
+
'metadata': task.metadata,
|
|
378
|
+
'created_at': task.created_at.isoformat(),
|
|
379
|
+
'updated_at': datetime.utcnow().isoformat(),
|
|
380
|
+
'started_at': task.started_at.isoformat()
|
|
381
|
+
if task.started_at
|
|
382
|
+
else None,
|
|
383
|
+
'completed_at': task.completed_at.isoformat()
|
|
384
|
+
if task.completed_at
|
|
385
|
+
else None,
|
|
386
|
+
}
|
|
387
|
+
)
|
|
388
|
+
except Exception as e:
|
|
389
|
+
logger.error(f'Failed to save task to PostgreSQL: {e}')
|
|
390
|
+
|
|
391
|
+
def _task_from_db_row(self, row: Dict[str, Any]) -> AgentTask:
|
|
392
|
+
"""Convert a database row to an AgentTask object."""
|
|
393
|
+
|
|
394
|
+
def parse_dt(val):
|
|
395
|
+
if val is None:
|
|
396
|
+
return None
|
|
397
|
+
if isinstance(val, datetime):
|
|
398
|
+
return val
|
|
399
|
+
if isinstance(val, str):
|
|
400
|
+
# Handle ISO format with or without timezone
|
|
401
|
+
try:
|
|
402
|
+
return datetime.fromisoformat(val.replace('Z', '+00:00'))
|
|
403
|
+
except ValueError:
|
|
404
|
+
return datetime.strptime(val, '%Y-%m-%dT%H:%M:%S.%f')
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
return AgentTask(
|
|
408
|
+
id=row['id'],
|
|
409
|
+
codebase_id=row.get('codebase_id', 'global'),
|
|
410
|
+
title=row.get('title', ''),
|
|
411
|
+
prompt=row.get('prompt', row.get('title', '')),
|
|
412
|
+
agent_type=row.get('agent_type', 'build'),
|
|
413
|
+
status=AgentTaskStatus(row.get('status', 'pending')),
|
|
414
|
+
priority=row.get('priority', 0),
|
|
415
|
+
created_at=parse_dt(row.get('created_at')) or datetime.utcnow(),
|
|
416
|
+
started_at=parse_dt(row.get('started_at')),
|
|
417
|
+
completed_at=parse_dt(row.get('completed_at')),
|
|
418
|
+
result=row.get('result'),
|
|
419
|
+
error=row.get('error'),
|
|
420
|
+
session_id=row.get('session_id'),
|
|
421
|
+
metadata=row.get('metadata') or {},
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
async def _load_task_from_db(self, task_id: str) -> Optional[AgentTask]:
|
|
425
|
+
"""Load a task from PostgreSQL and cache it in memory."""
|
|
426
|
+
try:
|
|
427
|
+
row = await db.db_get_task(task_id)
|
|
428
|
+
if row:
|
|
429
|
+
task = self._task_from_db_row(row)
|
|
430
|
+
# Cache in memory
|
|
431
|
+
self._tasks[task_id] = task
|
|
432
|
+
if task.codebase_id not in self._codebase_tasks:
|
|
433
|
+
self._codebase_tasks[task.codebase_id] = []
|
|
434
|
+
if task_id not in self._codebase_tasks[task.codebase_id]:
|
|
435
|
+
self._codebase_tasks[task.codebase_id].append(task_id)
|
|
436
|
+
return task
|
|
437
|
+
except Exception as e:
|
|
438
|
+
logger.error(f'Failed to load task from PostgreSQL: {e}')
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
async def _load_tasks_from_db(
|
|
442
|
+
self,
|
|
443
|
+
codebase_id: Optional[str] = None,
|
|
444
|
+
status: Optional[str] = None,
|
|
445
|
+
limit: int = 100,
|
|
446
|
+
) -> List[AgentTask]:
|
|
447
|
+
"""Load tasks from PostgreSQL and cache them in memory."""
|
|
448
|
+
try:
|
|
449
|
+
rows = await db.db_list_tasks(
|
|
450
|
+
codebase_id=codebase_id,
|
|
451
|
+
status=status,
|
|
452
|
+
limit=limit,
|
|
453
|
+
)
|
|
454
|
+
tasks = []
|
|
455
|
+
for row in rows:
|
|
456
|
+
task = self._task_from_db_row(row)
|
|
457
|
+
# Cache in memory
|
|
458
|
+
self._tasks[task.id] = task
|
|
459
|
+
if task.codebase_id not in self._codebase_tasks:
|
|
460
|
+
self._codebase_tasks[task.codebase_id] = []
|
|
461
|
+
if task.id not in self._codebase_tasks[task.codebase_id]:
|
|
462
|
+
self._codebase_tasks[task.codebase_id].append(task.id)
|
|
463
|
+
tasks.append(task)
|
|
464
|
+
return tasks
|
|
465
|
+
except Exception as e:
|
|
466
|
+
logger.error(f'Failed to load tasks from PostgreSQL: {e}')
|
|
467
|
+
return []
|
|
468
|
+
|
|
469
|
+
async def _update_codebase_status(
|
|
470
|
+
self, codebase: RegisteredCodebase, status: AgentStatus
|
|
471
|
+
):
|
|
472
|
+
"""Update codebase status and persist to PostgreSQL."""
|
|
473
|
+
codebase.status = status
|
|
474
|
+
await self._save_codebase(codebase)
|
|
475
|
+
|
|
476
|
+
def _find_opencode_binary(self) -> str:
|
|
477
|
+
"""Find the opencode binary in common locations."""
|
|
478
|
+
# Check environment variable first
|
|
479
|
+
env_bin = os.environ.get('OPENCODE_BIN_PATH')
|
|
480
|
+
if env_bin and os.path.exists(env_bin):
|
|
481
|
+
return env_bin
|
|
482
|
+
|
|
483
|
+
# Check common locations
|
|
484
|
+
locations = [
|
|
485
|
+
# Local project
|
|
486
|
+
str(
|
|
487
|
+
Path(__file__).parent.parent
|
|
488
|
+
/ 'opencode'
|
|
489
|
+
/ 'packages'
|
|
490
|
+
/ 'opencode'
|
|
491
|
+
/ 'bin'
|
|
492
|
+
/ 'opencode'
|
|
493
|
+
),
|
|
494
|
+
# System paths
|
|
495
|
+
'/usr/local/bin/opencode',
|
|
496
|
+
'/usr/bin/opencode',
|
|
497
|
+
# User paths
|
|
498
|
+
str(Path.home() / '.local' / 'bin' / 'opencode'),
|
|
499
|
+
str(Path.home() / 'bin' / 'opencode'),
|
|
500
|
+
str(Path.home() / '.opencode' / 'bin' / 'opencode'),
|
|
501
|
+
# npm/bun global
|
|
502
|
+
str(Path.home() / '.bun' / 'bin' / 'opencode'),
|
|
503
|
+
str(Path.home() / '.npm-global' / 'bin' / 'opencode'),
|
|
504
|
+
]
|
|
505
|
+
|
|
506
|
+
for loc in locations:
|
|
507
|
+
if Path(loc).exists() and os.access(loc, os.X_OK):
|
|
508
|
+
return loc
|
|
509
|
+
|
|
510
|
+
# Try which command
|
|
511
|
+
try:
|
|
512
|
+
result = subprocess.run(
|
|
513
|
+
['which', 'opencode'], capture_output=True, text=True
|
|
514
|
+
)
|
|
515
|
+
if result.returncode == 0:
|
|
516
|
+
return result.stdout.strip()
|
|
517
|
+
except Exception:
|
|
518
|
+
pass
|
|
519
|
+
|
|
520
|
+
# Fallback to just "opencode" (assume in PATH)
|
|
521
|
+
return 'opencode'
|
|
522
|
+
|
|
523
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
524
|
+
"""Get or create HTTP session."""
|
|
525
|
+
if self._session is None or self._session.closed:
|
|
526
|
+
self._session = aiohttp.ClientSession(
|
|
527
|
+
timeout=aiohttp.ClientTimeout(total=30)
|
|
528
|
+
)
|
|
529
|
+
return self._session
|
|
530
|
+
|
|
531
|
+
async def close(self):
|
|
532
|
+
"""Close the bridge and cleanup resources."""
|
|
533
|
+
# Stop all running processes
|
|
534
|
+
for codebase_id in list(self._processes.keys()):
|
|
535
|
+
await self.stop_agent(codebase_id)
|
|
536
|
+
|
|
537
|
+
# Close HTTP session
|
|
538
|
+
if self._session and not self._session.closed:
|
|
539
|
+
await self._session.close()
|
|
540
|
+
|
|
541
|
+
async def register_codebase(
|
|
542
|
+
self,
|
|
543
|
+
name: str,
|
|
544
|
+
path: str,
|
|
545
|
+
description: str = '',
|
|
546
|
+
agent_config: Optional[Dict[str, Any]] = None,
|
|
547
|
+
worker_id: Optional[str] = None,
|
|
548
|
+
codebase_id: Optional[str] = None,
|
|
549
|
+
) -> RegisteredCodebase:
|
|
550
|
+
"""
|
|
551
|
+
Register a codebase for agent work.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
name: Display name for the codebase
|
|
555
|
+
path: Absolute path to the codebase directory (on worker machine)
|
|
556
|
+
description: Optional description
|
|
557
|
+
agent_config: Optional OpenCode agent configuration
|
|
558
|
+
worker_id: ID of the worker that owns this codebase (for remote execution)
|
|
559
|
+
|
|
560
|
+
Returns:
|
|
561
|
+
The registered codebase entry
|
|
562
|
+
|
|
563
|
+
Note: Path validation is skipped when a worker_id is provided, as the path
|
|
564
|
+
exists on the remote worker machine, not on the A2A server.
|
|
565
|
+
"""
|
|
566
|
+
# Normalize path
|
|
567
|
+
path = os.path.abspath(os.path.expanduser(path))
|
|
568
|
+
|
|
569
|
+
# NOTE: Path validation removed - the control plane never executes locally.
|
|
570
|
+
# Paths are validated by workers when they register codebases.
|
|
571
|
+
|
|
572
|
+
# If the caller provided a specific ID and we already have it in-memory,
|
|
573
|
+
# update that entry in-place.
|
|
574
|
+
if codebase_id and codebase_id in self._codebases:
|
|
575
|
+
codebase = self._codebases[codebase_id]
|
|
576
|
+
codebase.name = name
|
|
577
|
+
codebase.description = description
|
|
578
|
+
codebase.agent_config = agent_config or {}
|
|
579
|
+
if worker_id:
|
|
580
|
+
codebase.worker_id = worker_id
|
|
581
|
+
codebase.opencode_port = (
|
|
582
|
+
None # Clear local port if it's now remote
|
|
583
|
+
)
|
|
584
|
+
codebase.status = AgentStatus.IDLE
|
|
585
|
+
await self._save_codebase(codebase) # Persist update
|
|
586
|
+
|
|
587
|
+
worker_info = f' (worker: {worker_id})' if worker_id else ''
|
|
588
|
+
logger.info(
|
|
589
|
+
f'Updated existing codebase: {name} ({codebase_id}) at {path}{worker_info}'
|
|
590
|
+
)
|
|
591
|
+
return codebase
|
|
592
|
+
|
|
593
|
+
# Check for existing codebase with same path - update instead of duplicate
|
|
594
|
+
existing_id = None
|
|
595
|
+
for cid, cb in self._codebases.items():
|
|
596
|
+
if cb.path == path:
|
|
597
|
+
existing_id = cid
|
|
598
|
+
break
|
|
599
|
+
|
|
600
|
+
if existing_id:
|
|
601
|
+
# Update existing codebase instead of creating duplicate.
|
|
602
|
+
# If the caller supplied a conflicting codebase_id, keep the existing
|
|
603
|
+
# in-memory ID (it is already referenced by tasks/sessions).
|
|
604
|
+
if codebase_id and codebase_id != existing_id:
|
|
605
|
+
logger.info(
|
|
606
|
+
f'register_codebase: ignoring provided codebase_id={codebase_id} '
|
|
607
|
+
f'because path is already registered as {existing_id}'
|
|
608
|
+
)
|
|
609
|
+
codebase = self._codebases[existing_id]
|
|
610
|
+
codebase.name = name
|
|
611
|
+
codebase.description = description
|
|
612
|
+
codebase.agent_config = agent_config or {}
|
|
613
|
+
if worker_id:
|
|
614
|
+
codebase.worker_id = worker_id
|
|
615
|
+
codebase.opencode_port = (
|
|
616
|
+
None # Clear local port if it's now remote
|
|
617
|
+
)
|
|
618
|
+
codebase.status = AgentStatus.IDLE
|
|
619
|
+
await self._save_codebase(codebase) # Persist update
|
|
620
|
+
|
|
621
|
+
worker_info = f' (worker: {worker_id})' if worker_id else ''
|
|
622
|
+
logger.info(
|
|
623
|
+
f'Updated existing codebase: {name} ({existing_id}) at {path}{worker_info}'
|
|
624
|
+
)
|
|
625
|
+
return codebase
|
|
626
|
+
|
|
627
|
+
# Use caller-provided ID when available (e.g., when rehydrating from
|
|
628
|
+
# PostgreSQL/Redis after a restart), otherwise generate a new ID.
|
|
629
|
+
if not codebase_id:
|
|
630
|
+
codebase_id = str(uuid.uuid4())[:8]
|
|
631
|
+
|
|
632
|
+
codebase = RegisteredCodebase(
|
|
633
|
+
id=codebase_id,
|
|
634
|
+
name=name,
|
|
635
|
+
path=path,
|
|
636
|
+
description=description,
|
|
637
|
+
agent_config=agent_config or {},
|
|
638
|
+
worker_id=worker_id,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
self._codebases[codebase_id] = codebase
|
|
642
|
+
await self._save_codebase(codebase) # Persist to database
|
|
643
|
+
|
|
644
|
+
worker_info = f' (worker: {worker_id})' if worker_id else ''
|
|
645
|
+
logger.info(
|
|
646
|
+
f'Registered codebase: {name} ({codebase_id}) at {path}{worker_info}'
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
return codebase
|
|
650
|
+
|
|
651
|
+
async def unregister_codebase(self, codebase_id: str) -> bool:
|
|
652
|
+
"""Remove a codebase from the registry."""
|
|
653
|
+
if codebase_id in self._codebases:
|
|
654
|
+
# Stop any running agent
|
|
655
|
+
if codebase_id in self._processes:
|
|
656
|
+
await self.stop_agent(codebase_id)
|
|
657
|
+
|
|
658
|
+
del self._codebases[codebase_id]
|
|
659
|
+
await self._delete_codebase(codebase_id) # Remove from database
|
|
660
|
+
logger.info(f'Unregistered codebase: {codebase_id}')
|
|
661
|
+
return True
|
|
662
|
+
return False
|
|
663
|
+
|
|
664
|
+
def get_codebase(self, codebase_id: str) -> Optional[RegisteredCodebase]:
|
|
665
|
+
"""Get a registered codebase by ID."""
|
|
666
|
+
return self._codebases.get(codebase_id)
|
|
667
|
+
|
|
668
|
+
def list_codebases(self) -> List[RegisteredCodebase]:
|
|
669
|
+
"""List all registered codebases."""
|
|
670
|
+
return list(self._codebases.values())
|
|
671
|
+
|
|
672
|
+
def _allocate_port(self, codebase_id: str) -> int:
|
|
673
|
+
"""Allocate a port for an OpenCode instance."""
|
|
674
|
+
if codebase_id in self._port_allocations:
|
|
675
|
+
return self._port_allocations[codebase_id]
|
|
676
|
+
|
|
677
|
+
port = self._next_port
|
|
678
|
+
self._port_allocations[codebase_id] = port
|
|
679
|
+
self._next_port += 1
|
|
680
|
+
return port
|
|
681
|
+
|
|
682
|
+
async def _start_opencode_server(self, codebase: RegisteredCodebase) -> int:
|
|
683
|
+
"""
|
|
684
|
+
Start an OpenCode server for a codebase.
|
|
685
|
+
|
|
686
|
+
Returns the port number.
|
|
687
|
+
"""
|
|
688
|
+
port = self._allocate_port(codebase.id)
|
|
689
|
+
|
|
690
|
+
# Build command
|
|
691
|
+
cmd = [
|
|
692
|
+
self.opencode_bin,
|
|
693
|
+
'serve',
|
|
694
|
+
'--port',
|
|
695
|
+
str(port),
|
|
696
|
+
]
|
|
697
|
+
|
|
698
|
+
logger.info(
|
|
699
|
+
f'Starting OpenCode server for {codebase.name} on port {port}'
|
|
700
|
+
)
|
|
701
|
+
logger.debug(f'Command: {" ".join(cmd)}')
|
|
702
|
+
|
|
703
|
+
try:
|
|
704
|
+
# Start process in the codebase directory
|
|
705
|
+
process = subprocess.Popen(
|
|
706
|
+
cmd,
|
|
707
|
+
cwd=codebase.path,
|
|
708
|
+
stdout=subprocess.PIPE,
|
|
709
|
+
stderr=subprocess.PIPE,
|
|
710
|
+
env={**os.environ, 'NO_COLOR': '1'},
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
self._processes[codebase.id] = process
|
|
714
|
+
codebase.opencode_port = port
|
|
715
|
+
await self._update_codebase_status(codebase, AgentStatus.RUNNING)
|
|
716
|
+
|
|
717
|
+
# Wait a moment for server to start
|
|
718
|
+
await asyncio.sleep(2)
|
|
719
|
+
|
|
720
|
+
# Verify server is running
|
|
721
|
+
if process.poll() is not None:
|
|
722
|
+
# Process exited
|
|
723
|
+
stderr = (
|
|
724
|
+
process.stderr.read().decode() if process.stderr else ''
|
|
725
|
+
)
|
|
726
|
+
raise RuntimeError(f'OpenCode server failed to start: {stderr}')
|
|
727
|
+
|
|
728
|
+
logger.info(f'OpenCode server started successfully on port {port}')
|
|
729
|
+
return port
|
|
730
|
+
|
|
731
|
+
except Exception as e:
|
|
732
|
+
logger.error(f'Failed to start OpenCode server: {e}')
|
|
733
|
+
await self._update_codebase_status(codebase, AgentStatus.ERROR)
|
|
734
|
+
raise
|
|
735
|
+
|
|
736
|
+
async def stop_agent(self, codebase_id: str) -> bool:
|
|
737
|
+
"""Stop a running OpenCode agent."""
|
|
738
|
+
codebase = self._codebases.get(codebase_id)
|
|
739
|
+
if not codebase:
|
|
740
|
+
return False
|
|
741
|
+
|
|
742
|
+
process = self._processes.get(codebase_id)
|
|
743
|
+
if process:
|
|
744
|
+
try:
|
|
745
|
+
process.terminate()
|
|
746
|
+
process.wait(timeout=5)
|
|
747
|
+
except subprocess.TimeoutExpired:
|
|
748
|
+
process.kill()
|
|
749
|
+
|
|
750
|
+
del self._processes[codebase_id]
|
|
751
|
+
|
|
752
|
+
await self._update_codebase_status(codebase, AgentStatus.STOPPED)
|
|
753
|
+
codebase.opencode_port = None
|
|
754
|
+
|
|
755
|
+
# Free port allocation
|
|
756
|
+
if codebase_id in self._port_allocations:
|
|
757
|
+
del self._port_allocations[codebase_id]
|
|
758
|
+
|
|
759
|
+
logger.info(f'Stopped OpenCode agent for {codebase.name}')
|
|
760
|
+
return True
|
|
761
|
+
|
|
762
|
+
async def trigger_agent(
|
|
763
|
+
self,
|
|
764
|
+
request: AgentTriggerRequest,
|
|
765
|
+
) -> AgentTriggerResponse:
|
|
766
|
+
"""
|
|
767
|
+
Trigger an OpenCode agent to work on a codebase.
|
|
768
|
+
|
|
769
|
+
Args:
|
|
770
|
+
request: The trigger request with prompt and configuration
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
Response with session ID and status
|
|
774
|
+
"""
|
|
775
|
+
codebase = self._codebases.get(request.codebase_id)
|
|
776
|
+
if not codebase:
|
|
777
|
+
return AgentTriggerResponse(
|
|
778
|
+
success=False,
|
|
779
|
+
error=f'Codebase not found: {request.codebase_id}',
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
# For remote workers, create a task instead of local execution
|
|
783
|
+
if codebase.worker_id:
|
|
784
|
+
task = await self.create_task(
|
|
785
|
+
codebase_id=request.codebase_id,
|
|
786
|
+
title=request.prompt[:80]
|
|
787
|
+
+ ('...' if len(request.prompt) > 80 else ''),
|
|
788
|
+
prompt=request.prompt,
|
|
789
|
+
agent_type=request.agent,
|
|
790
|
+
metadata={
|
|
791
|
+
'model': request.model,
|
|
792
|
+
'files': request.files,
|
|
793
|
+
**request.metadata,
|
|
794
|
+
},
|
|
795
|
+
)
|
|
796
|
+
if task:
|
|
797
|
+
logger.info(
|
|
798
|
+
f'Created task {task.id} for remote worker {codebase.worker_id}'
|
|
799
|
+
)
|
|
800
|
+
return AgentTriggerResponse(
|
|
801
|
+
success=True,
|
|
802
|
+
session_id=task.id, # Use task ID as session ID for tracking
|
|
803
|
+
message=f'Task queued for remote worker (task: {task.id})',
|
|
804
|
+
codebase_id=request.codebase_id,
|
|
805
|
+
agent=request.agent,
|
|
806
|
+
)
|
|
807
|
+
else:
|
|
808
|
+
return AgentTriggerResponse(
|
|
809
|
+
success=False,
|
|
810
|
+
error='Failed to create task for remote worker',
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
try:
|
|
814
|
+
# Local execution: Ensure OpenCode server is running
|
|
815
|
+
if (
|
|
816
|
+
not codebase.opencode_port
|
|
817
|
+
or codebase.status != AgentStatus.RUNNING
|
|
818
|
+
):
|
|
819
|
+
if self.auto_start:
|
|
820
|
+
await self._start_opencode_server(codebase)
|
|
821
|
+
else:
|
|
822
|
+
return AgentTriggerResponse(
|
|
823
|
+
success=False,
|
|
824
|
+
error='OpenCode server not running and auto_start is disabled',
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
# Build API URL - use configured host for container->host communication
|
|
828
|
+
base_url = self._get_opencode_base_url(codebase.opencode_port)
|
|
829
|
+
|
|
830
|
+
session = await self._get_session()
|
|
831
|
+
|
|
832
|
+
# Create a new session
|
|
833
|
+
async with session.post(f'{base_url}/session') as resp:
|
|
834
|
+
if resp.status != 200:
|
|
835
|
+
raise RuntimeError(
|
|
836
|
+
f'Failed to create session: {await resp.text()}'
|
|
837
|
+
)
|
|
838
|
+
session_data = await resp.json()
|
|
839
|
+
session_id = session_data.get('id')
|
|
840
|
+
|
|
841
|
+
codebase.session_id = session_id
|
|
842
|
+
codebase.last_triggered = datetime.utcnow()
|
|
843
|
+
await self._update_codebase_status(codebase, AgentStatus.BUSY)
|
|
844
|
+
|
|
845
|
+
# Build prompt parts
|
|
846
|
+
parts = [{'type': 'text', 'text': request.prompt}]
|
|
847
|
+
|
|
848
|
+
# Add file references if specified
|
|
849
|
+
for file_path in request.files:
|
|
850
|
+
full_path = os.path.join(codebase.path, file_path)
|
|
851
|
+
if os.path.exists(full_path):
|
|
852
|
+
parts.append(
|
|
853
|
+
{
|
|
854
|
+
'type': 'file',
|
|
855
|
+
'url': f'file://{full_path}',
|
|
856
|
+
'filename': file_path,
|
|
857
|
+
'mime': 'text/plain',
|
|
858
|
+
}
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
# Trigger prompt
|
|
862
|
+
prompt_payload = {
|
|
863
|
+
'sessionID': session_id,
|
|
864
|
+
'parts': parts,
|
|
865
|
+
'agent': request.agent,
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if request.model:
|
|
869
|
+
parts_model = request.model.split('/')
|
|
870
|
+
if len(parts_model) == 2:
|
|
871
|
+
prompt_payload['model'] = {
|
|
872
|
+
'providerID': parts_model[0],
|
|
873
|
+
'modelID': parts_model[1],
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async with session.post(
|
|
877
|
+
f'{base_url}/session/{session_id}/prompt',
|
|
878
|
+
json=prompt_payload,
|
|
879
|
+
) as resp:
|
|
880
|
+
if resp.status != 200:
|
|
881
|
+
raise RuntimeError(
|
|
882
|
+
f'Failed to send prompt: {await resp.text()}'
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
# Notify callbacks
|
|
886
|
+
await self._notify_status_change(codebase)
|
|
887
|
+
|
|
888
|
+
logger.info(
|
|
889
|
+
f'Triggered agent {request.agent} on {codebase.name} (session: {session_id})'
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
return AgentTriggerResponse(
|
|
893
|
+
success=True,
|
|
894
|
+
session_id=session_id,
|
|
895
|
+
message=f"Agent '{request.agent}' triggered successfully",
|
|
896
|
+
codebase_id=codebase.id,
|
|
897
|
+
agent=request.agent,
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
except Exception as e:
|
|
901
|
+
logger.error(f'Failed to trigger agent: {e}')
|
|
902
|
+
await self._update_codebase_status(codebase, AgentStatus.ERROR)
|
|
903
|
+
return AgentTriggerResponse(
|
|
904
|
+
success=False,
|
|
905
|
+
error=str(e),
|
|
906
|
+
codebase_id=codebase.id,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
async def get_available_models(self) -> List[Dict[str, Any]]:
|
|
910
|
+
"""
|
|
911
|
+
Fetch available models from OpenCode.
|
|
912
|
+
|
|
913
|
+
Tries to query an active OpenCode instance. If none are running,
|
|
914
|
+
it may start a temporary one or fall back to reading config.
|
|
915
|
+
"""
|
|
916
|
+
# 1. Try to find an active OpenCode instance
|
|
917
|
+
active_port = None
|
|
918
|
+
for codebase in self._codebases.values():
|
|
919
|
+
if (
|
|
920
|
+
codebase.opencode_port
|
|
921
|
+
and codebase.status == AgentStatus.RUNNING
|
|
922
|
+
):
|
|
923
|
+
active_port = codebase.opencode_port
|
|
924
|
+
break
|
|
925
|
+
|
|
926
|
+
if not active_port:
|
|
927
|
+
# Try default port
|
|
928
|
+
active_port = self.default_port
|
|
929
|
+
|
|
930
|
+
try:
|
|
931
|
+
base_url = self._get_opencode_base_url(active_port)
|
|
932
|
+
session = await self._get_session()
|
|
933
|
+
|
|
934
|
+
async with session.get(f'{base_url}/provider') as resp:
|
|
935
|
+
if resp.status == 200:
|
|
936
|
+
data = await resp.json()
|
|
937
|
+
# Transform OpenCode provider/model format to A2A format
|
|
938
|
+
models = []
|
|
939
|
+
all_providers = data.get('all', [])
|
|
940
|
+
for provider in all_providers:
|
|
941
|
+
provider_id = provider.get('id')
|
|
942
|
+
provider_name = provider.get('name', provider_id)
|
|
943
|
+
for model_id, model_info in provider.get(
|
|
944
|
+
'models', {}
|
|
945
|
+
).items():
|
|
946
|
+
models.append(
|
|
947
|
+
{
|
|
948
|
+
'id': f'{provider_id}/{model_id}',
|
|
949
|
+
'name': model_info.get('name', model_id),
|
|
950
|
+
'provider': provider_name,
|
|
951
|
+
'capabilities': {
|
|
952
|
+
'reasoning': model_info.get(
|
|
953
|
+
'reasoning', False
|
|
954
|
+
),
|
|
955
|
+
'attachment': model_info.get(
|
|
956
|
+
'attachment', False
|
|
957
|
+
),
|
|
958
|
+
'tool_call': model_info.get(
|
|
959
|
+
'tool_call', False
|
|
960
|
+
),
|
|
961
|
+
},
|
|
962
|
+
}
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
# Sort models: Gemini 3 Flash first, then by provider
|
|
966
|
+
models.sort(
|
|
967
|
+
key=lambda x: (
|
|
968
|
+
0 if 'gemini-3-flash' in x['id'].lower() else 1,
|
|
969
|
+
x['provider'],
|
|
970
|
+
x['name'],
|
|
971
|
+
)
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
return models
|
|
975
|
+
except Exception as e:
|
|
976
|
+
logger.debug(f'Failed to fetch models from OpenCode API: {e}')
|
|
977
|
+
|
|
978
|
+
return []
|
|
979
|
+
|
|
980
|
+
async def get_agent_status(
|
|
981
|
+
self, codebase_id: str
|
|
982
|
+
) -> Optional[Dict[str, Any]]:
|
|
983
|
+
"""Get the current status of an agent."""
|
|
984
|
+
codebase = self._codebases.get(codebase_id)
|
|
985
|
+
if not codebase:
|
|
986
|
+
return None
|
|
987
|
+
|
|
988
|
+
result = codebase.to_dict()
|
|
989
|
+
|
|
990
|
+
# If running, try to get more info from OpenCode API
|
|
991
|
+
if codebase.opencode_port and codebase.session_id:
|
|
992
|
+
try:
|
|
993
|
+
session = await self._get_session()
|
|
994
|
+
base_url = self._get_opencode_base_url(codebase.opencode_port)
|
|
995
|
+
|
|
996
|
+
async with session.get(
|
|
997
|
+
f'{base_url}/session/{codebase.session_id}/message',
|
|
998
|
+
params={'limit': 10},
|
|
999
|
+
) as resp:
|
|
1000
|
+
if resp.status == 200:
|
|
1001
|
+
messages = await resp.json()
|
|
1002
|
+
result['recent_messages'] = messages
|
|
1003
|
+
|
|
1004
|
+
except Exception as e:
|
|
1005
|
+
logger.debug(f'Could not fetch session info: {e}')
|
|
1006
|
+
|
|
1007
|
+
return result
|
|
1008
|
+
|
|
1009
|
+
async def send_message(
|
|
1010
|
+
self,
|
|
1011
|
+
codebase_id: str,
|
|
1012
|
+
message: str,
|
|
1013
|
+
agent: Optional[str] = None,
|
|
1014
|
+
) -> AgentTriggerResponse:
|
|
1015
|
+
"""Send an additional message to an active agent session."""
|
|
1016
|
+
codebase = self._codebases.get(codebase_id)
|
|
1017
|
+
if not codebase:
|
|
1018
|
+
return AgentTriggerResponse(
|
|
1019
|
+
success=False,
|
|
1020
|
+
error=f'Codebase not found: {codebase_id}',
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
if not codebase.session_id or not codebase.opencode_port:
|
|
1024
|
+
return AgentTriggerResponse(
|
|
1025
|
+
success=False,
|
|
1026
|
+
error='No active session for this codebase',
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
try:
|
|
1030
|
+
session = await self._get_session()
|
|
1031
|
+
base_url = self._get_opencode_base_url(codebase.opencode_port)
|
|
1032
|
+
|
|
1033
|
+
payload = {
|
|
1034
|
+
'sessionID': codebase.session_id,
|
|
1035
|
+
'parts': [{'type': 'text', 'text': message}],
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if agent:
|
|
1039
|
+
payload['agent'] = agent
|
|
1040
|
+
|
|
1041
|
+
async with session.post(
|
|
1042
|
+
f'{base_url}/session/{codebase.session_id}/prompt',
|
|
1043
|
+
json=payload,
|
|
1044
|
+
) as resp:
|
|
1045
|
+
if resp.status != 200:
|
|
1046
|
+
raise RuntimeError(
|
|
1047
|
+
f'Failed to send message: {await resp.text()}'
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
await self._update_codebase_status(codebase, AgentStatus.BUSY)
|
|
1051
|
+
codebase.last_triggered = datetime.utcnow()
|
|
1052
|
+
|
|
1053
|
+
return AgentTriggerResponse(
|
|
1054
|
+
success=True,
|
|
1055
|
+
session_id=codebase.session_id,
|
|
1056
|
+
message='Message sent successfully',
|
|
1057
|
+
codebase_id=codebase.id,
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
except Exception as e:
|
|
1061
|
+
logger.error(f'Failed to send message: {e}')
|
|
1062
|
+
return AgentTriggerResponse(
|
|
1063
|
+
success=False,
|
|
1064
|
+
error=str(e),
|
|
1065
|
+
codebase_id=codebase.id,
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
async def interrupt_agent(self, codebase_id: str) -> bool:
|
|
1069
|
+
"""Interrupt the current agent task."""
|
|
1070
|
+
codebase = self._codebases.get(codebase_id)
|
|
1071
|
+
if (
|
|
1072
|
+
not codebase
|
|
1073
|
+
or not codebase.session_id
|
|
1074
|
+
or not codebase.opencode_port
|
|
1075
|
+
):
|
|
1076
|
+
return False
|
|
1077
|
+
|
|
1078
|
+
try:
|
|
1079
|
+
session = await self._get_session()
|
|
1080
|
+
base_url = self._get_opencode_base_url(codebase.opencode_port)
|
|
1081
|
+
|
|
1082
|
+
async with session.post(
|
|
1083
|
+
f'{base_url}/session/{codebase.session_id}/interrupt'
|
|
1084
|
+
) as resp:
|
|
1085
|
+
if resp.status == 200:
|
|
1086
|
+
await self._update_codebase_status(
|
|
1087
|
+
codebase, AgentStatus.RUNNING
|
|
1088
|
+
)
|
|
1089
|
+
logger.info(f'Interrupted agent for {codebase.name}')
|
|
1090
|
+
return True
|
|
1091
|
+
|
|
1092
|
+
except Exception as e:
|
|
1093
|
+
logger.error(f'Failed to interrupt agent: {e}')
|
|
1094
|
+
|
|
1095
|
+
return False
|
|
1096
|
+
|
|
1097
|
+
def on_status_change(self, callback: Callable):
|
|
1098
|
+
"""Register a callback for status changes."""
|
|
1099
|
+
self._on_status_change.append(callback)
|
|
1100
|
+
|
|
1101
|
+
def on_message(self, callback: Callable):
|
|
1102
|
+
"""Register a callback for agent messages."""
|
|
1103
|
+
self._on_message.append(callback)
|
|
1104
|
+
|
|
1105
|
+
async def _notify_status_change(self, codebase: RegisteredCodebase):
|
|
1106
|
+
"""Notify registered callbacks of status change."""
|
|
1107
|
+
for callback in self._on_status_change:
|
|
1108
|
+
try:
|
|
1109
|
+
if asyncio.iscoroutinefunction(callback):
|
|
1110
|
+
await callback(codebase)
|
|
1111
|
+
else:
|
|
1112
|
+
callback(codebase)
|
|
1113
|
+
except Exception as e:
|
|
1114
|
+
logger.error(f'Error in status change callback: {e}')
|
|
1115
|
+
|
|
1116
|
+
# ========================================
|
|
1117
|
+
# Task Management
|
|
1118
|
+
# ========================================
|
|
1119
|
+
|
|
1120
|
+
async def create_task(
|
|
1121
|
+
self,
|
|
1122
|
+
codebase_id: str,
|
|
1123
|
+
title: str,
|
|
1124
|
+
prompt: str,
|
|
1125
|
+
agent_type: str = 'build',
|
|
1126
|
+
priority: int = 0,
|
|
1127
|
+
model: Optional[str] = None,
|
|
1128
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1129
|
+
) -> Optional[AgentTask]:
|
|
1130
|
+
"""
|
|
1131
|
+
Create a new task for an agent.
|
|
1132
|
+
|
|
1133
|
+
Special codebase_id values:
|
|
1134
|
+
- '__pending__': Registration tasks that any worker can claim
|
|
1135
|
+
- 'global': Tasks without a specific codebase that any worker with a registered global codebase can pick up
|
|
1136
|
+
|
|
1137
|
+
Args:
|
|
1138
|
+
model: Full provider/model-id (e.g., 'minimax/minimax-m2.1'). Use resolve_model() to convert friendly names.
|
|
1139
|
+
"""
|
|
1140
|
+
# Allow special '__pending__' and 'global' codebase_id values
|
|
1141
|
+
# 'global' is for MCP tasks that any worker with a registered global codebase can pick up
|
|
1142
|
+
if codebase_id not in ('__pending__', 'global'):
|
|
1143
|
+
codebase = self._codebases.get(codebase_id)
|
|
1144
|
+
if not codebase:
|
|
1145
|
+
logger.error(
|
|
1146
|
+
f'Cannot create task: codebase {codebase_id} not found'
|
|
1147
|
+
)
|
|
1148
|
+
return None
|
|
1149
|
+
codebase_name = codebase.name
|
|
1150
|
+
elif codebase_id == '__pending__':
|
|
1151
|
+
codebase_name = 'pending-registration'
|
|
1152
|
+
else:
|
|
1153
|
+
codebase_name = 'global'
|
|
1154
|
+
|
|
1155
|
+
task_id = str(uuid.uuid4())
|
|
1156
|
+
task = AgentTask(
|
|
1157
|
+
id=task_id,
|
|
1158
|
+
codebase_id=codebase_id,
|
|
1159
|
+
title=title,
|
|
1160
|
+
prompt=prompt,
|
|
1161
|
+
agent_type=agent_type,
|
|
1162
|
+
model=model,
|
|
1163
|
+
priority=priority,
|
|
1164
|
+
metadata=metadata or {},
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
self._tasks[task_id] = task
|
|
1168
|
+
|
|
1169
|
+
# Add to codebase task list
|
|
1170
|
+
if codebase_id not in self._codebase_tasks:
|
|
1171
|
+
self._codebase_tasks[codebase_id] = []
|
|
1172
|
+
self._codebase_tasks[codebase_id].append(task_id)
|
|
1173
|
+
|
|
1174
|
+
# Persist to database
|
|
1175
|
+
await self._save_task(task)
|
|
1176
|
+
|
|
1177
|
+
logger.info(f'Created task {task_id} for {codebase_name}: {title}')
|
|
1178
|
+
|
|
1179
|
+
# Notify callbacks
|
|
1180
|
+
asyncio.create_task(self._notify_task_update(task))
|
|
1181
|
+
|
|
1182
|
+
return task
|
|
1183
|
+
|
|
1184
|
+
async def get_task(self, task_id: str) -> Optional[AgentTask]:
|
|
1185
|
+
"""Get a task by ID. Checks in-memory cache first, then database."""
|
|
1186
|
+
# Check in-memory cache first
|
|
1187
|
+
task = self._tasks.get(task_id)
|
|
1188
|
+
if task:
|
|
1189
|
+
return task
|
|
1190
|
+
# Fall back to database
|
|
1191
|
+
return await self._load_task_from_db(task_id)
|
|
1192
|
+
|
|
1193
|
+
async def list_tasks(
|
|
1194
|
+
self,
|
|
1195
|
+
codebase_id: Optional[str] = None,
|
|
1196
|
+
status: Optional[AgentTaskStatus] = None,
|
|
1197
|
+
) -> List[AgentTask]:
|
|
1198
|
+
"""List tasks, optionally filtered by codebase or status. Loads from database."""
|
|
1199
|
+
# Load tasks from database (this also caches them in memory)
|
|
1200
|
+
status_str = status.value if status else None
|
|
1201
|
+
tasks = await self._load_tasks_from_db(
|
|
1202
|
+
codebase_id=codebase_id,
|
|
1203
|
+
status=status_str,
|
|
1204
|
+
limit=1000, # Reasonable limit
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
# Sort by priority (desc) then created_at (asc)
|
|
1208
|
+
tasks.sort(key=lambda t: (-t.priority, t.created_at))
|
|
1209
|
+
|
|
1210
|
+
return tasks
|
|
1211
|
+
|
|
1212
|
+
async def get_next_pending_task(
|
|
1213
|
+
self, codebase_id: str
|
|
1214
|
+
) -> Optional[AgentTask]:
|
|
1215
|
+
"""Get the next pending task for a codebase."""
|
|
1216
|
+
pending = await self.list_tasks(
|
|
1217
|
+
codebase_id=codebase_id, status=AgentTaskStatus.PENDING
|
|
1218
|
+
)
|
|
1219
|
+
return pending[0] if pending else None
|
|
1220
|
+
|
|
1221
|
+
async def update_task_status(
|
|
1222
|
+
self,
|
|
1223
|
+
task_id: str,
|
|
1224
|
+
status: AgentTaskStatus,
|
|
1225
|
+
result: Optional[str] = None,
|
|
1226
|
+
error: Optional[str] = None,
|
|
1227
|
+
session_id: Optional[str] = None,
|
|
1228
|
+
) -> Optional[AgentTask]:
|
|
1229
|
+
"""Update task status."""
|
|
1230
|
+
task = self._tasks.get(task_id)
|
|
1231
|
+
if not task:
|
|
1232
|
+
# Task not in memory - try to load from database
|
|
1233
|
+
# (handles tasks created via direct DB writes, e.g., trigger endpoint)
|
|
1234
|
+
task = await self._load_task_from_db(task_id)
|
|
1235
|
+
if not task:
|
|
1236
|
+
return None
|
|
1237
|
+
# Cache it for future updates
|
|
1238
|
+
self._tasks[task_id] = task
|
|
1239
|
+
|
|
1240
|
+
task.status = status
|
|
1241
|
+
|
|
1242
|
+
# Idempotency: workers may send multiple RUNNING updates (e.g., once
|
|
1243
|
+
# to claim and later to attach session_id). Preserve the original
|
|
1244
|
+
# started/completed timestamps.
|
|
1245
|
+
if status == AgentTaskStatus.RUNNING:
|
|
1246
|
+
if task.started_at is None:
|
|
1247
|
+
task.started_at = datetime.utcnow()
|
|
1248
|
+
elif status in (
|
|
1249
|
+
AgentTaskStatus.COMPLETED,
|
|
1250
|
+
AgentTaskStatus.FAILED,
|
|
1251
|
+
AgentTaskStatus.CANCELLED,
|
|
1252
|
+
):
|
|
1253
|
+
if task.completed_at is None:
|
|
1254
|
+
task.completed_at = datetime.utcnow()
|
|
1255
|
+
|
|
1256
|
+
# Allow workers (or the control plane) to attach the active OpenCode
|
|
1257
|
+
# session ID for UI deep-linking and eager message sync.
|
|
1258
|
+
if session_id and session_id != task.session_id:
|
|
1259
|
+
task.session_id = session_id
|
|
1260
|
+
|
|
1261
|
+
if result:
|
|
1262
|
+
task.result = result
|
|
1263
|
+
if error:
|
|
1264
|
+
task.error = error
|
|
1265
|
+
|
|
1266
|
+
# Persist to database
|
|
1267
|
+
await self._save_task(task)
|
|
1268
|
+
|
|
1269
|
+
asyncio.create_task(self._notify_task_update(task))
|
|
1270
|
+
|
|
1271
|
+
return task
|
|
1272
|
+
|
|
1273
|
+
async def cancel_task(self, task_id: str) -> bool:
|
|
1274
|
+
"""Cancel a pending task."""
|
|
1275
|
+
task = self._tasks.get(task_id)
|
|
1276
|
+
if not task:
|
|
1277
|
+
return False
|
|
1278
|
+
|
|
1279
|
+
if task.status not in (
|
|
1280
|
+
AgentTaskStatus.PENDING,
|
|
1281
|
+
AgentTaskStatus.ASSIGNED,
|
|
1282
|
+
):
|
|
1283
|
+
return False
|
|
1284
|
+
|
|
1285
|
+
task.status = AgentTaskStatus.CANCELLED
|
|
1286
|
+
task.completed_at = datetime.utcnow()
|
|
1287
|
+
|
|
1288
|
+
# Persist to database
|
|
1289
|
+
await self._save_task(task)
|
|
1290
|
+
|
|
1291
|
+
asyncio.create_task(self._notify_task_update(task))
|
|
1292
|
+
|
|
1293
|
+
return True
|
|
1294
|
+
|
|
1295
|
+
def on_task_update(self, callback: Callable):
|
|
1296
|
+
"""Register a callback for task updates."""
|
|
1297
|
+
self._on_task_update.append(callback)
|
|
1298
|
+
|
|
1299
|
+
async def _notify_task_update(self, task: AgentTask):
|
|
1300
|
+
"""Notify registered callbacks of task update."""
|
|
1301
|
+
for callback in self._on_task_update:
|
|
1302
|
+
try:
|
|
1303
|
+
if asyncio.iscoroutinefunction(callback):
|
|
1304
|
+
await callback(task)
|
|
1305
|
+
else:
|
|
1306
|
+
callback(task)
|
|
1307
|
+
except Exception as e:
|
|
1308
|
+
logger.error(f'Error in task update callback: {e}')
|
|
1309
|
+
|
|
1310
|
+
# ========================================
|
|
1311
|
+
# Watch Mode (Persistent Agent Workers)
|
|
1312
|
+
# ========================================
|
|
1313
|
+
|
|
1314
|
+
async def start_watch_mode(
|
|
1315
|
+
self, codebase_id: str, interval: int = 5
|
|
1316
|
+
) -> bool:
|
|
1317
|
+
"""
|
|
1318
|
+
Start watch mode for a codebase - agent will poll for and execute tasks.
|
|
1319
|
+
|
|
1320
|
+
Args:
|
|
1321
|
+
codebase_id: ID of the codebase
|
|
1322
|
+
interval: Seconds between task checks
|
|
1323
|
+
|
|
1324
|
+
Returns:
|
|
1325
|
+
True if watch mode started successfully
|
|
1326
|
+
"""
|
|
1327
|
+
codebase = self._codebases.get(codebase_id)
|
|
1328
|
+
if not codebase:
|
|
1329
|
+
logger.error(
|
|
1330
|
+
f'Cannot start watch mode: codebase {codebase_id} not found'
|
|
1331
|
+
)
|
|
1332
|
+
return False
|
|
1333
|
+
|
|
1334
|
+
if codebase_id in self._watch_tasks:
|
|
1335
|
+
logger.warning(f'Watch mode already running for {codebase.name}')
|
|
1336
|
+
return True
|
|
1337
|
+
|
|
1338
|
+
# For remote workers, watch mode is just a flag - the worker polls automatically
|
|
1339
|
+
if codebase.worker_id:
|
|
1340
|
+
codebase.watch_mode = True
|
|
1341
|
+
codebase.watch_interval = interval
|
|
1342
|
+
await self._update_codebase_status(codebase, AgentStatus.WATCHING)
|
|
1343
|
+
logger.info(
|
|
1344
|
+
f'Watch mode enabled for {codebase.name} (remote worker: {codebase.worker_id})'
|
|
1345
|
+
)
|
|
1346
|
+
await self._notify_status_change(codebase)
|
|
1347
|
+
return True
|
|
1348
|
+
|
|
1349
|
+
# For local execution, start the OpenCode server if not running
|
|
1350
|
+
if not codebase.opencode_port or codebase.status in (
|
|
1351
|
+
AgentStatus.IDLE,
|
|
1352
|
+
AgentStatus.STOPPED,
|
|
1353
|
+
):
|
|
1354
|
+
try:
|
|
1355
|
+
await self._start_opencode_server(codebase)
|
|
1356
|
+
except Exception as e:
|
|
1357
|
+
logger.error(
|
|
1358
|
+
f'Failed to start OpenCode server for watch mode: {e}'
|
|
1359
|
+
)
|
|
1360
|
+
return False
|
|
1361
|
+
|
|
1362
|
+
codebase.watch_mode = True
|
|
1363
|
+
codebase.watch_interval = interval
|
|
1364
|
+
await self._update_codebase_status(codebase, AgentStatus.WATCHING)
|
|
1365
|
+
|
|
1366
|
+
# Start background task for local execution
|
|
1367
|
+
watch_task = asyncio.create_task(self._watch_loop(codebase_id))
|
|
1368
|
+
self._watch_tasks[codebase_id] = watch_task
|
|
1369
|
+
|
|
1370
|
+
logger.info(
|
|
1371
|
+
f'Started watch mode for {codebase.name} (interval: {interval}s)'
|
|
1372
|
+
)
|
|
1373
|
+
await self._notify_status_change(codebase)
|
|
1374
|
+
|
|
1375
|
+
return True
|
|
1376
|
+
|
|
1377
|
+
async def stop_watch_mode(self, codebase_id: str) -> bool:
|
|
1378
|
+
"""Stop watch mode for a codebase."""
|
|
1379
|
+
codebase = self._codebases.get(codebase_id)
|
|
1380
|
+
if not codebase:
|
|
1381
|
+
return False
|
|
1382
|
+
|
|
1383
|
+
# For remote workers, just update the flag
|
|
1384
|
+
if codebase.worker_id:
|
|
1385
|
+
codebase.watch_mode = False
|
|
1386
|
+
await self._update_codebase_status(codebase, AgentStatus.IDLE)
|
|
1387
|
+
logger.info(
|
|
1388
|
+
f'Watch mode disabled for {codebase.name} (remote worker)'
|
|
1389
|
+
)
|
|
1390
|
+
await self._notify_status_change(codebase)
|
|
1391
|
+
return True
|
|
1392
|
+
|
|
1393
|
+
# Cancel local watch task
|
|
1394
|
+
watch_task = self._watch_tasks.get(codebase_id)
|
|
1395
|
+
if watch_task:
|
|
1396
|
+
watch_task.cancel()
|
|
1397
|
+
try:
|
|
1398
|
+
await watch_task
|
|
1399
|
+
except asyncio.CancelledError:
|
|
1400
|
+
pass
|
|
1401
|
+
del self._watch_tasks[codebase_id]
|
|
1402
|
+
|
|
1403
|
+
codebase.watch_mode = False
|
|
1404
|
+
await self._update_codebase_status(
|
|
1405
|
+
codebase,
|
|
1406
|
+
AgentStatus.RUNNING if codebase.opencode_port else AgentStatus.IDLE,
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
logger.info(f'Stopped watch mode for {codebase.name}')
|
|
1410
|
+
await self._notify_status_change(codebase)
|
|
1411
|
+
|
|
1412
|
+
return True
|
|
1413
|
+
|
|
1414
|
+
async def _watch_loop(self, codebase_id: str):
|
|
1415
|
+
"""Background loop that checks for and executes tasks."""
|
|
1416
|
+
codebase = self._codebases.get(codebase_id)
|
|
1417
|
+
if not codebase:
|
|
1418
|
+
return
|
|
1419
|
+
|
|
1420
|
+
logger.info(f'Watch loop started for {codebase.name}')
|
|
1421
|
+
|
|
1422
|
+
try:
|
|
1423
|
+
while True:
|
|
1424
|
+
# Check for pending tasks
|
|
1425
|
+
task = await self.get_next_pending_task(codebase_id)
|
|
1426
|
+
|
|
1427
|
+
if task:
|
|
1428
|
+
logger.info(f'Watch loop found task: {task.title}')
|
|
1429
|
+
await self._execute_task(task)
|
|
1430
|
+
|
|
1431
|
+
# Wait before next check
|
|
1432
|
+
await asyncio.sleep(codebase.watch_interval)
|
|
1433
|
+
|
|
1434
|
+
except asyncio.CancelledError:
|
|
1435
|
+
logger.info(f'Watch loop cancelled for {codebase.name}')
|
|
1436
|
+
raise
|
|
1437
|
+
except Exception as e:
|
|
1438
|
+
logger.error(f'Watch loop error for {codebase.name}: {e}')
|
|
1439
|
+
self._update_codebase_status(codebase, AgentStatus.ERROR)
|
|
1440
|
+
|
|
1441
|
+
async def _execute_task(self, task: AgentTask):
|
|
1442
|
+
"""Execute a task using the OpenCode agent."""
|
|
1443
|
+
codebase = self._codebases.get(task.codebase_id)
|
|
1444
|
+
if not codebase:
|
|
1445
|
+
task.status = AgentTaskStatus.FAILED
|
|
1446
|
+
task.error = 'Codebase not found'
|
|
1447
|
+
return
|
|
1448
|
+
|
|
1449
|
+
# Update task status
|
|
1450
|
+
task.status = AgentTaskStatus.RUNNING
|
|
1451
|
+
task.started_at = datetime.utcnow()
|
|
1452
|
+
await self._notify_task_update(task)
|
|
1453
|
+
|
|
1454
|
+
# Update codebase status
|
|
1455
|
+
await self._update_codebase_status(codebase, AgentStatus.BUSY)
|
|
1456
|
+
await self._notify_status_change(codebase)
|
|
1457
|
+
|
|
1458
|
+
try:
|
|
1459
|
+
# Create trigger request
|
|
1460
|
+
request = AgentTriggerRequest(
|
|
1461
|
+
codebase_id=task.codebase_id,
|
|
1462
|
+
prompt=task.prompt,
|
|
1463
|
+
agent=task.agent_type,
|
|
1464
|
+
metadata=task.metadata,
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
# Trigger the agent
|
|
1468
|
+
response = await self.trigger_agent(request)
|
|
1469
|
+
|
|
1470
|
+
if response.success:
|
|
1471
|
+
task.session_id = response.session_id
|
|
1472
|
+
|
|
1473
|
+
# Wait for agent to finish (poll status)
|
|
1474
|
+
await self._wait_for_agent_completion(task, codebase)
|
|
1475
|
+
|
|
1476
|
+
else:
|
|
1477
|
+
task.status = AgentTaskStatus.FAILED
|
|
1478
|
+
task.error = response.error
|
|
1479
|
+
task.completed_at = datetime.utcnow()
|
|
1480
|
+
|
|
1481
|
+
except Exception as e:
|
|
1482
|
+
logger.error(f'Task execution failed: {e}')
|
|
1483
|
+
task.status = AgentTaskStatus.FAILED
|
|
1484
|
+
task.error = str(e)
|
|
1485
|
+
task.completed_at = datetime.utcnow()
|
|
1486
|
+
|
|
1487
|
+
finally:
|
|
1488
|
+
# Restore codebase status if in watch mode
|
|
1489
|
+
if codebase.watch_mode:
|
|
1490
|
+
await self._update_codebase_status(
|
|
1491
|
+
codebase, AgentStatus.WATCHING
|
|
1492
|
+
)
|
|
1493
|
+
else:
|
|
1494
|
+
await self._update_codebase_status(
|
|
1495
|
+
codebase, AgentStatus.RUNNING
|
|
1496
|
+
)
|
|
1497
|
+
|
|
1498
|
+
await self._notify_status_change(codebase)
|
|
1499
|
+
await self._notify_task_update(task)
|
|
1500
|
+
|
|
1501
|
+
async def _wait_for_agent_completion(
|
|
1502
|
+
self, task: AgentTask, codebase: RegisteredCodebase, timeout: int = 600
|
|
1503
|
+
):
|
|
1504
|
+
"""Wait for an agent to complete its work."""
|
|
1505
|
+
if not codebase.opencode_port or not task.session_id:
|
|
1506
|
+
return
|
|
1507
|
+
|
|
1508
|
+
base_url = self._get_opencode_base_url(codebase.opencode_port)
|
|
1509
|
+
session = await self._get_session()
|
|
1510
|
+
|
|
1511
|
+
start_time = datetime.utcnow()
|
|
1512
|
+
|
|
1513
|
+
while True:
|
|
1514
|
+
# Check timeout
|
|
1515
|
+
elapsed = (datetime.utcnow() - start_time).total_seconds()
|
|
1516
|
+
if elapsed > timeout:
|
|
1517
|
+
task.status = AgentTaskStatus.FAILED
|
|
1518
|
+
task.error = 'Timeout waiting for agent completion'
|
|
1519
|
+
task.completed_at = datetime.utcnow()
|
|
1520
|
+
return
|
|
1521
|
+
|
|
1522
|
+
try:
|
|
1523
|
+
# Check session status
|
|
1524
|
+
async with session.get(
|
|
1525
|
+
f'{base_url}/session/{task.session_id}',
|
|
1526
|
+
params={'directory': codebase.path},
|
|
1527
|
+
) as resp:
|
|
1528
|
+
if resp.status == 200:
|
|
1529
|
+
data = await resp.json()
|
|
1530
|
+
status = data.get('status', {})
|
|
1531
|
+
|
|
1532
|
+
# Check if agent is idle (completed)
|
|
1533
|
+
if (
|
|
1534
|
+
status.get('idle', False)
|
|
1535
|
+
or status.get('status') == 'idle'
|
|
1536
|
+
):
|
|
1537
|
+
task.status = AgentTaskStatus.COMPLETED
|
|
1538
|
+
task.completed_at = datetime.utcnow()
|
|
1539
|
+
|
|
1540
|
+
# Get last message as result
|
|
1541
|
+
async with session.get(
|
|
1542
|
+
f'{base_url}/session/{task.session_id}/message',
|
|
1543
|
+
params={'limit': 1, 'directory': codebase.path},
|
|
1544
|
+
) as msg_resp:
|
|
1545
|
+
if msg_resp.status == 200:
|
|
1546
|
+
messages = await msg_resp.json()
|
|
1547
|
+
if messages:
|
|
1548
|
+
# Extract text content
|
|
1549
|
+
parts = messages[0].get('parts', [])
|
|
1550
|
+
text_parts = [
|
|
1551
|
+
p.get('text', '')
|
|
1552
|
+
for p in parts
|
|
1553
|
+
if p.get('type') == 'text'
|
|
1554
|
+
]
|
|
1555
|
+
task.result = '\n'.join(text_parts)[
|
|
1556
|
+
:5000
|
|
1557
|
+
] # Limit result size
|
|
1558
|
+
|
|
1559
|
+
return
|
|
1560
|
+
|
|
1561
|
+
except Exception as e:
|
|
1562
|
+
logger.debug(f'Error checking agent status: {e}')
|
|
1563
|
+
|
|
1564
|
+
# Wait before next check
|
|
1565
|
+
await asyncio.sleep(2)
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
# Global bridge instance
|
|
1569
|
+
_bridge: Optional[OpenCodeBridge] = None
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def get_bridge() -> OpenCodeBridge:
|
|
1573
|
+
"""Get the global OpenCode bridge instance."""
|
|
1574
|
+
global _bridge
|
|
1575
|
+
if _bridge is None:
|
|
1576
|
+
_bridge = OpenCodeBridge()
|
|
1577
|
+
return _bridge
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def init_bridge(
|
|
1581
|
+
opencode_bin: Optional[str] = None,
|
|
1582
|
+
default_port: int = None,
|
|
1583
|
+
auto_start: bool = True,
|
|
1584
|
+
opencode_host: Optional[str] = None,
|
|
1585
|
+
) -> OpenCodeBridge:
|
|
1586
|
+
"""Initialize the global OpenCode bridge instance."""
|
|
1587
|
+
global _bridge
|
|
1588
|
+
_bridge = OpenCodeBridge(
|
|
1589
|
+
opencode_bin=opencode_bin,
|
|
1590
|
+
default_port=default_port or OPENCODE_DEFAULT_PORT,
|
|
1591
|
+
auto_start=auto_start,
|
|
1592
|
+
opencode_host=opencode_host,
|
|
1593
|
+
)
|
|
1594
|
+
return _bridge
|