@moondeck/station 0.0.0-stage → 1.0.0

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 (325) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +131 -4
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
@@ -0,0 +1,1357 @@
1
+ import { isOwnedInternalConnectionRuntime } from "./internal-connections";
2
+ import type { TaskSecretApplied } from "../src/task-secrets";
3
+ import { openTaskSecrets, closeTaskSecrets } from "./credential-consumers/task-secrets";
4
+ /**
5
+ * ADR-154 on the runner: the one place a task on cloud credentials is wired.
6
+ *
7
+ * - The lease registry owns each task's grant, its reads and its gate. This
8
+ * composes what it installs, opens and closes: the engines' accounts, MCP
9
+ * connections, the owner's GitHub sign-in and SSH key, the env replica and
10
+ * the task's Files install from the registry's install; each opens with
11
+ * its gate on the exact committed view; every one closes with it,
12
+ * synchronously, each attempted even when another throws; and each is
13
+ * cleaned up through the one cleanup procedure
14
+ * (`TaskCredentialCleanupCoordinator`), whose hooks this supplies, with the
15
+ * registry's own qualified identities.
16
+ * - Task-up's seam. `beginStart` in the short admission transaction begins
17
+ * the registry's startup there (its synchronous part: the older run ended,
18
+ * the gate closed) and hands back the start's token; a newer run's start
19
+ * interrupts an older one's. `waitForLaunchReady` outside any lifecycle
20
+ * slot, bounded, ends at that startup's cleanup and the grant installed,
21
+ * its env snapshot exact and its gate open. Inside the long slot,
22
+ * `tryAcquireTaskUpHold`: a counted hold on the installed grant that
23
+ * task-up carries through its clone, provisioning and the runtime-ready
24
+ * writes. `interruptStarts` is synchronous, for Stop and a newer run to
25
+ * call before they queue behind a wedged start: the token's signal aborts
26
+ * its wait and its hold. `assertCurrent` is the last word before a start
27
+ * publishes its success; `abandonStart` releases the hold before it closes
28
+ * the run, so the cleanup never waits on it.
29
+ * - Frames: v1 lease frames and the env replica's frames reach their owners
30
+ * here. A `lease.deny` the registry did not ask for (ADR-133's) is left to
31
+ * whoever did. After shutdown, nothing is taken.
32
+ * - Refreshes: an engine's or MCP connection's reactive refresh goes to the
33
+ * cloud's broker through the registry, never to a local refresher.
34
+ */
35
+
36
+ import {
37
+ isProjectEnvReplicaV1ApplyFrame,
38
+ isProjectEnvReplicaV1BeginFrame,
39
+ isProjectEnvReplicaV1CommitFrame,
40
+ isProjectEnvReplicaV1PageFrame,
41
+ type ProjectEnvReplicaV1AckFrame,
42
+ } from "../src/protocol";
43
+ import type { CredentialRefreshFrame, LeaseRequestV1Frame } from "../src/cloud-credentials";
44
+ import { adaptLeaseCredentialCleanupRequest } from "./cloud-credential-cleanup-adapter";
45
+ import {
46
+ CredentialLeaseUnavailableError,
47
+ TaskCredentialLeaseRegistry,
48
+ type CredentialCleanupReason,
49
+ type CredentialCleanupRequest,
50
+ type CredentialInstallRequest,
51
+ type TaskAssignment,
52
+ type TaskCredentialLeaseRegistryOptions,
53
+ type TaskCredentialView,
54
+ } from "./cloud-credential-leases";
55
+ import {
56
+ cleanupEngineCredentials,
57
+ closeEngineGate,
58
+ drainLeasedEngineWrites,
59
+ forgetLeasedEngines,
60
+ installEngineCredentials,
61
+ openEngineGate,
62
+ setEngineCredentialRefresher,
63
+ setEngineLaunchFenceEscalator,
64
+ } from "./credential-consumers/engines";
65
+ import {
66
+ cleanupPersonCredentials,
67
+ closePersonCredentialGate,
68
+ drainPersonCredentialWrites,
69
+ holdPersonCredentialGate,
70
+ holdTaskGitCredentials,
71
+ installPersonCredentials,
72
+ openPersonCredentialGate,
73
+ type PersonCredentialLane,
74
+ type TaskGitCredentialHold,
75
+ } from "./credential-consumers/github";
76
+ import {
77
+ cleanupMcpConnections,
78
+ closeMcpGate,
79
+ drainLeasedMcpRequests,
80
+ forgetLeasedMcp,
81
+ installMcpConnections,
82
+ leasedMcpFor,
83
+ openMcpGate,
84
+ setMcpCredentialRefresher,
85
+ } from "./credential-consumers/mcp";
86
+ import type { ProjectEnvTaskReplicaManager, ProjectEnvTaskReplicaSnapshot } from "./project-env-task-replica";
87
+ import type { SharedFilesMode } from "./shared-files-s3";
88
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
89
+ import {
90
+ TaskCredentialCleanupCoordinator,
91
+ type TaskCredentialCleanupDeps,
92
+ type TaskCredentialCleanupRequest,
93
+ } from "./task-credential-cleanup";
94
+
95
+ /** How long a start waits for its grant, installed and opened. */
96
+ export const LAUNCH_READY_DEADLINE_MS = 3 * 60_000;
97
+ /** How long an install waits for the env replica's exact snapshot. */
98
+ export const ENV_READY_TIMEOUT_MS = 60_000;
99
+ /** How often an install looks again for that snapshot. */
100
+ const ENV_READY_POLL_MS = 50;
101
+ /** How long shutdown waits for the holds it aborted and the cleanups under
102
+ * way; the next process's recovery sweep finishes what is left. */
103
+ export const SHUTDOWN_DRAIN_MS = 30_000;
104
+
105
+ /** The only MCP state an agent config exposes. Material, provider URL,
106
+ * revision and availability stay behind the task gateway, so changing any of
107
+ * them must not churn a long-lived agent process. */
108
+ function mcpAgentMembershipFingerprint(view: TaskCredentialView): string {
109
+ return JSON.stringify(
110
+ view.mcp
111
+ .map((connection) => connection.manifest.slug)
112
+ .sort((left, right) => left.localeCompare(right)),
113
+ );
114
+ }
115
+
116
+ /** The task's Files (ADR-154 §12): installed, opened and closed with the
117
+ * rest. `close` is synchronous: no new Files admission after it. */
118
+ export interface TaskFilesConsumer {
119
+ install(request: CredentialInstallRequest): Promise<void>;
120
+ open(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void;
121
+ close(assignment: Readonly<TaskAssignment>): void;
122
+ closeConsumers(request: TaskCredentialCleanupRequest): Promise<void>;
123
+ /** A trusted task-machine runtime could not establish its Files boundary.
124
+ * This run cannot be reopened on the same machine. */
125
+ retire(assignment: Readonly<TaskAssignment>, error: unknown): void;
126
+ /** Optional only so owned runners can use the same composer without a
127
+ * task-machine Files implementation. */
128
+ runtimeReady?(
129
+ assignment: Readonly<TaskAssignment>,
130
+ environment: TaskEnvironmentHandle<unknown>,
131
+ mode: SharedFilesMode,
132
+ signal: AbortSignal,
133
+ ): Promise<void>;
134
+ /** Drop secrets at process shutdown without starting cleanup. */
135
+ shutdown?(): void;
136
+ }
137
+
138
+ /** A Files implementation proved that the runtime-ready operation which
139
+ * failed still owns the exact open run and requested terminal retirement of
140
+ * its one-task machine; a stale operation throws its ordinary error. */
141
+ export class AuthoritativeTaskFilesFailure extends Error {
142
+ override readonly cause: unknown;
143
+
144
+ constructor(cause: unknown) {
145
+ super(cause instanceof Error ? cause.message : "the task's Files boundary failed");
146
+ this.name = "AuthoritativeTaskFilesFailure";
147
+ this.cause = cause;
148
+ }
149
+ }
150
+
151
+ export interface CloudCredentialRuntimeDeps {
152
+ runnerKind: "owned" | "task-machine";
153
+ /** Actual persisted provider, or the authoritative plan for a new runtime.
154
+ * Missing/unknown refuses internal credentials. Rechecked before opening. */
155
+ internalRuntimeProvider?(assignment: Readonly<TaskAssignment>): string | null | undefined;
156
+ /** To the cloud, over the authenticated bridge. False: not sent now. */
157
+ send(frame: LeaseRequestV1Frame | CredentialRefreshFrame | ProjectEnvReplicaV1AckFrame | TaskSecretApplied): boolean;
158
+ /** Every engine of the run (and older) proven stopped in every runtime it
159
+ * ran in: the orchestrator's `quiesceLeasedEngineAgents`. */
160
+ quiesceProcesses(request: TaskCredentialCleanupRequest): Promise<void>;
161
+ /** The leased MCP connections a task serves changed: rewrite its MCP
162
+ * configuration from them. */
163
+ mcpServedChanged?(taskId: string): void;
164
+ /** The env replica, once the cloud streams one. Without it, an install
165
+ * waits for no env snapshot. */
166
+ envReplica?: ProjectEnvTaskReplicaManager | null;
167
+ /** The composer started or stopped serving a task's env (its gate opened
168
+ * on a grant's snapshot, or closed): whoever follows the task's env
169
+ * (the proxy's trust delivery) looks again. */
170
+ envServedChanged?(taskId: string): void;
171
+ /** Reconcile the owner's GitHub/SSH files in an already-running task.
172
+ * The signal closes with the exact person-credential gate, and the caller
173
+ * retries a transient rejection while that gate and material stay current. */
174
+ personCredentialsReady?(
175
+ assignment: Readonly<TaskAssignment>,
176
+ lanes: readonly PersonCredentialLane[],
177
+ signal: AbortSignal,
178
+ ): Promise<void>;
179
+ files?: TaskFilesConsumer | null;
180
+ launchDeadlineMs?: number;
181
+ envReadyTimeoutMs?: number;
182
+ shutdownDrainMs?: number;
183
+ /** The first retry's delay for a recovered start whose startup failed. */
184
+ recoveryRetryBaseMs?: number;
185
+ /** The first retry's delay for task-runtime person-credential writes. */
186
+ personReconcileRetryBaseMs?: number;
187
+ registry?: Partial<
188
+ Pick<TaskCredentialLeaseRegistryOptions, "createSessionReader" | "now" | "setTimer" | "clearTimer" | "randomId" | "onError">
189
+ >;
190
+ cleanup?: Pick<TaskCredentialCleanupDeps, "db" | "removePath" | "destroyRuntime">;
191
+ }
192
+
193
+ interface PersonCredentialStamp {
194
+ /** Stable without retaining another copy of any credential material. */
195
+ identity: string;
196
+ available: boolean;
197
+ }
198
+
199
+ interface PersonCredentialSnapshot {
200
+ runEpoch: number;
201
+ github: PersonCredentialStamp;
202
+ ssh: PersonCredentialStamp;
203
+ }
204
+
205
+ function personCredentialStamp(
206
+ credential: TaskCredentialView["github"] | TaskCredentialView["ssh"],
207
+ ): PersonCredentialStamp {
208
+ if (credential === null) return { identity: "absent", available: false };
209
+ return {
210
+ identity: [
211
+ credential.manifest.credentialId,
212
+ credential.manifest.revision,
213
+ credential.availability,
214
+ credential.observedVersion ?? "unreadable",
215
+ ].join("\u0000"),
216
+ available: credential.availability === "available",
217
+ };
218
+ }
219
+
220
+ function personCredentialSnapshot(view: TaskCredentialView): PersonCredentialSnapshot {
221
+ return {
222
+ runEpoch: view.assignment.runEpoch,
223
+ github: personCredentialStamp(view.github),
224
+ ssh: personCredentialStamp(view.ssh),
225
+ };
226
+ }
227
+
228
+ function samePersonCredentialSnapshot(
229
+ left: PersonCredentialSnapshot | undefined,
230
+ right: PersonCredentialSnapshot,
231
+ ): boolean {
232
+ return Boolean(
233
+ left &&
234
+ left.runEpoch === right.runEpoch &&
235
+ left.github.identity === right.github.identity &&
236
+ left.ssh.identity === right.ssh.identity,
237
+ );
238
+ }
239
+
240
+ /** What must be rewritten for `next`. GitHub and SSH transport are coupled:
241
+ * when GitHub leaves, the surviving SSH key must select SSH again. */
242
+ function changedPersonCredentialLanes(
243
+ previous: PersonCredentialSnapshot | undefined,
244
+ next: PersonCredentialSnapshot,
245
+ ): PersonCredentialLane[] {
246
+ if (samePersonCredentialSnapshot(previous, next)) return [];
247
+ // The pair shares Git transport configuration. Reconcile both available
248
+ // lanes, including a write interrupted by the other lane changing.
249
+ const lanes: PersonCredentialLane[] = [];
250
+ if (next.github.available) lanes.push("github");
251
+ if (next.ssh.available) lanes.push("ssh");
252
+ return lanes;
253
+ }
254
+
255
+ /**
256
+ * Why a start was interrupted, as its signal's reason: Stop, teardown or a
257
+ * newer run took it over, or the runner is shutting down. The first
258
+ * interruption sets it and nothing changes it after, so a start Stop
259
+ * interrupted stays Stop's even when shutdown begins while it unwinds.
260
+ */
261
+ export class StartInterruptedError extends CredentialLeaseUnavailableError {
262
+ constructor(readonly by: "takeover" | "shutdown") {
263
+ super(by === "shutdown" ? "the station is shutting down" : "this start was interrupted");
264
+ }
265
+ }
266
+
267
+ /** One start of a run on cloud credentials, from its admission to its
268
+ * outcome: what Stop, a newer run and shutdown interrupt. */
269
+ export interface TaskCredentialStart {
270
+ readonly assignment: Readonly<TaskAssignment>;
271
+ /** Aborted when the start is interrupted: its wait and its hold end. */
272
+ readonly signal: AbortSignal;
273
+ /** Throws unless the start is still this run's and uninterrupted: called
274
+ * right before it publishes its success. */
275
+ assertCurrent(): void;
276
+ }
277
+
278
+ /** A start's hold on its installed grant (task-up's clone, provisioning and
279
+ * runtime-ready writes). */
280
+ export interface TaskUpHold {
281
+ readonly assignment: Readonly<TaskAssignment>;
282
+ /** The committed view the start begins on. */
283
+ readonly view: TaskCredentialView;
284
+ /** The owner's GitHub sign-in and SSH key for the clone. */
285
+ readonly git: TaskGitCredentialHold;
286
+ /** Aborted when the gate closes for the run, a cleanup reaches it or the
287
+ * start is interrupted: stop at once, then release. */
288
+ readonly signal: AbortSignal;
289
+ /** The start no longer uses the grant. Idempotent. */
290
+ release(): void;
291
+ }
292
+
293
+ /** A recovered run (resumeRecovered). */
294
+ interface RecoveredRun {
295
+ readonly runEpoch: number;
296
+ /** Aborted when the recovery ends: Stop, teardown, a newer run, shutdown. */
297
+ readonly controller: AbortController;
298
+ /** Its runtime's setup, until its gate first opens. */
299
+ setup: ((signal: AbortSignal) => Promise<void> | void) | null;
300
+ /** The setup under way. */
301
+ running: Promise<void> | null;
302
+ /** A startup retry pending. */
303
+ retry: ReturnType<typeof setTimeout> | null;
304
+ attempts: number;
305
+ /** An ordinary start of the run took its startup over. */
306
+ yielded: boolean;
307
+ }
308
+
309
+ /** A recovered start whose startup cleanup failed is tried again after
310
+ * this, doubling to the most. */
311
+ const RECOVERY_RETRY_BASE_MS = 1_000;
312
+ const RECOVERY_RETRY_MAX_MS = 30_000;
313
+
314
+ interface StartEntry {
315
+ start: TaskCredentialStart;
316
+ controller: AbortController;
317
+ /** The registry's startup for the run: its predecessor's cleanup, then
318
+ * its request. */
319
+ started: Promise<void>;
320
+ }
321
+
322
+ interface HoldEntry {
323
+ runEpoch: number;
324
+ controller: AbortController;
325
+ released: Promise<void>;
326
+ }
327
+
328
+ const CLEANUP_CLASSES = ["engine", "mcp", "github", "ssh"] as const;
329
+
330
+ /** What a consumer's cleanup reads: the registry's own request, exact. One
331
+ * the coordinator starts itself (boot recovery) is whole-task; were one
332
+ * ever selective, it would take every revision of the ids it names. */
333
+ function consumerCleanupRequest(request: TaskCredentialCleanupRequest): CredentialCleanupRequest {
334
+ if (request.lease) return request.lease;
335
+ const assignment = { taskId: request.taskId, runEpoch: request.runEpoch };
336
+ if (request.credentialIds !== undefined) {
337
+ return {
338
+ assignment,
339
+ mode: "selective",
340
+ reason: "lease-replaced",
341
+ removed: request.credentialIds.flatMap((credentialId) =>
342
+ CLEANUP_CLASSES.map((kind) => ({ class: kind, credentialId, revision: null })),
343
+ ),
344
+ removeFilesSession: false,
345
+ };
346
+ }
347
+ const reason: CredentialCleanupReason =
348
+ request.reason === "teardown" ? "teardown" : request.reason === "stop" ? "stopped" : request.reason === "park" ? "parked" : "recovery";
349
+ return { assignment, mode: "full", reason, removed: [], removeFilesSession: false };
350
+ }
351
+
352
+ /** Every outcome settled, then the first failure. */
353
+ async function allSettledThenFirstFailure(work: readonly Promise<unknown>[]): Promise<void> {
354
+ const results = await Promise.allSettled(work);
355
+ const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
356
+ if (failure) throw failure.reason;
357
+ }
358
+
359
+ export class CloudCredentialRuntime {
360
+ readonly registry: TaskCredentialLeaseRegistry;
361
+ readonly coordinator: TaskCredentialCleanupCoordinator;
362
+ readonly #deps: CloudCredentialRuntimeDeps;
363
+ /** The run each task's gate is open for: what is served, held and read. */
364
+ readonly #open = new Map<string, number>();
365
+ /** Running tasks that survived the restart, being recovered (§5, §6). */
366
+ readonly #recovered = new Map<string, RecoveredRun>();
367
+ /** The env replica's reader generation each committed view was installed
368
+ * with, and the one each open task serves from: a snapshot another
369
+ * grant's reader produced is never served for it. */
370
+ readonly #generations = new WeakMap<TaskCredentialView, number>();
371
+ readonly #served = new Map<string, number>();
372
+ /** Each task's run begun here and not yet closed. */
373
+ readonly #assignments = new Map<string, TaskAssignment>();
374
+ /** The newest run of each task ever begun: an older one never begins. */
375
+ readonly #floors = new Map<string, number>();
376
+ /** Each task's starts not yet ended, and its newest start: the one start
377
+ * with any authority, while it is uninterrupted and not ended. */
378
+ readonly #starts = new Map<string, Set<StartEntry>>();
379
+ readonly #newest = new Map<string, StartEntry>();
380
+ readonly #holds = new Map<string, Set<HoldEntry>>();
381
+ /** Launch waits under way, aborted at shutdown. */
382
+ readonly #waits = new Set<AbortController>();
383
+ /** Closes under way, by run and reason: a second asks for the same one. */
384
+ readonly #closing = new Map<string, Promise<void>>();
385
+ /** Work accepted and not yet over (startups, frames, installs, holds not
386
+ * yet released, closes): what shutdown joins. */
387
+ readonly #work = new Set<Promise<unknown>>();
388
+ /** Last agent-facing MCP membership published for each task. Kept across a
389
+ * gate close/renewal because the config remains in the task runtime. */
390
+ readonly #publishedMcpMembership = new Map<string, string>();
391
+ /** Person material last opened or installed for each task. Only identities,
392
+ * availability and observed versions: never another copy of a secret. */
393
+ readonly #personCredentialSnapshots = new Map<string, PersonCredentialSnapshot>();
394
+ readonly #personReconciles = new Map<string, { runEpoch: number; controller: AbortController }>();
395
+ /** Stop fences work before it can acquire the lifecycle slot or close the
396
+ * lease. A late grant cannot restart writes in that interval. */
397
+ readonly #personInterrupted = new Map<string, number>();
398
+ readonly #setTimer: typeof setTimeout;
399
+ readonly #clearTimer: typeof clearTimeout;
400
+ #shutDown = false;
401
+ #shutdown: Promise<void> | null = null;
402
+
403
+ constructor(deps: CloudCredentialRuntimeDeps) {
404
+ this.#deps = deps;
405
+ this.#setTimer = deps.registry?.setTimer ?? setTimeout;
406
+ this.#clearTimer = deps.registry?.clearTimer ?? clearTimeout;
407
+ this.coordinator = new TaskCredentialCleanupCoordinator({
408
+ ...deps.cleanup,
409
+ hooks: {
410
+ // The registry closes its gate before any cleanup it asks for, a
411
+ // selective one included; closing again is idempotent, and is what
412
+ // closes it for one the coordinator starts itself (boot recovery).
413
+ refuseNewWork: (request) => this.#close({ taskId: request.taskId, runEpoch: request.runEpoch }),
414
+ closeGate: (request) => this.#close({ taskId: request.taskId, runEpoch: request.runEpoch }),
415
+ drainWriters: (request) => this.#drainWriters(request),
416
+ closeConsumers: (request) => this.#closeConsumers(request),
417
+ quiesceProcesses: (request) => deps.quiesceProcesses(request),
418
+ dropMemory: (request) => this.#dropMemory(request),
419
+ },
420
+ });
421
+ this.registry = new TaskCredentialLeaseRegistry({
422
+ ...deps.registry,
423
+ runnerKind: deps.runnerKind,
424
+ send: (frame) => deps.send(frame),
425
+ closeTaskGate: (assignment) => this.#close(assignment),
426
+ openTaskGate: (assignment, view) => this.#openGate(assignment, view),
427
+ credentialMaterialChanged: (assignment, view) => this.#personCredentialsChanged(assignment, view),
428
+ installExternal: (request) => this.#track(this.#install(request)),
429
+ // From a grant's acceptance until its own reader is active, the env
430
+ // replica refuses the run's frames for the cloud to retry: none is
431
+ // read through the reader the grant replaces.
432
+ grantAccepted: (assignment) => {
433
+ const replica = deps.envReplica;
434
+ if (!replica || this.#shutDown) return;
435
+ const token = replica.expectActivation(assignment.taskId, assignment.runEpoch);
436
+ return () => replica.settleActivation(assignment.taskId, token);
437
+ },
438
+ // The registry's own request rides through the coordinator, exact.
439
+ cleanupExternal: (request) =>
440
+ this.coordinator.cleanup({ ...adaptLeaseCredentialCleanupRequest(request, deps.runnerKind), lease: request }),
441
+ });
442
+ // A reactive refresh is the cloud broker's, through the task's lease.
443
+ setEngineCredentialRefresher((assignment, credentialId, revision) =>
444
+ this.registry.requestCredentialRefresh(assignment, credentialId, revision),
445
+ );
446
+ // A launch slot that could not be retired in its runtime: the run's full
447
+ // cleanup, its gate closed first (ADR-154 §6).
448
+ setEngineLaunchFenceEscalator((assignment) => this.registry.escalateLaunchFence(assignment));
449
+ setMcpCredentialRefresher((assignment, credentialId, revision) =>
450
+ this.registry.requestCredentialRefresh(assignment, credentialId, revision),
451
+ );
452
+ }
453
+
454
+ // -- task-up's seam -----------------------------------------------------------
455
+
456
+ /**
457
+ * Phase one, synchronously, in the short admission transaction: the exact
458
+ * run's startup begins (an older run of the task ended, the gate closed,
459
+ * the task on cloud credentials from here) and its start's token comes
460
+ * back. Every earlier start of the task, of this run or an older one, is
461
+ * interrupted: one start has authority. Throws for a run older than the
462
+ * task's newest, or after shutdown.
463
+ */
464
+ beginStart(assignment: TaskAssignment, options: { recovery?: boolean } = {}): TaskCredentialStart {
465
+ if (this.#shutDown) throw new StartInterruptedError("shutdown");
466
+ if ((this.#floors.get(assignment.taskId) ?? 0) > assignment.runEpoch) {
467
+ throw new CredentialLeaseUnavailableError("a newer run of this task has begun");
468
+ }
469
+ this.#floors.set(assignment.taskId, assignment.runEpoch);
470
+ if (!options.recovery) this.#personInterrupted.delete(assignment.taskId);
471
+ this.#interruptTokens(assignment.taskId, {});
472
+ // A newer run's start ends an older run's recovery; an ordinary start of
473
+ // the run being recovered takes its startup over.
474
+ this.#endRecovery(assignment.taskId, assignment.runEpoch - 1);
475
+ if (!options.recovery) this.#yieldRecovery(assignment.taskId, assignment.runEpoch);
476
+ const controller = new AbortController();
477
+ const frozen = Object.freeze({ ...assignment });
478
+ const entry = { controller, started: Promise.resolve() } as StartEntry;
479
+ entry.start = {
480
+ assignment: frozen,
481
+ signal: controller.signal,
482
+ assertCurrent: () => {
483
+ if (!this.#live(entry.start)) throw new CredentialLeaseUnavailableError("this start was interrupted");
484
+ if (this.#open.get(frozen.taskId) !== frozen.runEpoch) {
485
+ throw new CredentialLeaseUnavailableError("the task's credentials are no longer open for this run");
486
+ }
487
+ },
488
+ };
489
+ const entries = this.#starts.get(assignment.taskId) ?? new Set<StartEntry>();
490
+ entries.add(entry);
491
+ this.#starts.set(assignment.taskId, entries);
492
+ this.#newest.set(assignment.taskId, entry);
493
+ this.#assignments.set(assignment.taskId, { ...assignment });
494
+ // The registry's synchronous part runs here, in the slot; its cleanup
495
+ // and request are awaited outside it.
496
+ entry.started = this.#track(this.registry.startAssignment(assignment, options));
497
+ entry.started.catch(() => {});
498
+ return entry.start;
499
+ }
500
+
501
+ /**
502
+ * Synchronously interrupt starts of the task: `exact` the one run (a
503
+ * manual Stop or teardown), `below` every older run, or all. Their waits
504
+ * reject and their holds abort. Callers interrupt before they queue for a
505
+ * lifecycle slot, so a wedged start holding it ends instead of making them
506
+ * wait. An idle Stop does not interrupt: a start vetoes it.
507
+ */
508
+ interruptStarts(taskId: string, which: { exact?: number; below?: number } = {}): void {
509
+ this.#interruptTokens(taskId, which);
510
+ const assignment = this.#assignments.get(taskId);
511
+ if (assignment && (which.exact === undefined || assignment.runEpoch === which.exact) &&
512
+ (which.below === undefined || assignment.runEpoch < which.below)) {
513
+ this.#interruptPersonCredentials(assignment);
514
+ }
515
+ // A recovery of the run ends with it, ended start token or not.
516
+ const recovered = this.#recovered.get(taskId);
517
+ if (
518
+ recovered &&
519
+ (which.exact === undefined || recovered.runEpoch === which.exact) &&
520
+ (which.below === undefined || recovered.runEpoch < which.below)
521
+ ) {
522
+ this.#endRecovery(taskId, recovered.runEpoch);
523
+ }
524
+ }
525
+
526
+ #interruptTokens(taskId: string, which: { exact?: number; below?: number }): void {
527
+ for (const entry of this.#starts.get(taskId) ?? []) {
528
+ const runEpoch = entry.start.assignment.runEpoch;
529
+ if (which.exact !== undefined && runEpoch !== which.exact) continue;
530
+ if (which.below !== undefined && runEpoch >= which.below) continue;
531
+ entry.controller.abort(new StartInterruptedError("takeover"));
532
+ }
533
+ }
534
+
535
+ /** A local Stop: the task's recovery, whatever its run, ends now. One
536
+ * only exists for the run this runner owned at its start. */
537
+ interruptRecovery(taskId: string): void {
538
+ const assignment = this.#assignments.get(taskId);
539
+ if (assignment) this.#interruptPersonCredentials(assignment);
540
+ const recovered = this.#recovered.get(taskId);
541
+ if (recovered) this.#endRecovery(taskId, recovered.runEpoch);
542
+ }
543
+
544
+ /** The task's newest start, while it has authority: what a local Stop
545
+ * snapshots to interrupt later with `interrupt`. */
546
+ currentStart(taskId: string): TaskCredentialStart | null {
547
+ const newest = this.#newest.get(taskId);
548
+ return newest && this.#live(newest.start) ? newest.start : null;
549
+ }
550
+
551
+ /** Interrupt exactly this start, never one that replaced it. */
552
+ interrupt(start: TaskCredentialStart): void {
553
+ for (const entry of this.#starts.get(start.assignment.taskId) ?? []) {
554
+ if (entry.start === start) entry.controller.abort(new StartInterruptedError("takeover"));
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Phase two, outside any lifecycle slot: the run's startup done, its
560
+ * grant installed, its env snapshot exact and its gate open, or a failure.
561
+ * Bounded: a lease refused or never answered rejects with
562
+ * `CredentialLeaseUnavailableError` at the deadline, as do an interrupted,
563
+ * replaced or ended start and shutdown. Holds nothing.
564
+ */
565
+ async waitForLaunchReady(start: TaskCredentialStart): Promise<TaskCredentialView> {
566
+ const entry = this.#live(start);
567
+ if (!entry) throw new CredentialLeaseUnavailableError("this start was interrupted");
568
+ const controller = new AbortController();
569
+ this.#waits.add(controller);
570
+ const timer = this.#setTimer(
571
+ () => controller.abort(new CredentialLeaseUnavailableError("the task's credentials did not arrive in time")),
572
+ this.#deps.launchDeadlineMs ?? LAUNCH_READY_DEADLINE_MS,
573
+ );
574
+ const onInterrupt = (): void => controller.abort(start.signal.reason);
575
+ if (start.signal.aborted) onInterrupt();
576
+ else start.signal.addEventListener("abort", onInterrupt, { once: true });
577
+ try {
578
+ await Promise.race([
579
+ entry.started,
580
+ new Promise<never>((_, reject) => {
581
+ const fail = (): void => reject(controller.signal.reason);
582
+ if (controller.signal.aborted) fail();
583
+ else controller.signal.addEventListener("abort", fail, { once: true });
584
+ }),
585
+ ]);
586
+ const view = await this.registry.waitUntilUsable(start.assignment, controller.signal);
587
+ if (!this.#live(start)) throw new CredentialLeaseUnavailableError("this start was interrupted");
588
+ return view;
589
+ } finally {
590
+ this.#clearTimer(timer);
591
+ start.signal.removeEventListener("abort", onInterrupt);
592
+ this.#waits.delete(controller);
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Phase three, synchronously, inside the long lifecycle slot after the run
598
+ * is revalidated: a counted hold on the installed grant, or null unless
599
+ * the start is the task's newest, uninterrupted and not ended, and the
600
+ * gate is open for its exact run. Throws `CredentialLeaseUnavailableError`
601
+ * for a GitHub sign-in or SSH key the manifest names but the lease could
602
+ * not read: never "none".
603
+ */
604
+ tryAcquireTaskUpHold(start: TaskCredentialStart): TaskUpHold | null {
605
+ if (!this.#live(start)) return null;
606
+ const assignment = start.assignment;
607
+ if (this.#open.get(assignment.taskId) !== assignment.runEpoch) return null;
608
+ const view = this.registry.currentView(assignment);
609
+ if (!view) return null;
610
+ const git = holdTaskGitCredentials(assignment);
611
+ const controller = new AbortController();
612
+ let settle!: () => void;
613
+ const entry: HoldEntry = {
614
+ runEpoch: assignment.runEpoch,
615
+ controller,
616
+ released: new Promise<void>((resolve) => {
617
+ settle = resolve;
618
+ }),
619
+ };
620
+ void this.#track(entry.released);
621
+ const holds = this.#holds.get(assignment.taskId) ?? new Set<HoldEntry>();
622
+ holds.add(entry);
623
+ this.#holds.set(assignment.taskId, holds);
624
+ let released = false;
625
+ return {
626
+ assignment,
627
+ view,
628
+ git,
629
+ signal: AbortSignal.any([controller.signal, git.signal, start.signal]),
630
+ release: () => {
631
+ if (released) return;
632
+ released = true;
633
+ git.release();
634
+ holds.delete(entry);
635
+ if (holds.size === 0 && this.#holds.get(assignment.taskId) === holds) this.#holds.delete(assignment.taskId);
636
+ settle();
637
+ },
638
+ };
639
+ }
640
+
641
+ /** Whether this process has the task-machine Files-v1 consumer wired. */
642
+ taskFilesV1(): boolean {
643
+ return typeof this.#deps.files?.runtimeReady === "function";
644
+ }
645
+
646
+ /** Runtime-ready half of Files v1. It is tracked with the composer's
647
+ * accepted work so shutdown joins a write/mount already admitted. */
648
+ prepareTaskFiles(
649
+ assignment: Readonly<TaskAssignment>,
650
+ environment: TaskEnvironmentHandle<unknown>,
651
+ mode: SharedFilesMode,
652
+ signal: AbortSignal,
653
+ ): Promise<void> {
654
+ const ready = this.#deps.files?.runtimeReady;
655
+ if (!ready) return Promise.reject(new Error("task-machine Files v1 is unavailable"));
656
+ return this.#track(
657
+ ready.call(this.#deps.files, assignment, environment, mode, signal).catch((error: unknown) => {
658
+ if (error instanceof AuthoritativeTaskFilesFailure) {
659
+ // Never reopen this run on the same machine. The consumer already
660
+ // proved this is authoritative; centrally latch/request exact-run
661
+ // terminal retirement too, so an implementation cannot turn this
662
+ // into an ordinary cleanup/regrant by omission.
663
+ let retirementError: unknown = null;
664
+ try {
665
+ this.#deps.files!.retire(assignment, error.cause);
666
+ } catch (failure) {
667
+ retirementError = failure;
668
+ }
669
+ void this.closeAssignment({ ...assignment }, "stop").catch(() => {});
670
+ if (retirementError !== null) throw retirementError;
671
+ throw error.cause;
672
+ }
673
+ throw error;
674
+ }),
675
+ );
676
+ }
677
+
678
+ /** A recovered task whose trusted Files metadata is absent or invalid has
679
+ * failed the same boundary before the consumer can be called. Request its
680
+ * terminal machine retirement, close its gate synchronously, and run the
681
+ * run's full cleanup. */
682
+ failTaskFilesSetup(
683
+ assignment: Readonly<TaskAssignment>,
684
+ error: unknown = new Error("the recovered task has no trusted Files-v1 metadata"),
685
+ ): void {
686
+ try {
687
+ this.#deps.files?.retire(assignment, error);
688
+ } catch (retirementError) {
689
+ console.warn(
690
+ `[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: Files machine retirement could not be requested: ${
691
+ retirementError instanceof Error ? retirementError.message : String(retirementError)
692
+ }`,
693
+ );
694
+ }
695
+ void this.closeAssignment({ ...assignment }, "stop").catch((error: unknown) => {
696
+ console.warn(
697
+ `[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: Files recovery cleanup failed: ${
698
+ error instanceof Error ? error.message : String(error)
699
+ }`,
700
+ );
701
+ });
702
+ }
703
+
704
+ /** The start is over, successful or not: its token has no authority left,
705
+ * and it is no longer the task's newest. */
706
+ endStart(start: TaskCredentialStart): void {
707
+ const taskId = start.assignment.taskId;
708
+ if (this.#newest.get(taskId)?.start === start) this.#newest.delete(taskId);
709
+ const entries = this.#starts.get(taskId);
710
+ if (!entries) return;
711
+ for (const entry of entries) {
712
+ if (entry.start === start) entries.delete(entry);
713
+ }
714
+ if (entries.size === 0 && this.#starts.get(taskId) === entries) this.#starts.delete(taskId);
715
+ }
716
+
717
+ /** A start that failed after its assignment began: its hold released
718
+ * first (the cleanup waits for it), then the exact run closed and every
719
+ * credential it installed cleaned up. Only a start not yet ended that is
720
+ * still the task's newest closes: an older start's delayed failure, or
721
+ * one reported after the start ended, never closes what is running. */
722
+ async abandonStart(start: TaskCredentialStart, hold?: TaskUpHold | null): Promise<void> {
723
+ hold?.release();
724
+ const taskId = start.assignment.taskId;
725
+ const newest = this.#newest.get(taskId);
726
+ const closes = newest?.start === start && (this.#starts.get(taskId)?.has(newest) ?? false);
727
+ this.endStart(start);
728
+ if (closes) await this.closeAssignment(start.assignment, "stop");
729
+ }
730
+
731
+ /** The run ended (stop, park, teardown) or was superseded: close it and
732
+ * clean up everything it installed. After shutdown, no cleanup starts:
733
+ * one already under way for the run and reason is joined. */
734
+ closeAssignment(
735
+ assignment: TaskAssignment,
736
+ reason: "stop" | "park" | "teardown" | "superseded" = "stop",
737
+ ): Promise<void> {
738
+ this.#endRecovery(assignment.taskId, assignment.runEpoch);
739
+ const key = `${assignment.taskId}\u0000${String(assignment.runEpoch)}\u0000${reason}`;
740
+ const under = this.#closing.get(key);
741
+ if (under) return under;
742
+ if (this.#shutDown) return Promise.resolve();
743
+ const current = this.#assignments.get(assignment.taskId);
744
+ if (current && current.runEpoch === assignment.runEpoch) this.#assignments.delete(assignment.taskId);
745
+ const closing = this.#track(this.registry.closeAssignment(assignment, reason)).finally(() => {
746
+ if (this.#closing.get(key) === closing) this.#closing.delete(key);
747
+ });
748
+ this.#closing.set(key, closing);
749
+ return closing;
750
+ }
751
+
752
+ /** What the task's env serves for this exact run and project set, while
753
+ * its gate is open, from the snapshot its open grant's own reader
754
+ * produced; null otherwise (a candidate grant's snapshot included, until
755
+ * that grant is the open one). */
756
+ envSnapshot(assignment: TaskAssignment, projectIds: readonly string[]): ProjectEnvTaskReplicaSnapshot | null {
757
+ if (this.#open.get(assignment.taskId) !== assignment.runEpoch) return null;
758
+ const generation = this.#served.get(assignment.taskId);
759
+ if (generation === undefined) return null;
760
+ return this.#deps.envReplica?.readViewAt(assignment.taskId, assignment.runEpoch, projectIds, generation) ?? null;
761
+ }
762
+
763
+ /**
764
+ * Boot recovery (ADR-154 §5, §6): every task with cleanup left from the
765
+ * last process (a crash, a cleanup that failed) cleaned up, at the newer
766
+ * of its recorded and its admitted run, before any grant is asked for.
767
+ * Every task is attempted; then any failure fails the sweep.
768
+ */
769
+ recover(runEpochForTask: (taskId: string) => number): Promise<void> {
770
+ if (this.#shutDown) return Promise.reject(new StartInterruptedError("shutdown"));
771
+ return this.#track(this.coordinator.recoverAll(runEpochForTask));
772
+ }
773
+
774
+ /**
775
+ * A running task on cloud credentials that survived the runner's restart:
776
+ * its run's credentials asked for again, after its startup cleanup, as a
777
+ * start of its own; a startup cleanup that fails is tried again, with
778
+ * backoff. Its runtime's `setup` runs once its gate first opens, however
779
+ * late the grant comes, told by its signal when to stop. Stop, teardown,
780
+ * a newer run and shutdown end the recovery: no retry, no setup to come,
781
+ * a setup running told to stop and drained by the run's cleanup.
782
+ */
783
+ resumeRecovered(assignment: TaskAssignment, setup?: (signal: AbortSignal) => Promise<void> | void): void {
784
+ if (this.#shutDown) return;
785
+ const previous = this.#recovered.get(assignment.taskId);
786
+ if (previous) this.#endRecovery(assignment.taskId, previous.runEpoch);
787
+ const recovered: RecoveredRun = {
788
+ runEpoch: assignment.runEpoch,
789
+ controller: new AbortController(),
790
+ setup: setup ?? null,
791
+ running: null,
792
+ retry: null,
793
+ attempts: 0,
794
+ yielded: false,
795
+ };
796
+ this.#recovered.set(assignment.taskId, recovered);
797
+ this.#attemptRecovery({ ...assignment }, recovered);
798
+ }
799
+
800
+ #attemptRecovery(assignment: TaskAssignment, recovered: RecoveredRun): void {
801
+ if (!this.#recovering(assignment.taskId, recovered)) return;
802
+ let start: TaskCredentialStart;
803
+ try {
804
+ start = this.beginStart(assignment, { recovery: true });
805
+ } catch (error) {
806
+ console.warn(
807
+ `[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: no recovered start: ${
808
+ error instanceof Error ? error.message : String(error)
809
+ }`,
810
+ );
811
+ return;
812
+ }
813
+ const started = this.#newest.get(assignment.taskId)!.started;
814
+ void this.#track(
815
+ started.then(
816
+ // Ended once usable or its bounded wait gives up: the registry keeps
817
+ // asking for a grant it has had no answer to.
818
+ () => this.waitForLaunchReady(start).then(
819
+ () => this.endStart(start),
820
+ () => this.endStart(start),
821
+ ),
822
+ // Its startup cleanup failed: again, with backoff, while this run's
823
+ // recovery goes on.
824
+ () => {
825
+ this.endStart(start);
826
+ if (start.signal.aborted || !this.#recovering(assignment.taskId, recovered)) return;
827
+ const base = this.#deps.recoveryRetryBaseMs ?? RECOVERY_RETRY_BASE_MS;
828
+ const delay = Math.min(RECOVERY_RETRY_MAX_MS, base * 2 ** recovered.attempts);
829
+ recovered.attempts += 1;
830
+ recovered.retry = this.#setTimer(() => {
831
+ recovered.retry = null;
832
+ this.#attemptRecovery(assignment, recovered);
833
+ }, delay);
834
+ },
835
+ ),
836
+ );
837
+ }
838
+
839
+ /** Whether the recovery itself still drives its run's startup. */
840
+ #recovering(taskId: string, recovered: RecoveredRun): boolean {
841
+ return (
842
+ !this.#shutDown &&
843
+ !recovered.controller.signal.aborted &&
844
+ !recovered.yielded &&
845
+ this.#recovered.get(taskId) === recovered
846
+ );
847
+ }
848
+
849
+ /** An ordinary start of the run being recovered (task-up driven again,
850
+ * a lost response's retry): its own startup does the cleanup and asks
851
+ * for the grant from here, so the recovery makes no attempt again and
852
+ * a retry pending is cancelled. Its runtime's setup still runs when the
853
+ * gate first opens. */
854
+ #yieldRecovery(taskId: string, runEpoch: number): void {
855
+ const recovered = this.#recovered.get(taskId);
856
+ if (!recovered || recovered.runEpoch !== runEpoch) return;
857
+ recovered.yielded = true;
858
+ if (recovered.retry !== null) this.#clearTimer(recovered.retry);
859
+ recovered.retry = null;
860
+ }
861
+
862
+ /** The task's recovery of a run through `throughRunEpoch` ends: no retry,
863
+ * no setup to come, a running one told to stop (the run's cleanup drains
864
+ * it). Never the recovery's own startup cleanup's doing. */
865
+ #endRecovery(taskId: string, throughRunEpoch: number): void {
866
+ const recovered = this.#recovered.get(taskId);
867
+ if (!recovered || recovered.runEpoch > throughRunEpoch) return;
868
+ recovered.controller.abort(new StartInterruptedError("takeover"));
869
+ if (recovered.retry !== null) this.#clearTimer(recovered.retry);
870
+ recovered.retry = null;
871
+ recovered.setup = null;
872
+ if (recovered.running === null) this.#recovered.delete(taskId);
873
+ }
874
+
875
+ /** A recovered runtime is ready only after setup succeeds. Transient
876
+ * reconstruction/exec failures retry; Stop, cleanup and takeover fence
877
+ * every attempt and its retry delay. */
878
+ #runRecoveredSetup(assignment: Readonly<TaskAssignment>): void {
879
+ const recovered = this.#recovered.get(assignment.taskId);
880
+ if (!recovered || recovered.runEpoch !== assignment.runEpoch || recovered.running !== null) return;
881
+ if (recovered.controller.signal.aborted || this.#open.get(assignment.taskId) !== assignment.runEpoch) return;
882
+ if (recovered.retry !== null) this.#clearTimer(recovered.retry);
883
+ recovered.retry = null;
884
+ const setup = recovered.setup;
885
+ if (setup === null) {
886
+ this.#recovered.delete(assignment.taskId);
887
+ return;
888
+ }
889
+ const hold = holdPersonCredentialGate(assignment);
890
+ const signal = AbortSignal.any([recovered.controller.signal, hold.signal]);
891
+ let complete = false;
892
+ recovered.running = this.#track(
893
+ Promise.resolve()
894
+ .then(async () => {
895
+ signal.throwIfAborted();
896
+ await setup(signal);
897
+ signal.throwIfAborted();
898
+ complete = true;
899
+ })
900
+ .catch(() => {
901
+ if (!signal.aborted) {
902
+ console.warn(`[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: recovered runtime setup failed; retrying`);
903
+ }
904
+ })
905
+ .finally(() => {
906
+ hold.release();
907
+ recovered.running = null;
908
+ if (this.#recovered.get(assignment.taskId) !== recovered) return;
909
+ if (complete || recovered.controller.signal.aborted || this.#shutDown) {
910
+ this.#recovered.delete(assignment.taskId);
911
+ return;
912
+ }
913
+ const base = this.#deps.recoveryRetryBaseMs ?? RECOVERY_RETRY_BASE_MS;
914
+ const delay = Math.min(RECOVERY_RETRY_MAX_MS, base * 2 ** Math.min(recovered.attempts++, 20));
915
+ recovered.retry = this.#setTimer(() => {
916
+ recovered.retry = null;
917
+ this.#runRecoveredSetup(assignment);
918
+ }, delay);
919
+ }),
920
+ );
921
+ }
922
+
923
+ /** Every frame, startup, install and close under way now, settled. */
924
+ async settled(): Promise<void> {
925
+ await Promise.allSettled([...this.#work]);
926
+ }
927
+
928
+ // -- frames -------------------------------------------------------------------
929
+
930
+ /**
931
+ * A frame from the cloud that is this runtime's: true when handled. A
932
+ * `lease.deny` is offered to the registry, which ignores one it did not
933
+ * ask for, and reported unhandled so ADR-133's lease manager sees it too.
934
+ * A lease frame's handling goes on after this returns: a grant's install
935
+ * waits for the env frames that follow it, so a dispatcher that awaits each
936
+ * frame before the next must not wait on it. After shutdown, every v1
937
+ * frame is taken and ignored.
938
+ */
939
+ async handleFrame(frame: { kind: string }): Promise<boolean> {
940
+ const replica = this.#deps.envReplica;
941
+ const isReplicaFrame =
942
+ isProjectEnvReplicaV1BeginFrame(frame) ||
943
+ isProjectEnvReplicaV1PageFrame(frame) ||
944
+ isProjectEnvReplicaV1ApplyFrame(frame) ||
945
+ isProjectEnvReplicaV1CommitFrame(frame);
946
+ if (this.#shutDown) return frame.kind !== "lease.deny" && (V1_FRAME_KINDS.has(frame.kind) || isReplicaFrame);
947
+ switch (frame.kind) {
948
+ case "lease.grant.v1":
949
+ this.#background(this.registry.handleGrant(frame));
950
+ return true;
951
+ case "lease.unavailable":
952
+ this.registry.handleUnavailable(frame);
953
+ return true;
954
+ case "lease.deny":
955
+ this.#background(this.registry.handleDenied(frame));
956
+ return false;
957
+ case "lease.stale":
958
+ this.registry.handleStale(frame);
959
+ return true;
960
+ case "credential.changed":
961
+ this.#background(this.registry.handleCredentialChanged(frame));
962
+ return true;
963
+ case "credential.refresh.result":
964
+ this.#background(this.registry.handleRefreshResult(frame));
965
+ return true;
966
+ }
967
+ if (replica && isReplicaFrame) {
968
+ const ack = await this.#track(replica.handleFrame(frame));
969
+ if (!this.#shutDown) this.#deps.send(ack);
970
+ return true;
971
+ }
972
+ return false;
973
+ }
974
+
975
+ /** Same-process authenticated reconnect: keep safe bundles, ask again. */
976
+ bridgeAuthenticated(): void {
977
+ if (!this.#shutDown) this.registry.bridgeAuthenticated();
978
+ }
979
+
980
+ /**
981
+ * Process shutdown. Nothing is taken after it: no frame, start, open,
982
+ * install or close. Every assignment ends first, its gate closed (env,
983
+ * engines, MCP, the owner's Git credentials and Files), so that anything
984
+ * the aborts below set off finds no run to clean up; then every start,
985
+ * wait and hold is aborted, and the env replica's values leave memory.
986
+ * What was accepted before is joined, bounded: startups and the cleanups
987
+ * they began, frames, installs, holds being released, closes, and any
988
+ * cleanup those register meanwhile. None is started: agents that survive
989
+ * the restart keep what they hold (ADR-154 §5), and the next process's
990
+ * recovery sweep cleans up before it reattaches them.
991
+ */
992
+ shutdown(): Promise<void> {
993
+ this.#shutdown ??= this.#shutDownNow();
994
+ return this.#shutdown;
995
+ }
996
+
997
+ async #shutDownNow(): Promise<void> {
998
+ this.#shutDown = true;
999
+ const ended = this.registry.shutdown();
1000
+ // A run the registry no longer tracks (its close under way) closes too.
1001
+ for (const assignment of this.#assignments.values()) {
1002
+ try {
1003
+ this.#close(assignment);
1004
+ } catch {
1005
+ // Each fence was attempted; the process is ending.
1006
+ }
1007
+ }
1008
+ this.#assignments.clear();
1009
+ const reason = new StartInterruptedError("shutdown");
1010
+ for (const controller of this.#waits) controller.abort(reason);
1011
+ for (const entries of this.#starts.values()) for (const entry of entries) entry.controller.abort(reason);
1012
+ for (const holds of this.#holds.values()) for (const entry of holds) entry.controller.abort(reason);
1013
+ this.#scrub();
1014
+ let bound: ReturnType<typeof setTimeout> | undefined;
1015
+ await Promise.race([
1016
+ this.#drain(ended),
1017
+ new Promise<void>((resolve) => {
1018
+ bound = this.#setTimer(resolve, this.#deps.shutdownDrainMs ?? SHUTDOWN_DRAIN_MS);
1019
+ }),
1020
+ ]);
1021
+ this.#clearTimer(bound);
1022
+ // Whatever the drain finished leaves memory too.
1023
+ this.#scrub();
1024
+ }
1025
+
1026
+ /** Until no accepted work is left, no consumer's write or request is
1027
+ * under way (one already admitted would otherwise land after the process
1028
+ * exits, behind the next process's recovery cleanup), and no cleanup is
1029
+ * under way. */
1030
+ async #drain(ended: Promise<void>): Promise<void> {
1031
+ await ended;
1032
+ for (;;) {
1033
+ await Promise.allSettled([
1034
+ ...this.#work,
1035
+ drainLeasedEngineWrites(),
1036
+ drainPersonCredentialWrites(),
1037
+ drainLeasedMcpRequests(),
1038
+ ]);
1039
+ await this.coordinator.whenIdle();
1040
+ if (this.#work.size === 0) return;
1041
+ }
1042
+ }
1043
+
1044
+ #scrub(): void {
1045
+ for (const [taskId, recovered] of [...this.#recovered]) this.#endRecovery(taskId, recovered.runEpoch);
1046
+ this.#deps.envReplica?.dropAll();
1047
+ setEngineCredentialRefresher(null);
1048
+ setEngineLaunchFenceEscalator(null);
1049
+ setMcpCredentialRefresher(null);
1050
+ this.#deps.files?.shutdown?.();
1051
+ }
1052
+
1053
+ // -- the registry's gate and install --------------------------------------------
1054
+
1055
+ /** The start's entry, while it is the task's newest, uninterrupted and not
1056
+ * ended; null for any other token, a forged one included. */
1057
+ #live(start: TaskCredentialStart): StartEntry | null {
1058
+ if (this.#shutDown) return null;
1059
+ const entry = this.#newest.get(start.assignment.taskId);
1060
+ if (!entry || entry.start !== start || entry.controller.signal.aborted) return null;
1061
+ return this.#starts.get(start.assignment.taskId)?.has(entry) ? entry : null;
1062
+ }
1063
+
1064
+ #track<T>(work: Promise<T>): Promise<T> {
1065
+ this.#work.add(work);
1066
+ const forget = (): void => void this.#work.delete(work);
1067
+ work.then(forget, forget);
1068
+ return work;
1069
+ }
1070
+
1071
+ /** Work no caller awaits: its owner reports its own failures. */
1072
+ #background(work: Promise<unknown>): void {
1073
+ this.#track(work).catch(() => {});
1074
+ }
1075
+
1076
+ #openGate(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void {
1077
+ if (this.#shutDown) throw new CredentialLeaseUnavailableError("the station is shutting down");
1078
+ if (view.engines.some(e => e.manifest.connection?.internal === true) &&
1079
+ (this.#deps.runnerKind !== "owned" || !isOwnedInternalConnectionRuntime(this.#deps.internalRuntimeProvider?.(assignment)))) throw new CredentialLeaseUnavailableError("unsupported");
1080
+ openTaskSecrets(assignment, view);
1081
+ openEngineGate(assignment, view);
1082
+ openMcpGate(assignment, view);
1083
+ openPersonCredentialGate(assignment, view);
1084
+ this.#open.set(assignment.taskId, assignment.runEpoch);
1085
+ const generation = this.#generations.get(view);
1086
+ if (generation === undefined) this.#served.delete(assignment.taskId);
1087
+ else this.#served.set(assignment.taskId, generation);
1088
+ this.#envServedChanged(assignment.taskId);
1089
+ // Last throwing boundary: a recovered runtime's mounts stay staged and
1090
+ // root-inaccessible until every other consumer accepted this exact view.
1091
+ // Nothing which follows publishes work synchronously. If Files itself
1092
+ // fails, the registry compensates by closing the earlier gates.
1093
+ this.#deps.files?.open(assignment, view);
1094
+ this.#personCredentialsChanged(assignment, view);
1095
+ this.#publishMcpMembership(assignment.taskId, view);
1096
+ this.#runRecoveredSetup(assignment);
1097
+ // Empty sets matter too: a new runner process may first see a parked task after revocation.
1098
+ queueMicrotask(() => {
1099
+ if (this.#shutDown || this.registry.currentView(assignment) !== view) return;
1100
+ this.#deps.send({ kind: "task.secret.applied", taskId: assignment.taskId, runEpoch: assignment.runEpoch, manifestRevision: view.manifestRevision, manifestDigest: view.manifestDigest });
1101
+ });
1102
+ }
1103
+
1104
+ #personCredentialsChanged(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void {
1105
+ const reconcile = this.#deps.personCredentialsReady;
1106
+ if (!reconcile || this.#shutDown || this.#open.get(assignment.taskId) !== assignment.runEpoch) return;
1107
+ if ((this.#personInterrupted.get(assignment.taskId) ?? -1) >= assignment.runEpoch) return;
1108
+ const previous = this.#personCredentialSnapshots.get(assignment.taskId);
1109
+ const snapshot = personCredentialSnapshot(view);
1110
+ if (samePersonCredentialSnapshot(previous, snapshot)) return;
1111
+ this.#personCredentialSnapshots.set(assignment.taskId, snapshot);
1112
+ this.#personReconciles.get(assignment.taskId)?.controller.abort();
1113
+ const lanes = changedPersonCredentialLanes(previous, snapshot);
1114
+ if (lanes.length === 0) return;
1115
+ const entry = { runEpoch: assignment.runEpoch, controller: new AbortController() };
1116
+ const hold = holdPersonCredentialGate(assignment);
1117
+ const signal = AbortSignal.any([entry.controller.signal, hold.signal]);
1118
+ this.#personReconciles.set(assignment.taskId, entry);
1119
+ this.#background(
1120
+ Promise.resolve().then(async () => {
1121
+ let attempts = 0;
1122
+ while (!signal.aborted) {
1123
+ try {
1124
+ await reconcile(assignment, lanes, signal);
1125
+ signal.throwIfAborted();
1126
+ return;
1127
+ } catch {
1128
+ if (signal.aborted) return;
1129
+ if (attempts === 0) {
1130
+ console.warn(`[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: person credential files not ready; retrying`);
1131
+ }
1132
+ const base = this.#deps.personReconcileRetryBaseMs ?? RECOVERY_RETRY_BASE_MS;
1133
+ await this.#retryDelay(Math.min(RECOVERY_RETRY_MAX_MS, base * 2 ** Math.min(attempts++, 20)), signal);
1134
+ }
1135
+ }
1136
+ }).finally(() => {
1137
+ hold.release();
1138
+ if (this.#personReconciles.get(assignment.taskId) === entry) this.#personReconciles.delete(assignment.taskId);
1139
+ }),
1140
+ );
1141
+ }
1142
+
1143
+ #interruptPersonCredentials(assignment: Readonly<TaskAssignment>): void {
1144
+ this.#personInterrupted.set(assignment.taskId, Math.max(assignment.runEpoch, this.#personInterrupted.get(assignment.taskId) ?? -1));
1145
+ const snapshot = this.#personCredentialSnapshots.get(assignment.taskId);
1146
+ if (snapshot && snapshot.runEpoch <= assignment.runEpoch) this.#personCredentialSnapshots.delete(assignment.taskId);
1147
+ const reconcile = this.#personReconciles.get(assignment.taskId);
1148
+ if (reconcile && reconcile.runEpoch <= assignment.runEpoch) reconcile.controller.abort();
1149
+ }
1150
+
1151
+ /** A retry never holds cleanup for its backoff interval. */
1152
+ #retryDelay(delay: number, signal: AbortSignal): Promise<void> {
1153
+ return new Promise((resolve) => {
1154
+ const finish = (): void => {
1155
+ this.#clearTimer(timer);
1156
+ signal.removeEventListener("abort", finish);
1157
+ resolve();
1158
+ };
1159
+ const timer = this.#setTimer(finish, delay);
1160
+ signal.addEventListener("abort", finish, { once: true });
1161
+ if (signal.aborted) finish();
1162
+ });
1163
+ }
1164
+
1165
+ #envServedChanged(taskId: string): void {
1166
+ try {
1167
+ this.#deps.envServedChanged?.(taskId);
1168
+ } catch {
1169
+ // Serving already changed; a follower's failure is its own.
1170
+ }
1171
+ }
1172
+
1173
+ /** Publish only a normalized change visible to agent configuration. A
1174
+ * renewed lease, a refreshed OAuth token, and available/unavailable
1175
+ * transitions all keep the same named gateway route and therefore do not
1176
+ * rewrite config or restart agents; an unavailable route answers 503. */
1177
+ #publishMcpMembership(taskId: string, view: TaskCredentialView): void {
1178
+ const fingerprint = mcpAgentMembershipFingerprint(view);
1179
+ if (this.#publishedMcpMembership.get(taskId) === fingerprint) return;
1180
+ this.#publishedMcpMembership.set(taskId, fingerprint);
1181
+ try {
1182
+ this.#deps.mcpServedChanged?.(taskId);
1183
+ } catch {
1184
+ // The gateway gate is authoritative even when its config follower fails.
1185
+ }
1186
+ }
1187
+
1188
+ /**
1189
+ * Synchronous and idempotent: every consumer of this run (and older)
1190
+ * stops being served, env and Files included, and every start holding its
1191
+ * grant is aborted. Each fence is attempted even when another throws;
1192
+ * then the first failure is thrown, or all of them together.
1193
+ */
1194
+ #close(assignment: Readonly<TaskAssignment>): void {
1195
+ const failures: unknown[] = [];
1196
+ const attempt = (fence: () => void): void => {
1197
+ try {
1198
+ fence();
1199
+ } catch (error) {
1200
+ failures.push(error);
1201
+ }
1202
+ };
1203
+ // Env serving closes first, and cannot throw.
1204
+ const open = this.#open.get(assignment.taskId);
1205
+ if (open !== undefined && open <= assignment.runEpoch) {
1206
+ this.#open.delete(assignment.taskId);
1207
+ this.#served.delete(assignment.taskId);
1208
+ this.#personCredentialSnapshots.delete(assignment.taskId);
1209
+ this.#envServedChanged(assignment.taskId);
1210
+ }
1211
+ const person = this.#personReconciles.get(assignment.taskId);
1212
+ if (person && person.runEpoch <= assignment.runEpoch) person.controller.abort();
1213
+ for (const entry of this.#holds.get(assignment.taskId) ?? []) {
1214
+ if (entry.runEpoch <= assignment.runEpoch) {
1215
+ attempt(() => entry.controller.abort(new CredentialLeaseUnavailableError("the task's credentials were withdrawn")));
1216
+ }
1217
+ }
1218
+ attempt(() => closeTaskSecrets(assignment));
1219
+ attempt(() => closeEngineGate(assignment));
1220
+ attempt(() => {
1221
+ const mcp = leasedMcpFor(assignment.taskId);
1222
+ if (mcp && mcp.assignment.runEpoch <= assignment.runEpoch) closeMcpGate(mcp.assignment);
1223
+ });
1224
+ attempt(() => closePersonCredentialGate(assignment));
1225
+ attempt(() => this.#deps.files?.close(assignment));
1226
+ if (failures.length === 1) throw failures[0];
1227
+ if (failures.length > 1) throw new AggregateError(failures, "the task's credentials could not all be closed");
1228
+ }
1229
+
1230
+ /**
1231
+ * The registry's install of a grant or credential change, fenced after
1232
+ * every step: once the runner is shutting down, or the grant can no longer
1233
+ * commit, nothing more is installed.
1234
+ */
1235
+ async #install(request: CredentialInstallRequest): Promise<void> {
1236
+ this.#assertInstallCurrent(request);
1237
+ const replica = this.#deps.envReplica;
1238
+ if (replica && request.reason === "grant") {
1239
+ const { taskId, runEpoch } = request.assignment;
1240
+ // The grant's reader first: the cloud's env frames for it are taken
1241
+ // from here.
1242
+ replica.activateAssignment({ taskId, runEpoch, orgId: request.owner.orgId, reader: request.projectValues });
1243
+ const generation = replica.readerGeneration(taskId, runEpoch);
1244
+ if (generation === null) throw new CredentialLeaseUnavailableError("the task's env replica is not active");
1245
+ this.#generations.set(request.next, generation);
1246
+ // Every grant waits for a snapshot of its exact project set read
1247
+ // entirely through its own reader: never one another grant read.
1248
+ await this.#awaitEnvSnapshot(request, generation);
1249
+ this.#assertInstallCurrent(request);
1250
+ } else if (request.reason === "credential-change" && request.previous) {
1251
+ // The same grant, the same reader: its view serves the same snapshot.
1252
+ const generation = this.#generations.get(request.previous);
1253
+ if (generation !== undefined) this.#generations.set(request.next, generation);
1254
+ }
1255
+ await installEngineCredentials(request);
1256
+ this.#assertInstallCurrent(request);
1257
+ installMcpConnections(request);
1258
+ installPersonCredentials(request);
1259
+ await this.#deps.files?.install(request);
1260
+ this.#assertInstallCurrent(request);
1261
+ // Credential changes normally affect material or availability only. Keep
1262
+ // this normalized comparison at the install boundary so even a future
1263
+ // same-run membership change publishes exactly once.
1264
+ if (request.reason === "credential-change") {
1265
+ this.#publishMcpMembership(request.assignment.taskId, request.next);
1266
+ }
1267
+ }
1268
+
1269
+ #assertInstallCurrent(request: CredentialInstallRequest): void {
1270
+ if (request.next.engines.some(e => e.manifest.connection?.internal === true) &&
1271
+ (this.#deps.runnerKind !== "owned" || !isOwnedInternalConnectionRuntime(this.#deps.internalRuntimeProvider?.(request.assignment)))) throw new CredentialLeaseUnavailableError("unsupported");
1272
+ if (this.#shutDown) throw new CredentialLeaseUnavailableError("the station is shutting down");
1273
+ if (!request.isCurrent()) {
1274
+ throw new CredentialLeaseUnavailableError("the grant was withdrawn while it was being installed");
1275
+ }
1276
+ }
1277
+
1278
+ /** Until the replica serves the exact project set this grant names, from
1279
+ * a snapshot read through this grant's own reader: never an older ready
1280
+ * snapshot of another set, nor one another grant's reader produced.
1281
+ * Bounded, and given up as soon as the grant can no longer commit. */
1282
+ async #awaitEnvSnapshot(request: CredentialInstallRequest, generation: number): Promise<void> {
1283
+ const replica = this.#deps.envReplica!;
1284
+ const { taskId, runEpoch } = request.assignment;
1285
+ let expired = false;
1286
+ const deadline = this.#setTimer(() => {
1287
+ expired = true;
1288
+ }, this.#deps.envReadyTimeoutMs ?? ENV_READY_TIMEOUT_MS);
1289
+ try {
1290
+ while (replica.readViewAt(taskId, runEpoch, request.next.projectIds, generation) === null) {
1291
+ if (!request.isCurrent() || this.#shutDown) {
1292
+ throw new CredentialLeaseUnavailableError("the grant was withdrawn while its env values were awaited");
1293
+ }
1294
+ if (expired) throw new CredentialLeaseUnavailableError("the task's env values did not arrive in time");
1295
+ await new Promise<void>((resolve) => this.#setTimer(resolve, ENV_READY_POLL_MS));
1296
+ }
1297
+ } finally {
1298
+ this.#clearTimer(deadline);
1299
+ }
1300
+ }
1301
+
1302
+ // -- the cleanup coordinator's hooks --------------------------------------------
1303
+
1304
+ /** Every start holding the run's grant (or an older one's) ends, and every
1305
+ * consumer's writes and requests under way end, all of them settled,
1306
+ * before anything is deleted. */
1307
+ async #drainWriters(request: TaskCredentialCleanupRequest): Promise<void> {
1308
+ const holds = [...(this.#holds.get(request.taskId) ?? [])].filter((entry) => entry.runEpoch <= request.runEpoch);
1309
+ for (const entry of holds) {
1310
+ entry.controller.abort(new CredentialLeaseUnavailableError("the task's credentials are being cleaned up"));
1311
+ }
1312
+ // A recovered run's setup under way (root writes in its runtime) stops
1313
+ // and is joined by its run's full cleanup. Its own startup cleanup comes
1314
+ // before its gate opens, so before any setup runs.
1315
+ const recovered = this.#recovered.get(request.taskId);
1316
+ const setups: Promise<void>[] = [];
1317
+ if (request.credentialIds === undefined && recovered && recovered.runEpoch <= request.runEpoch && recovered.running) {
1318
+ // The gate hold already cancelled this attempt. A transient full
1319
+ // cleanup may reacquire the same run's lease, so retain its setup for
1320
+ // that next open. Stop/takeover cancel recovery at their own boundary.
1321
+ setups.push(recovered.running);
1322
+ }
1323
+ const consumer = consumerCleanupRequest(request);
1324
+ await allSettledThenFirstFailure([
1325
+ ...setups,
1326
+ ...holds.map((entry) => entry.released),
1327
+ cleanupEngineCredentials(consumer),
1328
+ cleanupPersonCredentials(consumer),
1329
+ cleanupMcpConnections(consumer),
1330
+ ]);
1331
+ }
1332
+
1333
+ async #closeConsumers(request: TaskCredentialCleanupRequest): Promise<void> {
1334
+ await this.#deps.files?.closeConsumers(request);
1335
+ }
1336
+
1337
+ /** What the cleanup deleted leaves memory too. A whole-task cleanup drops
1338
+ * the env replica's values; a teardown forgets the task entirely. */
1339
+ #dropMemory(request: TaskCredentialCleanupRequest): void {
1340
+ if (request.credentialIds !== undefined) return;
1341
+ this.#deps.envReplica?.dropAssignment(request.taskId, request.runEpoch);
1342
+ if (request.reason !== "teardown") return;
1343
+ this.#publishedMcpMembership.delete(request.taskId);
1344
+ forgetLeasedEngines({ taskId: request.taskId, runEpoch: request.runEpoch });
1345
+ const mcp = leasedMcpFor(request.taskId);
1346
+ if (mcp && mcp.assignment.runEpoch <= request.runEpoch) forgetLeasedMcp(request.taskId);
1347
+ }
1348
+ }
1349
+
1350
+ /** The v1 lease frames this runtime owns. */
1351
+ const V1_FRAME_KINDS = new Set([
1352
+ "lease.grant.v1",
1353
+ "lease.unavailable",
1354
+ "lease.stale",
1355
+ "credential.changed",
1356
+ "credential.refresh.result",
1357
+ ]);