@runuai/host 0.9.150 → 0.9.152

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 (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1837 -224
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +650 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1061 -139
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -0,0 +1,1035 @@
1
+ /**
2
+ * ADR-154 on the runner: the one place a task on cloud credentials is wired.
3
+ *
4
+ * - The lease registry owns each task's grant, its reads and its gate. This
5
+ * composes what it installs, opens and closes: the engines' accounts, MCP
6
+ * connections, the owner's GitHub sign-in and SSH key, the env replica and
7
+ * the task's Files install from the registry's install; each opens with
8
+ * its gate on the exact committed view; every one closes with it,
9
+ * synchronously, each attempted even when another throws; and each is
10
+ * cleaned up through the one cleanup procedure
11
+ * (`TaskCredentialCleanupCoordinator`), whose hooks this supplies, with the
12
+ * registry's own qualified identities.
13
+ * - Task-up's seam. `beginStart` in the short admission transaction begins
14
+ * the registry's startup there (its synchronous part: the older run ended,
15
+ * the gate closed) and hands back the start's token; a newer run's start
16
+ * interrupts an older one's. `waitForLaunchReady` outside any lifecycle
17
+ * slot, bounded, ends at that startup's cleanup and the grant installed,
18
+ * its env snapshot exact and its gate open. Inside the long slot,
19
+ * `tryAcquireTaskUpHold`: a counted hold on the installed grant that
20
+ * task-up carries through its clone, provisioning and the runtime-ready
21
+ * writes. `interruptStarts` is synchronous, for Stop and a newer run to
22
+ * call before they queue behind a wedged start: the token's signal aborts
23
+ * its wait and its hold. `assertCurrent` is the last word before a start
24
+ * publishes its success; `abandonStart` releases the hold before it closes
25
+ * the run, so the cleanup never waits on it.
26
+ * - Frames: v1 lease frames and the env replica's frames reach their owners
27
+ * here. A `lease.deny` the registry did not ask for (ADR-133's) is left to
28
+ * whoever did. After shutdown, nothing is taken.
29
+ * - Refreshes: an engine's or MCP connection's reactive refresh goes to the
30
+ * cloud's broker through the registry, never to a local refresher.
31
+ */
32
+
33
+ import {
34
+ isProjectEnvReplicaV1ApplyFrame,
35
+ isProjectEnvReplicaV1BeginFrame,
36
+ isProjectEnvReplicaV1CommitFrame,
37
+ isProjectEnvReplicaV1PageFrame,
38
+ type ProjectEnvReplicaV1AckFrame,
39
+ } from "../src/protocol";
40
+ import type { CredentialRefreshFrame, LeaseRequestV1Frame } from "../src/cloud-credentials";
41
+ import { adaptLeaseCredentialCleanupRequest } from "./cloud-credential-cleanup-adapter";
42
+ import {
43
+ CredentialLeaseUnavailableError,
44
+ TaskCredentialLeaseRegistry,
45
+ type CredentialCleanupReason,
46
+ type CredentialCleanupRequest,
47
+ type CredentialInstallRequest,
48
+ type TaskAssignment,
49
+ type TaskCredentialLeaseRegistryOptions,
50
+ type TaskCredentialView,
51
+ } from "./cloud-credential-leases";
52
+ import {
53
+ cleanupEngineCredentials,
54
+ closeEngineGate,
55
+ drainLeasedEngineWrites,
56
+ forgetLeasedEngines,
57
+ installEngineCredentials,
58
+ openEngineGate,
59
+ setEngineCredentialRefresher,
60
+ setEngineLaunchFenceEscalator,
61
+ } from "./credential-consumers/engines";
62
+ import {
63
+ cleanupPersonCredentials,
64
+ closePersonCredentialGate,
65
+ drainPersonCredentialWrites,
66
+ holdTaskGitCredentials,
67
+ installPersonCredentials,
68
+ openPersonCredentialGate,
69
+ type TaskGitCredentialHold,
70
+ } from "./credential-consumers/github";
71
+ import {
72
+ cleanupMcpConnections,
73
+ closeMcpGate,
74
+ drainLeasedMcpRequests,
75
+ forgetLeasedMcp,
76
+ installMcpConnections,
77
+ leasedMcpFor,
78
+ openMcpGate,
79
+ setMcpCredentialRefresher,
80
+ } from "./credential-consumers/mcp";
81
+ import type { ProjectEnvTaskReplicaManager, ProjectEnvTaskReplicaSnapshot } from "./project-env-task-replica";
82
+ import {
83
+ TaskCredentialCleanupCoordinator,
84
+ type TaskCredentialCleanupDeps,
85
+ type TaskCredentialCleanupRequest,
86
+ } from "./task-credential-cleanup";
87
+
88
+ /** How long a start waits for its grant, installed and opened. */
89
+ export const LAUNCH_READY_DEADLINE_MS = 3 * 60_000;
90
+ /** How long an install waits for the env replica's exact snapshot. */
91
+ export const ENV_READY_TIMEOUT_MS = 60_000;
92
+ /** How often an install looks again for that snapshot. */
93
+ const ENV_READY_POLL_MS = 50;
94
+ /** How long shutdown waits for the holds it aborted and the cleanups under
95
+ * way; the next process's recovery sweep finishes what is left. */
96
+ export const SHUTDOWN_DRAIN_MS = 30_000;
97
+
98
+ /** The task's Files (ADR-154 §12): installed, opened and closed with the
99
+ * rest. `close` is synchronous: no new Files admission after it. */
100
+ export interface TaskFilesConsumer {
101
+ install(request: CredentialInstallRequest): Promise<void>;
102
+ open(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void;
103
+ close(assignment: Readonly<TaskAssignment>): void;
104
+ closeConsumers(request: TaskCredentialCleanupRequest): Promise<void>;
105
+ }
106
+
107
+ export interface CloudCredentialRuntimeDeps {
108
+ runnerKind: "owned" | "task-machine";
109
+ /** To the cloud, over the authenticated bridge. False: not sent now. */
110
+ send(frame: LeaseRequestV1Frame | CredentialRefreshFrame | ProjectEnvReplicaV1AckFrame): boolean;
111
+ /** Every engine of the run (and older) proven stopped in every runtime it
112
+ * ran in: the orchestrator's `quiesceLeasedEngineAgents`. */
113
+ quiesceProcesses(request: TaskCredentialCleanupRequest): Promise<void>;
114
+ /** The leased MCP connections a task serves changed: rewrite its MCP
115
+ * configuration from them. */
116
+ mcpServedChanged?(taskId: string): void;
117
+ /** The env replica, once the cloud streams one. Without it, an install
118
+ * waits for no env snapshot. */
119
+ envReplica?: ProjectEnvTaskReplicaManager | null;
120
+ /** The composer started or stopped serving a task's env (its gate opened
121
+ * on a grant's snapshot, or closed): whoever follows the task's env
122
+ * (the proxy's trust delivery) looks again. */
123
+ envServedChanged?(taskId: string): void;
124
+ files?: TaskFilesConsumer | null;
125
+ launchDeadlineMs?: number;
126
+ envReadyTimeoutMs?: number;
127
+ shutdownDrainMs?: number;
128
+ /** The first retry's delay for a recovered start whose startup failed. */
129
+ recoveryRetryBaseMs?: number;
130
+ registry?: Partial<
131
+ Pick<TaskCredentialLeaseRegistryOptions, "createSessionReader" | "now" | "setTimer" | "clearTimer" | "randomId" | "onError">
132
+ >;
133
+ cleanup?: Pick<TaskCredentialCleanupDeps, "db" | "removePath" | "destroyRuntime">;
134
+ }
135
+
136
+ /**
137
+ * Why a start was interrupted, as its signal's reason: Stop, teardown or a
138
+ * newer run took it over, or the runner is shutting down. The first
139
+ * interruption sets it and nothing changes it after, so a start Stop
140
+ * interrupted stays Stop's even when shutdown begins while it unwinds.
141
+ */
142
+ export class StartInterruptedError extends CredentialLeaseUnavailableError {
143
+ constructor(readonly by: "takeover" | "shutdown") {
144
+ super(by === "shutdown" ? "the runner is shutting down" : "this start was interrupted");
145
+ }
146
+ }
147
+
148
+ /** One start of a run on cloud credentials, from its admission to its
149
+ * outcome: what Stop, a newer run and shutdown interrupt. */
150
+ export interface TaskCredentialStart {
151
+ readonly assignment: Readonly<TaskAssignment>;
152
+ /** Aborted when the start is interrupted: its wait and its hold end. */
153
+ readonly signal: AbortSignal;
154
+ /** Throws unless the start is still this run's and uninterrupted: called
155
+ * right before it publishes its success. */
156
+ assertCurrent(): void;
157
+ }
158
+
159
+ /** A start's hold on its installed grant (task-up's clone, provisioning and
160
+ * runtime-ready writes). */
161
+ export interface TaskUpHold {
162
+ readonly assignment: Readonly<TaskAssignment>;
163
+ /** The committed view the start begins on. */
164
+ readonly view: TaskCredentialView;
165
+ /** The owner's GitHub sign-in and SSH key for the clone. */
166
+ readonly git: TaskGitCredentialHold;
167
+ /** Aborted when the gate closes for the run, a cleanup reaches it or the
168
+ * start is interrupted: stop at once, then release. */
169
+ readonly signal: AbortSignal;
170
+ /** The start no longer uses the grant. Idempotent. */
171
+ release(): void;
172
+ }
173
+
174
+ /** A recovered run (resumeRecovered). */
175
+ interface RecoveredRun {
176
+ readonly runEpoch: number;
177
+ /** Aborted when the recovery ends: Stop, teardown, a newer run, shutdown. */
178
+ readonly controller: AbortController;
179
+ /** Its runtime's setup, until its gate first opens. */
180
+ setup: ((signal: AbortSignal) => Promise<void> | void) | null;
181
+ /** The setup under way. */
182
+ running: Promise<void> | null;
183
+ /** A startup retry pending. */
184
+ retry: ReturnType<typeof setTimeout> | null;
185
+ attempts: number;
186
+ /** An ordinary start of the run took its startup over. */
187
+ yielded: boolean;
188
+ }
189
+
190
+ /** A recovered start whose startup cleanup failed is tried again after
191
+ * this, doubling to the most. */
192
+ const RECOVERY_RETRY_BASE_MS = 1_000;
193
+ const RECOVERY_RETRY_MAX_MS = 30_000;
194
+
195
+ interface StartEntry {
196
+ start: TaskCredentialStart;
197
+ controller: AbortController;
198
+ /** The registry's startup for the run: its predecessor's cleanup, then
199
+ * its request. */
200
+ started: Promise<void>;
201
+ }
202
+
203
+ interface HoldEntry {
204
+ runEpoch: number;
205
+ controller: AbortController;
206
+ released: Promise<void>;
207
+ }
208
+
209
+ const CLEANUP_CLASSES = ["engine", "mcp", "github", "ssh"] as const;
210
+
211
+ /** What a consumer's cleanup reads: the registry's own request, exact. One
212
+ * the coordinator starts itself (boot recovery) is whole-task; were one
213
+ * ever selective, it would take every revision of the ids it names. */
214
+ function consumerCleanupRequest(request: TaskCredentialCleanupRequest): CredentialCleanupRequest {
215
+ if (request.lease) return request.lease;
216
+ const assignment = { taskId: request.taskId, runEpoch: request.runEpoch };
217
+ if (request.credentialIds !== undefined) {
218
+ return {
219
+ assignment,
220
+ mode: "selective",
221
+ reason: "lease-replaced",
222
+ removed: request.credentialIds.flatMap((credentialId) =>
223
+ CLEANUP_CLASSES.map((kind) => ({ class: kind, credentialId, revision: null })),
224
+ ),
225
+ removeFilesSession: false,
226
+ };
227
+ }
228
+ const reason: CredentialCleanupReason =
229
+ request.reason === "teardown" ? "teardown" : request.reason === "stop" ? "stopped" : request.reason === "park" ? "parked" : "recovery";
230
+ return { assignment, mode: "full", reason, removed: [], removeFilesSession: false };
231
+ }
232
+
233
+ /** Every outcome settled, then the first failure. */
234
+ async function allSettledThenFirstFailure(work: readonly Promise<unknown>[]): Promise<void> {
235
+ const results = await Promise.allSettled(work);
236
+ const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
237
+ if (failure) throw failure.reason;
238
+ }
239
+
240
+ export class CloudCredentialRuntime {
241
+ readonly registry: TaskCredentialLeaseRegistry;
242
+ readonly coordinator: TaskCredentialCleanupCoordinator;
243
+ readonly #deps: CloudCredentialRuntimeDeps;
244
+ /** The run each task's gate is open for: what is served, held and read. */
245
+ readonly #open = new Map<string, number>();
246
+ /** Running tasks that survived the restart, being recovered (§5, §6). */
247
+ readonly #recovered = new Map<string, RecoveredRun>();
248
+ /** The env replica's reader generation each committed view was installed
249
+ * with, and the one each open task serves from: a snapshot another
250
+ * grant's reader produced is never served for it. */
251
+ readonly #generations = new WeakMap<TaskCredentialView, number>();
252
+ readonly #served = new Map<string, number>();
253
+ /** Each task's run begun here and not yet closed. */
254
+ readonly #assignments = new Map<string, TaskAssignment>();
255
+ /** The newest run of each task ever begun: an older one never begins. */
256
+ readonly #floors = new Map<string, number>();
257
+ /** Each task's starts not yet ended, and its newest start: the one start
258
+ * with any authority, while it is uninterrupted and not ended. */
259
+ readonly #starts = new Map<string, Set<StartEntry>>();
260
+ readonly #newest = new Map<string, StartEntry>();
261
+ readonly #holds = new Map<string, Set<HoldEntry>>();
262
+ /** Launch waits under way, aborted at shutdown. */
263
+ readonly #waits = new Set<AbortController>();
264
+ /** Closes under way, by run and reason: a second asks for the same one. */
265
+ readonly #closing = new Map<string, Promise<void>>();
266
+ /** Work accepted and not yet over (startups, frames, installs, holds not
267
+ * yet released, closes): what shutdown joins. */
268
+ readonly #work = new Set<Promise<unknown>>();
269
+ readonly #setTimer: typeof setTimeout;
270
+ readonly #clearTimer: typeof clearTimeout;
271
+ #shutDown = false;
272
+ #shutdown: Promise<void> | null = null;
273
+
274
+ constructor(deps: CloudCredentialRuntimeDeps) {
275
+ this.#deps = deps;
276
+ this.#setTimer = deps.registry?.setTimer ?? setTimeout;
277
+ this.#clearTimer = deps.registry?.clearTimer ?? clearTimeout;
278
+ this.coordinator = new TaskCredentialCleanupCoordinator({
279
+ ...deps.cleanup,
280
+ hooks: {
281
+ // The registry closes its gate before any cleanup it asks for, a
282
+ // selective one included; closing again is idempotent, and is what
283
+ // closes it for one the coordinator starts itself (boot recovery).
284
+ refuseNewWork: (request) => this.#close({ taskId: request.taskId, runEpoch: request.runEpoch }),
285
+ closeGate: (request) => this.#close({ taskId: request.taskId, runEpoch: request.runEpoch }),
286
+ drainWriters: (request) => this.#drainWriters(request),
287
+ closeConsumers: (request) => this.#closeConsumers(request),
288
+ quiesceProcesses: (request) => deps.quiesceProcesses(request),
289
+ dropMemory: (request) => this.#dropMemory(request),
290
+ },
291
+ });
292
+ this.registry = new TaskCredentialLeaseRegistry({
293
+ ...deps.registry,
294
+ runnerKind: deps.runnerKind,
295
+ send: (frame) => deps.send(frame),
296
+ closeTaskGate: (assignment) => this.#close(assignment),
297
+ openTaskGate: (assignment, view) => this.#openGate(assignment, view),
298
+ installExternal: (request) => this.#track(this.#install(request)),
299
+ // From a grant's acceptance until its own reader is active, the env
300
+ // replica refuses the run's frames for the cloud to retry: none is
301
+ // read through the reader the grant replaces.
302
+ grantAccepted: (assignment) => {
303
+ const replica = deps.envReplica;
304
+ if (!replica || this.#shutDown) return;
305
+ const token = replica.expectActivation(assignment.taskId, assignment.runEpoch);
306
+ return () => replica.settleActivation(assignment.taskId, token);
307
+ },
308
+ // The registry's own request rides through the coordinator, exact.
309
+ cleanupExternal: (request) =>
310
+ this.coordinator.cleanup({ ...adaptLeaseCredentialCleanupRequest(request, deps.runnerKind), lease: request }),
311
+ });
312
+ // A reactive refresh is the cloud broker's, through the task's lease.
313
+ setEngineCredentialRefresher((assignment, credentialId, revision) =>
314
+ this.registry.requestCredentialRefresh(assignment, credentialId, revision),
315
+ );
316
+ // A launch slot that could not be retired in its runtime: the run's full
317
+ // cleanup, its gate closed first (ADR-154 §6).
318
+ setEngineLaunchFenceEscalator((assignment) => this.registry.escalateLaunchFence(assignment));
319
+ setMcpCredentialRefresher((assignment, credentialId, revision) =>
320
+ this.registry.requestCredentialRefresh(assignment, credentialId, revision),
321
+ );
322
+ }
323
+
324
+ // -- task-up's seam -----------------------------------------------------------
325
+
326
+ /**
327
+ * Phase one, synchronously, in the short admission transaction: the exact
328
+ * run's startup begins (an older run of the task ended, the gate closed,
329
+ * the task on cloud credentials from here) and its start's token comes
330
+ * back. Every earlier start of the task, of this run or an older one, is
331
+ * interrupted: one start has authority. Throws for a run older than the
332
+ * task's newest, or after shutdown.
333
+ */
334
+ beginStart(assignment: TaskAssignment, options: { recovery?: boolean } = {}): TaskCredentialStart {
335
+ if (this.#shutDown) throw new StartInterruptedError("shutdown");
336
+ if ((this.#floors.get(assignment.taskId) ?? 0) > assignment.runEpoch) {
337
+ throw new CredentialLeaseUnavailableError("a newer run of this task has begun");
338
+ }
339
+ this.#floors.set(assignment.taskId, assignment.runEpoch);
340
+ this.#interruptTokens(assignment.taskId, {});
341
+ // A newer run's start ends an older run's recovery; an ordinary start of
342
+ // the run being recovered takes its startup over.
343
+ this.#endRecovery(assignment.taskId, assignment.runEpoch - 1);
344
+ if (!options.recovery) this.#yieldRecovery(assignment.taskId, assignment.runEpoch);
345
+ const controller = new AbortController();
346
+ const frozen = Object.freeze({ ...assignment });
347
+ const entry = { controller, started: Promise.resolve() } as StartEntry;
348
+ entry.start = {
349
+ assignment: frozen,
350
+ signal: controller.signal,
351
+ assertCurrent: () => {
352
+ if (!this.#live(entry.start)) throw new CredentialLeaseUnavailableError("this start was interrupted");
353
+ if (this.#open.get(frozen.taskId) !== frozen.runEpoch) {
354
+ throw new CredentialLeaseUnavailableError("the task's credentials are no longer open for this run");
355
+ }
356
+ },
357
+ };
358
+ const entries = this.#starts.get(assignment.taskId) ?? new Set<StartEntry>();
359
+ entries.add(entry);
360
+ this.#starts.set(assignment.taskId, entries);
361
+ this.#newest.set(assignment.taskId, entry);
362
+ this.#assignments.set(assignment.taskId, { ...assignment });
363
+ // The registry's synchronous part runs here, in the slot; its cleanup
364
+ // and request are awaited outside it.
365
+ entry.started = this.#track(this.registry.startAssignment(assignment, options));
366
+ entry.started.catch(() => {});
367
+ return entry.start;
368
+ }
369
+
370
+ /**
371
+ * Synchronously interrupt starts of the task: `exact` the one run (a
372
+ * manual Stop or teardown), `below` every older run, or all. Their waits
373
+ * reject and their holds abort. Callers interrupt before they queue for a
374
+ * lifecycle slot, so a wedged start holding it ends instead of making them
375
+ * wait. An idle Stop does not interrupt: a start vetoes it.
376
+ */
377
+ interruptStarts(taskId: string, which: { exact?: number; below?: number } = {}): void {
378
+ this.#interruptTokens(taskId, which);
379
+ // A recovery of the run ends with it, ended start token or not.
380
+ const recovered = this.#recovered.get(taskId);
381
+ if (
382
+ recovered &&
383
+ (which.exact === undefined || recovered.runEpoch === which.exact) &&
384
+ (which.below === undefined || recovered.runEpoch < which.below)
385
+ ) {
386
+ this.#endRecovery(taskId, recovered.runEpoch);
387
+ }
388
+ }
389
+
390
+ #interruptTokens(taskId: string, which: { exact?: number; below?: number }): void {
391
+ for (const entry of this.#starts.get(taskId) ?? []) {
392
+ const runEpoch = entry.start.assignment.runEpoch;
393
+ if (which.exact !== undefined && runEpoch !== which.exact) continue;
394
+ if (which.below !== undefined && runEpoch >= which.below) continue;
395
+ entry.controller.abort(new StartInterruptedError("takeover"));
396
+ }
397
+ }
398
+
399
+ /** A local Stop: the task's recovery, whatever its run, ends now. One
400
+ * only exists for the run this runner owned at its start. */
401
+ interruptRecovery(taskId: string): void {
402
+ const recovered = this.#recovered.get(taskId);
403
+ if (recovered) this.#endRecovery(taskId, recovered.runEpoch);
404
+ }
405
+
406
+ /** The task's newest start, while it has authority: what a local Stop
407
+ * snapshots to interrupt later with `interrupt`. */
408
+ currentStart(taskId: string): TaskCredentialStart | null {
409
+ const newest = this.#newest.get(taskId);
410
+ return newest && this.#live(newest.start) ? newest.start : null;
411
+ }
412
+
413
+ /** Interrupt exactly this start, never one that replaced it. */
414
+ interrupt(start: TaskCredentialStart): void {
415
+ for (const entry of this.#starts.get(start.assignment.taskId) ?? []) {
416
+ if (entry.start === start) entry.controller.abort(new StartInterruptedError("takeover"));
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Phase two, outside any lifecycle slot: the run's startup done, its
422
+ * grant installed, its env snapshot exact and its gate open, or a failure.
423
+ * Bounded: a lease refused or never answered rejects with
424
+ * `CredentialLeaseUnavailableError` at the deadline, as do an interrupted,
425
+ * replaced or ended start and shutdown. Holds nothing.
426
+ */
427
+ async waitForLaunchReady(start: TaskCredentialStart): Promise<TaskCredentialView> {
428
+ const entry = this.#live(start);
429
+ if (!entry) throw new CredentialLeaseUnavailableError("this start was interrupted");
430
+ const controller = new AbortController();
431
+ this.#waits.add(controller);
432
+ const timer = this.#setTimer(
433
+ () => controller.abort(new CredentialLeaseUnavailableError("the task's credentials did not arrive in time")),
434
+ this.#deps.launchDeadlineMs ?? LAUNCH_READY_DEADLINE_MS,
435
+ );
436
+ const onInterrupt = (): void => controller.abort(start.signal.reason);
437
+ if (start.signal.aborted) onInterrupt();
438
+ else start.signal.addEventListener("abort", onInterrupt, { once: true });
439
+ try {
440
+ await Promise.race([
441
+ entry.started,
442
+ new Promise<never>((_, reject) => {
443
+ const fail = (): void => reject(controller.signal.reason);
444
+ if (controller.signal.aborted) fail();
445
+ else controller.signal.addEventListener("abort", fail, { once: true });
446
+ }),
447
+ ]);
448
+ const view = await this.registry.waitUntilUsable(start.assignment, controller.signal);
449
+ if (!this.#live(start)) throw new CredentialLeaseUnavailableError("this start was interrupted");
450
+ return view;
451
+ } finally {
452
+ this.#clearTimer(timer);
453
+ start.signal.removeEventListener("abort", onInterrupt);
454
+ this.#waits.delete(controller);
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Phase three, synchronously, inside the long lifecycle slot after the run
460
+ * is revalidated: a counted hold on the installed grant, or null unless
461
+ * the start is the task's newest, uninterrupted and not ended, and the
462
+ * gate is open for its exact run. Throws `CredentialLeaseUnavailableError`
463
+ * for a GitHub sign-in or SSH key the manifest names but the lease could
464
+ * not read: never "none".
465
+ */
466
+ tryAcquireTaskUpHold(start: TaskCredentialStart): TaskUpHold | null {
467
+ if (!this.#live(start)) return null;
468
+ const assignment = start.assignment;
469
+ if (this.#open.get(assignment.taskId) !== assignment.runEpoch) return null;
470
+ const view = this.registry.currentView(assignment);
471
+ if (!view) return null;
472
+ const git = holdTaskGitCredentials(assignment);
473
+ const controller = new AbortController();
474
+ let settle!: () => void;
475
+ const entry: HoldEntry = {
476
+ runEpoch: assignment.runEpoch,
477
+ controller,
478
+ released: new Promise<void>((resolve) => {
479
+ settle = resolve;
480
+ }),
481
+ };
482
+ void this.#track(entry.released);
483
+ const holds = this.#holds.get(assignment.taskId) ?? new Set<HoldEntry>();
484
+ holds.add(entry);
485
+ this.#holds.set(assignment.taskId, holds);
486
+ let released = false;
487
+ return {
488
+ assignment,
489
+ view,
490
+ git,
491
+ signal: AbortSignal.any([controller.signal, git.signal, start.signal]),
492
+ release: () => {
493
+ if (released) return;
494
+ released = true;
495
+ git.release();
496
+ holds.delete(entry);
497
+ if (holds.size === 0 && this.#holds.get(assignment.taskId) === holds) this.#holds.delete(assignment.taskId);
498
+ settle();
499
+ },
500
+ };
501
+ }
502
+
503
+ /** The start is over, successful or not: its token has no authority left,
504
+ * and it is no longer the task's newest. */
505
+ endStart(start: TaskCredentialStart): void {
506
+ const taskId = start.assignment.taskId;
507
+ if (this.#newest.get(taskId)?.start === start) this.#newest.delete(taskId);
508
+ const entries = this.#starts.get(taskId);
509
+ if (!entries) return;
510
+ for (const entry of entries) {
511
+ if (entry.start === start) entries.delete(entry);
512
+ }
513
+ if (entries.size === 0 && this.#starts.get(taskId) === entries) this.#starts.delete(taskId);
514
+ }
515
+
516
+ /** A start that failed after its assignment began: its hold released
517
+ * first (the cleanup waits for it), then the exact run closed and every
518
+ * credential it installed cleaned up. Only a start not yet ended that is
519
+ * still the task's newest closes: an older start's delayed failure, or
520
+ * one reported after the start ended, never closes what is running. */
521
+ async abandonStart(start: TaskCredentialStart, hold?: TaskUpHold | null): Promise<void> {
522
+ hold?.release();
523
+ const taskId = start.assignment.taskId;
524
+ const newest = this.#newest.get(taskId);
525
+ const closes = newest?.start === start && (this.#starts.get(taskId)?.has(newest) ?? false);
526
+ this.endStart(start);
527
+ if (closes) await this.closeAssignment(start.assignment, "stop");
528
+ }
529
+
530
+ /** The run ended (stop, park, teardown) or was superseded: close it and
531
+ * clean up everything it installed. After shutdown, no cleanup starts:
532
+ * one already under way for the run and reason is joined. */
533
+ closeAssignment(
534
+ assignment: TaskAssignment,
535
+ reason: "stop" | "park" | "teardown" | "superseded" = "stop",
536
+ ): Promise<void> {
537
+ this.#endRecovery(assignment.taskId, assignment.runEpoch);
538
+ const key = `${assignment.taskId}\u0000${String(assignment.runEpoch)}\u0000${reason}`;
539
+ const under = this.#closing.get(key);
540
+ if (under) return under;
541
+ if (this.#shutDown) return Promise.resolve();
542
+ const current = this.#assignments.get(assignment.taskId);
543
+ if (current && current.runEpoch === assignment.runEpoch) this.#assignments.delete(assignment.taskId);
544
+ const closing = this.#track(this.registry.closeAssignment(assignment, reason)).finally(() => {
545
+ if (this.#closing.get(key) === closing) this.#closing.delete(key);
546
+ });
547
+ this.#closing.set(key, closing);
548
+ return closing;
549
+ }
550
+
551
+ /** What the task's env serves for this exact run and project set, while
552
+ * its gate is open, from the snapshot its open grant's own reader
553
+ * produced; null otherwise (a candidate grant's snapshot included, until
554
+ * that grant is the open one). */
555
+ envSnapshot(assignment: TaskAssignment, projectIds: readonly string[]): ProjectEnvTaskReplicaSnapshot | null {
556
+ if (this.#open.get(assignment.taskId) !== assignment.runEpoch) return null;
557
+ const generation = this.#served.get(assignment.taskId);
558
+ if (generation === undefined) return null;
559
+ return this.#deps.envReplica?.readViewAt(assignment.taskId, assignment.runEpoch, projectIds, generation) ?? null;
560
+ }
561
+
562
+ /**
563
+ * Boot recovery (ADR-154 §5, §6): every task with cleanup left from the
564
+ * last process (a crash, a cleanup that failed) cleaned up, at the newer
565
+ * of its recorded and its admitted run, before any grant is asked for.
566
+ * Every task is attempted; then any failure fails the sweep.
567
+ */
568
+ recover(runEpochForTask: (taskId: string) => number): Promise<void> {
569
+ if (this.#shutDown) return Promise.reject(new StartInterruptedError("shutdown"));
570
+ return this.#track(this.coordinator.recoverAll(runEpochForTask));
571
+ }
572
+
573
+ /**
574
+ * A running task on cloud credentials that survived the runner's restart:
575
+ * its run's credentials asked for again, after its startup cleanup, as a
576
+ * start of its own; a startup cleanup that fails is tried again, with
577
+ * backoff. Its runtime's `setup` runs once its gate first opens, however
578
+ * late the grant comes, told by its signal when to stop. Stop, teardown,
579
+ * a newer run and shutdown end the recovery: no retry, no setup to come,
580
+ * a setup running told to stop and drained by the run's cleanup.
581
+ */
582
+ resumeRecovered(assignment: TaskAssignment, setup?: (signal: AbortSignal) => Promise<void> | void): void {
583
+ if (this.#shutDown) return;
584
+ const previous = this.#recovered.get(assignment.taskId);
585
+ if (previous) this.#endRecovery(assignment.taskId, previous.runEpoch);
586
+ const recovered: RecoveredRun = {
587
+ runEpoch: assignment.runEpoch,
588
+ controller: new AbortController(),
589
+ setup: setup ?? null,
590
+ running: null,
591
+ retry: null,
592
+ attempts: 0,
593
+ yielded: false,
594
+ };
595
+ this.#recovered.set(assignment.taskId, recovered);
596
+ this.#attemptRecovery({ ...assignment }, recovered);
597
+ }
598
+
599
+ #attemptRecovery(assignment: TaskAssignment, recovered: RecoveredRun): void {
600
+ if (!this.#recovering(assignment.taskId, recovered)) return;
601
+ let start: TaskCredentialStart;
602
+ try {
603
+ start = this.beginStart(assignment, { recovery: true });
604
+ } catch (error) {
605
+ console.warn(
606
+ `[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: no recovered start: ${
607
+ error instanceof Error ? error.message : String(error)
608
+ }`,
609
+ );
610
+ return;
611
+ }
612
+ const started = this.#newest.get(assignment.taskId)!.started;
613
+ void this.#track(
614
+ started.then(
615
+ // Ended once usable or its bounded wait gives up: the registry keeps
616
+ // asking for a grant it has had no answer to.
617
+ () => this.waitForLaunchReady(start).then(
618
+ () => this.endStart(start),
619
+ () => this.endStart(start),
620
+ ),
621
+ // Its startup cleanup failed: again, with backoff, while this run's
622
+ // recovery goes on.
623
+ () => {
624
+ this.endStart(start);
625
+ if (start.signal.aborted || !this.#recovering(assignment.taskId, recovered)) return;
626
+ const base = this.#deps.recoveryRetryBaseMs ?? RECOVERY_RETRY_BASE_MS;
627
+ const delay = Math.min(RECOVERY_RETRY_MAX_MS, base * 2 ** recovered.attempts);
628
+ recovered.attempts += 1;
629
+ recovered.retry = this.#setTimer(() => {
630
+ recovered.retry = null;
631
+ this.#attemptRecovery(assignment, recovered);
632
+ }, delay);
633
+ },
634
+ ),
635
+ );
636
+ }
637
+
638
+ /** Whether the recovery itself still drives its run's startup. */
639
+ #recovering(taskId: string, recovered: RecoveredRun): boolean {
640
+ return (
641
+ !this.#shutDown &&
642
+ !recovered.controller.signal.aborted &&
643
+ !recovered.yielded &&
644
+ this.#recovered.get(taskId) === recovered
645
+ );
646
+ }
647
+
648
+ /** An ordinary start of the run being recovered (task-up driven again,
649
+ * a lost response's retry): its own startup does the cleanup and asks
650
+ * for the grant from here, so the recovery makes no attempt again and
651
+ * a retry pending is cancelled. Its runtime's setup still runs when the
652
+ * gate first opens. */
653
+ #yieldRecovery(taskId: string, runEpoch: number): void {
654
+ const recovered = this.#recovered.get(taskId);
655
+ if (!recovered || recovered.runEpoch !== runEpoch) return;
656
+ recovered.yielded = true;
657
+ if (recovered.retry !== null) this.#clearTimer(recovered.retry);
658
+ recovered.retry = null;
659
+ }
660
+
661
+ /** The task's recovery of a run through `throughRunEpoch` ends: no retry,
662
+ * no setup to come, a running one told to stop (the run's cleanup drains
663
+ * it). Never the recovery's own startup cleanup's doing. */
664
+ #endRecovery(taskId: string, throughRunEpoch: number): void {
665
+ const recovered = this.#recovered.get(taskId);
666
+ if (!recovered || recovered.runEpoch > throughRunEpoch) return;
667
+ recovered.controller.abort(new StartInterruptedError("takeover"));
668
+ if (recovered.retry !== null) this.#clearTimer(recovered.retry);
669
+ recovered.retry = null;
670
+ recovered.setup = null;
671
+ if (recovered.running === null) this.#recovered.delete(taskId);
672
+ }
673
+
674
+ /** A recovered run's gate opened: its recovery is done, and its setup
675
+ * runs, once, in the background, its failure its own. */
676
+ #runRecoveredSetup(assignment: Readonly<TaskAssignment>): void {
677
+ const recovered = this.#recovered.get(assignment.taskId);
678
+ if (!recovered || recovered.runEpoch !== assignment.runEpoch || recovered.running !== null) return;
679
+ if (recovered.controller.signal.aborted) return;
680
+ if (recovered.retry !== null) this.#clearTimer(recovered.retry);
681
+ recovered.retry = null;
682
+ const setup = recovered.setup;
683
+ recovered.setup = null;
684
+ if (setup === null) {
685
+ this.#recovered.delete(assignment.taskId);
686
+ return;
687
+ }
688
+ const signal = recovered.controller.signal;
689
+ recovered.running = this.#track(
690
+ Promise.resolve()
691
+ .then(() => setup(signal))
692
+ .catch((error: unknown) =>
693
+ console.warn(
694
+ `[credentials] task ${assignment.taskId} run ${String(assignment.runEpoch)}: recovered runtime setup failed: ${
695
+ error instanceof Error ? error.message : String(error)
696
+ }`,
697
+ ),
698
+ )
699
+ .finally(() => {
700
+ recovered.running = null;
701
+ if (this.#recovered.get(assignment.taskId) === recovered) this.#recovered.delete(assignment.taskId);
702
+ }),
703
+ );
704
+ }
705
+
706
+ /** Every frame, startup, install and close under way now, settled. */
707
+ async settled(): Promise<void> {
708
+ await Promise.allSettled([...this.#work]);
709
+ }
710
+
711
+ // -- frames -------------------------------------------------------------------
712
+
713
+ /**
714
+ * A frame from the cloud that is this runtime's: true when handled. A
715
+ * `lease.deny` is offered to the registry, which ignores one it did not
716
+ * ask for, and reported unhandled so ADR-133's lease manager sees it too.
717
+ * A lease frame's handling goes on after this returns: a grant's install
718
+ * waits for the env frames that follow it, so a dispatcher that awaits each
719
+ * frame before the next must not wait on it. After shutdown, every v1
720
+ * frame is taken and ignored.
721
+ */
722
+ async handleFrame(frame: { kind: string }): Promise<boolean> {
723
+ const replica = this.#deps.envReplica;
724
+ const isReplicaFrame =
725
+ isProjectEnvReplicaV1BeginFrame(frame) ||
726
+ isProjectEnvReplicaV1PageFrame(frame) ||
727
+ isProjectEnvReplicaV1ApplyFrame(frame) ||
728
+ isProjectEnvReplicaV1CommitFrame(frame);
729
+ if (this.#shutDown) return frame.kind !== "lease.deny" && (V1_FRAME_KINDS.has(frame.kind) || isReplicaFrame);
730
+ switch (frame.kind) {
731
+ case "lease.grant.v1":
732
+ this.#background(this.registry.handleGrant(frame));
733
+ return true;
734
+ case "lease.unavailable":
735
+ this.registry.handleUnavailable(frame);
736
+ return true;
737
+ case "lease.deny":
738
+ this.#background(this.registry.handleDenied(frame));
739
+ return false;
740
+ case "lease.stale":
741
+ this.registry.handleStale(frame);
742
+ return true;
743
+ case "credential.changed":
744
+ this.#background(this.registry.handleCredentialChanged(frame));
745
+ return true;
746
+ case "credential.refresh.result":
747
+ this.#background(this.registry.handleRefreshResult(frame));
748
+ return true;
749
+ }
750
+ if (replica && isReplicaFrame) {
751
+ const ack = await this.#track(replica.handleFrame(frame));
752
+ if (!this.#shutDown) this.#deps.send(ack);
753
+ return true;
754
+ }
755
+ return false;
756
+ }
757
+
758
+ /** Same-process authenticated reconnect: keep safe bundles, ask again. */
759
+ bridgeAuthenticated(): void {
760
+ if (!this.#shutDown) this.registry.bridgeAuthenticated();
761
+ }
762
+
763
+ /**
764
+ * Process shutdown. Nothing is taken after it: no frame, start, open,
765
+ * install or close. Every assignment ends first, its gate closed (env,
766
+ * engines, MCP, the owner's Git credentials and Files), so that anything
767
+ * the aborts below set off finds no run to clean up; then every start,
768
+ * wait and hold is aborted, and the env replica's values leave memory.
769
+ * What was accepted before is joined, bounded: startups and the cleanups
770
+ * they began, frames, installs, holds being released, closes, and any
771
+ * cleanup those register meanwhile. None is started: agents that survive
772
+ * the restart keep what they hold (ADR-154 §5), and the next process's
773
+ * recovery sweep cleans up before it reattaches them.
774
+ */
775
+ shutdown(): Promise<void> {
776
+ this.#shutdown ??= this.#shutDownNow();
777
+ return this.#shutdown;
778
+ }
779
+
780
+ async #shutDownNow(): Promise<void> {
781
+ this.#shutDown = true;
782
+ const ended = this.registry.shutdown();
783
+ // A run the registry no longer tracks (its close under way) closes too.
784
+ for (const assignment of this.#assignments.values()) {
785
+ try {
786
+ this.#close(assignment);
787
+ } catch {
788
+ // Each fence was attempted; the process is ending.
789
+ }
790
+ }
791
+ this.#assignments.clear();
792
+ const reason = new StartInterruptedError("shutdown");
793
+ for (const controller of this.#waits) controller.abort(reason);
794
+ for (const entries of this.#starts.values()) for (const entry of entries) entry.controller.abort(reason);
795
+ for (const holds of this.#holds.values()) for (const entry of holds) entry.controller.abort(reason);
796
+ this.#scrub();
797
+ let bound: ReturnType<typeof setTimeout> | undefined;
798
+ await Promise.race([
799
+ this.#drain(ended),
800
+ new Promise<void>((resolve) => {
801
+ bound = this.#setTimer(resolve, this.#deps.shutdownDrainMs ?? SHUTDOWN_DRAIN_MS);
802
+ }),
803
+ ]);
804
+ this.#clearTimer(bound);
805
+ // Whatever the drain finished leaves memory too.
806
+ this.#scrub();
807
+ }
808
+
809
+ /** Until no accepted work is left, no consumer's write or request is
810
+ * under way (one already admitted would otherwise land after the process
811
+ * exits, behind the next process's recovery cleanup), and no cleanup is
812
+ * under way. */
813
+ async #drain(ended: Promise<void>): Promise<void> {
814
+ await ended;
815
+ for (;;) {
816
+ await Promise.allSettled([
817
+ ...this.#work,
818
+ drainLeasedEngineWrites(),
819
+ drainPersonCredentialWrites(),
820
+ drainLeasedMcpRequests(),
821
+ ]);
822
+ await this.coordinator.whenIdle();
823
+ if (this.#work.size === 0) return;
824
+ }
825
+ }
826
+
827
+ #scrub(): void {
828
+ for (const [taskId, recovered] of [...this.#recovered]) this.#endRecovery(taskId, recovered.runEpoch);
829
+ this.#deps.envReplica?.dropAll();
830
+ setEngineCredentialRefresher(null);
831
+ setEngineLaunchFenceEscalator(null);
832
+ setMcpCredentialRefresher(null);
833
+ }
834
+
835
+ // -- the registry's gate and install --------------------------------------------
836
+
837
+ /** The start's entry, while it is the task's newest, uninterrupted and not
838
+ * ended; null for any other token, a forged one included. */
839
+ #live(start: TaskCredentialStart): StartEntry | null {
840
+ if (this.#shutDown) return null;
841
+ const entry = this.#newest.get(start.assignment.taskId);
842
+ if (!entry || entry.start !== start || entry.controller.signal.aborted) return null;
843
+ return this.#starts.get(start.assignment.taskId)?.has(entry) ? entry : null;
844
+ }
845
+
846
+ #track<T>(work: Promise<T>): Promise<T> {
847
+ this.#work.add(work);
848
+ const forget = (): void => void this.#work.delete(work);
849
+ work.then(forget, forget);
850
+ return work;
851
+ }
852
+
853
+ /** Work no caller awaits: its owner reports its own failures. */
854
+ #background(work: Promise<unknown>): void {
855
+ this.#track(work).catch(() => {});
856
+ }
857
+
858
+ #openGate(assignment: Readonly<TaskAssignment>, view: TaskCredentialView): void {
859
+ if (this.#shutDown) throw new CredentialLeaseUnavailableError("the runner is shutting down");
860
+ openEngineGate(assignment, view);
861
+ openMcpGate(assignment, view);
862
+ openPersonCredentialGate(assignment, view);
863
+ this.#deps.files?.open(assignment, view);
864
+ this.#open.set(assignment.taskId, assignment.runEpoch);
865
+ const generation = this.#generations.get(view);
866
+ if (generation === undefined) this.#served.delete(assignment.taskId);
867
+ else this.#served.set(assignment.taskId, generation);
868
+ this.#deps.mcpServedChanged?.(assignment.taskId);
869
+ this.#envServedChanged(assignment.taskId);
870
+ this.#runRecoveredSetup(assignment);
871
+ }
872
+
873
+ #envServedChanged(taskId: string): void {
874
+ try {
875
+ this.#deps.envServedChanged?.(taskId);
876
+ } catch {
877
+ // Serving already changed; a follower's failure is its own.
878
+ }
879
+ }
880
+
881
+ /**
882
+ * Synchronous and idempotent: every consumer of this run (and older)
883
+ * stops being served, env and Files included, and every start holding its
884
+ * grant is aborted. Each fence is attempted even when another throws;
885
+ * then the first failure is thrown, or all of them together.
886
+ */
887
+ #close(assignment: Readonly<TaskAssignment>): void {
888
+ const failures: unknown[] = [];
889
+ const attempt = (fence: () => void): void => {
890
+ try {
891
+ fence();
892
+ } catch (error) {
893
+ failures.push(error);
894
+ }
895
+ };
896
+ // Env serving closes first, and cannot throw.
897
+ const open = this.#open.get(assignment.taskId);
898
+ if (open !== undefined && open <= assignment.runEpoch) {
899
+ this.#open.delete(assignment.taskId);
900
+ this.#served.delete(assignment.taskId);
901
+ this.#envServedChanged(assignment.taskId);
902
+ }
903
+ for (const entry of this.#holds.get(assignment.taskId) ?? []) {
904
+ if (entry.runEpoch <= assignment.runEpoch) {
905
+ attempt(() => entry.controller.abort(new CredentialLeaseUnavailableError("the task's credentials were withdrawn")));
906
+ }
907
+ }
908
+ attempt(() => closeEngineGate(assignment));
909
+ attempt(() => {
910
+ const mcp = leasedMcpFor(assignment.taskId);
911
+ if (mcp && mcp.assignment.runEpoch <= assignment.runEpoch) closeMcpGate(mcp.assignment);
912
+ });
913
+ attempt(() => closePersonCredentialGate(assignment));
914
+ attempt(() => this.#deps.files?.close(assignment));
915
+ if (failures.length === 1) throw failures[0];
916
+ if (failures.length > 1) throw new AggregateError(failures, "the task's credentials could not all be closed");
917
+ }
918
+
919
+ /**
920
+ * The registry's install of a grant or credential change, fenced after
921
+ * every step: once the runner is shutting down, or the grant can no longer
922
+ * commit, nothing more is installed.
923
+ */
924
+ async #install(request: CredentialInstallRequest): Promise<void> {
925
+ this.#assertInstallCurrent(request);
926
+ const replica = this.#deps.envReplica;
927
+ if (replica && request.reason === "grant") {
928
+ const { taskId, runEpoch } = request.assignment;
929
+ // The grant's reader first: the cloud's env frames for it are taken
930
+ // from here.
931
+ replica.activateAssignment({ taskId, runEpoch, orgId: request.owner.orgId, reader: request.projectValues });
932
+ const generation = replica.readerGeneration(taskId, runEpoch);
933
+ if (generation === null) throw new CredentialLeaseUnavailableError("the task's env replica is not active");
934
+ this.#generations.set(request.next, generation);
935
+ // Every grant waits for a snapshot of its exact project set read
936
+ // entirely through its own reader: never one another grant read.
937
+ await this.#awaitEnvSnapshot(request, generation);
938
+ this.#assertInstallCurrent(request);
939
+ } else if (request.reason === "credential-change" && request.previous) {
940
+ // The same grant, the same reader: its view serves the same snapshot.
941
+ const generation = this.#generations.get(request.previous);
942
+ if (generation !== undefined) this.#generations.set(request.next, generation);
943
+ }
944
+ await installEngineCredentials(request);
945
+ this.#assertInstallCurrent(request);
946
+ installMcpConnections(request);
947
+ installPersonCredentials(request);
948
+ await this.#deps.files?.install(request);
949
+ this.#assertInstallCurrent(request);
950
+ }
951
+
952
+ #assertInstallCurrent(request: CredentialInstallRequest): void {
953
+ if (this.#shutDown) throw new CredentialLeaseUnavailableError("the runner is shutting down");
954
+ if (!request.isCurrent()) {
955
+ throw new CredentialLeaseUnavailableError("the grant was withdrawn while it was being installed");
956
+ }
957
+ }
958
+
959
+ /** Until the replica serves the exact project set this grant names, from
960
+ * a snapshot read through this grant's own reader: never an older ready
961
+ * snapshot of another set, nor one another grant's reader produced.
962
+ * Bounded, and given up as soon as the grant can no longer commit. */
963
+ async #awaitEnvSnapshot(request: CredentialInstallRequest, generation: number): Promise<void> {
964
+ const replica = this.#deps.envReplica!;
965
+ const { taskId, runEpoch } = request.assignment;
966
+ let expired = false;
967
+ const deadline = this.#setTimer(() => {
968
+ expired = true;
969
+ }, this.#deps.envReadyTimeoutMs ?? ENV_READY_TIMEOUT_MS);
970
+ try {
971
+ while (replica.readViewAt(taskId, runEpoch, request.next.projectIds, generation) === null) {
972
+ if (!request.isCurrent() || this.#shutDown) {
973
+ throw new CredentialLeaseUnavailableError("the grant was withdrawn while its env values were awaited");
974
+ }
975
+ if (expired) throw new CredentialLeaseUnavailableError("the task's env values did not arrive in time");
976
+ await new Promise<void>((resolve) => this.#setTimer(resolve, ENV_READY_POLL_MS));
977
+ }
978
+ } finally {
979
+ this.#clearTimer(deadline);
980
+ }
981
+ }
982
+
983
+ // -- the cleanup coordinator's hooks --------------------------------------------
984
+
985
+ /** Every start holding the run's grant (or an older one's) ends, and every
986
+ * consumer's writes and requests under way end, all of them settled,
987
+ * before anything is deleted. */
988
+ async #drainWriters(request: TaskCredentialCleanupRequest): Promise<void> {
989
+ const holds = [...(this.#holds.get(request.taskId) ?? [])].filter((entry) => entry.runEpoch <= request.runEpoch);
990
+ for (const entry of holds) {
991
+ entry.controller.abort(new CredentialLeaseUnavailableError("the task's credentials are being cleaned up"));
992
+ }
993
+ // A recovered run's setup under way (root writes in its runtime) stops
994
+ // and is joined by its run's full cleanup. Its own startup cleanup comes
995
+ // before its gate opens, so before any setup runs.
996
+ const recovered = this.#recovered.get(request.taskId);
997
+ const setups: Promise<void>[] = [];
998
+ if (request.credentialIds === undefined && recovered && recovered.runEpoch <= request.runEpoch && recovered.running) {
999
+ recovered.controller.abort(new StartInterruptedError("takeover"));
1000
+ setups.push(recovered.running);
1001
+ }
1002
+ const consumer = consumerCleanupRequest(request);
1003
+ await allSettledThenFirstFailure([
1004
+ ...setups,
1005
+ ...holds.map((entry) => entry.released),
1006
+ cleanupEngineCredentials(consumer),
1007
+ cleanupPersonCredentials(consumer),
1008
+ cleanupMcpConnections(consumer),
1009
+ ]);
1010
+ }
1011
+
1012
+ async #closeConsumers(request: TaskCredentialCleanupRequest): Promise<void> {
1013
+ await this.#deps.files?.closeConsumers(request);
1014
+ }
1015
+
1016
+ /** What the cleanup deleted leaves memory too. A whole-task cleanup drops
1017
+ * the env replica's values; a teardown forgets the task entirely. */
1018
+ #dropMemory(request: TaskCredentialCleanupRequest): void {
1019
+ if (request.credentialIds !== undefined) return;
1020
+ this.#deps.envReplica?.dropAssignment(request.taskId, request.runEpoch);
1021
+ if (request.reason !== "teardown") return;
1022
+ forgetLeasedEngines({ taskId: request.taskId, runEpoch: request.runEpoch });
1023
+ const mcp = leasedMcpFor(request.taskId);
1024
+ if (mcp && mcp.assignment.runEpoch <= request.runEpoch) forgetLeasedMcp(request.taskId);
1025
+ }
1026
+ }
1027
+
1028
+ /** The v1 lease frames this runtime owns. */
1029
+ const V1_FRAME_KINDS = new Set([
1030
+ "lease.grant.v1",
1031
+ "lease.unavailable",
1032
+ "lease.stale",
1033
+ "credential.changed",
1034
+ "credential.refresh.result",
1035
+ ]);