@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,264 @@
1
+ """Detached, resumable signed-update preparation while agent admission stays open.
2
+
3
+ This process may download and build a private candidate, never activate it.
4
+ The server's existing pending-update waiter owns the later idle handoff.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import base64
10
+ from contextlib import contextmanager
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 tempfile
20
+ import time
21
+ from typing import Any
22
+
23
+ import update_runner as updates
24
+
25
+
26
+ class PreparationSuperseded(RuntimeError):
27
+ pass
28
+
29
+
30
+ def _owned_directory(path: Path, *, create: bool = False) -> Path:
31
+ if not path.is_absolute() or path.resolve() != path:
32
+ raise ValueError("Preparation paths must be absolute and contain no links")
33
+ if create:
34
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
35
+ info = path.lstat()
36
+ if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o022:
37
+ raise ValueError("Preparation directory is not safely owned")
38
+ return path
39
+
40
+
41
+ def preparation_directory(root: Path, preparation_id: str, *, create: bool = False) -> Path:
42
+ if re.fullmatch(r"[0-9a-f]{32}", preparation_id) is None:
43
+ raise ValueError("Invalid preparation identity")
44
+ _owned_directory(root)
45
+ parent = _owned_directory(root / ".update-preparations", create=create)
46
+ return _owned_directory(parent / preparation_id, create=create)
47
+
48
+
49
+ @contextmanager
50
+ def preparation_lease(directory: Path, *, blocking: bool = False):
51
+ path = directory / "runner.lock"
52
+ descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
53
+ try:
54
+ info = os.fstat(descriptor)
55
+ if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
56
+ or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1):
57
+ raise ValueError("Preparation lock is unsafe")
58
+ fcntl.flock(descriptor, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
59
+ current = path.lstat()
60
+ if (current.st_dev, current.st_ino) != (info.st_dev, info.st_ino):
61
+ raise ValueError("Preparation lock changed")
62
+ yield descriptor
63
+ finally:
64
+ os.close(descriptor)
65
+
66
+
67
+ def preparation_is_active(root: Path, preparation_id: str) -> bool:
68
+ try:
69
+ directory = preparation_directory(root, preparation_id)
70
+ except FileNotFoundError:
71
+ return False
72
+ try:
73
+ with preparation_lease(directory):
74
+ return False
75
+ except BlockingIOError:
76
+ return True
77
+
78
+
79
+ def update_pending(status_path: Path, preparation_id: str, **changes: Any) -> dict[str, Any]:
80
+ with updates.server_update_status_lock(status_path):
81
+ current = updates._read_status_unlocked(status_path)
82
+ if current.get("phase") != "pending" or current.get("preparation_id") != preparation_id:
83
+ raise PreparationSuperseded("Preparation no longer owns the pending update")
84
+ return updates._update_status_unlocked(status_path, current,
85
+ preparation_heartbeat_at=updates.utc_now(), **changes)
86
+
87
+
88
+ def receipt_path(root: Path, preparation_id: str) -> Path:
89
+ if re.fullmatch(r"[0-9a-f]{32}", preparation_id) is None:
90
+ raise ValueError("Invalid preparation identity")
91
+ return root / ".prepared-receipts" / f"{preparation_id}.json"
92
+
93
+
94
+ def receipt_digest(path: Path) -> str:
95
+ from execution_preparation import MAX_RECEIPT_BYTES
96
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
97
+ try:
98
+ info = os.fstat(descriptor)
99
+ if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
100
+ or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1
101
+ or info.st_size > MAX_RECEIPT_BYTES):
102
+ raise ValueError("Prepared candidate receipt is unsafe")
103
+ digest = hashlib.sha256()
104
+ length = 0
105
+ while True:
106
+ data = os.read(descriptor, min(1024 * 1024, MAX_RECEIPT_BYTES + 1 - length))
107
+ if not data:
108
+ break
109
+ length += len(data)
110
+ if length > MAX_RECEIPT_BYTES:
111
+ raise ValueError("Prepared candidate receipt exceeds its limit")
112
+ digest.update(data)
113
+ current = path.lstat()
114
+ after = os.fstat(descriptor)
115
+ if (length != info.st_size or (current.st_dev, current.st_ino) != (info.st_dev, info.st_ino)
116
+ or (after.st_size, after.st_mtime_ns, after.st_ctime_ns) != (info.st_size, info.st_mtime_ns, info.st_ctime_ns)):
117
+ raise ValueError("Prepared candidate receipt changed while reading")
118
+ return digest.hexdigest()
119
+ finally:
120
+ os.close(descriptor)
121
+
122
+
123
+ def verify_prepared_status(status: dict[str, Any], *, root: Path, public_key: Path) -> dict[str, Any]:
124
+ prepared = status.get("_prepared_update")
125
+ if not isinstance(prepared, dict) or prepared.get("preparation_id") != status.get("preparation_id"):
126
+ raise ValueError("Prepared update is not bound to its pending intent")
127
+ envelope = prepared.get("envelope")
128
+ if (not isinstance(envelope, dict) or set(envelope) != {"manifest_base64", "signature_base64"}
129
+ or not all(isinstance(value, str) for value in envelope.values())
130
+ or len(envelope["manifest_base64"]) > updates.MAX_METADATA_BYTES * 2
131
+ or len(envelope["signature_base64"]) != 88):
132
+ raise ValueError("Prepared update signed envelope is invalid")
133
+ manifest = updates.verify_manifest(
134
+ base64.b64decode(envelope["manifest_base64"], validate=True),
135
+ base64.b64decode(envelope["signature_base64"], validate=True), public_key,
136
+ expected_version=status["target_version"], track=status["track"], allow_npm=True)
137
+ preparation_directory(root, status["preparation_id"])
138
+ if prepared.get("mode") == "legacy":
139
+ return {"manifest": manifest, "candidate": None, "receipt": None}
140
+ if prepared.get("mode") != "prepared":
141
+ raise ValueError("Unknown prepared update mode")
142
+ receipt = receipt_path(root, status["preparation_id"])
143
+ if receipt_digest(receipt) != prepared.get("receipt_sha256"):
144
+ raise ValueError("Prepared candidate receipt changed")
145
+ from execution_preparation import validate_prepared
146
+ validated = validate_prepared(root=root, version=manifest["version"],
147
+ api_contract=int(manifest["api_contract_version"]), archive_sha256=manifest["archive"]["sha256"], receipt=receipt)
148
+ return {"manifest": manifest, "candidate": str(validated["candidate"]), "receipt": str(receipt)}
149
+
150
+
151
+ def _signed_envelope(status: dict[str, Any], key: Path) -> tuple[dict[str, str], dict[str, Any]]:
152
+ envelope = status.get("_npm_release")
153
+ if envelope is not None:
154
+ manifest = updates.verify_npm_release_envelope(envelope, key, expected_version=status["target_version"])
155
+ if manifest["track"] != status["track"]:
156
+ raise ValueError("Preparation release track changed")
157
+ return envelope, manifest
158
+ version = status["target_version"]
159
+ document = updates.download_bytes(updates.release_manifest_url(version), updates.MAX_METADATA_BYTES)
160
+ signature = updates.download_bytes(updates.release_signature_url(version), updates.MAX_METADATA_BYTES)
161
+ manifest = updates.verify_manifest(document, signature, key, expected_version=version, track=status["track"])
162
+ return {"manifest_base64": base64.b64encode(document).decode(),
163
+ "signature_base64": base64.b64encode(signature).decode()}, manifest
164
+
165
+
166
+ def run_preparer(command: list[str], *, source: Path, directory: Path,
167
+ status_path: Path, preparation_id: str, lease_descriptor: int) -> None:
168
+ log = directory / "prepare.log"
169
+ descriptor = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600)
170
+ info = os.fstat(descriptor)
171
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1:
172
+ os.close(descriptor)
173
+ raise ValueError("Preparation log is unsafe")
174
+ with os.fdopen(descriptor, "ab") as output:
175
+ process = subprocess.Popen(command, cwd=source, env=updates.installer_environment(),
176
+ stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT, start_new_session=True,
177
+ # Keep the preparation lease with the actual staging shell if its
178
+ # observer dies. A resumed observer waits, then consumes its receipt.
179
+ pass_fds=(lease_descriptor,))
180
+ deadline = time.monotonic() + updates.INSTALLER_TIMEOUT_SECONDS
181
+ try:
182
+ while process.poll() is None:
183
+ update_pending(status_path, preparation_id, preparation_runner_pid=os.getpid())
184
+ if time.monotonic() >= deadline:
185
+ raise RuntimeError("Candidate dependency preparation timed out")
186
+ time.sleep(0.5)
187
+ if process.returncode:
188
+ raise RuntimeError("Candidate preparation failed; inspect the preparation log")
189
+ finally:
190
+ if process.poll() is None:
191
+ # Prepare-only cannot activate a service. Still allow its own
192
+ # cleanup to finish; never kill a process outside this owner.
193
+ updates.terminate_installer(process)
194
+
195
+
196
+ def run_preparation(*, status_path: Path, root: Path, public_key: Path, preparation_id: str) -> None:
197
+ directory = preparation_directory(root, preparation_id, create=True)
198
+ with preparation_lease(directory) as lease_descriptor:
199
+ status = update_pending(status_path, preparation_id, preparation_phase="checking",
200
+ preparation_runner_pid=os.getpid(), message="Preparing the signed server update; agents can keep working.")
201
+ envelope, manifest = _signed_envelope(status, public_key)
202
+ receipt = receipt_path(root, preparation_id)
203
+ if receipt.exists() or receipt.is_symlink():
204
+ # A killed runner may have finished staging before persisting ready.
205
+ prepared = {"mode": "prepared", "preparation_id": preparation_id, "envelope": envelope,
206
+ "receipt_sha256": receipt_digest(receipt)}
207
+ verify_prepared_status({**status, "_prepared_update": prepared}, root=root, public_key=public_key)
208
+ else:
209
+ update_pending(status_path, preparation_id, preparation_phase="downloading")
210
+ data = (updates.download_npm_archive(manifest) if manifest.get("schema") == 2
211
+ else updates.download_bytes(manifest["archive"]["url"], updates.MAX_ARCHIVE_BYTES, timeout=120))
212
+ if hashlib.sha256(data).hexdigest() != manifest["archive"]["sha256"]:
213
+ raise ValueError("Preparation archive does not match its signed hash")
214
+ with tempfile.TemporaryDirectory(prefix="source-", dir=directory) as temporary:
215
+ work = Path(temporary)
216
+ archive = work / "release.tar.gz"
217
+ archive.write_bytes(data)
218
+ source = updates.safe_extract(archive, work / "extracted",
219
+ npm_manifest=manifest if manifest.get("schema") == 2 else None)
220
+ if not (source / "execution_preparation.py").is_file():
221
+ # Older signed releases retain their established installer.
222
+ prepared = {"mode": "legacy", "preparation_id": preparation_id, "envelope": envelope}
223
+ else:
224
+ update_pending(status_path, preparation_id, preparation_phase="staging")
225
+ command = ["/bin/bash", str(source / "install.sh"), "--non-interactive", "--prepare-only",
226
+ "--prepared-receipt", str(receipt), "--release-version", manifest["version"],
227
+ "--expected-api-contract", str(manifest["api_contract_version"]),
228
+ "--prepared-archive-sha256", manifest["archive"]["sha256"]]
229
+ run_preparer(command, source=source, directory=directory,
230
+ status_path=status_path, preparation_id=preparation_id,
231
+ lease_descriptor=lease_descriptor)
232
+ prepared = {"mode": "prepared", "preparation_id": preparation_id, "envelope": envelope,
233
+ "receipt_sha256": receipt_digest(receipt)}
234
+ verify_prepared_status({**status, "_prepared_update": prepared}, root=root, public_key=public_key)
235
+ update_pending(status_path, preparation_id, preparation_phase="ready", _prepared_update=prepared,
236
+ preparation_runner_pid=None, message="Server update prepared; it will activate when current work finishes.")
237
+
238
+
239
+ def main() -> int:
240
+ parser = argparse.ArgumentParser()
241
+ parser.add_argument("--status-file", type=Path, required=True)
242
+ parser.add_argument("--install-root", type=Path, required=True)
243
+ parser.add_argument("--public-key", type=Path, required=True)
244
+ parser.add_argument("--preparation-id", required=True)
245
+ args = parser.parse_args()
246
+ try:
247
+ run_preparation(status_path=args.status_file, root=args.install_root,
248
+ public_key=args.public_key, preparation_id=args.preparation_id)
249
+ return 0
250
+ except (PreparationSuperseded, BlockingIOError):
251
+ return 0
252
+ except Exception:
253
+ try:
254
+ update_pending(args.status_file, args.preparation_id, phase="failed", preparation_runner_pid=None,
255
+ message="Server update preparation failed; running agents were left untouched.",
256
+ error_code="server_update_preparation_failed", error_action="Inspect the preparation log and retry the update.",
257
+ retryable=True, finished_at=updates.utc_now())
258
+ except PreparationSuperseded:
259
+ pass
260
+ return 1
261
+
262
+
263
+ if __name__ == "__main__":
264
+ raise SystemExit(main())
@@ -0,0 +1,140 @@
1
+ """Resume one admitted activation; never stage or create another installation.
2
+
3
+ The durable intent is written before install.sh starts. Its directory identities
4
+ bind the outer journal to that exact verified candidate, including after rename
5
+ or reboot. The installer remains the sole service/state rollback authority.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ from pathlib import Path
12
+ import re
13
+ import sys
14
+ from typing import Any
15
+
16
+ import activation_transaction as activation
17
+ import execution_preparation as preparation
18
+ import update_runner as updates
19
+
20
+
21
+ def activation_intent(*, root: Path, candidate: Path, version: str, api_contract: int,
22
+ update_id: str, server_identity: str) -> dict[str, Any]:
23
+ root, candidate = preparation._path(root), preparation._path(candidate)
24
+ preparation._candidate(root, candidate)
25
+ if re.fullmatch(r"[0-9a-f]{32}", update_id) is None or not server_identity:
26
+ raise ValueError("activation recovery identity is invalid")
27
+ return {"format": 1, "root": str(root), "root_binding": preparation._binding(root),
28
+ "candidate_binding": preparation._binding(candidate), "version": version,
29
+ "api_contract": api_contract, "update_id": update_id, "server_identity": server_identity}
30
+
31
+
32
+ def recovery_context(root: Path, status: dict[str, Any], *, server_identity: str) -> dict[str, Any] | None:
33
+ """Inspect without cleaning journal temporaries or taking service ownership."""
34
+ root = preparation._path(root)
35
+ journal = root / ".activation-transaction"
36
+ if not journal.exists() and not journal.is_symlink():
37
+ return None
38
+ intent = status.get("_activation_recovery")
39
+ if (not isinstance(intent, dict) or set(intent) != {
40
+ "format", "root", "root_binding", "candidate_binding", "version", "api_contract", "update_id", "server_identity",
41
+ } or type(intent["format"]) is not int or intent["format"] != 1
42
+ or intent["root"] != str(root) or intent["server_identity"] != server_identity
43
+ or intent["update_id"] != status.get("update_id")
44
+ or re.fullmatch(r"[0-9a-f]{32}", str(intent["update_id"])) is None
45
+ or intent["version"] != status.get("target_version")
46
+ or type(intent["api_contract"]) is not int or intent["api_contract"] < 1):
47
+ raise RuntimeError("activation journal is not bound to this admitted update")
48
+ preparation._check_binding(root, intent["root_binding"])
49
+ value = activation.execution_context(root)
50
+ if (value is None or value["release_version"] != intent["version"]
51
+ or value["execution"]["api_contract"] != intent["api_contract"]):
52
+ raise RuntimeError("activation journal release pins changed")
53
+ saved = intent["candidate_binding"]
54
+ candidate = value["candidate_release"]
55
+ if (not isinstance(saved, dict) or set(saved) != {"device", "inode", "volume_uuid"}
56
+ or any(type(saved.get(name)) is not int or saved[name] < 1 for name in ("device", "inode"))
57
+ or saved["inode"] != candidate["inode"]):
58
+ raise RuntimeError("activation journal candidate identity changed")
59
+ # execution_context already checks persistent volume UUIDs and rebases
60
+ # Darwin device IDs; candidates and the install root share the same volume.
61
+ if sys.platform == "darwin":
62
+ if not saved["volume_uuid"] or saved["volume_uuid"] != intent["root_binding"]["volume_uuid"]:
63
+ raise RuntimeError("activation candidate volume changed")
64
+ elif saved["volume_uuid"] is not None or saved["device"] != candidate["device"]:
65
+ raise RuntimeError("activation candidate filesystem changed")
66
+ handoff = value["execution"]["handoff"]
67
+ if handoff is not None and handoff != status.get("_execution_handoff"):
68
+ raise RuntimeError("activation journal worker handoff changed")
69
+ return value
70
+
71
+
72
+ def run_recovery(args: argparse.Namespace) -> None:
73
+ """Run only the retained journal, with the same status owner and heartbeat."""
74
+ status_path = Path(args.status_file).expanduser().resolve()
75
+ root = preparation._path(os.environ["AGENTS_SERVER_INSTALL_DIR"])
76
+ with updates.server_update_status_lock(status_path):
77
+ status = updates._read_status_unlocked(status_path)
78
+ if (status.get("update_id") != args.update_id
79
+ or status.get("phase") not in updates.RUNNER_OWNED_ACTIVE_PHASES):
80
+ raise updates.UpdateOwnershipLostError("activation recovery lost its admitted update")
81
+ context = recovery_context(root, status, server_identity=args.expected_server_identity)
82
+ if (context is None or context["transaction_id"] != args.recovery_transaction
83
+ or context["release_version"] != args.expected_version):
84
+ raise RuntimeError("activation recovery transaction changed before launch")
85
+ version = context["release_version"]
86
+ api = context["execution"]["api_contract"]
87
+ token = updates.consume_auth_token_file(getattr(args, "auth_token_file", None))
88
+ updates.update_status(status_path, expected_update_id=args.update_id, phase="installing",
89
+ runner_pid=os.getpid(), heartbeat_at=updates.utc_now(),
90
+ message="Recovering the interrupted server activation.")
91
+ # Use the running release's recovery implementation. The candidate may have
92
+ # already been retired by a partly completed rollback.
93
+ source = Path(__file__).resolve().parent
94
+ command = [str(source / "install.sh"), "--recover-only", "--non-interactive",
95
+ "--release-version", version, "--expected-api-contract", str(api),
96
+ "--expected-activation-id", context["transaction_id"],
97
+ "--expected-server-identity", args.expected_server_identity,
98
+ "--managed-update-id", args.update_id,
99
+ "--port", str(args.port), "--bind", args.bind]
100
+ try:
101
+ updates.run_installer(command, cwd=source, status_path=status_path,
102
+ log_path=status_path.with_name("server-update.log"), version=version,
103
+ expected_update_id=args.update_id, managed_update_id=args.update_id,
104
+ expected_service_cgroup=getattr(args, "expected_service_cgroup", None),
105
+ accepted_returncodes=(0, 75))
106
+ except updates.InstallerRolledBack:
107
+ if journal_present(root):
108
+ raise RuntimeError("rollback recovery retained an unfinished journal")
109
+ updates.update_status(status_path, expected_update_id=args.update_id,
110
+ phase="failed", runner_pid=None, heartbeat_at=None, retryable=True,
111
+ error_code="server_update_rolled_back",
112
+ error_action="Retry the bundled update when ready.",
113
+ message="The interrupted update was rolled back to the verified previous installation.",
114
+ finished_at=updates.utc_now())
115
+ return
116
+ if journal_present(root):
117
+ raise RuntimeError("activation recovery exited without finalizing its journal")
118
+ identity = dict(token=token, expected_server_identity=args.expected_server_identity,
119
+ expected_server_version=version, expected_api_contract_version=api)
120
+ if status.get("team_hub_id"):
121
+ identity.update(expected_team_hub_id=status["team_hub_id"],
122
+ expected_team_hub_transport=status.get("team_hub_transport"),
123
+ expected_team_hub_url=status.get("team_hub_url"),
124
+ expected_team_hub_direct_ip_url=status.get("team_hub_direct_ip_url"))
125
+ updates.assert_post_update_identity(args.port, **identity)
126
+ if status.get("team_hub_repair_mode") == "failed_start":
127
+ updates.assert_repaired_team_hub_identity(args.port, token=token,
128
+ expected_server_identity=args.expected_server_identity,
129
+ expected_team_hub_transport=status.get("team_hub_transport") or "",
130
+ expected_team_hub_url=status.get("team_hub_url"),
131
+ expected_team_hub_direct_ip_url=status.get("team_hub_direct_ip_url") or "")
132
+ updates.update_status(status_path, expected_update_id=args.update_id, phase="complete",
133
+ installed_version=version, update_available=False, runner_pid=None, heartbeat_at=None,
134
+ elapsed_seconds=None, error_code=None, error_action=None, retryable=None,
135
+ message=f"AgentsServer {version} recovered and is healthy.", finished_at=updates.utc_now())
136
+
137
+
138
+ def journal_present(root: Path) -> bool:
139
+ return any((root / name).exists() or (root / name).is_symlink()
140
+ for name in (".activation-transaction", ".execution-transaction"))