@runuai/host 0.9.150 → 0.9.151

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 +1876 -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 +1830 -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 +623 -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,525 @@
1
+ /**
2
+ * ADR-154's single credential cleanup procedure.
3
+ *
4
+ * The coordinator owns ordering and failure semantics; credential consumers
5
+ * supply their lane-specific drain/quiesce/drop hooks. Durable inventory is
6
+ * removed only after an exact path is independently proven absent.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+
10
+ import type { HostCredentialCleanupPath } from "../db/schema";
11
+ import {
12
+ assertCredentialCleanupPath,
13
+ forgetCredentialCleanupPending,
14
+ forgetCredentialCleanupPath,
15
+ listCredentialCleanupRecoveryTasks,
16
+ listCredentialCleanupPaths,
17
+ rememberCredentialCleanupPending,
18
+ type CredentialCleanupLane,
19
+ type CredentialCleanupPendingReason,
20
+ } from "./credential-cleanup-inventory";
21
+ import type { CredentialCleanupRequest as LeaseCredentialCleanupRequest } from "./cloud-credential-leases";
22
+ import type { Db } from "./db";
23
+ import { reconstructTaskEnvironment } from "./task-environment";
24
+ import {
25
+ parseTaskEnvironmentLocator,
26
+ type TaskEnvironmentLocator,
27
+ type TaskEnvironmentHandle,
28
+ } from "./task-environment/types";
29
+
30
+ const CLEANUP_TIMEOUT_MS = 10_000;
31
+ const CLEANUP_OUTPUT_BYTES = 4 * 1024;
32
+
33
+ export type TaskCredentialCleanupReason = CredentialCleanupPendingReason;
34
+
35
+ export interface TaskCredentialCleanupRequest {
36
+ taskId: string;
37
+ runEpoch: number;
38
+ reason: TaskCredentialCleanupReason;
39
+ /** Absent means every credential and every recovered epoch for the task. */
40
+ credentialIds?: readonly string[];
41
+ /** The lease registry's own request this one carries out, with its exact
42
+ * qualified identities (class, id, revision), reason and Files scope, for
43
+ * the hooks; absent for one the coordinator starts itself (boot
44
+ * recovery). The durable inventory keys on `credentialIds` alone. */
45
+ lease?: Readonly<LeaseCredentialCleanupRequest>;
46
+ }
47
+
48
+ export interface TaskCredentialCleanupHooks {
49
+ /** These two hooks must be synchronous and idempotent. */
50
+ refuseNewWork(request: TaskCredentialCleanupRequest): void;
51
+ closeGate(request: TaskCredentialCleanupRequest): void;
52
+ drainWriters(request: TaskCredentialCleanupRequest): Promise<void>;
53
+ closeConsumers(request: TaskCredentialCleanupRequest): Promise<void>;
54
+ quiesceProcesses(request: TaskCredentialCleanupRequest): Promise<void>;
55
+ dropMemory(request: TaskCredentialCleanupRequest): void;
56
+ }
57
+
58
+ export interface TaskCredentialCleanupDeps {
59
+ db?: Db;
60
+ hooks: TaskCredentialCleanupHooks;
61
+ removePath?: (row: HostCredentialCleanupPath) => Promise<void>;
62
+ /** Full cleanup only: destroy one exact runtime and prove it absent. */
63
+ destroyRuntime?: (request: {
64
+ taskId: string;
65
+ runEpoch: number;
66
+ runtime: TaskEnvironmentLocator;
67
+ }) => Promise<boolean>;
68
+ }
69
+
70
+ export class TaskCredentialCleanupError extends Error {
71
+ constructor(
72
+ readonly taskId: string,
73
+ readonly failedPaths: readonly string[],
74
+ ) {
75
+ super(
76
+ `credential cleanup for task ${taskId} could not prove ${String(failedPaths.length)} path(s) absent`,
77
+ );
78
+ }
79
+ }
80
+
81
+ async function execCleanup(
82
+ environment: Pick<TaskEnvironmentHandle, "exec">,
83
+ argv: [string, ...string[]],
84
+ stdin?: Uint8Array,
85
+ ): Promise<{ exitCode: number | null; stdout: string }> {
86
+ const result = await environment.exec({
87
+ argv,
88
+ user: "root",
89
+ env: {
90
+ LD_AUDIT: "",
91
+ LD_LIBRARY_PATH: "",
92
+ LD_PRELOAD: "",
93
+ PYTHONHOME: "",
94
+ PYTHONPATH: "",
95
+ },
96
+ projectEnvProfile: "maintenance",
97
+ timeoutMs: CLEANUP_TIMEOUT_MS,
98
+ maxOutputBytes: CLEANUP_OUTPUT_BYTES,
99
+ ...(stdin === undefined ? {} : { stdin }),
100
+ });
101
+ return {
102
+ exitCode: result.exitCode,
103
+ stdout: Buffer.from(result.stdout).toString("utf8"),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * One root process performs traversal, unlink and the absence proof through
109
+ * directory file descriptors. No task-owned parent is re-resolved after it is
110
+ * checked, so renaming it or replacing it with a symlink cannot redirect the
111
+ * root deletion. The program itself is streamed over stdin; no shell parses
112
+ * either it or the validated path.
113
+ */
114
+ export const CREDENTIAL_UNLINK_HELPER = String.raw`
115
+ import os
116
+ import sys
117
+
118
+ root, absolute = sys.argv[1:3]
119
+ prefix = root.rstrip("/") + "/"
120
+ if not absolute.startswith(prefix):
121
+ raise RuntimeError("credential path escaped its root")
122
+ parts = absolute[len(prefix):].split("/")
123
+ if not parts or any(part in ("", ".", "..") for part in parts):
124
+ raise RuntimeError("credential path is not canonical")
125
+
126
+ flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
127
+ opened = []
128
+ try:
129
+ try:
130
+ current = os.open(root, flags)
131
+ except FileNotFoundError:
132
+ sys.exit(0)
133
+ opened.append(current)
134
+ for component in parts[:-1]:
135
+ try:
136
+ current = os.open(component, flags, dir_fd=current)
137
+ except FileNotFoundError:
138
+ sys.exit(0)
139
+ opened.append(current)
140
+ leaf = parts[-1]
141
+ try:
142
+ os.unlink(leaf, dir_fd=current)
143
+ except FileNotFoundError:
144
+ pass
145
+ try:
146
+ os.stat(leaf, dir_fd=current, follow_symlinks=False)
147
+ except FileNotFoundError:
148
+ sys.exit(0)
149
+ raise RuntimeError("credential path still exists")
150
+ finally:
151
+ for descriptor in reversed(opened):
152
+ os.close(descriptor)
153
+ `;
154
+
155
+ /** Host-side form of the same confined primitive. The fixed, isolated
156
+ * interpreter is a runner dependency (and is also baked into task machines);
157
+ * neither PATH, Python environment/site injection nor a shell participates. */
158
+ export async function removeHostCredentialFile(
159
+ root: string,
160
+ path: string,
161
+ ): Promise<void> {
162
+ const result = await new Promise<{ exitCode: number | null }>(
163
+ (resolve, reject) => {
164
+ const child = spawn(
165
+ "/usr/bin/python3",
166
+ ["-I", "-", root, path],
167
+ {
168
+ cwd: "/",
169
+ env: {},
170
+ stdio: ["pipe", "ignore", "ignore"],
171
+ },
172
+ );
173
+ const timeout = setTimeout(() => {
174
+ child.kill("SIGKILL");
175
+ }, CLEANUP_TIMEOUT_MS);
176
+ child.once("error", (error) => {
177
+ clearTimeout(timeout);
178
+ reject(error);
179
+ });
180
+ child.once("close", (exitCode) => {
181
+ clearTimeout(timeout);
182
+ resolve({ exitCode });
183
+ });
184
+ child.stdin.on("error", () => undefined);
185
+ child.stdin.end(Buffer.from(CREDENTIAL_UNLINK_HELPER, "utf8"));
186
+ },
187
+ );
188
+ if (result.exitCode !== 0) {
189
+ throw new Error("runner credential path could not be proven absent");
190
+ }
191
+ }
192
+
193
+ async function removeLocalFile(path: string): Promise<void> {
194
+ if (!path.startsWith("/run/uai/")) {
195
+ throw new Error("runner credential path is outside the managed root");
196
+ }
197
+ await removeHostCredentialFile("/run/uai", path);
198
+ }
199
+
200
+ export async function removeRuntimeCredentialFile(
201
+ environment: Pick<TaskEnvironmentHandle, "exec">,
202
+ path: string,
203
+ ): Promise<void> {
204
+ if (!path.startsWith("/home/node/")) {
205
+ throw new Error("runtime credential path is outside the task home");
206
+ }
207
+ const removed = await execCleanup(
208
+ environment,
209
+ ["/usr/bin/python3", "-I", "-", "/home/node", path],
210
+ Buffer.from(CREDENTIAL_UNLINK_HELPER, "utf8"),
211
+ );
212
+ if (removed.exitCode !== 0) {
213
+ throw new Error("credential path could not be proven absent");
214
+ }
215
+ }
216
+
217
+ /** Exact, non-recursive deletion for one validated inventory row. */
218
+ export async function removeCredentialCleanupPath(
219
+ row: HostCredentialCleanupPath,
220
+ ): Promise<void> {
221
+ assertCredentialCleanupPath(
222
+ row.lane as CredentialCleanupLane,
223
+ row.path,
224
+ );
225
+ if (row.lane === "files-session") {
226
+ await removeLocalFile(row.path);
227
+ return;
228
+ }
229
+ const locator = parseTaskEnvironmentLocator(row.runtimeLocator);
230
+ const environment = await reconstructTaskEnvironment(locator, "loader");
231
+ if (environment.descriptor.taskId !== row.taskId) {
232
+ throw new Error("cleanup runtime belongs to a different task");
233
+ }
234
+ await removeRuntimeCredentialFile(environment, row.path);
235
+ }
236
+
237
+ async function destroyCredentialRuntime(request: {
238
+ taskId: string;
239
+ runEpoch: number;
240
+ runtime: TaskEnvironmentLocator;
241
+ }): Promise<boolean> {
242
+ const environment = await reconstructTaskEnvironment(request.runtime, "loader");
243
+ if (environment.descriptor.taskId !== request.taskId) {
244
+ throw new Error("cleanup runtime belongs to a different task");
245
+ }
246
+ await environment.teardown();
247
+ const status = await environment.status();
248
+ return status.state === "absent";
249
+ }
250
+
251
+ function selectRows(
252
+ rows: readonly HostCredentialCleanupPath[],
253
+ request: TaskCredentialCleanupRequest,
254
+ ): HostCredentialCleanupPath[] {
255
+ const throughEpoch = rows.filter((row) => row.runEpoch <= request.runEpoch);
256
+ if (request.credentialIds === undefined) return throughEpoch;
257
+ const ids = new Set(request.credentialIds);
258
+ if (ids.size !== request.credentialIds.length || ids.size === 0) {
259
+ throw new Error("selective credential cleanup ids must be unique and nonempty");
260
+ }
261
+ return throughEpoch.filter((row) => ids.has(row.credentialId));
262
+ }
263
+
264
+ function isSamePhysicalPath(
265
+ left: HostCredentialCleanupPath,
266
+ right: HostCredentialCleanupPath,
267
+ ): boolean {
268
+ // Files-session paths live on the host, so a recreated task runtime still
269
+ // points at the same /run/uai file. Every other managed path lives inside
270
+ // one exact runtime and is identified by that locator plus its path.
271
+ if (left.lane === "files-session" || right.lane === "files-session") {
272
+ return (
273
+ left.lane === "files-session" &&
274
+ right.lane === "files-session" &&
275
+ left.path === right.path
276
+ );
277
+ }
278
+ return (
279
+ left.runtimeLocator === right.runtimeLocator && left.path === right.path
280
+ );
281
+ }
282
+
283
+ function forgetRow(row: HostCredentialCleanupPath, db?: Db): void {
284
+ forgetCredentialCleanupPath(
285
+ {
286
+ taskId: row.taskId,
287
+ runEpoch: row.runEpoch,
288
+ runtime: parseTaskEnvironmentLocator(row.runtimeLocator),
289
+ lane: row.lane as CredentialCleanupLane,
290
+ path: row.path,
291
+ },
292
+ db,
293
+ );
294
+ }
295
+
296
+ /** Serializes cleanup per task and never reopens a gate on failure. */
297
+ export class TaskCredentialCleanupCoordinator {
298
+ /** The serialized state retains the highest failed epoch until an equal or
299
+ * newer full cleanup succeeds. This prevents a delayed lower-epoch cleanup,
300
+ * or an unrelated selective success, from clearing the crash witness. */
301
+ readonly #tails = new Map<
302
+ string,
303
+ Promise<{ failedEpoch: number; clearThroughEpoch: number }>
304
+ >();
305
+ readonly #deps: TaskCredentialCleanupDeps;
306
+
307
+ constructor(deps: TaskCredentialCleanupDeps) {
308
+ this.#deps = deps;
309
+ }
310
+
311
+ cleanup(request: TaskCredentialCleanupRequest): Promise<void> {
312
+ if (!Number.isSafeInteger(request.runEpoch) || request.runEpoch < 1) {
313
+ return Promise.reject(new Error("cleanup run epoch must be positive"));
314
+ }
315
+ if (request.credentialIds !== undefined) {
316
+ const ids = new Set(request.credentialIds);
317
+ if (ids.size !== request.credentialIds.length || ids.size === 0) {
318
+ return Promise.reject(
319
+ new Error("selective credential cleanup ids must be unique and nonempty"),
320
+ );
321
+ }
322
+ }
323
+ const prior =
324
+ this.#tails.get(request.taskId) ??
325
+ Promise.resolve({ failedEpoch: 0, clearThroughEpoch: 0 });
326
+ let hooksFailed = false;
327
+ let hookError: unknown;
328
+ const recordFailure = (error: unknown): void => {
329
+ if (!hooksFailed) hookError = error;
330
+ hooksFailed = true;
331
+ };
332
+ try {
333
+ // This witness precedes even the synchronous gate hooks. A crash at any
334
+ // later instruction therefore makes boot recovery run the full cleanup.
335
+ rememberCredentialCleanupPending(request, this.#deps.db);
336
+ } catch (error) {
337
+ recordFailure(error);
338
+ }
339
+ // Both fail-closed hooks get their chance even if the other hook (or the
340
+ // durable write) fails. Their first failure remains the returned cause.
341
+ try {
342
+ this.#deps.hooks.refuseNewWork(request);
343
+ } catch (error) {
344
+ recordFailure(error);
345
+ }
346
+ try {
347
+ this.#deps.hooks.closeGate(request);
348
+ } catch (error) {
349
+ recordFailure(error);
350
+ }
351
+ const outcome = prior.then(async (state) => {
352
+ try {
353
+ if (hooksFailed) throw hookError;
354
+ await this.#run(request);
355
+ return {
356
+ state: {
357
+ failedEpoch:
358
+ request.credentialIds === undefined &&
359
+ request.runEpoch >= state.failedEpoch
360
+ ? 0
361
+ : state.failedEpoch,
362
+ clearThroughEpoch: Math.max(
363
+ state.clearThroughEpoch,
364
+ request.runEpoch,
365
+ ),
366
+ },
367
+ };
368
+ } catch (error) {
369
+ return {
370
+ state: {
371
+ failedEpoch: Math.max(state.failedEpoch, request.runEpoch),
372
+ clearThroughEpoch: state.clearThroughEpoch,
373
+ },
374
+ error,
375
+ };
376
+ }
377
+ });
378
+ const tail = outcome.then((result) => result.state);
379
+ this.#tails.set(request.taskId, tail);
380
+ const run = outcome.then((result) => {
381
+ if (
382
+ this.#tails.get(request.taskId) === tail &&
383
+ result.state.failedEpoch === 0
384
+ ) {
385
+ forgetCredentialCleanupPending(
386
+ request.taskId,
387
+ result.state.clearThroughEpoch,
388
+ this.#deps.db,
389
+ );
390
+ }
391
+ if ("error" in result) throw result.error;
392
+ });
393
+ void tail.then((state) => {
394
+ if (
395
+ state.failedEpoch === 0 &&
396
+ this.#tails.get(request.taskId) === tail
397
+ ) {
398
+ this.#tails.delete(request.taskId);
399
+ }
400
+ });
401
+ return run;
402
+ }
403
+
404
+ async #run(request: TaskCredentialCleanupRequest): Promise<void> {
405
+ await this.#deps.hooks.drainWriters(request);
406
+ await this.#deps.hooks.closeConsumers(request);
407
+ await this.#deps.hooks.quiesceProcesses(request);
408
+
409
+ const allRows = listCredentialCleanupPaths(request.taskId, this.#deps.db);
410
+ const selectedRows = selectRows(allRows, request);
411
+ const selected = new Set(selectedRows);
412
+ const rows: HostCredentialCleanupPath[] = [];
413
+ for (const row of selectedRows) {
414
+ const transferred = allRows.some(
415
+ (candidate) =>
416
+ candidate.runEpoch > row.runEpoch &&
417
+ !selected.has(candidate) &&
418
+ isSamePhysicalPath(candidate, row),
419
+ );
420
+ if (!transferred) {
421
+ rows.push(row);
422
+ continue;
423
+ }
424
+ // Gates are closed and writers/processes have drained before this
425
+ // serialized inventory observation. The newer row is therefore the
426
+ // durable ownership witness for the reused physical path: retire only
427
+ // the obsolete row, never unlink the newer run's file.
428
+ forgetRow(row, this.#deps.db);
429
+ }
430
+ let failed: HostCredentialCleanupPath[] = [];
431
+ for (const row of rows) {
432
+ try {
433
+ await (this.#deps.removePath ?? removeCredentialCleanupPath)(row);
434
+ forgetRow(row, this.#deps.db);
435
+ } catch {
436
+ failed.push(row);
437
+ }
438
+ }
439
+
440
+ // A whole-task cleanup may prove absence by destroying the exact writable
441
+ // runtime instead. Selective removal must never take this broader action.
442
+ if (request.credentialIds === undefined && failed.length > 0) {
443
+ const byRuntime = new Map<string, HostCredentialCleanupPath[]>();
444
+ for (const row of failed) {
445
+ if (row.lane === "files-session") continue;
446
+ const group = byRuntime.get(row.runtimeLocator) ?? [];
447
+ group.push(row);
448
+ byRuntime.set(row.runtimeLocator, group);
449
+ }
450
+ const proven = new Set<HostCredentialCleanupPath>();
451
+ for (const [runtimeLocator, runtimeRows] of byRuntime) {
452
+ // A locator may have been reused by a later assignment. Destroying it
453
+ // for delayed cleanup would then erase the newer run's credential
454
+ // files. Exact deletion may still proceed; the broad fallback may not.
455
+ if (
456
+ allRows.some(
457
+ (row) =>
458
+ row.runtimeLocator === runtimeLocator &&
459
+ row.runEpoch > request.runEpoch,
460
+ )
461
+ ) {
462
+ continue;
463
+ }
464
+ try {
465
+ const runtime = parseTaskEnvironmentLocator(runtimeLocator);
466
+ const absent = await (
467
+ this.#deps.destroyRuntime ?? destroyCredentialRuntime
468
+ )({
469
+ taskId: request.taskId,
470
+ runEpoch: request.runEpoch,
471
+ runtime,
472
+ });
473
+ if (!absent) continue;
474
+ for (const row of runtimeRows) {
475
+ forgetRow(row, this.#deps.db);
476
+ proven.add(row);
477
+ }
478
+ } catch {
479
+ // The original exact-path failure remains authoritative.
480
+ }
481
+ }
482
+ failed = failed.filter((row) => !proven.has(row));
483
+ }
484
+ if (failed.length > 0) {
485
+ throw new TaskCredentialCleanupError(
486
+ request.taskId,
487
+ failed.map((row) => row.path),
488
+ );
489
+ }
490
+ this.#deps.hooks.dropMemory(request);
491
+ }
492
+
493
+ /** Every cleanup under way, and any that registers meanwhile, settled
494
+ * (process shutdown). */
495
+ async whenIdle(): Promise<void> {
496
+ for (;;) {
497
+ const tails = [...this.#tails.values()];
498
+ await Promise.allSettled(tails);
499
+ const now = [...this.#tails.values()];
500
+ if (now.length === tails.length && now.every((tail, index) => tail === tails[index])) return;
501
+ }
502
+ }
503
+
504
+ /** Boot recovery sweeps every durable task before any grant may be asked.
505
+ * Every task is attempted, one failing never leaving the next unswept;
506
+ * then any failure, all of them, fails the sweep. */
507
+ async recoverAll(runEpochForTask: (taskId: string) => number): Promise<void> {
508
+ const tasks = listCredentialCleanupRecoveryTasks(this.#deps.db);
509
+ const failures: unknown[] = [];
510
+ for (const task of tasks) {
511
+ try {
512
+ await this.cleanup({
513
+ taskId: task.taskId,
514
+ runEpoch: Math.max(task.runEpoch, runEpochForTask(task.taskId)),
515
+ reason: "restart",
516
+ });
517
+ } catch (error) {
518
+ failures.push(error);
519
+ }
520
+ }
521
+ if (failures.length > 0) {
522
+ throw new AggregateError(failures, `credential recovery failed for ${String(failures.length)} task(s)`);
523
+ }
524
+ }
525
+ }