@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.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. 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())