aicortexpy 0.1.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.
- aicortexpy/__init__.py +3 -0
- aicortexpy/__main__.py +5 -0
- aicortexpy/acp_client.py +559 -0
- aicortexpy/acp_conn.py +853 -0
- aicortexpy/acp_errors.py +107 -0
- aicortexpy/agent_env.py +88 -0
- aicortexpy/artifact_ws.py +233 -0
- aicortexpy/artifacts.py +67 -0
- aicortexpy/attachments.py +85 -0
- aicortexpy/auto_commit.py +106 -0
- aicortexpy/capabilities.py +92 -0
- aicortexpy/chat_acp_pool.py +321 -0
- aicortexpy/cli.py +133 -0
- aicortexpy/client.py +438 -0
- aicortexpy/codex_config.py +98 -0
- aicortexpy/config.py +298 -0
- aicortexpy/daemon.py +677 -0
- aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
- aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
- aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
- aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
- aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
- aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
- aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
- aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
- aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
- aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
- aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
- aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
- aicortexpy/design_seed.py +59 -0
- aicortexpy/diskusage.py +209 -0
- aicortexpy/execenv.py +264 -0
- aicortexpy/gc.py +286 -0
- aicortexpy/handoff_bundle.py +65 -0
- aicortexpy/health.py +178 -0
- aicortexpy/local_skills.py +157 -0
- aicortexpy/models.py +385 -0
- aicortexpy/openclaw_config.py +153 -0
- aicortexpy/pinned_workdir.py +60 -0
- aicortexpy/platform_info.py +61 -0
- aicortexpy/poisoned.py +44 -0
- aicortexpy/process.py +168 -0
- aicortexpy/procutil.py +61 -0
- aicortexpy/prompt.py +235 -0
- aicortexpy/repo.py +462 -0
- aicortexpy/runner.py +969 -0
- aicortexpy/runtime_config.py +540 -0
- aicortexpy/sandbox.py +264 -0
- aicortexpy/stream.py +422 -0
- aicortexpy/terminal.py +147 -0
- aicortexpy/tunnel.py +82 -0
- aicortexpy/wakeup.py +222 -0
- aicortexpy-0.1.0.dist-info/METADATA +106 -0
- aicortexpy-0.1.0.dist-info/RECORD +58 -0
- aicortexpy-0.1.0.dist-info/WHEEL +5 -0
- aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/daemon.py
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
"""Core daemon loops: register, heartbeat actions, wakeup, claim/run, GC."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import signal
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .artifacts import report_issue_artifacts
|
|
14
|
+
from .auto_commit import auto_commit_and_push
|
|
15
|
+
from .chat_acp_pool import ChatACPPool
|
|
16
|
+
from .client import APIError, DaemonClient, Task, is_not_found
|
|
17
|
+
from .config import Config, as_register_runtimes, detect_local_timezone
|
|
18
|
+
from .gc import ActiveEnvRoots, gc_meta_for_task, run_gc_loop, write_gc_meta
|
|
19
|
+
from .health import HealthServer
|
|
20
|
+
from .local_skills import list_runtime_local_skills, load_runtime_local_skill_bundle
|
|
21
|
+
from .models import list_models, model_selection_supported
|
|
22
|
+
from .platform_info import platform_label
|
|
23
|
+
from .poisoned import classify_poisoned_error, classify_poisoned_output
|
|
24
|
+
from .repo import RepoAllowlist
|
|
25
|
+
from .runner import run_agent
|
|
26
|
+
from .wakeup import WakeupHub, run_wakeup_loop
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("aicortexpy")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def should_interrupt_agent(status: str, err: Exception | None) -> bool:
|
|
32
|
+
if err is not None:
|
|
33
|
+
return is_not_found(err, "task not found")
|
|
34
|
+
return status == "cancelled"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Daemon:
|
|
38
|
+
def __init__(self, cfg: Config):
|
|
39
|
+
self.cfg = cfg
|
|
40
|
+
self.client = DaemonClient(
|
|
41
|
+
cfg.server_base_url,
|
|
42
|
+
cfg.token,
|
|
43
|
+
version=cfg.cli_version,
|
|
44
|
+
)
|
|
45
|
+
self._stop = threading.Event()
|
|
46
|
+
self._wake = threading.Event()
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self.runtime_index: dict[str, dict[str, Any]] = {}
|
|
49
|
+
self.workspace_runtimes: dict[str, list[str]] = {}
|
|
50
|
+
self.workspace_names: dict[str, str] = {}
|
|
51
|
+
self._reregister_backoff: dict[str, float] = {}
|
|
52
|
+
self.active_tasks = 0
|
|
53
|
+
self._health: HealthServer | None = None
|
|
54
|
+
self._pool = ThreadPoolExecutor(max_workers=max(1, cfg.max_concurrent_tasks))
|
|
55
|
+
self.chat_acp_pool = ChatACPPool.from_config(cfg)
|
|
56
|
+
self.active_env_roots = ActiveEnvRoots()
|
|
57
|
+
self.repo_allowlist = RepoAllowlist()
|
|
58
|
+
self.wakeup_hub = WakeupHub()
|
|
59
|
+
|
|
60
|
+
def stop(self) -> None:
|
|
61
|
+
self._stop.set()
|
|
62
|
+
self._wake.set()
|
|
63
|
+
try:
|
|
64
|
+
self.chat_acp_pool.close_all()
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def _snapshot(self) -> dict[str, Any]:
|
|
69
|
+
with self._lock:
|
|
70
|
+
workspaces = [
|
|
71
|
+
{"id": wid, "runtimes": list(rids)}
|
|
72
|
+
for wid, rids in self.workspace_runtimes.items()
|
|
73
|
+
]
|
|
74
|
+
agents = list(self.cfg.agents.keys())
|
|
75
|
+
active = self.active_tasks
|
|
76
|
+
return {
|
|
77
|
+
"daemon_id": self.cfg.daemon_id,
|
|
78
|
+
"device_name": self.cfg.device_name,
|
|
79
|
+
"server_url": self.cfg.server_base_url,
|
|
80
|
+
"cli_version": self.cfg.cli_version,
|
|
81
|
+
"implementation": "python",
|
|
82
|
+
"platform": platform_label(),
|
|
83
|
+
"active_task_count": active,
|
|
84
|
+
"agents": agents,
|
|
85
|
+
"workspaces": workspaces,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def _runtime_ids(self) -> list[str]:
|
|
89
|
+
with self._lock:
|
|
90
|
+
return list(self.runtime_index.keys())
|
|
91
|
+
|
|
92
|
+
def run(self) -> int:
|
|
93
|
+
log.info(
|
|
94
|
+
"starting python daemon version=%s server=%s agents=%s",
|
|
95
|
+
self.cfg.cli_version,
|
|
96
|
+
self.cfg.server_base_url,
|
|
97
|
+
list(self.cfg.agents.keys()),
|
|
98
|
+
)
|
|
99
|
+
self._health = HealthServer(
|
|
100
|
+
self.cfg.health_port,
|
|
101
|
+
self._snapshot,
|
|
102
|
+
workspaces_root=self.cfg.workspaces_root,
|
|
103
|
+
on_shutdown=self.stop,
|
|
104
|
+
repo_allowlist=self.repo_allowlist,
|
|
105
|
+
client=self.client,
|
|
106
|
+
)
|
|
107
|
+
try:
|
|
108
|
+
self._health.start()
|
|
109
|
+
except RuntimeError as e:
|
|
110
|
+
log.error("%s", e)
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
def _sig(_signum: int, _frame: Any) -> None:
|
|
114
|
+
log.info("signal received, shutting down")
|
|
115
|
+
self.stop()
|
|
116
|
+
|
|
117
|
+
signal.signal(signal.SIGINT, _sig)
|
|
118
|
+
signal.signal(signal.SIGTERM, _sig)
|
|
119
|
+
if hasattr(signal, "SIGHUP"):
|
|
120
|
+
try:
|
|
121
|
+
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
|
122
|
+
except (ValueError, OSError):
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
self._sync_workspaces()
|
|
127
|
+
except Exception as e:
|
|
128
|
+
log.error("initial workspace sync failed: %s", e)
|
|
129
|
+
self._health.stop()
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
threads = [
|
|
133
|
+
threading.Thread(target=self._heartbeat_loop, name="heartbeat", daemon=True),
|
|
134
|
+
threading.Thread(target=self._sync_loop, name="ws-sync", daemon=True),
|
|
135
|
+
threading.Thread(target=self._wakeup_loop, name="wakeup", daemon=True),
|
|
136
|
+
threading.Thread(
|
|
137
|
+
target=run_gc_loop,
|
|
138
|
+
kwargs={
|
|
139
|
+
"stop": self._stop,
|
|
140
|
+
"client": self.client,
|
|
141
|
+
"workspaces_root": self.cfg.workspaces_root,
|
|
142
|
+
"interval": self.cfg.gc_interval,
|
|
143
|
+
"ttl": self.cfg.gc_ttl,
|
|
144
|
+
"orphan_ttl": self.cfg.gc_orphan_ttl,
|
|
145
|
+
"artifact_ttl": self.cfg.gc_artifact_ttl,
|
|
146
|
+
"enabled": self.cfg.gc_enabled,
|
|
147
|
+
"active_roots": self.active_env_roots,
|
|
148
|
+
},
|
|
149
|
+
name="gc",
|
|
150
|
+
daemon=True,
|
|
151
|
+
),
|
|
152
|
+
]
|
|
153
|
+
for t in threads:
|
|
154
|
+
t.start()
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
self._poll_loop()
|
|
158
|
+
finally:
|
|
159
|
+
log.info("stopping…")
|
|
160
|
+
self.stop()
|
|
161
|
+
ids = list(self.runtime_index.keys())
|
|
162
|
+
if ids:
|
|
163
|
+
try:
|
|
164
|
+
self.client.deregister(ids)
|
|
165
|
+
log.info("deregistered %s runtimes", len(ids))
|
|
166
|
+
except Exception as e:
|
|
167
|
+
log.warning("deregister failed: %s", e)
|
|
168
|
+
self._pool.shutdown(wait=False, cancel_futures=True)
|
|
169
|
+
if self._health:
|
|
170
|
+
self._health.stop()
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
def _wakeup_loop(self) -> None:
|
|
174
|
+
run_wakeup_loop(
|
|
175
|
+
stop=self._stop,
|
|
176
|
+
wake=self._wake,
|
|
177
|
+
get_runtime_ids=self._runtime_ids,
|
|
178
|
+
base_url=self.cfg.server_base_url,
|
|
179
|
+
token=self.cfg.token,
|
|
180
|
+
version=self.cfg.cli_version,
|
|
181
|
+
hub=self.wakeup_hub,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _sync_loop(self) -> None:
|
|
185
|
+
while not self._stop.wait(30.0):
|
|
186
|
+
try:
|
|
187
|
+
self._sync_workspaces()
|
|
188
|
+
except Exception as e:
|
|
189
|
+
log.warning("workspace sync failed: %s", e)
|
|
190
|
+
|
|
191
|
+
def _sync_workspaces(self) -> None:
|
|
192
|
+
workspaces = self.client.list_workspaces()
|
|
193
|
+
api_ids = {str(w.get("id")) for w in workspaces if w.get("id")}
|
|
194
|
+
with self._lock:
|
|
195
|
+
known = set(self.workspace_runtimes.keys())
|
|
196
|
+
|
|
197
|
+
for w in workspaces:
|
|
198
|
+
wid = str(w.get("id") or "")
|
|
199
|
+
name = str(w.get("name") or wid)
|
|
200
|
+
if not wid:
|
|
201
|
+
continue
|
|
202
|
+
self.workspace_names[wid] = name
|
|
203
|
+
with self._lock:
|
|
204
|
+
has = wid in self.workspace_runtimes and self.workspace_runtimes[wid]
|
|
205
|
+
if has:
|
|
206
|
+
self._refresh_workspace_repos(wid)
|
|
207
|
+
continue
|
|
208
|
+
self._register_workspace(wid, name)
|
|
209
|
+
|
|
210
|
+
for wid in list(known - api_ids):
|
|
211
|
+
with self._lock:
|
|
212
|
+
rids = self.workspace_runtimes.pop(wid, [])
|
|
213
|
+
for rid in rids:
|
|
214
|
+
self.runtime_index.pop(rid, None)
|
|
215
|
+
self.repo_allowlist.drop_workspace(wid)
|
|
216
|
+
log.info("dropped workspace %s", wid)
|
|
217
|
+
|
|
218
|
+
def _refresh_workspace_repos(self, workspace_id: str) -> None:
|
|
219
|
+
try:
|
|
220
|
+
urls = self.repo_allowlist.refresh_from_api(self.client, workspace_id)
|
|
221
|
+
log.debug("workspace %s repos allowlist=%s", workspace_id[:8], len(urls))
|
|
222
|
+
except Exception as e:
|
|
223
|
+
log.warning("workspace repos refresh %s: %s", workspace_id[:8], e)
|
|
224
|
+
|
|
225
|
+
def _register_workspace(self, workspace_id: str, name: str) -> None:
|
|
226
|
+
now = time.time()
|
|
227
|
+
with self._lock:
|
|
228
|
+
until = self._reregister_backoff.get(workspace_id, 0)
|
|
229
|
+
if now < until:
|
|
230
|
+
return
|
|
231
|
+
runtimes = as_register_runtimes(self.cfg)
|
|
232
|
+
if not runtimes:
|
|
233
|
+
raise RuntimeError("no agent runtimes to register")
|
|
234
|
+
try:
|
|
235
|
+
resp = self.client.register(
|
|
236
|
+
{
|
|
237
|
+
"workspace_id": workspace_id,
|
|
238
|
+
"daemon_id": self.cfg.daemon_id,
|
|
239
|
+
"legacy_daemon_ids": [],
|
|
240
|
+
"device_name": self.cfg.device_name,
|
|
241
|
+
"cli_version": self.cfg.cli_version,
|
|
242
|
+
"launched_by": "python-daemon",
|
|
243
|
+
"timezone": detect_local_timezone(),
|
|
244
|
+
"runtimes": runtimes,
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
with self._lock:
|
|
249
|
+
self._reregister_backoff[workspace_id] = time.time() + 30
|
|
250
|
+
raise e
|
|
251
|
+
|
|
252
|
+
rts = resp.get("runtimes") or []
|
|
253
|
+
ids: list[str] = []
|
|
254
|
+
with self._lock:
|
|
255
|
+
# replace runtime set for workspace
|
|
256
|
+
old = self.workspace_runtimes.get(workspace_id, [])
|
|
257
|
+
for oid in old:
|
|
258
|
+
self.runtime_index.pop(oid, None)
|
|
259
|
+
for rt in rts:
|
|
260
|
+
if not isinstance(rt, dict):
|
|
261
|
+
continue
|
|
262
|
+
rid = str(rt.get("id") or "")
|
|
263
|
+
if not rid:
|
|
264
|
+
continue
|
|
265
|
+
ids.append(rid)
|
|
266
|
+
self.runtime_index[rid] = rt
|
|
267
|
+
self.workspace_runtimes[workspace_id] = ids
|
|
268
|
+
self._reregister_backoff.pop(workspace_id, None)
|
|
269
|
+
|
|
270
|
+
for rid in ids:
|
|
271
|
+
try:
|
|
272
|
+
self.client.recover_orphans(rid)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
log.warning("recover-orphans %s failed: %s", rid, e)
|
|
275
|
+
|
|
276
|
+
self._refresh_workspace_repos(workspace_id)
|
|
277
|
+
log.info(
|
|
278
|
+
"watching workspace id=%s name=%s runtimes=%s",
|
|
279
|
+
workspace_id,
|
|
280
|
+
name,
|
|
281
|
+
len(ids),
|
|
282
|
+
)
|
|
283
|
+
self._wake.set()
|
|
284
|
+
|
|
285
|
+
def _predict_env_roots(self, task: Task) -> list[Path]:
|
|
286
|
+
"""Candidate env roots that GC must not reclaim during this run."""
|
|
287
|
+
roots: list[Path] = []
|
|
288
|
+
ws = task.workspace_id or "default"
|
|
289
|
+
predicted = self.cfg.workspaces_root / ws / task.id
|
|
290
|
+
roots.append(predicted)
|
|
291
|
+
prior = (task.prior_work_dir or "").strip()
|
|
292
|
+
if prior:
|
|
293
|
+
p = Path(prior)
|
|
294
|
+
roots.append(p.parent if p.name == "workdir" else p)
|
|
295
|
+
if task.project_id.strip():
|
|
296
|
+
from .pinned_workdir import extract_local_path, pinned_workdir_path
|
|
297
|
+
|
|
298
|
+
local = extract_local_path(task.project_resources)
|
|
299
|
+
if local:
|
|
300
|
+
roots.append(Path(local))
|
|
301
|
+
else:
|
|
302
|
+
roots.append(
|
|
303
|
+
pinned_workdir_path(self.cfg.workspaces_root, ws, task.project_id)
|
|
304
|
+
)
|
|
305
|
+
# de-dupe while preserving order
|
|
306
|
+
seen: set[str] = set()
|
|
307
|
+
out: list[Path] = []
|
|
308
|
+
for r in roots:
|
|
309
|
+
key = str(r)
|
|
310
|
+
if key in seen:
|
|
311
|
+
continue
|
|
312
|
+
seen.add(key)
|
|
313
|
+
out.append(r)
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
def _workspace_for_runtime(self, runtime_id: str) -> str | None:
|
|
317
|
+
with self._lock:
|
|
318
|
+
for wid, rids in self.workspace_runtimes.items():
|
|
319
|
+
if runtime_id in rids:
|
|
320
|
+
return wid
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
def _handle_runtime_gone(self, runtime_id: str) -> None:
|
|
324
|
+
wid = self._workspace_for_runtime(runtime_id)
|
|
325
|
+
with self._lock:
|
|
326
|
+
self.runtime_index.pop(runtime_id, None)
|
|
327
|
+
if wid and wid in self.workspace_runtimes:
|
|
328
|
+
self.workspace_runtimes[wid] = [
|
|
329
|
+
x for x in self.workspace_runtimes[wid] if x != runtime_id
|
|
330
|
+
]
|
|
331
|
+
if not wid:
|
|
332
|
+
return
|
|
333
|
+
name = self.workspace_names.get(wid, wid)
|
|
334
|
+
log.warning("runtime gone %s — re-register workspace %s", runtime_id[:8], wid)
|
|
335
|
+
try:
|
|
336
|
+
self._register_workspace(wid, name)
|
|
337
|
+
except Exception as e:
|
|
338
|
+
log.warning("re-register failed: %s", e)
|
|
339
|
+
|
|
340
|
+
def _heartbeat_loop(self) -> None:
|
|
341
|
+
while not self._stop.wait(self.cfg.heartbeat_interval):
|
|
342
|
+
with self._lock:
|
|
343
|
+
items = list(self.runtime_index.items())
|
|
344
|
+
for rid, rt in items:
|
|
345
|
+
if self._stop.is_set():
|
|
346
|
+
return
|
|
347
|
+
try:
|
|
348
|
+
resp = self.client.heartbeat(rid)
|
|
349
|
+
self._handle_heartbeat_actions(rid, rt, resp)
|
|
350
|
+
except Exception as e:
|
|
351
|
+
if is_not_found(e, "runtime not found"):
|
|
352
|
+
self._handle_runtime_gone(rid)
|
|
353
|
+
else:
|
|
354
|
+
log.warning("heartbeat %s failed: %s", rid[:8], e)
|
|
355
|
+
|
|
356
|
+
def _handle_heartbeat_actions(
|
|
357
|
+
self, runtime_id: str, rt: dict[str, Any], resp: dict[str, Any]
|
|
358
|
+
) -> None:
|
|
359
|
+
# skip pending_update (auto_update n/a)
|
|
360
|
+
provider = str(rt.get("provider") or rt.get("type") or "")
|
|
361
|
+
pml = resp.get("pending_model_list") or resp.get("pendingModelList")
|
|
362
|
+
if isinstance(pml, dict) and pml.get("id"):
|
|
363
|
+
threading.Thread(
|
|
364
|
+
target=self._handle_model_list,
|
|
365
|
+
args=(runtime_id, provider, str(pml["id"])),
|
|
366
|
+
daemon=True,
|
|
367
|
+
).start()
|
|
368
|
+
pls = resp.get("pending_local_skills") or resp.get("pendingLocalSkills")
|
|
369
|
+
if isinstance(pls, dict) and pls.get("id"):
|
|
370
|
+
threading.Thread(
|
|
371
|
+
target=self._handle_local_skill_list,
|
|
372
|
+
args=(runtime_id, provider, str(pls["id"])),
|
|
373
|
+
daemon=True,
|
|
374
|
+
).start()
|
|
375
|
+
pli = resp.get("pending_local_skill_import") or resp.get(
|
|
376
|
+
"pendingLocalSkillImport"
|
|
377
|
+
)
|
|
378
|
+
if isinstance(pli, dict) and pli.get("id"):
|
|
379
|
+
threading.Thread(
|
|
380
|
+
target=self._handle_local_skill_import,
|
|
381
|
+
args=(runtime_id, provider, pli),
|
|
382
|
+
daemon=True,
|
|
383
|
+
).start()
|
|
384
|
+
|
|
385
|
+
def _handle_model_list(self, runtime_id: str, provider: str, request_id: str) -> None:
|
|
386
|
+
entry = self.cfg.agents.get(provider)
|
|
387
|
+
if not entry:
|
|
388
|
+
self.client.report_model_list_result(
|
|
389
|
+
runtime_id,
|
|
390
|
+
request_id,
|
|
391
|
+
{"status": "failed", "error": f"no agent for {provider}"},
|
|
392
|
+
)
|
|
393
|
+
return
|
|
394
|
+
models = list_models(provider, entry.path)
|
|
395
|
+
self.client.report_model_list_result(
|
|
396
|
+
runtime_id,
|
|
397
|
+
request_id,
|
|
398
|
+
{
|
|
399
|
+
"status": "completed",
|
|
400
|
+
"models": models,
|
|
401
|
+
"supported": model_selection_supported(provider),
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def _handle_local_skill_list(
|
|
406
|
+
self, runtime_id: str, provider: str, request_id: str
|
|
407
|
+
) -> None:
|
|
408
|
+
skills, supported = list_runtime_local_skills(provider)
|
|
409
|
+
self.client.report_local_skill_list_result(
|
|
410
|
+
runtime_id,
|
|
411
|
+
request_id,
|
|
412
|
+
{"status": "completed", "skills": skills, "supported": supported},
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def _handle_local_skill_import(
|
|
416
|
+
self, runtime_id: str, provider: str, pending: dict[str, Any]
|
|
417
|
+
) -> None:
|
|
418
|
+
request_id = str(pending.get("id") or "")
|
|
419
|
+
skill_key = str(pending.get("skill_key") or pending.get("skillKey") or "")
|
|
420
|
+
bundle, supported, err = load_runtime_local_skill_bundle(provider, skill_key)
|
|
421
|
+
if err or not bundle:
|
|
422
|
+
self.client.report_local_skill_import_result(
|
|
423
|
+
runtime_id,
|
|
424
|
+
request_id,
|
|
425
|
+
{
|
|
426
|
+
"status": "failed",
|
|
427
|
+
"error": err or "import failed",
|
|
428
|
+
"supported": supported,
|
|
429
|
+
},
|
|
430
|
+
)
|
|
431
|
+
return
|
|
432
|
+
self.client.report_local_skill_import_result(
|
|
433
|
+
runtime_id,
|
|
434
|
+
request_id,
|
|
435
|
+
{"status": "completed", "skill": bundle, "supported": supported},
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def _poll_loop(self) -> None:
|
|
439
|
+
while not self._stop.is_set():
|
|
440
|
+
self._claim_round()
|
|
441
|
+
self._wake.clear()
|
|
442
|
+
self._wake.wait(timeout=self.cfg.poll_interval)
|
|
443
|
+
|
|
444
|
+
def _claim_round(self) -> None:
|
|
445
|
+
with self._lock:
|
|
446
|
+
ids = list(self.runtime_index.keys())
|
|
447
|
+
active = self.active_tasks
|
|
448
|
+
capacity = self.cfg.max_concurrent_tasks - active
|
|
449
|
+
if capacity <= 0:
|
|
450
|
+
return
|
|
451
|
+
claimed = 0
|
|
452
|
+
for rid in ids:
|
|
453
|
+
if claimed >= capacity or self._stop.is_set():
|
|
454
|
+
break
|
|
455
|
+
try:
|
|
456
|
+
task = self.client.claim_task(rid)
|
|
457
|
+
except Exception as e:
|
|
458
|
+
log.warning("claim %s failed: %s", rid[:8], e)
|
|
459
|
+
continue
|
|
460
|
+
if not task:
|
|
461
|
+
continue
|
|
462
|
+
claimed += 1
|
|
463
|
+
with self._lock:
|
|
464
|
+
self.active_tasks += 1
|
|
465
|
+
self._pool.submit(self._handle_task, task)
|
|
466
|
+
|
|
467
|
+
def _handle_task(self, task: Task) -> None:
|
|
468
|
+
try:
|
|
469
|
+
self._run_one(task)
|
|
470
|
+
finally:
|
|
471
|
+
with self._lock:
|
|
472
|
+
self.active_tasks = max(0, self.active_tasks - 1)
|
|
473
|
+
|
|
474
|
+
def _watch_cancel(self, task_id: str, cancel_event: threading.Event) -> None:
|
|
475
|
+
while not self._stop.is_set() and not cancel_event.is_set():
|
|
476
|
+
try:
|
|
477
|
+
status = self.client.get_task_status(task_id)
|
|
478
|
+
err: Exception | None = None
|
|
479
|
+
except Exception as e:
|
|
480
|
+
status = ""
|
|
481
|
+
err = e
|
|
482
|
+
if should_interrupt_agent(status, err):
|
|
483
|
+
cancel_event.set()
|
|
484
|
+
return
|
|
485
|
+
time.sleep(5.0)
|
|
486
|
+
|
|
487
|
+
def _run_one(self, task: Task) -> None:
|
|
488
|
+
with self._lock:
|
|
489
|
+
rt = self.runtime_index.get(task.runtime_id) or {}
|
|
490
|
+
provider = str(rt.get("provider") or rt.get("type") or "")
|
|
491
|
+
if not provider:
|
|
492
|
+
provider = next(iter(self.cfg.agents), "")
|
|
493
|
+
entry = self.cfg.agents.get(provider)
|
|
494
|
+
agent_name = (task.agent or {}).get("name") or "agent"
|
|
495
|
+
log.info(
|
|
496
|
+
"picked task=%s issue=%s agent=%s provider=%s",
|
|
497
|
+
task.id[:8],
|
|
498
|
+
(task.issue_id or "-")[:8],
|
|
499
|
+
agent_name,
|
|
500
|
+
provider,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
try:
|
|
504
|
+
self.client.start_task(task.id)
|
|
505
|
+
except Exception as e:
|
|
506
|
+
log.error("start task failed: %s", e)
|
|
507
|
+
try:
|
|
508
|
+
self.client.fail_task(task.id, f"start task failed: {e}")
|
|
509
|
+
except Exception:
|
|
510
|
+
pass
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
if not entry:
|
|
514
|
+
self.client.fail_task(task.id, f"no local agent for provider {provider!r}")
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
self.client.report_progress(task.id, f"Launching {provider}", 1, 2)
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
cancel_event = threading.Event()
|
|
523
|
+
threading.Thread(
|
|
524
|
+
target=self._watch_cancel,
|
|
525
|
+
args=(task.id, cancel_event),
|
|
526
|
+
name=f"cancel-{task.id[:8]}",
|
|
527
|
+
daemon=True,
|
|
528
|
+
).start()
|
|
529
|
+
|
|
530
|
+
def on_messages(batch: list[dict]) -> None:
|
|
531
|
+
try:
|
|
532
|
+
self.client.report_task_messages(task.id, batch)
|
|
533
|
+
except Exception as e:
|
|
534
|
+
log.warning("report messages failed: %s", e)
|
|
535
|
+
|
|
536
|
+
def on_session(session_id: str, work_dir: str) -> None:
|
|
537
|
+
try:
|
|
538
|
+
self.client.pin_task_session(
|
|
539
|
+
task.id, session_id=session_id, work_dir=work_dir
|
|
540
|
+
)
|
|
541
|
+
except Exception as e:
|
|
542
|
+
log.warning("pin session failed: %s", e)
|
|
543
|
+
|
|
544
|
+
# Task-scoped repos (e.g. project github_repo) join the checkout allowlist.
|
|
545
|
+
task_urls = [str(r.get("url")) for r in task.repos if r.get("url")]
|
|
546
|
+
if task_urls:
|
|
547
|
+
self.repo_allowlist.register_task_repos(task.workspace_id, task_urls)
|
|
548
|
+
|
|
549
|
+
marked_roots = self._predict_env_roots(task)
|
|
550
|
+
for root in marked_roots:
|
|
551
|
+
self.active_env_roots.mark(root)
|
|
552
|
+
result = None
|
|
553
|
+
try:
|
|
554
|
+
with self._lock:
|
|
555
|
+
task_slot = self.active_tasks
|
|
556
|
+
result = run_agent(
|
|
557
|
+
task,
|
|
558
|
+
provider,
|
|
559
|
+
entry,
|
|
560
|
+
self.cfg.workspaces_root,
|
|
561
|
+
self.cfg.agent_timeout,
|
|
562
|
+
client=self.client,
|
|
563
|
+
cfg=self.cfg,
|
|
564
|
+
task_slot=task_slot,
|
|
565
|
+
cancel_event=cancel_event,
|
|
566
|
+
on_messages=on_messages,
|
|
567
|
+
on_session=on_session,
|
|
568
|
+
acp_pool=self.chat_acp_pool,
|
|
569
|
+
idle_watchdog=self.cfg.agent_idle_watchdog,
|
|
570
|
+
)
|
|
571
|
+
except Exception as e:
|
|
572
|
+
log.exception("run_agent crashed: %s", e)
|
|
573
|
+
try:
|
|
574
|
+
self.client.fail_task(task.id, f"daemon run crashed: {e}")
|
|
575
|
+
except Exception:
|
|
576
|
+
pass
|
|
577
|
+
return
|
|
578
|
+
finally:
|
|
579
|
+
for root in marked_roots:
|
|
580
|
+
self.active_env_roots.unmark(root)
|
|
581
|
+
cancel_event.set()
|
|
582
|
+
|
|
583
|
+
if result is None:
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
if result.usage:
|
|
587
|
+
try:
|
|
588
|
+
self.client.report_task_usage(task.id, result.usage)
|
|
589
|
+
except Exception as e:
|
|
590
|
+
log.warning("report usage failed: %s", e)
|
|
591
|
+
|
|
592
|
+
if result.cancelled:
|
|
593
|
+
log.info("task %s cancelled, discarding result", task.id[:8])
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
status = self.client.get_task_status(task.id)
|
|
598
|
+
if should_interrupt_agent(status, None):
|
|
599
|
+
log.info("task %s cancelled, discarding result", task.id[:8])
|
|
600
|
+
return
|
|
601
|
+
except APIError as e:
|
|
602
|
+
if should_interrupt_agent("", e):
|
|
603
|
+
log.info("task %s deleted, discarding result", task.id[:8])
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
self.client.report_progress(task.id, "Finishing task", 2, 2)
|
|
608
|
+
except Exception:
|
|
609
|
+
pass
|
|
610
|
+
|
|
611
|
+
# poisoned classification
|
|
612
|
+
fail_reason = "agent_error"
|
|
613
|
+
if result.ok:
|
|
614
|
+
poisoned_reason, is_poisoned = classify_poisoned_output(result.output)
|
|
615
|
+
if is_poisoned:
|
|
616
|
+
result.ok = False
|
|
617
|
+
result.error = result.output or poisoned_reason
|
|
618
|
+
fail_reason = poisoned_reason
|
|
619
|
+
else:
|
|
620
|
+
poisoned_reason, is_poisoned = classify_poisoned_error(result.error)
|
|
621
|
+
if is_poisoned:
|
|
622
|
+
fail_reason = poisoned_reason
|
|
623
|
+
|
|
624
|
+
if result.ok:
|
|
625
|
+
try:
|
|
626
|
+
auto_commit_and_push(
|
|
627
|
+
result.work_dir, task.id, str(agent_name)
|
|
628
|
+
)
|
|
629
|
+
except Exception as e:
|
|
630
|
+
log.warning("auto-commit: %s", e)
|
|
631
|
+
try:
|
|
632
|
+
report_issue_artifacts(
|
|
633
|
+
result.work_dir,
|
|
634
|
+
lambda arts: self.client.report_task_artifacts(task.id, arts),
|
|
635
|
+
)
|
|
636
|
+
except Exception as e:
|
|
637
|
+
log.warning("artifacts: %s", e)
|
|
638
|
+
try:
|
|
639
|
+
self.client.complete_task(
|
|
640
|
+
task.id,
|
|
641
|
+
result.output[:100_000],
|
|
642
|
+
work_dir=result.work_dir,
|
|
643
|
+
session_id=result.session_id,
|
|
644
|
+
)
|
|
645
|
+
log.info("task %s completed", task.id[:8])
|
|
646
|
+
except Exception as e:
|
|
647
|
+
log.error("complete task failed: %s", e)
|
|
648
|
+
else:
|
|
649
|
+
try:
|
|
650
|
+
self.client.fail_task(
|
|
651
|
+
task.id,
|
|
652
|
+
result.error or "agent failed",
|
|
653
|
+
work_dir=result.work_dir,
|
|
654
|
+
session_id=result.session_id,
|
|
655
|
+
failure_reason=fail_reason,
|
|
656
|
+
)
|
|
657
|
+
log.info("task %s failed: %s", task.id[:8], result.error)
|
|
658
|
+
except Exception as e:
|
|
659
|
+
log.error("fail task callback failed: %s", e)
|
|
660
|
+
|
|
661
|
+
# GC meta for non-pinned task roots
|
|
662
|
+
if result.work_dir:
|
|
663
|
+
env_root = Path(result.work_dir)
|
|
664
|
+
if env_root.name == "workdir":
|
|
665
|
+
env_root = env_root.parent
|
|
666
|
+
if "projects" not in env_root.parts:
|
|
667
|
+
write_gc_meta(
|
|
668
|
+
env_root,
|
|
669
|
+
gc_meta_for_task(
|
|
670
|
+
task.id,
|
|
671
|
+
task.issue_id,
|
|
672
|
+
task.chat_session_id,
|
|
673
|
+
task.autopilot_run_id,
|
|
674
|
+
workspace_id=task.workspace_id,
|
|
675
|
+
quick_create=bool(task.quick_create_prompt),
|
|
676
|
+
),
|
|
677
|
+
)
|