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.
Files changed (58) hide show
  1. aicortexpy/__init__.py +3 -0
  2. aicortexpy/__main__.py +5 -0
  3. aicortexpy/acp_client.py +559 -0
  4. aicortexpy/acp_conn.py +853 -0
  5. aicortexpy/acp_errors.py +107 -0
  6. aicortexpy/agent_env.py +88 -0
  7. aicortexpy/artifact_ws.py +233 -0
  8. aicortexpy/artifacts.py +67 -0
  9. aicortexpy/attachments.py +85 -0
  10. aicortexpy/auto_commit.py +106 -0
  11. aicortexpy/capabilities.py +92 -0
  12. aicortexpy/chat_acp_pool.py +321 -0
  13. aicortexpy/cli.py +133 -0
  14. aicortexpy/client.py +438 -0
  15. aicortexpy/codex_config.py +98 -0
  16. aicortexpy/config.py +298 -0
  17. aicortexpy/daemon.py +677 -0
  18. aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
  19. aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
  20. aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
  21. aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
  22. aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
  23. aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
  24. aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
  25. aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
  26. aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
  27. aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
  28. aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
  29. aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
  30. aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
  31. aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
  32. aicortexpy/design_seed.py +59 -0
  33. aicortexpy/diskusage.py +209 -0
  34. aicortexpy/execenv.py +264 -0
  35. aicortexpy/gc.py +286 -0
  36. aicortexpy/handoff_bundle.py +65 -0
  37. aicortexpy/health.py +178 -0
  38. aicortexpy/local_skills.py +157 -0
  39. aicortexpy/models.py +385 -0
  40. aicortexpy/openclaw_config.py +153 -0
  41. aicortexpy/pinned_workdir.py +60 -0
  42. aicortexpy/platform_info.py +61 -0
  43. aicortexpy/poisoned.py +44 -0
  44. aicortexpy/process.py +168 -0
  45. aicortexpy/procutil.py +61 -0
  46. aicortexpy/prompt.py +235 -0
  47. aicortexpy/repo.py +462 -0
  48. aicortexpy/runner.py +969 -0
  49. aicortexpy/runtime_config.py +540 -0
  50. aicortexpy/sandbox.py +264 -0
  51. aicortexpy/stream.py +422 -0
  52. aicortexpy/terminal.py +147 -0
  53. aicortexpy/tunnel.py +82 -0
  54. aicortexpy/wakeup.py +222 -0
  55. aicortexpy-0.1.0.dist-info/METADATA +106 -0
  56. aicortexpy-0.1.0.dist-info/RECORD +58 -0
  57. aicortexpy-0.1.0.dist-info/WHEEL +5 -0
  58. 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
+ )