@agentsdock/server 1.0.7-beta.5
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/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Settle the exact admitted update after independent native recovery.
|
|
2
|
+
|
|
3
|
+
This module and its dependencies use only the standard library: the durable
|
|
4
|
+
recovery job runs independently of the candidate virtual environment. A terminal
|
|
5
|
+
installer receipt, not a matching version string, authorizes settlement.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
import fcntl
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import stat
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import execution_install as files
|
|
23
|
+
from execution_preparation import _check_binding
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ACTIVE = {"starting", "checking", "downloading", "verifying", "installing", "restarting"}
|
|
27
|
+
INTENT_KEYS = {"format", "root", "root_binding", "candidate_binding", "version",
|
|
28
|
+
"api_contract", "update_id", "server_identity"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read(path: Path) -> dict:
|
|
32
|
+
raw, _mode = files._read_file(path, private=True)
|
|
33
|
+
def unique(pairs):
|
|
34
|
+
value = {}
|
|
35
|
+
for key, item in pairs:
|
|
36
|
+
if key in value:
|
|
37
|
+
raise ValueError("duplicate update recovery status field")
|
|
38
|
+
value[key] = item
|
|
39
|
+
return value
|
|
40
|
+
value = json.loads(raw, object_pairs_hook=unique)
|
|
41
|
+
if not isinstance(value, dict):
|
|
42
|
+
raise ValueError("update recovery status is not an object")
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@contextmanager
|
|
47
|
+
def _status_lock(path: Path):
|
|
48
|
+
files._path(path)
|
|
49
|
+
files._owned_directory(path.parent)
|
|
50
|
+
lock = path.with_name(f".{path.name}.lock")
|
|
51
|
+
descriptor = os.open(lock, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
52
|
+
try:
|
|
53
|
+
info = os.fstat(descriptor)
|
|
54
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
|
|
55
|
+
or info.st_nlink != 1 or stat.S_IMODE(info.st_mode) != 0o600):
|
|
56
|
+
raise PermissionError("update recovery status lock is unsafe")
|
|
57
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
|
58
|
+
current = lock.lstat()
|
|
59
|
+
if (current.st_dev, current.st_ino) != (info.st_dev, info.st_ino):
|
|
60
|
+
raise RuntimeError("update recovery status lock changed")
|
|
61
|
+
yield
|
|
62
|
+
finally:
|
|
63
|
+
os.close(descriptor)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _intent_digest(intent: dict) -> str:
|
|
67
|
+
return hashlib.sha256(files._json_bytes(intent)).hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def capture_status_binding(root: Path, context: dict, *, supplied_update_id: str,
|
|
71
|
+
expected_server_identity: str) -> dict | None:
|
|
72
|
+
"""Bind old-runner status only through its already verified recovery intent."""
|
|
73
|
+
root = files._path(root)
|
|
74
|
+
state = Path(context["execution"]["runtime_dir"]).parent
|
|
75
|
+
path = files._path(state / "admin/server-update.json")
|
|
76
|
+
if not path.exists() and not path.is_symlink():
|
|
77
|
+
if supplied_update_id:
|
|
78
|
+
raise RuntimeError("admitted update status is missing")
|
|
79
|
+
return None
|
|
80
|
+
with _status_lock(path):
|
|
81
|
+
status = _read(path)
|
|
82
|
+
intent = status.get("_activation_recovery")
|
|
83
|
+
if not supplied_update_id and status.get("phase") not in ACTIVE:
|
|
84
|
+
return None # historical receipt during an explicit manual install
|
|
85
|
+
if intent is None and not supplied_update_id:
|
|
86
|
+
raise RuntimeError("active update has no bound native recovery intent")
|
|
87
|
+
if (not isinstance(intent, dict) or set(intent) != INTENT_KEYS
|
|
88
|
+
or type(intent["format"]) is not int or intent["format"] != 1
|
|
89
|
+
or intent["root"] != str(root)
|
|
90
|
+
or intent["server_identity"] != expected_server_identity
|
|
91
|
+
or not expected_server_identity
|
|
92
|
+
or re.fullmatch(r"[0-9a-f]{32}", str(intent["update_id"])) is None
|
|
93
|
+
or intent["update_id"] != status.get("update_id")
|
|
94
|
+
or (supplied_update_id and supplied_update_id != intent["update_id"])
|
|
95
|
+
or status.get("phase") not in ACTIVE
|
|
96
|
+
or intent["version"] != status.get("target_version")
|
|
97
|
+
or intent["version"] != context["release_version"]
|
|
98
|
+
or type(intent["api_contract"]) is not int or intent["api_contract"] < 1
|
|
99
|
+
or intent["api_contract"] != context["execution"]["api_contract"]):
|
|
100
|
+
raise RuntimeError("native recovery does not own the admitted update")
|
|
101
|
+
_check_binding(root, intent["root_binding"])
|
|
102
|
+
saved, candidate = intent["candidate_binding"], context["candidate_release"]
|
|
103
|
+
if (not isinstance(saved, dict) or set(saved) != {"device", "inode", "volume_uuid"}
|
|
104
|
+
or any(type(saved[name]) is not int or saved[name] < 1 for name in ("device", "inode"))
|
|
105
|
+
or saved["inode"] != candidate["inode"]):
|
|
106
|
+
raise RuntimeError("native recovery candidate differs from the admitted update")
|
|
107
|
+
if sys.platform == "darwin":
|
|
108
|
+
if not saved["volume_uuid"] or saved["volume_uuid"] != intent["root_binding"]["volume_uuid"]:
|
|
109
|
+
raise RuntimeError("native recovery candidate volume changed")
|
|
110
|
+
elif saved["volume_uuid"] is not None or saved["device"] != candidate["device"]:
|
|
111
|
+
raise RuntimeError("native recovery candidate device changed")
|
|
112
|
+
if context["execution"]["handoff"] is not None and context["execution"]["handoff"] != status.get("_execution_handoff"):
|
|
113
|
+
raise RuntimeError("native recovery handoff differs from the admitted update")
|
|
114
|
+
return {"format": 1, "path": str(path), "update_id": intent["update_id"],
|
|
115
|
+
"target_version": intent["version"], "intent_sha256": _intent_digest(intent)}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _live_runner(status: dict, update_id: str) -> bool:
|
|
119
|
+
pid = status.get("runner_pid")
|
|
120
|
+
if pid is None:
|
|
121
|
+
# A newly launched recovery runner may not have published its PID yet.
|
|
122
|
+
try:
|
|
123
|
+
updated = datetime.fromisoformat(str(status["updated_at"]).replace("Z", "+00:00"))
|
|
124
|
+
return 0 <= (datetime.now(timezone.utc) - updated).total_seconds() < 45
|
|
125
|
+
except (KeyError, ValueError, TypeError):
|
|
126
|
+
return False
|
|
127
|
+
if type(pid) is not int or pid <= 1:
|
|
128
|
+
raise RuntimeError("saved update runner identity is invalid")
|
|
129
|
+
result = subprocess.run(["/bin/ps", "-ww", "-p", str(pid), "-o", "command="],
|
|
130
|
+
capture_output=True, text=True, timeout=5, check=False)
|
|
131
|
+
if result.returncode not in {0, 1}:
|
|
132
|
+
raise RuntimeError("cannot inspect the admitted update runner")
|
|
133
|
+
command = result.stdout.strip()
|
|
134
|
+
return bool("update_runner.py" in command and re.search(
|
|
135
|
+
rf"(?:^|\s)--update-id\s+{re.escape(update_id)}(?:\s|$)", command))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def settle_status(owner: dict, terminal: dict) -> bool:
|
|
139
|
+
"""Return False while a live runner owns settlement; never replace a new row.
|
|
140
|
+
|
|
141
|
+
The caller has validated native terminal health and finalization under the
|
|
142
|
+
installation lock. This adds status ownership, not another recovery path.
|
|
143
|
+
"""
|
|
144
|
+
binding = owner.get("status_binding")
|
|
145
|
+
if binding is None:
|
|
146
|
+
return True
|
|
147
|
+
if (not isinstance(binding, dict)
|
|
148
|
+
or set(binding) != {"format", "path", "update_id", "target_version", "intent_sha256"}
|
|
149
|
+
or type(binding["format"]) is not int or binding["format"] != 1
|
|
150
|
+
or binding["target_version"] != owner["version"]
|
|
151
|
+
or re.fullmatch(r"[0-9a-f]{32}", str(binding["update_id"])) is None
|
|
152
|
+
or re.fullmatch(r"[0-9a-f]{64}", str(binding["intent_sha256"])) is None):
|
|
153
|
+
raise RuntimeError("native recovery status binding is invalid")
|
|
154
|
+
path = files._path(binding["path"])
|
|
155
|
+
if path != Path(owner["state_root"]) / "admin/server-update.json":
|
|
156
|
+
raise RuntimeError("native recovery status path changed")
|
|
157
|
+
if (not isinstance(terminal, dict) or type(terminal.get("format")) is not int
|
|
158
|
+
or terminal["format"] != 1
|
|
159
|
+
or terminal.get("transaction_id") != owner["transaction_id"]
|
|
160
|
+
or not isinstance(terminal.get("snapshot"), dict)
|
|
161
|
+
or terminal.get("phase") not in {"committed", "rollback-healthy"}):
|
|
162
|
+
raise RuntimeError("native recovery terminal proof is invalid")
|
|
163
|
+
health = terminal["snapshot"].get("health")
|
|
164
|
+
if not isinstance(health, dict) or health.get("server_identity") != owner["expected_server_identity"]:
|
|
165
|
+
raise RuntimeError("native recovery terminal identity changed")
|
|
166
|
+
completed = terminal["phase"] == "committed"
|
|
167
|
+
if completed and (health.get("server_version") != owner["version"]
|
|
168
|
+
or health.get("api_contract_version") != owner["api_contract"]
|
|
169
|
+
or health.get("worker_version") != owner["version"]
|
|
170
|
+
or health.get("gateway_version") != owner["version"]):
|
|
171
|
+
raise RuntimeError("native recovery completion is not the paired target")
|
|
172
|
+
with _status_lock(path):
|
|
173
|
+
current = _read(path)
|
|
174
|
+
if current.get("update_id") != binding["update_id"] or current.get("phase") not in ACTIVE:
|
|
175
|
+
return True # another update, or the original runner already settled
|
|
176
|
+
if (current.get("target_version") != binding["target_version"]
|
|
177
|
+
or _intent_digest(current.get("_activation_recovery")) != binding["intent_sha256"]):
|
|
178
|
+
raise RuntimeError("admitted update changed before recovery settlement")
|
|
179
|
+
if any((Path(owner["root"]) / name).exists() or (Path(owner["root"]) / name).is_symlink()
|
|
180
|
+
for name in (".activation-transaction", ".execution-transaction", ".execution-uninstall.json")):
|
|
181
|
+
raise RuntimeError("lifecycle transaction still owns recovery settlement")
|
|
182
|
+
if _live_runner(current, binding["update_id"]):
|
|
183
|
+
return False
|
|
184
|
+
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
185
|
+
current.update(phase="complete" if completed else "failed", runner_pid=None,
|
|
186
|
+
heartbeat_at=None, elapsed_seconds=None, updated_at=now, finished_at=now,
|
|
187
|
+
error_code=None if completed else "server_update_rolled_back",
|
|
188
|
+
error_action=None if completed else "Retry the bundled update when ready.",
|
|
189
|
+
retryable=None if completed else True,
|
|
190
|
+
message=(f"AgentsServer {owner['version']} recovered and is healthy." if completed else
|
|
191
|
+
"The interrupted update was rolled back to the verified previous installation."))
|
|
192
|
+
if completed:
|
|
193
|
+
current.update(installed_version=owner["version"], update_available=False)
|
|
194
|
+
files._atomic_write(path, files._json_bytes(current))
|
|
195
|
+
return True
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Independent execution owner and replaceable public ASGI gateway.
|
|
2
|
+
|
|
3
|
+
The worker owns the application's lifespan, provider transports and state. The
|
|
4
|
+
gateway owns only public client connections. In particular, losing the gateway
|
|
5
|
+
must never run the application's shutdown handlers or stop its provider children.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import errno
|
|
13
|
+
import fcntl
|
|
14
|
+
import importlib
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import socket
|
|
20
|
+
import stat
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any
|
|
23
|
+
import uuid
|
|
24
|
+
|
|
25
|
+
import uvicorn
|
|
26
|
+
|
|
27
|
+
from execution_transport import (
|
|
28
|
+
ExecutionGateway,
|
|
29
|
+
ExecutionTransportServer,
|
|
30
|
+
ensure_execution_secret,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
PROTOCOL_VERSION = 1
|
|
35
|
+
MAX_HEALTH_BYTES = 1024 * 1024
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProcessLease:
|
|
39
|
+
"""One owner per role, independent of PIDs reused after a crash."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, runtime_dir: Path, role: str) -> None:
|
|
42
|
+
if role not in {"worker", "gateway"}:
|
|
43
|
+
raise ValueError("Unknown execution service role")
|
|
44
|
+
self.directory = runtime_dir
|
|
45
|
+
self.role = role
|
|
46
|
+
self.fd: int | None = None
|
|
47
|
+
self.instance_id = uuid.uuid4().hex
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> "ProcessLease":
|
|
50
|
+
self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
51
|
+
ensure_execution_secret(self.directory / "control.token")
|
|
52
|
+
path = self.directory / f"{self.role}.lock"
|
|
53
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
54
|
+
try:
|
|
55
|
+
info = os.fstat(fd)
|
|
56
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
|
|
57
|
+
raise ValueError("Execution lease is not an owned regular file")
|
|
58
|
+
if stat.S_IMODE(info.st_mode) != 0o600:
|
|
59
|
+
raise ValueError("Execution lease must be owner-only")
|
|
60
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
61
|
+
os.set_inheritable(fd, False)
|
|
62
|
+
except BaseException:
|
|
63
|
+
os.close(fd)
|
|
64
|
+
raise
|
|
65
|
+
self.fd = fd
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def publish(self, **values: Any) -> None:
|
|
69
|
+
if self.fd is None:
|
|
70
|
+
raise RuntimeError("Execution lease is not held")
|
|
71
|
+
receipt = {
|
|
72
|
+
"schema": 1,
|
|
73
|
+
"protocol": PROTOCOL_VERSION,
|
|
74
|
+
"role": self.role,
|
|
75
|
+
"pid": os.getpid(),
|
|
76
|
+
"instance_id": self.instance_id,
|
|
77
|
+
"started_at": time.time(),
|
|
78
|
+
**values,
|
|
79
|
+
}
|
|
80
|
+
temporary = self.directory / f".{self.role}.{self.instance_id}.tmp"
|
|
81
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
82
|
+
try:
|
|
83
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
84
|
+
json.dump(receipt, stream, sort_keys=True)
|
|
85
|
+
stream.write("\n")
|
|
86
|
+
stream.flush()
|
|
87
|
+
os.fsync(stream.fileno())
|
|
88
|
+
os.replace(temporary, self.directory / f"{self.role}.json")
|
|
89
|
+
finally:
|
|
90
|
+
temporary.unlink(missing_ok=True)
|
|
91
|
+
|
|
92
|
+
def __exit__(self, *unused: Any) -> None:
|
|
93
|
+
# The lock remains held while removing this process's receipt. Leave the
|
|
94
|
+
# lock inode in place so a waiting process cannot acquire another inode.
|
|
95
|
+
receipt_path = self.directory / f"{self.role}.json"
|
|
96
|
+
try:
|
|
97
|
+
if not receipt_path.is_symlink():
|
|
98
|
+
receipt = json.loads(receipt_path.read_text())
|
|
99
|
+
if isinstance(receipt, dict) and receipt.get("instance_id") == self.instance_id:
|
|
100
|
+
receipt_path.unlink()
|
|
101
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
102
|
+
pass
|
|
103
|
+
finally:
|
|
104
|
+
if self.fd is not None:
|
|
105
|
+
os.close(self.fd)
|
|
106
|
+
self.fd = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ComponentHealth:
|
|
110
|
+
"""Add component identity without relabeling an old engine as upgraded."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, app: Any, field: str, identity: dict[str, Any], live_identity: Any = None) -> None:
|
|
113
|
+
self.app = app
|
|
114
|
+
self.field = field
|
|
115
|
+
self.identity = dict(identity)
|
|
116
|
+
self.live_identity = live_identity
|
|
117
|
+
|
|
118
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
119
|
+
if scope["type"] != "http" or scope.get("path") != "/api/health":
|
|
120
|
+
await self.app(scope, receive, send)
|
|
121
|
+
return
|
|
122
|
+
start: dict[str, Any] | None = None
|
|
123
|
+
chunks: list[bytes] = []
|
|
124
|
+
count = 0
|
|
125
|
+
passthrough = False
|
|
126
|
+
|
|
127
|
+
async def component_send(message: dict[str, Any]) -> None:
|
|
128
|
+
nonlocal start, count, passthrough
|
|
129
|
+
if message["type"] == "http.response.start":
|
|
130
|
+
headers = message.get("headers", [])
|
|
131
|
+
content_types = [v for k, v in headers if k.lower() == b"content-type"]
|
|
132
|
+
encoded = any(k.lower() == b"content-encoding" for k, _ in headers)
|
|
133
|
+
if message["status"] != 200 or encoded or not any(
|
|
134
|
+
v.lower().startswith(b"application/json") for v in content_types
|
|
135
|
+
):
|
|
136
|
+
passthrough = True
|
|
137
|
+
await send(message)
|
|
138
|
+
else:
|
|
139
|
+
start = dict(message)
|
|
140
|
+
return
|
|
141
|
+
if passthrough or start is None or message["type"] != "http.response.body":
|
|
142
|
+
await send(message)
|
|
143
|
+
return
|
|
144
|
+
chunk = message.get("body", b"")
|
|
145
|
+
chunks.append(chunk)
|
|
146
|
+
count += len(chunk)
|
|
147
|
+
if count > MAX_HEALTH_BYTES:
|
|
148
|
+
passthrough = True
|
|
149
|
+
await send(start)
|
|
150
|
+
await send({**message, "body": b"".join(chunks)})
|
|
151
|
+
chunks.clear()
|
|
152
|
+
return
|
|
153
|
+
if message.get("more_body", False):
|
|
154
|
+
return
|
|
155
|
+
body = b"".join(chunks)
|
|
156
|
+
try:
|
|
157
|
+
document = json.loads(body)
|
|
158
|
+
if isinstance(document, dict):
|
|
159
|
+
document[self.field] = {**self.identity,
|
|
160
|
+
**(self.live_identity() if self.live_identity is not None else {})}
|
|
161
|
+
body = json.dumps(document, separators=(",", ":")).encode()
|
|
162
|
+
except (ValueError, UnicodeError):
|
|
163
|
+
pass
|
|
164
|
+
headers = [(k, v) for k, v in start.get("headers", []) if k.lower() != b"content-length"]
|
|
165
|
+
headers.append((b"content-length", str(len(body)).encode("ascii")))
|
|
166
|
+
await send({**start, "headers": headers})
|
|
167
|
+
await send({**message, "body": body})
|
|
168
|
+
|
|
169
|
+
await self.app(scope, receive, component_send)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class WorkerLifespan:
|
|
173
|
+
"""Start IPC after the application's one startup and stop it before teardown."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, app: Any, transport: ExecutionTransportServer, ready: Any) -> None:
|
|
176
|
+
self.app = app
|
|
177
|
+
self.transport = transport
|
|
178
|
+
self.ready = ready
|
|
179
|
+
self.started = False
|
|
180
|
+
|
|
181
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
182
|
+
if scope["type"] != "lifespan":
|
|
183
|
+
await self.app(scope, receive, send)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
async def lifecycle_receive() -> dict[str, Any]:
|
|
187
|
+
message = await receive()
|
|
188
|
+
if message["type"] == "lifespan.shutdown" and self.started:
|
|
189
|
+
await self.transport.close()
|
|
190
|
+
self.started = False
|
|
191
|
+
return message
|
|
192
|
+
|
|
193
|
+
async def lifecycle_send(message: dict[str, Any]) -> None:
|
|
194
|
+
if message["type"] == "lifespan.startup.complete":
|
|
195
|
+
await self.transport.start()
|
|
196
|
+
self.started = True
|
|
197
|
+
self.ready()
|
|
198
|
+
await send(message)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
await self.app(scope, lifecycle_receive, lifecycle_send)
|
|
202
|
+
finally:
|
|
203
|
+
if self.started:
|
|
204
|
+
await self.transport.close()
|
|
205
|
+
self.started = False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def load_application(reference: str) -> tuple[Any, Any]:
|
|
209
|
+
module_name, separator, attribute = reference.partition(":")
|
|
210
|
+
if not separator or not module_name or not attribute or ":" in attribute:
|
|
211
|
+
raise ValueError("Application must be an importable module:attribute")
|
|
212
|
+
module = importlib.import_module(module_name)
|
|
213
|
+
return module, getattr(module, attribute)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def source_version() -> str:
|
|
217
|
+
return Path(__file__).with_name("VERSION").read_text().strip()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def remove_stale_worker_socket(path: Path) -> None:
|
|
221
|
+
"""Recover a crashed listener only while the caller holds the worker lease."""
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
before = path.lstat()
|
|
225
|
+
except FileNotFoundError:
|
|
226
|
+
return
|
|
227
|
+
if not stat.S_ISSOCK(before.st_mode) or before.st_uid != os.getuid():
|
|
228
|
+
raise ValueError("Refusing to replace an unexpected execution socket")
|
|
229
|
+
probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
230
|
+
probe.settimeout(0.5)
|
|
231
|
+
try:
|
|
232
|
+
probe.connect(str(path))
|
|
233
|
+
except OSError as error:
|
|
234
|
+
if error.errno != errno.ECONNREFUSED:
|
|
235
|
+
raise RuntimeError("Execution listener ownership is ambiguous") from error
|
|
236
|
+
else:
|
|
237
|
+
raise RuntimeError("An execution listener is still running")
|
|
238
|
+
finally:
|
|
239
|
+
probe.close()
|
|
240
|
+
after = path.lstat()
|
|
241
|
+
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
|
|
242
|
+
raise RuntimeError("Execution socket changed during recovery")
|
|
243
|
+
path.unlink()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def serve_worker(args: argparse.Namespace, lease: ProcessLease) -> None:
|
|
247
|
+
module, app = load_application(args.application)
|
|
248
|
+
if args.application == "agent_server:app":
|
|
249
|
+
expected_runtime = module.STATE_DIR.expanduser().resolve() / "execution"
|
|
250
|
+
if args.runtime_dir != expected_runtime:
|
|
251
|
+
raise ValueError("Worker runtime directory must be its state directory's execution folder")
|
|
252
|
+
remove_stale_worker_socket(args.runtime_dir / "worker.socket")
|
|
253
|
+
callback_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
254
|
+
callback_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
255
|
+
callback_socket.bind(("127.0.0.1", args.callback_port))
|
|
256
|
+
callback_socket.listen(128)
|
|
257
|
+
callback_socket.setblocking(False)
|
|
258
|
+
callback_port = callback_socket.getsockname()[1]
|
|
259
|
+
callback_origin = f"http://127.0.0.1:{callback_port}"
|
|
260
|
+
maintenance = None
|
|
261
|
+
if args.application == "agent_server:app":
|
|
262
|
+
# Public addresses still describe the gateway, including Team Hub URLs.
|
|
263
|
+
# Only provider callbacks use the worker-owned stable private listener.
|
|
264
|
+
module.SERVER_BIND_ADDRESS = args.bind
|
|
265
|
+
module.SERVER_PORT = args.port
|
|
266
|
+
module.PROVIDER_CALLBACK_ORIGIN = callback_origin
|
|
267
|
+
module.configure_server_logging(module.STATE_DIR)
|
|
268
|
+
from execution_install import pending_worker_operation
|
|
269
|
+
from execution_maintenance import ExecutionMaintenance
|
|
270
|
+
|
|
271
|
+
maintenance = ExecutionMaintenance(
|
|
272
|
+
module.SERVER_ADMIN_ROOT / "execution-maintenance.json", lease.instance_id,
|
|
273
|
+
)
|
|
274
|
+
configured_install = os.environ.get("AGENTS_SERVER_INSTALL_DIR")
|
|
275
|
+
if configured_install:
|
|
276
|
+
operation = pending_worker_operation(
|
|
277
|
+
Path(configured_install).expanduser().resolve(),
|
|
278
|
+
Path(__file__).resolve().parent,
|
|
279
|
+
)
|
|
280
|
+
if operation is not None:
|
|
281
|
+
maintenance.hold_for_startup(operation)
|
|
282
|
+
# Set before app startup so recovered queues and scheduled work cannot
|
|
283
|
+
# start in a candidate that has not passed activation health checks.
|
|
284
|
+
module.EXECUTION_MAINTENANCE = maintenance
|
|
285
|
+
version = str(getattr(module, "SERVER_VERSION", source_version()))
|
|
286
|
+
identity = {
|
|
287
|
+
"protocol": PROTOCOL_VERSION,
|
|
288
|
+
"instance_id": lease.instance_id,
|
|
289
|
+
"pid": os.getpid(),
|
|
290
|
+
"version": version,
|
|
291
|
+
"worker_upgrade_policy": "when_idle",
|
|
292
|
+
"rolling_worker_upgrade": False,
|
|
293
|
+
}
|
|
294
|
+
health_app = ComponentHealth(app, "execution_service", identity,
|
|
295
|
+
(lambda: {"maintenance_held": maintenance.is_held()}) if maintenance is not None else None)
|
|
296
|
+
transport = ExecutionTransportServer(
|
|
297
|
+
health_app,
|
|
298
|
+
socket_path=args.runtime_dir / "worker.socket",
|
|
299
|
+
secret_path=args.runtime_dir / "control.token",
|
|
300
|
+
)
|
|
301
|
+
lifecycle_app = WorkerLifespan(
|
|
302
|
+
health_app,
|
|
303
|
+
transport,
|
|
304
|
+
lambda: lease.publish(
|
|
305
|
+
version=version,
|
|
306
|
+
release_root=str(Path(__file__).resolve().parent),
|
|
307
|
+
callback_origin=callback_origin,
|
|
308
|
+
public_bind=args.bind,
|
|
309
|
+
public_port=args.port,
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
callback_app = lifecycle_app
|
|
313
|
+
if maintenance is not None:
|
|
314
|
+
from execution_control import ExecutionControl
|
|
315
|
+
|
|
316
|
+
callback_app = ExecutionControl(
|
|
317
|
+
lifecycle_app,
|
|
318
|
+
token=ensure_execution_secret(args.runtime_dir / "control.token"),
|
|
319
|
+
worker_instance_id=lease.instance_id,
|
|
320
|
+
status_callback=module.execution_maintenance_control,
|
|
321
|
+
maintenance_callback=module.execution_maintenance_control,
|
|
322
|
+
)
|
|
323
|
+
server = uvicorn.Server(uvicorn.Config(
|
|
324
|
+
callback_app, host="127.0.0.1", port=callback_port,
|
|
325
|
+
lifespan="on", proxy_headers=False, server_header=False,
|
|
326
|
+
log_level="info", timeout_graceful_shutdown=20,
|
|
327
|
+
**({"log_config": None} if args.application == "agent_server:app" else {}),
|
|
328
|
+
))
|
|
329
|
+
try:
|
|
330
|
+
await server.serve(sockets=[callback_socket])
|
|
331
|
+
finally:
|
|
332
|
+
callback_socket.close()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def serve_gateway(args: argparse.Namespace, lease: ProcessLease) -> None:
|
|
336
|
+
gateway = ExecutionGateway(
|
|
337
|
+
socket_path=args.runtime_dir / "worker.socket",
|
|
338
|
+
secret_path=args.runtime_dir / "control.token",
|
|
339
|
+
)
|
|
340
|
+
identity = {
|
|
341
|
+
"protocol": PROTOCOL_VERSION,
|
|
342
|
+
"instance_id": lease.instance_id,
|
|
343
|
+
"pid": os.getpid(),
|
|
344
|
+
"version": source_version(),
|
|
345
|
+
"restart_preserves_execution": True,
|
|
346
|
+
}
|
|
347
|
+
app = ComponentHealth(gateway, "gateway", identity)
|
|
348
|
+
lease.publish(
|
|
349
|
+
version=source_version(), release_root=str(Path(__file__).resolve().parent),
|
|
350
|
+
public_bind=args.bind, public_port=args.port,
|
|
351
|
+
)
|
|
352
|
+
server = uvicorn.Server(uvicorn.Config(
|
|
353
|
+
app, host=args.bind, port=args.port,
|
|
354
|
+
lifespan="off", proxy_headers=False, server_header=False,
|
|
355
|
+
# Only client attachments belong to this process. There is no reason
|
|
356
|
+
# to delay replacement while an execution-owned request keeps running.
|
|
357
|
+
timeout_graceful_shutdown=2, log_level="warning", access_log=False,
|
|
358
|
+
))
|
|
359
|
+
await server.serve()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def main() -> int:
|
|
363
|
+
parser = argparse.ArgumentParser(description="AgentsServer execution service")
|
|
364
|
+
parser.add_argument("role", choices=["worker", "gateway"])
|
|
365
|
+
parser.add_argument("--runtime-dir", type=Path, required=True)
|
|
366
|
+
parser.add_argument("--bind", default="127.0.0.1")
|
|
367
|
+
parser.add_argument("--port", type=int, default=7850)
|
|
368
|
+
parser.add_argument("--callback-port", type=int, default=0)
|
|
369
|
+
parser.add_argument("--application", default="agent_server:app")
|
|
370
|
+
args = parser.parse_args()
|
|
371
|
+
if not args.runtime_dir.is_absolute():
|
|
372
|
+
parser.error("--runtime-dir must be absolute")
|
|
373
|
+
if not 0 <= args.callback_port <= 65535 or not 1 <= args.port <= 65535:
|
|
374
|
+
parser.error("Invalid listener port")
|
|
375
|
+
if args.role == "gateway" and args.application != "agent_server:app":
|
|
376
|
+
parser.error("Only workers load applications")
|
|
377
|
+
try:
|
|
378
|
+
with ProcessLease(args.runtime_dir, args.role) as lease:
|
|
379
|
+
asyncio.run(serve_worker(args, lease) if args.role == "worker" else serve_gateway(args, lease))
|
|
380
|
+
except BlockingIOError:
|
|
381
|
+
logging.error("Another %s already owns this execution directory", args.role)
|
|
382
|
+
return 2
|
|
383
|
+
return 0
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if __name__ == "__main__":
|
|
387
|
+
raise SystemExit(main())
|