@agentsdock/server 1.0.10-beta.1 → 1.0.10-beta.6
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.
- package/README.md +6 -0
- package/npm/cli.cjs +29 -3
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +230 -79
- package/server/claude_goals.py +16 -4
- package/server/codex_app_server.py +4 -0
- package/server/codex_auth.py +31 -0
- package/server/codex_history_repair.py +92 -32
- package/server/codex_provider.py +12 -2
- package/server/execution_activation.py +1 -1
- package/server/execution_control.py +4 -0
- package/server/execution_install.py +48 -7
- package/server/execution_manage.py +304 -0
- package/server/execution_recovery.py +1 -1
- package/server/execution_uninstall.py +19 -0
- package/server/execution_update_status.py +3 -10
- package/server/opencode_agent_client.py +5 -0
- package/server/provider_connections.py +21 -11
- package/server/server_instances.py +211 -4
- package/server/update_handoff.py +6 -11
|
@@ -12,7 +12,9 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import argparse
|
|
14
14
|
import base64
|
|
15
|
+
from contextlib import ExitStack
|
|
15
16
|
import errno
|
|
17
|
+
import hashlib
|
|
16
18
|
import json
|
|
17
19
|
import os
|
|
18
20
|
from pathlib import Path
|
|
@@ -25,6 +27,7 @@ import sys
|
|
|
25
27
|
import time
|
|
26
28
|
from typing import Any, Callable
|
|
27
29
|
import urllib.parse
|
|
30
|
+
import uuid
|
|
28
31
|
|
|
29
32
|
import execution_install as files
|
|
30
33
|
from execution_transport import _read_secret
|
|
@@ -216,6 +219,35 @@ class NativeServices:
|
|
|
216
219
|
raise RuntimeError("native service is transitioning; retry after it settles")
|
|
217
220
|
return states
|
|
218
221
|
|
|
222
|
+
def validate_registered_bindings(self) -> None:
|
|
223
|
+
"""Reject foreign or stale manager-loaded jobs without exposing output."""
|
|
224
|
+
for role in ("worker", "gateway"):
|
|
225
|
+
path = str(self.layout.service_path(role))
|
|
226
|
+
if self.layout.platform == "Linux":
|
|
227
|
+
result = self._command(["systemctl", "--user", "show", self._unit(role), "--no-pager",
|
|
228
|
+
"--property=FragmentPath,DropInPaths,NeedDaemonReload"])
|
|
229
|
+
values = {}
|
|
230
|
+
for line in result.stdout.splitlines():
|
|
231
|
+
key, separator, value = line.partition("=")
|
|
232
|
+
if not separator or key in values:
|
|
233
|
+
raise RuntimeError("systemd returned ambiguous registered bindings")
|
|
234
|
+
values[key] = value
|
|
235
|
+
if values != {"FragmentPath": path, "DropInPaths": "", "NeedDaemonReload": "no"}:
|
|
236
|
+
raise RuntimeError("native unit registration is foreign, overridden, or stale")
|
|
237
|
+
else:
|
|
238
|
+
result = self._command(["/bin/launchctl", "print", self._target(role)], allow_failure=True)
|
|
239
|
+
if result.returncode:
|
|
240
|
+
if "Could not find service" in result.stderr:
|
|
241
|
+
continue # bootout removed the job; exact plist is checked separately.
|
|
242
|
+
raise RuntimeError("launchd registration ownership is unknown")
|
|
243
|
+
paths = re.findall(r"(?m)^\s*path = (.+)$", result.stdout)
|
|
244
|
+
programs = re.findall(r"(?m)^\s*program = (.+)$", result.stdout)
|
|
245
|
+
arguments = re.findall(r"(?ms)^\s*arguments = \{\n(.*?)^\s*\}", result.stdout)
|
|
246
|
+
expected = files.service_arguments(self.layout, role)
|
|
247
|
+
if (paths != [path] or programs != [expected[0]] or len(arguments) != 1
|
|
248
|
+
or [line.strip() for line in arguments[0].splitlines() if line.strip()] != expected):
|
|
249
|
+
raise RuntimeError("native launchd registration does not match the installed service")
|
|
250
|
+
|
|
219
251
|
def stop(self, role: str) -> None:
|
|
220
252
|
state = self._observe(role)
|
|
221
253
|
if state["state"] in {"absent", "stopped"}:
|
|
@@ -426,6 +458,278 @@ class WorkerControl:
|
|
|
426
458
|
"maintenance_held": worker.get("maintenance_held")}
|
|
427
459
|
|
|
428
460
|
|
|
461
|
+
LIFECYCLE_NAME = ".execution-lifecycle.json"
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def read_lifecycle_intent(layout: files.ExecutionLayout) -> dict | None:
|
|
465
|
+
"""Read owned stop/restart evidence; never repair or normalize user state."""
|
|
466
|
+
from execution_preparation import _check_binding
|
|
467
|
+
try:
|
|
468
|
+
data, _mode = files._read_file(layout.install_root / LIFECYCLE_NAME, private=True)
|
|
469
|
+
except FileNotFoundError:
|
|
470
|
+
return None
|
|
471
|
+
value = json.loads(data)
|
|
472
|
+
expected = {"format", "operation_id", "layout_sha256", "identities", "server_identity",
|
|
473
|
+
"worker_pid", "gateway_pid", "worker_instance_id", "lease_id", "phase", "started"}
|
|
474
|
+
if (not isinstance(value, dict) or set(value) != expected or type(value["format"]) is not int
|
|
475
|
+
or value["format"] != 1 or value["phase"] not in {"prepared", "sealed", "stopped", "starting", "releasing"}
|
|
476
|
+
or not isinstance(value["operation_id"], str)
|
|
477
|
+
or str(uuid.UUID(value["operation_id"])) != value["operation_id"]
|
|
478
|
+
or any(type(value[key]) is not int or value[key] <= 0 for key in ("worker_pid", "gateway_pid"))
|
|
479
|
+
or any(not isinstance(value[key], str) or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", value[key]) is None
|
|
480
|
+
for key in ("server_identity", "worker_instance_id"))
|
|
481
|
+
or (value["lease_id"] is not None and (not isinstance(value["lease_id"], str)
|
|
482
|
+
or str(uuid.UUID(value["lease_id"])) != value["lease_id"]))):
|
|
483
|
+
raise ValueError("native lifecycle intent is invalid")
|
|
484
|
+
started = value["started"]
|
|
485
|
+
if started is not None and (not isinstance(started, dict) or set(started) != {"pid", "instance_id"}
|
|
486
|
+
or type(started["pid"]) is not int or started["pid"] <= 0
|
|
487
|
+
or not isinstance(started["instance_id"], str)
|
|
488
|
+
or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", started["instance_id"]) is None):
|
|
489
|
+
raise ValueError("native lifecycle startup identity is invalid")
|
|
490
|
+
if (value["phase"] == "releasing" and started is None
|
|
491
|
+
or value["phase"] not in {"starting", "releasing"} and started is not None
|
|
492
|
+
or value["phase"] != "prepared" and value["lease_id"] is None):
|
|
493
|
+
raise ValueError("native lifecycle phase lacks its admission evidence")
|
|
494
|
+
paths = {"root": layout.install_root, "worker": layout.worker_release,
|
|
495
|
+
"gateway": layout.gateway_release, "config": layout.config_root, "state": layout.state_root}
|
|
496
|
+
if not isinstance(value["identities"], dict) or set(value["identities"]) != set(paths):
|
|
497
|
+
raise ValueError("native lifecycle directory identities are invalid")
|
|
498
|
+
for name, path in paths.items():
|
|
499
|
+
_check_binding(path, value["identities"][name])
|
|
500
|
+
manifest, _mode = files._read_file(layout.manifest_path, private=True)
|
|
501
|
+
if hashlib.sha256(manifest).hexdigest() != value["layout_sha256"]:
|
|
502
|
+
raise RuntimeError("installed layout changed after lifecycle admission")
|
|
503
|
+
return value
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def pending_lifecycle_operation(root: Path, runtime_root: Path) -> str:
|
|
507
|
+
layout = files.installed_layout(root)
|
|
508
|
+
intent = read_lifecycle_intent(layout)
|
|
509
|
+
if intent is None or files._path(runtime_root) != layout.worker_release:
|
|
510
|
+
raise RuntimeError("worker is outside the retained lifecycle operation")
|
|
511
|
+
marker = root / ".execution-uninstall.json"
|
|
512
|
+
if marker.exists() or marker.is_symlink():
|
|
513
|
+
from execution_uninstall import _read_intent
|
|
514
|
+
removal = _read_intent(root, layout)
|
|
515
|
+
if (intent["phase"] != "stopped" or removal is None
|
|
516
|
+
or removal["operation_id"] != intent["operation_id"]
|
|
517
|
+
or removal["server_identity"] != intent["server_identity"]):
|
|
518
|
+
raise RuntimeError("uninstall and lifecycle ownership conflict")
|
|
519
|
+
return intent["operation_id"]
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def retire_lifecycle_intent(layout: files.ExecutionLayout, intent: dict) -> None:
|
|
523
|
+
if read_lifecycle_intent(layout) != intent:
|
|
524
|
+
raise RuntimeError("native lifecycle intent changed before retirement")
|
|
525
|
+
(layout.install_root / LIFECYCLE_NAME).unlink()
|
|
526
|
+
files._fsync_directory(layout.install_root)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class LifecycleController:
|
|
530
|
+
"""Idle-only control of an installed pair, not an installer or force repair.
|
|
531
|
+
|
|
532
|
+
Failures retain an exact operation journal and any sealed admission hold.
|
|
533
|
+
Retry may finish that operation; an unjournaled broken/stopped installation
|
|
534
|
+
or a changed worker epoch is never guessed safe. Explicit start releases
|
|
535
|
+
startup admission only after both components have authenticated health.
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
def __init__(self, layout: files.ExecutionLayout, *, services: NativeServices | None = None,
|
|
539
|
+
control: WorkerControl | None = None, health_timeout: float = 30.0):
|
|
540
|
+
self.layout = layout
|
|
541
|
+
self.services = services or NativeServices(layout)
|
|
542
|
+
self.control = control or WorkerControl(services=self.services)
|
|
543
|
+
self.health_timeout = health_timeout
|
|
544
|
+
|
|
545
|
+
def _save(self, intent: dict, **changes) -> dict:
|
|
546
|
+
if read_lifecycle_intent(self.layout) != intent:
|
|
547
|
+
raise RuntimeError("native lifecycle intent changed")
|
|
548
|
+
updated = {**intent, **changes}
|
|
549
|
+
files._atomic_write(self.layout.install_root / LIFECYCLE_NAME, files._json_bytes(updated))
|
|
550
|
+
return updated
|
|
551
|
+
|
|
552
|
+
def _unchanged(self, originals: dict) -> None:
|
|
553
|
+
from execution_uninstall import _service_files, _no_pending_update
|
|
554
|
+
from update_handoff import _no_activation
|
|
555
|
+
_no_activation(self.layout.install_root)
|
|
556
|
+
marker = self.layout.install_root / ".execution-uninstall.json"
|
|
557
|
+
if marker.exists() or marker.is_symlink():
|
|
558
|
+
raise RuntimeError("uninstall owns this native lifecycle")
|
|
559
|
+
_no_pending_update(self.layout)
|
|
560
|
+
if _service_files(self.layout) != originals:
|
|
561
|
+
raise RuntimeError("native service bindings changed during lifecycle operation")
|
|
562
|
+
self.services.validate_registered_bindings()
|
|
563
|
+
|
|
564
|
+
def _new_intent(self, prior: dict) -> dict:
|
|
565
|
+
from execution_preparation import _binding
|
|
566
|
+
receipt = self.control.receipt(self.layout, self.services)
|
|
567
|
+
record, status = self.control.status(self.layout)
|
|
568
|
+
if type(status.get("native_lifecycle_protocol")) is not int or status["native_lifecycle_protocol"] != 1:
|
|
569
|
+
raise RuntimeError("installed worker does not support safe paired lifecycle commands; update the installed server through its signed managed update first")
|
|
570
|
+
if (receipt.get("worker_pid") != prior["worker"].get("pid")
|
|
571
|
+
or receipt.get("gateway_pid") != prior["gateway"].get("pid")
|
|
572
|
+
or receipt.get("worker_release") != str(self.layout.worker_release)
|
|
573
|
+
or record.get("pid") != receipt.get("worker_pid")
|
|
574
|
+
or record.get("instance_id") != receipt.get("worker_instance_id")
|
|
575
|
+
or status.get("idle") is not True or status.get("lease") is not None):
|
|
576
|
+
raise RuntimeError("execution is busy, changed, or another operation owns admission; no services stopped")
|
|
577
|
+
manifest, _mode = files._read_file(self.layout.manifest_path, private=True)
|
|
578
|
+
intent = {"format": 1, "operation_id": str(uuid.uuid4()), "phase": "prepared", "lease_id": None,
|
|
579
|
+
"started": None, "server_identity": receipt.get("server_identity"),
|
|
580
|
+
"worker_pid": receipt["worker_pid"], "gateway_pid": receipt["gateway_pid"],
|
|
581
|
+
"worker_instance_id": receipt["worker_instance_id"],
|
|
582
|
+
"layout_sha256": hashlib.sha256(manifest).hexdigest(),
|
|
583
|
+
"identities": {name: _binding(path) for name, path in {
|
|
584
|
+
"root": self.layout.install_root, "worker": self.layout.worker_release,
|
|
585
|
+
"gateway": self.layout.gateway_release, "config": self.layout.config_root,
|
|
586
|
+
"state": self.layout.state_root}.items()}}
|
|
587
|
+
if not isinstance(intent["server_identity"], str) or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", intent["server_identity"]) is None:
|
|
588
|
+
raise RuntimeError("authenticated server identity is unavailable")
|
|
589
|
+
if self.services.snapshot() != prior or read_lifecycle_intent(self.layout) is not None:
|
|
590
|
+
raise RuntimeError("native ownership changed before lifecycle admission")
|
|
591
|
+
files._atomic_write(self.layout.install_root / LIFECYCLE_NAME, files._json_bytes(intent))
|
|
592
|
+
return intent
|
|
593
|
+
|
|
594
|
+
def _stop(self, intent: dict, originals: dict) -> dict:
|
|
595
|
+
from execution_uninstall import _owned_lease, _no_provider_children
|
|
596
|
+
if intent["phase"] in {"starting", "releasing"}:
|
|
597
|
+
raise RuntimeError("an owned start must finish before another stop")
|
|
598
|
+
self._unchanged(originals)
|
|
599
|
+
prior = self.services.snapshot()
|
|
600
|
+
if any(prior[role].get("state") not in {"running", "stopped", "absent"} for role in ("worker", "gateway")):
|
|
601
|
+
raise RuntimeError("native lifecycle state is uncertain")
|
|
602
|
+
if prior["worker"].get("state") == "running":
|
|
603
|
+
record, status = self.control.status(self.layout)
|
|
604
|
+
if (record.get("pid") != intent["worker_pid"] or prior["worker"].get("pid") != intent["worker_pid"]
|
|
605
|
+
or record.get("instance_id") != intent["worker_instance_id"]
|
|
606
|
+
or record.get("release_root") != str(self.layout.worker_release)):
|
|
607
|
+
raise RuntimeError("worker epoch changed after lifecycle admission")
|
|
608
|
+
if intent["lease_id"] is not None and (status.get("lease") or {}).get("lease_id") != intent["lease_id"]:
|
|
609
|
+
raise RuntimeError("owned lifecycle admission changed")
|
|
610
|
+
try:
|
|
611
|
+
sealed = self.control.seal_for_stop(self.layout, intent["operation_id"], intent["worker_pid"])
|
|
612
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
613
|
+
raise RuntimeError("lifecycle admission was not confirmed; its owned retry evidence was retained and no service was stopped by this attempt; retry the same command when work is idle (a changed worker requires ownership recovery)") from error
|
|
614
|
+
if sealed["record"] != record:
|
|
615
|
+
raise RuntimeError("worker changed while lifecycle admission was sealed")
|
|
616
|
+
intent = self._save(intent, phase="sealed", lease_id=sealed["lease"]["lease_id"])
|
|
617
|
+
self._unchanged(originals)
|
|
618
|
+
if self.services.snapshot() != prior:
|
|
619
|
+
raise RuntimeError("native jobs changed after lifecycle admission")
|
|
620
|
+
for role in ("gateway", "worker"):
|
|
621
|
+
current = self.services.snapshot()[role]
|
|
622
|
+
if current.get("state") in {"stopped", "absent"}:
|
|
623
|
+
self.services.set_enabled(role, False)
|
|
624
|
+
continue
|
|
625
|
+
if current.get("state") != "running" or current.get("pid") != intent[role + "_pid"]:
|
|
626
|
+
raise RuntimeError("native process changed before its authorized stop")
|
|
627
|
+
if role == "worker":
|
|
628
|
+
now_record, now_status = self.control.status(self.layout)
|
|
629
|
+
if (now_record != sealed["record"] or now_status.get("lease") != sealed["lease"]
|
|
630
|
+
or now_status.get("idle") is not True):
|
|
631
|
+
raise RuntimeError("worker admission changed before its authorized stop")
|
|
632
|
+
self._unchanged(originals)
|
|
633
|
+
self.services.set_enabled(role, False)
|
|
634
|
+
self.services.stop(role)
|
|
635
|
+
elif intent["lease_id"] is None or prior["gateway"].get("state") == "running":
|
|
636
|
+
raise RuntimeError("stopped worker lacks an owned completed shutdown")
|
|
637
|
+
else:
|
|
638
|
+
for role in ("gateway", "worker"):
|
|
639
|
+
self.services.set_enabled(role, False)
|
|
640
|
+
if any(self.services.snapshot()[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway")):
|
|
641
|
+
raise RuntimeError("both native services must stop before lifecycle completion")
|
|
642
|
+
with _owned_lease(self.layout.state_root / "admin/state-owner.lock"):
|
|
643
|
+
_no_provider_children(self.layout.state_root)
|
|
644
|
+
self._unchanged(originals)
|
|
645
|
+
return self._save(intent, phase="stopped")
|
|
646
|
+
|
|
647
|
+
def _wait_worker(self, intent: dict) -> dict:
|
|
648
|
+
deadline = time.monotonic() + self.health_timeout
|
|
649
|
+
while True:
|
|
650
|
+
try:
|
|
651
|
+
record, status = self.control.status(self.layout)
|
|
652
|
+
native = self.services.snapshot()["worker"]
|
|
653
|
+
if (native.get("state") != "running" or native.get("pid") != record.get("pid")
|
|
654
|
+
or record.get("release_root") != str(self.layout.worker_release)):
|
|
655
|
+
raise RuntimeError("native worker does not own startup admission")
|
|
656
|
+
started = {"pid": record["pid"], "instance_id": record["instance_id"]}
|
|
657
|
+
if intent["started"] is not None and started != intent["started"]:
|
|
658
|
+
raise RuntimeError("worker epoch changed during lifecycle startup")
|
|
659
|
+
if intent["phase"] != "releasing" or status.get("lease") is not None:
|
|
660
|
+
self.control.require_startup_hold(self.layout, intent["operation_id"])
|
|
661
|
+
return started
|
|
662
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
663
|
+
if time.monotonic() >= deadline:
|
|
664
|
+
raise RuntimeError("owned native worker did not reach held startup; retry start after recovery") from error
|
|
665
|
+
time.sleep(0.1)
|
|
666
|
+
|
|
667
|
+
def _start(self, intent: dict, originals: dict) -> dict:
|
|
668
|
+
from execution_uninstall import _owned_lease, _no_provider_children
|
|
669
|
+
if intent["phase"] == "stopped":
|
|
670
|
+
with _owned_lease(self.layout.state_root / "admin/state-owner.lock"):
|
|
671
|
+
_no_provider_children(self.layout.state_root)
|
|
672
|
+
self._unchanged(originals)
|
|
673
|
+
if any(self.services.snapshot()[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway")):
|
|
674
|
+
raise RuntimeError("stopped native jobs changed before authorized start")
|
|
675
|
+
intent = self._save(intent, phase="starting")
|
|
676
|
+
if intent["phase"] not in {"starting", "releasing"}:
|
|
677
|
+
raise RuntimeError("native shutdown must complete before starting")
|
|
678
|
+
self._unchanged(originals)
|
|
679
|
+
native = self.services.snapshot()
|
|
680
|
+
if native["worker"].get("state") in {"stopped", "absent"}:
|
|
681
|
+
if intent["started"] is not None or native["gateway"].get("state") == "running":
|
|
682
|
+
raise RuntimeError("owned startup process disappeared; automatic replacement is refused")
|
|
683
|
+
self.services.start("worker")
|
|
684
|
+
started = self._wait_worker(intent)
|
|
685
|
+
if intent["started"] is None:
|
|
686
|
+
intent = self._save(intent, started=started)
|
|
687
|
+
self._unchanged(originals)
|
|
688
|
+
gateway = self.services.snapshot()["gateway"]
|
|
689
|
+
if gateway.get("state") in {"stopped", "absent"}:
|
|
690
|
+
self.services.start("gateway")
|
|
691
|
+
receipt = ActivationController(self.layout, services=self.services, control=self.control,
|
|
692
|
+
health_timeout=self.health_timeout)._wait_receipt(self.layout)
|
|
693
|
+
if (receipt.get("server_identity") != intent["server_identity"]
|
|
694
|
+
or receipt.get("worker_pid") != intent["started"]["pid"]
|
|
695
|
+
or receipt.get("worker_instance_id") != intent["started"]["instance_id"]):
|
|
696
|
+
raise RuntimeError("authenticated lifecycle identity changed")
|
|
697
|
+
self._wait_worker(intent)
|
|
698
|
+
self._unchanged(originals)
|
|
699
|
+
intent = self._save(intent, phase="releasing")
|
|
700
|
+
self.control.release(self.layout, intent["operation_id"])
|
|
701
|
+
retire_lifecycle_intent(self.layout, intent)
|
|
702
|
+
return {"ok": True, "status": "running", "receipt": receipt}
|
|
703
|
+
|
|
704
|
+
def run(self, action: str) -> dict:
|
|
705
|
+
if action not in {"start", "stop", "restart"}:
|
|
706
|
+
raise ValueError("unknown native lifecycle action")
|
|
707
|
+
from execution_uninstall import _service_files, _hold_preparation_leases
|
|
708
|
+
from execution_recovery import require_retired_owners
|
|
709
|
+
with InstallationLock(self.layout.install_root), ExitStack() as leases:
|
|
710
|
+
if files.installed_layout(self.layout.install_root) != self.layout:
|
|
711
|
+
raise RuntimeError("native lifecycle layout changed")
|
|
712
|
+
originals = _service_files(self.layout)
|
|
713
|
+
self._unchanged(originals)
|
|
714
|
+
require_retired_owners(self.layout.install_root)
|
|
715
|
+
_hold_preparation_leases(self.layout.install_root, leases)
|
|
716
|
+
intent = read_lifecycle_intent(self.layout)
|
|
717
|
+
if intent is None:
|
|
718
|
+
prior = self.services.snapshot()
|
|
719
|
+
if any(prior[role].get("state") != "running" for role in ("worker", "gateway")):
|
|
720
|
+
raise RuntimeError("unjournaled stopped or broken services require ownership recovery")
|
|
721
|
+
if action == "start":
|
|
722
|
+
return {"ok": True, "status": "running", "receipt": self.control.receipt(self.layout, self.services)}
|
|
723
|
+
intent = self._new_intent(prior)
|
|
724
|
+
if intent["phase"] not in {"starting", "releasing"}:
|
|
725
|
+
intent = self._stop(intent, originals)
|
|
726
|
+
if action == "stop":
|
|
727
|
+
if intent["phase"] != "stopped":
|
|
728
|
+
raise RuntimeError("retry start to finish the pending owned startup")
|
|
729
|
+
return {"ok": True, "status": "stopped"}
|
|
730
|
+
return self._start(intent, originals)
|
|
731
|
+
|
|
732
|
+
|
|
429
733
|
class ActivationController:
|
|
430
734
|
def __init__(self, layout: files.ExecutionLayout, *, services: NativeServices | None = None,
|
|
431
735
|
control: WorkerControl | None = None, health_timeout: float = 30.0) -> None:
|
|
@@ -107,7 +107,7 @@ def _source_inventory(source: Path) -> dict[str, str]:
|
|
|
107
107
|
|
|
108
108
|
|
|
109
109
|
def _conflicts(root: Path) -> None:
|
|
110
|
-
for name in (".execution-transaction", ".execution-uninstall.json"):
|
|
110
|
+
for name in (".execution-transaction", ".execution-uninstall.json", ".execution-lifecycle.json"):
|
|
111
111
|
if (root / name).exists() or (root / name).is_symlink():
|
|
112
112
|
raise RuntimeError("another lifecycle operation owns this installation")
|
|
113
113
|
|
|
@@ -205,6 +205,7 @@ def uninstall(root: Path, *, config_root: Path, state_root: Path, home: Path,
|
|
|
205
205
|
native = services or NativeServices(layout)
|
|
206
206
|
control = control or WorkerControl()
|
|
207
207
|
originals = _service_files(layout)
|
|
208
|
+
native.validate_registered_bindings()
|
|
208
209
|
_no_pending_update(layout)
|
|
209
210
|
_hold_preparation_leases(root, leases)
|
|
210
211
|
_removable_tree(root)
|
|
@@ -216,6 +217,24 @@ def uninstall(root: Path, *, config_root: Path, state_root: Path, home: Path,
|
|
|
216
217
|
if intent is not None and intent["purge_state"] != purge_state:
|
|
217
218
|
raise RuntimeError("retry must preserve the existing uninstall data-removal choice")
|
|
218
219
|
prior = native.snapshot()
|
|
220
|
+
from execution_manage import read_lifecycle_intent, retire_lifecycle_intent
|
|
221
|
+
lifecycle = read_lifecycle_intent(layout)
|
|
222
|
+
if lifecycle is not None:
|
|
223
|
+
# Adopt only a proven completed owned stop. Never start a worker
|
|
224
|
+
# merely to remove it: that could run saved scheduled/queued work.
|
|
225
|
+
if (lifecycle["phase"] != "stopped"
|
|
226
|
+
or any(prior[role].get("state") not in {"stopped", "absent"} for role in ("worker", "gateway"))):
|
|
227
|
+
raise RuntimeError("finish the owned lifecycle stop before uninstalling")
|
|
228
|
+
native.validate_registered_bindings()
|
|
229
|
+
with _owned_lease(state_root / "admin/state-owner.lock"):
|
|
230
|
+
_no_provider_children(state_root)
|
|
231
|
+
if intent is None:
|
|
232
|
+
intent = _write_intent(root, layout, lifecycle["operation_id"], purge_state,
|
|
233
|
+
lifecycle["server_identity"])
|
|
234
|
+
elif (intent["operation_id"] != lifecycle["operation_id"]
|
|
235
|
+
or intent["server_identity"] != lifecycle["server_identity"]):
|
|
236
|
+
raise RuntimeError("uninstall and lifecycle journal ownership conflict")
|
|
237
|
+
retire_lifecycle_intent(layout, lifecycle)
|
|
219
238
|
# An interrupted graceful stop can precede the stopped-phase write.
|
|
220
239
|
# The exact intent, empty child registry and exclusive state lease
|
|
221
240
|
# below supply the missing proof; an unjournaled stopped install fails.
|
|
@@ -6,7 +6,6 @@ activation remains incomplete even after it has published the new files.
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
import json
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
from typing import Any
|
|
12
11
|
|
|
@@ -22,17 +21,11 @@ def current_components(
|
|
|
22
21
|
"""Read-only proof, invoked off the server event loop for an equal-version ensure."""
|
|
23
22
|
root = files._path(root)
|
|
24
23
|
files._owned_directory(root)
|
|
25
|
-
for name in (".activation-transaction", ".execution-transaction"):
|
|
24
|
+
for name in (".activation-transaction", ".execution-transaction", ".execution-lifecycle.json", ".execution-uninstall.json"):
|
|
26
25
|
journal = root / name
|
|
27
26
|
if journal.exists() or journal.is_symlink():
|
|
28
27
|
return False
|
|
29
|
-
|
|
30
|
-
document = json.loads(data)
|
|
31
|
-
if not isinstance(document, dict) or document.get("format") != files.FORMAT:
|
|
32
|
-
raise ValueError("installed execution layout is invalid")
|
|
33
|
-
layout = files.ExecutionLayout.from_dict(document.get("layout"))
|
|
34
|
-
if layout.install_root != root or document != files.layout_manifest(layout):
|
|
35
|
-
raise ValueError("installed execution layout does not match its runtime")
|
|
28
|
+
layout = files.installed_layout(root)
|
|
36
29
|
receipt: dict[str, Any] = WorkerControl().receipt(layout, NativeServices(layout))
|
|
37
30
|
if (receipt.get("server_identity") != expected_server_identity
|
|
38
31
|
or receipt.get("worker_instance_id") != expected_worker_instance):
|
|
@@ -45,5 +38,5 @@ def current_components(
|
|
|
45
38
|
# Do not claim completion over the operation that just acquired ownership.
|
|
46
39
|
return complete and not any(
|
|
47
40
|
(root / name).exists() or (root / name).is_symlink()
|
|
48
|
-
for name in (".activation-transaction", ".execution-transaction")
|
|
41
|
+
for name in (".activation-transaction", ".execution-transaction", ".execution-lifecycle.json", ".execution-uninstall.json")
|
|
49
42
|
)
|
|
@@ -156,6 +156,11 @@ def opencode_error_message(error: Any) -> str:
|
|
|
156
156
|
if not message:
|
|
157
157
|
message = str(error.get("message") or "")
|
|
158
158
|
name = str(error.get("name") or "")
|
|
159
|
+
status = data.get("statusCode") if isinstance(data, dict) else None
|
|
160
|
+
if type(status) is int and 100 <= status <= 599:
|
|
161
|
+
# Preserve useful HTTP diagnostics without exposing request headers,
|
|
162
|
+
# gateway response bodies or URLs that may contain credentials.
|
|
163
|
+
name = f"{name} (HTTP {status})" if name else f"HTTP {status}"
|
|
159
164
|
if message and name:
|
|
160
165
|
return f"{name}: {message}"
|
|
161
166
|
return message or name
|
|
@@ -22,9 +22,12 @@ import httpx
|
|
|
22
22
|
from fastapi import APIRouter, HTTPException, Request
|
|
23
23
|
from fastapi.responses import JSONResponse
|
|
24
24
|
|
|
25
|
-
from codex_provider import validate_selection, validate_model, endpoint_model_catalog
|
|
25
|
+
from codex_provider import validate_selection, validate_model, endpoint_model_catalog, endpoint_api_base_url
|
|
26
26
|
|
|
27
27
|
MAX_BODY_BYTES = 16 * 1024
|
|
28
|
+
# Reasoning can consume output tokens before the short visible check answer.
|
|
29
|
+
# Keep this explicit probe bounded and single-shot, including for such models.
|
|
30
|
+
MODEL_CHECK_MAX_OUTPUT_TOKENS = 256
|
|
28
31
|
PROTOCOLS = {"claude": {"anthropic"}, "opencode": {"anthropic", "chat_completions", "responses"}, "cursor": {"cursor"}}
|
|
29
32
|
RESULTS = {"verified", "authentication_failed", "rate_limited", "unsupported", "connection_failed", "invalid_response", "model_required", "cli_update_required"}
|
|
30
33
|
|
|
@@ -277,7 +280,7 @@ class ConnectionStore:
|
|
|
277
280
|
model = require_model(value)
|
|
278
281
|
config = json.loads(env.get("OPENCODE_CONFIG_CONTENT") or "{}")
|
|
279
282
|
adapter = {"anthropic": "@ai-sdk/anthropic", "chat_completions": "@ai-sdk/openai-compatible", "responses": "@ai-sdk/openai"}[value["protocol"]]
|
|
280
|
-
options = {"baseURL": value["base_url"], "apiKey": "{env:AGENTSDOCK_CUSTOM_API_KEY}"}
|
|
283
|
+
options = {"baseURL": endpoint_api_base_url(value["base_url"], value["protocol"]), "apiKey": "{env:AGENTSDOCK_CUSTOM_API_KEY}"}
|
|
281
284
|
if value["protocol"] == "anthropic" and value["auth_header"] == "bearer":
|
|
282
285
|
options["headers"] = {"Authorization": "Bearer {env:AGENTSDOCK_CUSTOM_API_KEY}", "x-api-key": ""}
|
|
283
286
|
config.setdefault("provider", {})["agentsdock_custom"] = {"npm": adapter, "name": "Custom endpoint", "options": options, "models": {model: {"name": model}}}
|
|
@@ -361,8 +364,7 @@ async def probe_credentials(selected: dict) -> str:
|
|
|
361
364
|
headers["Authorization"] = "Bearer " + selected["api_key"]
|
|
362
365
|
url = urlsplit(base)
|
|
363
366
|
router = url.scheme == "https" and url.netloc == "openrouter.ai" and url.path in {"/api", "/api/v1"}
|
|
364
|
-
target = "https://openrouter.ai/api/v1/key" if router else base +
|
|
365
|
-
"/v1/models" if protocol == "anthropic" and not base.endswith("/v1") else "/models")
|
|
367
|
+
target = "https://openrouter.ai/api/v1/key" if router else endpoint_api_base_url(base, protocol) + "/models"
|
|
366
368
|
async def send():
|
|
367
369
|
async with httpx.AsyncClient(timeout=10, follow_redirects=False, trust_env=False) as client:
|
|
368
370
|
async with client.stream("GET", target, headers=headers) as response:
|
|
@@ -403,18 +405,18 @@ async def probe(selected: dict) -> str:
|
|
|
403
405
|
headers["x-api-key"] = selected["api_key"]
|
|
404
406
|
else:
|
|
405
407
|
headers["Authorization"] = "Bearer " + selected["api_key"]
|
|
406
|
-
base = selected["base_url"]
|
|
408
|
+
base = endpoint_api_base_url(selected["base_url"], protocol)
|
|
407
409
|
prompt = "Connection check. Reply with OK only."
|
|
408
410
|
if protocol == "anthropic":
|
|
409
|
-
suffix = "/messages"
|
|
411
|
+
suffix = "/messages"
|
|
410
412
|
headers["anthropic-version"] = "2023-06-01"
|
|
411
|
-
body = {"model": selected["model"], "max_tokens":
|
|
413
|
+
body = {"model": selected["model"], "max_tokens": MODEL_CHECK_MAX_OUTPUT_TOKENS, "messages": [{"role": "user", "content": prompt}]}
|
|
412
414
|
elif protocol == "responses":
|
|
413
415
|
suffix = "/responses"
|
|
414
|
-
body = {"model": selected["model"], "max_output_tokens":
|
|
416
|
+
body = {"model": selected["model"], "max_output_tokens": MODEL_CHECK_MAX_OUTPUT_TOKENS, "input": prompt, "store": False}
|
|
415
417
|
else:
|
|
416
418
|
suffix = "/chat/completions"
|
|
417
|
-
body = {"model": selected["model"], "max_tokens":
|
|
419
|
+
body = {"model": selected["model"], "max_tokens": MODEL_CHECK_MAX_OUTPUT_TOKENS, "messages": [{"role": "user", "content": prompt}]}
|
|
418
420
|
async def send():
|
|
419
421
|
async with httpx.AsyncClient(timeout=15, follow_redirects=False, trust_env=False) as client:
|
|
420
422
|
async with client.stream("POST", base + suffix, headers=headers, json=body) as response:
|
|
@@ -600,7 +602,11 @@ def create_router(*, authorize, store: ConnectionStore, check=probe, account=nat
|
|
|
600
602
|
current = store.read(backend)
|
|
601
603
|
if current["revision"] != revision(payload["expected_revision"]):
|
|
602
604
|
raise HTTPException(409, "Endpoint settings changed. Refresh and try again.")
|
|
603
|
-
if current.get("
|
|
605
|
+
if not current.get("configured"):
|
|
606
|
+
raise HTTPException(409, "Connect a custom API first.")
|
|
607
|
+
# A saved, unverified connection can choose its first model.
|
|
608
|
+
# This write does not verify it or make it usable by chats;
|
|
609
|
+
# the explicit /check operation must still succeed.
|
|
604
610
|
value = {key: current[key] for key in ("base_url", "api_key", "protocol", "auth_header")}
|
|
605
611
|
saved = store.write(backend, current["revision"], {**value, "model": model, "expected_revision": current["revision"]}, current["last_result"], checked_at=current["checked_at"])
|
|
606
612
|
cached = store.catalog_cache.get((backend, current["revision"]))
|
|
@@ -651,7 +657,11 @@ def create_router(*, authorize, store: ConnectionStore, check=probe, account=nat
|
|
|
651
657
|
result = "cli_update_required" if discovered.get("cli_update_required") else "verified" if discovered["discovery_status"] == "ready" and discovered["models"] else "connection_failed"
|
|
652
658
|
else:
|
|
653
659
|
result = await check(selected)
|
|
654
|
-
|
|
660
|
+
# Public catalogs cannot authenticate a key. Keep a private,
|
|
661
|
+
# explicitly unverified setup so the client can list models
|
|
662
|
+
# before choosing one for a subsequent explicit check.
|
|
663
|
+
pending_model = result == "model_required" and not selected.get("model") and backend != "cursor"
|
|
664
|
+
if request.method == "PUT" and result != "verified" and not pending_model:
|
|
655
665
|
return reply({"ok": False, "status": result})
|
|
656
666
|
configuration = await asyncio.to_thread(store.write, backend, expected, selected, result)
|
|
657
667
|
if backend == "cursor" and result == "verified":
|