@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,123 @@
1
+ /**
2
+ * Fail-closed boundary between the in-memory lease registry and the durable
3
+ * task credential cleanup procedure.
4
+ *
5
+ * The registry describes why its gate closed and whether the cleanup is
6
+ * selective or whole-task. The durable coordinator uses a smaller audit
7
+ * vocabulary and represents selectivity solely by the presence of
8
+ * `credentialIds`. Keep that lossy conversion here so a new registry reason
9
+ * cannot accidentally become a whole-task cleanup (or vice versa).
10
+ */
11
+ import type {
12
+ CredentialCleanupReason as LeaseCredentialCleanupReason,
13
+ CredentialCleanupRequest as LeaseCredentialCleanupRequest,
14
+ } from "./cloud-credential-leases";
15
+ import type {
16
+ TaskCredentialCleanupCoordinator,
17
+ TaskCredentialCleanupReason,
18
+ TaskCredentialCleanupRequest,
19
+ } from "./task-credential-cleanup";
20
+
21
+ export type CredentialCleanupRunnerKind = "owned" | "task-machine";
22
+
23
+ type LeaseCleanupMode = LeaseCredentialCleanupRequest["mode"];
24
+ type CleanupReasonByMode = Readonly<
25
+ Record<LeaseCleanupMode, TaskCredentialCleanupReason | null>
26
+ >;
27
+
28
+ /**
29
+ * `null` marks a reason/mode pair the registry must never emit. The `satisfies`
30
+ * clause makes additions to either the reason union or the mode union a
31
+ * compile-time decision at this boundary.
32
+ */
33
+ const CLEANUP_REASON_BY_MODE = {
34
+ starting: { selective: null, full: null },
35
+ recovery: { selective: null, full: "restart" },
36
+ superseded: { selective: null, full: "assignment" },
37
+ "minimum-revision": { selective: "removed", full: "removed" },
38
+ "manifest-narrowed": { selective: null, full: "removed" },
39
+ denied: { selective: null, full: "denied" },
40
+ expired: { selective: null, full: "expired" },
41
+ stopped: { selective: null, full: "stop" },
42
+ parked: { selective: null, full: "park" },
43
+ teardown: { selective: null, full: "teardown" },
44
+ // The registry emits this fail-closed fallback when its synchronous gate
45
+ // callback throws during assignment start/recovery. The durable vocabulary
46
+ // has no internal-callback-failure reason; `restart` is the conservative
47
+ // whole-task recovery category and, critically, lets the coordinator write
48
+ // its pending witness before retrying the quarantine.
49
+ "cleanup-failed": { selective: null, full: "restart" },
50
+ // A launch slot that could not be retired in its runtime (ADR-154 §6):
51
+ // the whole run's cleanup, in the same conservative category.
52
+ "fence-failed": { selective: null, full: "restart" },
53
+ // Shutdown ends assignments without a cleanup: the next process's
54
+ // recovery sweep runs it.
55
+ shutdown: { selective: null, full: null },
56
+ "lease-replaced": { selective: "removed", full: null },
57
+ } as const satisfies Readonly<
58
+ Record<LeaseCredentialCleanupReason, CleanupReasonByMode>
59
+ >;
60
+
61
+ function mappedReason(
62
+ reason: LeaseCredentialCleanupReason,
63
+ mode: LeaseCleanupMode,
64
+ ): TaskCredentialCleanupReason {
65
+ const mapped = CLEANUP_REASON_BY_MODE[reason][mode];
66
+ if (mapped === null) {
67
+ throw new Error(`invalid ${mode} lease credential cleanup reason: ${reason}`);
68
+ }
69
+ return mapped;
70
+ }
71
+
72
+ /** Convert one registry callback into the coordinator's durable request. */
73
+ export function adaptLeaseCredentialCleanupRequest(
74
+ request: Readonly<LeaseCredentialCleanupRequest>,
75
+ runnerKind: CredentialCleanupRunnerKind,
76
+ ): TaskCredentialCleanupRequest {
77
+ const reason = mappedReason(request.reason, request.mode);
78
+
79
+ if (request.mode === "selective") {
80
+ if (request.removeFilesSession) {
81
+ throw new Error("selective credential cleanup cannot remove the files session");
82
+ }
83
+ if (request.removed.length === 0) {
84
+ throw new Error("selective credential cleanup requires a removed credential");
85
+ }
86
+ // Several revisions of one stable credential can retire before a floor
87
+ // grant lands. Inventory is keyed by the stable id, so collapse those
88
+ // qualified identities rather than widening or rejecting the cleanup.
89
+ const credentialIds = [
90
+ ...new Set(request.removed.map((identity) => identity.credentialId)),
91
+ ];
92
+ return {
93
+ taskId: request.assignment.taskId,
94
+ runEpoch: request.assignment.runEpoch,
95
+ reason,
96
+ credentialIds,
97
+ };
98
+ }
99
+
100
+ if (request.removed.length !== 0) {
101
+ throw new Error("full credential cleanup cannot carry selective identities");
102
+ }
103
+ const expectedFilesSession = runnerKind === "task-machine";
104
+ if (request.removeFilesSession !== expectedFilesSession) {
105
+ throw new Error(
106
+ `${runnerKind} full credential cleanup has the wrong files-session scope`,
107
+ );
108
+ }
109
+ return {
110
+ taskId: request.assignment.taskId,
111
+ runEpoch: request.assignment.runEpoch,
112
+ reason,
113
+ };
114
+ }
115
+
116
+ /** Callback with the exact shape consumed by TaskCredentialLeaseRegistry. */
117
+ export function createLeaseCredentialCleanupAdapter(
118
+ coordinator: Pick<TaskCredentialCleanupCoordinator, "cleanup">,
119
+ runnerKind: CredentialCleanupRunnerKind,
120
+ ): (request: LeaseCredentialCleanupRequest) => Promise<void> {
121
+ return (request) =>
122
+ coordinator.cleanup(adaptLeaseCredentialCleanupRequest(request, runnerKind));
123
+ }
@@ -0,0 +1,512 @@
1
+ /**
2
+ * ADR-154 on this runner: whether its tasks run on cloud credentials, and
3
+ * the one composer that serves them.
4
+ *
5
+ * The mode comes from the runner's durable activation authority (three
6
+ * states, ADR-154 §13; lib/credentials-activation-authority.ts), and only
7
+ * moves forward:
8
+ * - `legacy`: the composer stays unused and every task runs as before;
9
+ * - `pending`: task starts are refused, neither the runner's own credentials
10
+ * nor cloud ones may run;
11
+ * - `active`: every task runs on cloud credentials through the composer, and
12
+ * no local fallback exists.
13
+ * Until main.ts installs that authority, the mode is `legacy`.
14
+ *
15
+ * Leaving `legacy` is a counted transition. Each start on the runner's own
16
+ * credentials holds a permit from its dispatch to its outcome, and checks it
17
+ * again inside each lifecycle slot before anything happens there; its
18
+ * permit's signal ends any wait for a legacy readiness the transition stops.
19
+ * Work on those credentials outside a start (a start's background GitHub
20
+ * sign-in, a scheduled re-injection) runs only through
21
+ * `whileOnOwnCredentials`, and workers register a stop. `leaveLegacy()`
22
+ * closes the permits, aborts their signals and stops the workers at once,
23
+ * then resolves when every start, every such work and every worker's work
24
+ * already under way has ended.
25
+ */
26
+
27
+ import type { CloudCredentialRuntime } from "./cloud-credential-runtime";
28
+ import { readTaskRunEpochState, runningTaskRuns } from "./runtime-state";
29
+
30
+ export type CredentialsMode = "legacy" | "pending" | "active";
31
+
32
+ let modeSource: () => CredentialsMode = () => "legacy";
33
+ let runtime: CloudCredentialRuntime | null = null;
34
+
35
+ /** The activation authority answers from here on. */
36
+ export function setCredentialsModeSource(source: () => CredentialsMode): void {
37
+ modeSource = source;
38
+ }
39
+
40
+ export function credentialsMode(): CredentialsMode {
41
+ return modeSource();
42
+ }
43
+
44
+ export function setCloudCredentialRuntime(next: CloudCredentialRuntime | null): void {
45
+ runtime = next;
46
+ }
47
+
48
+ /** The composer, once constructed; null before (and in tests that run no
49
+ * task on cloud credentials). */
50
+ export function cloudCredentialRuntime(): CloudCredentialRuntime | null {
51
+ return runtime;
52
+ }
53
+
54
+ /** The composer while the runner's tasks run on cloud credentials; null
55
+ * otherwise. Read where it is used, inside the task's lifecycle slot. */
56
+ export function activeCloudCredentials(): CloudCredentialRuntime | null {
57
+ return credentialsMode() === "active" ? runtime : null;
58
+ }
59
+
60
+ // -- leaving legacy ------------------------------------------------------------
61
+
62
+ /** One start on the runner's own credentials, from its dispatch to its
63
+ * outcome. */
64
+ export interface LegacyStartPermit {
65
+ /** Still on the runner's own credentials, with no transition begun. Once
66
+ * false, never true again. */
67
+ valid(): boolean;
68
+ /** Aborted when the runner begins leaving its own credentials: a wait for
69
+ * something the transition stops (the v0 lease's readiness) ends then. */
70
+ readonly signal: AbortSignal;
71
+ /** The start ended. Idempotent. */
72
+ release(): void;
73
+ }
74
+
75
+ /** Set first, before anything a worker's stop could run. */
76
+ let left = false;
77
+ /** The activation authority is retiring the runner's task runtimes: no new
78
+ * start on its own credentials until it resumes them or leaves them. */
79
+ let startsPaused = false;
80
+ /** A runtime activation has asked the one-shot boot barrier for its
81
+ * recovery (whatever came of it), or leaving the runner's own credentials
82
+ * has begun: every pass from then on recovers afresh, its sweep pass and
83
+ * that pass's own follow-ups alike. */
84
+ let recoverAfresh = false;
85
+ let leaving: Promise<void> | null = null;
86
+ let legacyStarts = 0;
87
+ let legacyDrained: Array<() => void> = [];
88
+ const legacyWorkers = new Set<() => Promise<void> | void>();
89
+ const legacyPermitSignals = new Set<AbortController>();
90
+ const legacyWork = new Set<Promise<unknown>>();
91
+
92
+ function stillLegacy(): boolean {
93
+ return !left && credentialsMode() === "legacy";
94
+ }
95
+
96
+ /** The runner is on its own credentials, and has not begun leaving them. */
97
+ export function onOwnCredentials(): boolean {
98
+ return stillLegacy();
99
+ }
100
+
101
+ /** A permit for one start on the runner's own credentials; null once the
102
+ * runner is leaving them (or has left), and while starts are paused. */
103
+ export function admitLegacyStart(): LegacyStartPermit | null {
104
+ if (!stillLegacy() || startsPaused) return null;
105
+ legacyStarts += 1;
106
+ const controller = new AbortController();
107
+ legacyPermitSignals.add(controller);
108
+ let valid = true;
109
+ let released = false;
110
+ return {
111
+ valid: () => (valid &&= stillLegacy()),
112
+ signal: controller.signal,
113
+ release: () => {
114
+ if (released) return;
115
+ released = true;
116
+ legacyPermitSignals.delete(controller);
117
+ legacyStarts -= 1;
118
+ if (legacyStarts === 0) for (const drained of legacyDrained.splice(0)) drained();
119
+ },
120
+ };
121
+ }
122
+
123
+ /**
124
+ * ADR-154 §13, preparing: no new start on the runner's own credentials (each
125
+ * is answered as switching, which the cloud retries), while everything else
126
+ * on them goes on. `drained` resolves once the starts admitted before have
127
+ * ended, so every task runtime they create exists by then. `resume` admits
128
+ * starts again, for a preparation that failed; leaving the runner's own
129
+ * credentials needs no resume. Idempotent.
130
+ */
131
+ export function pauseLegacyStarts(): { drained: Promise<void>; resume(): void } {
132
+ startsPaused = true;
133
+ const drained =
134
+ legacyStarts === 0 ? Promise.resolve() : new Promise<void>((resolve) => legacyDrained.push(resolve));
135
+ let resumed = false;
136
+ return {
137
+ drained,
138
+ resume: () => {
139
+ if (resumed) return;
140
+ resumed = true;
141
+ startsPaused = false;
142
+ },
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Work on the runner's own credentials outside any start (a start's
148
+ * background GitHub sign-in, a scheduled re-injection, the write that ends
149
+ * either): `run` only while the runner is on them, and leaving them waits
150
+ * for it to end. Null, and `run` never called, once the runner is leaving
151
+ * them: the caller writes nothing.
152
+ */
153
+ export function whileOnOwnCredentials<T>(run: () => Promise<T>): Promise<T> | null {
154
+ if (!stillLegacy()) return null;
155
+ const work = run();
156
+ legacyWork.add(work);
157
+ const forget = (): void => void legacyWork.delete(work);
158
+ work.then(forget, forget);
159
+ return work;
160
+ }
161
+
162
+ /**
163
+ * A hold on the runner's own credentials for work whose end is no single
164
+ * promise (an agent's creation through its binding, a pass that decides on
165
+ * them and then starts a process): leaving them waits until it is
166
+ * released. Null, and nothing held, once the runner is leaving them: the
167
+ * caller uses none. The release is idempotent.
168
+ */
169
+ export function holdOwnCredentials(): (() => void) | null {
170
+ let release!: () => void;
171
+ const held = whileOnOwnCredentials(() => new Promise<void>((resolve) => (release = resolve)));
172
+ return held === null ? null : release;
173
+ }
174
+
175
+ /** A process on the runner's own credentials refused at its provider's
176
+ * last moment: the runner has begun leaving them. */
177
+ export class LeftOwnCredentialsError extends Error {
178
+ constructor() {
179
+ super("the runner is switching its tasks to cloud credentials");
180
+ this.name = "LeftOwnCredentialsError";
181
+ }
182
+ }
183
+
184
+ const LAUNCH_METHODS: ReadonlySet<PropertyKey> = new Set(["exec", "spawn", "spawnSession", "launchDetachedSession"]);
185
+
186
+ /**
187
+ * `environment` (a task environment), for an agent on the runner's own
188
+ * credentials: every process it starts there, then or at any later turn,
189
+ * is admitted at the provider's last moment only while the runner is on
190
+ * them, so none starts once leaving them has begun, however long the
191
+ * provider took to get there (a machine looks up its address first).
192
+ */
193
+ export function onOwnCredentialsEnvironment<T extends object>(environment: T): T {
194
+ return new Proxy(environment, {
195
+ get(target, property) {
196
+ const value: unknown = Reflect.get(target, property, target);
197
+ if (typeof value !== "function") return value;
198
+ const method = value as (...args: unknown[]) => unknown;
199
+ if (!LAUNCH_METHODS.has(property)) return method.bind(target);
200
+ return (request: { admit?: () => void }, ...rest: unknown[]) =>
201
+ method.call(
202
+ target,
203
+ {
204
+ ...request,
205
+ admit: () => {
206
+ if (!stillLegacy()) throw new LeftOwnCredentialsError();
207
+ request.admit?.();
208
+ },
209
+ },
210
+ ...rest,
211
+ );
212
+ },
213
+ });
214
+ }
215
+
216
+ /** Work on the runner's own credentials that must end before the runner
217
+ * leaves them: `stop` stops it at once when called, and resolves once what
218
+ * it had under way has ended. Returns the registration's removal. */
219
+ export function onLeavingLegacy(stop: () => Promise<void> | void): () => void {
220
+ legacyWorkers.add(stop);
221
+ return () => void legacyWorkers.delete(stop);
222
+ }
223
+
224
+ /**
225
+ * The activation authority, as the runner leaves its own credentials (it
226
+ * enters `pending`): no start on them is admitted from here on, every
227
+ * registered worker stops now, synchronously, and this resolves once the
228
+ * starts admitted before and the workers' work under way have all ended.
229
+ * Rejects (after all of that) when a worker's stop failed: the runner stays
230
+ * `pending`. Idempotent: every call shares the first one's outcome.
231
+ */
232
+ export function leaveLegacy(): Promise<void> {
233
+ if (leaving === null) {
234
+ left = true;
235
+ recoverAfresh = true;
236
+ for (const controller of legacyPermitSignals) {
237
+ controller.abort(new Error("the runner is switching its tasks to cloud credentials"));
238
+ }
239
+ leaving = drainLegacy();
240
+ }
241
+ return leaving;
242
+ }
243
+
244
+ async function drainLegacy(): Promise<void> {
245
+ const stopping: Array<Promise<void> | void> = [];
246
+ for (const stop of legacyWorkers) {
247
+ try {
248
+ stopping.push(stop());
249
+ } catch (error) {
250
+ stopping.push(Promise.reject(error));
251
+ }
252
+ }
253
+ const starts =
254
+ legacyStarts === 0 ? Promise.resolve() : new Promise<void>((resolve) => legacyDrained.push(resolve));
255
+ const results = await Promise.allSettled([...stopping, starts]);
256
+ // Work launched before leaving began ends too; none starts after.
257
+ while (legacyWork.size > 0) await Promise.allSettled([...legacyWork]);
258
+ const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason as unknown] : []));
259
+ if (failures.length > 0) {
260
+ throw new AggregateError(failures, "work on the runner's own credentials did not stop");
261
+ }
262
+ }
263
+
264
+ /** Tests: the runner back on its own credentials, nothing registered. */
265
+ export function resetLegacyTransitionForTests(): void {
266
+ left = false;
267
+ startsPaused = false;
268
+ recoverAfresh = false;
269
+ leaving = null;
270
+ legacyStarts = 0;
271
+ legacyDrained = [];
272
+ legacyWorkers.clear();
273
+ legacyPermitSignals.clear();
274
+ legacyWork.clear();
275
+ }
276
+
277
+ // -- boot recovery -----------------------------------------------------------
278
+
279
+ let recovering: Promise<void> | null = null;
280
+ let markRecovered!: () => void;
281
+ let recoveredSignal = new Promise<void>((resolve) => (markRecovered = resolve));
282
+ let recovered = false;
283
+
284
+ /**
285
+ * ADR-154 boot recovery (§5, §6): the composer's sweep of every task with
286
+ * cleanup left from the last process, before any surviving agent is
287
+ * reattached or any grant asked for. Once per process: after it succeeds,
288
+ * a call does nothing, so a later runtime re-activation never sweeps the
289
+ * credentials of tasks running now; concurrent calls share one sweep.
290
+ * Rejects, every task attempted, when any could not be cleaned up: the
291
+ * runner stays `pending`, and a later call tries again.
292
+ */
293
+ export function recoverCloudCredentials(): Promise<void> {
294
+ if (recovered) return Promise.resolve();
295
+ if (recovering !== null) return recovering;
296
+ const composer = runtime;
297
+ if (composer === null) return Promise.reject(new Error("the runner has no cloud credentials composer"));
298
+ const sweep = composer
299
+ .recover((taskId) => readTaskRunEpochState(taskId)?.highestRunEpoch ?? 0)
300
+ .then(() => {
301
+ recovered = true;
302
+ markRecovered();
303
+ })
304
+ .finally(() => {
305
+ if (recovering === sweep) recovering = null;
306
+ });
307
+ recovering = sweep;
308
+ return sweep;
309
+ }
310
+
311
+ /**
312
+ * ADR-154: a runtime activation's currency, on the credentials it began on.
313
+ * One that began on the runner's own credentials stops being current the
314
+ * moment leaving them begins, through its last step, publication included:
315
+ * what its recovery reattached did so on their terms, and a new pass, which
316
+ * sweeps first, must take over (`leftLegacy()` says it was cut short so).
317
+ * One that began off them is current while its runtime generation is.
318
+ */
319
+ export function credentialsActivationFence(isRuntimeCurrent: () => boolean): {
320
+ isCurrent(): boolean;
321
+ leftLegacy(): boolean;
322
+ /** Cut short by leaving, its runtime still current and not operational
323
+ * (however it was suspended): another pass, which sweeps first. */
324
+ needsSweepPass(runtimeOperational: boolean): boolean;
325
+ } {
326
+ const beganOnOwn = onOwnCredentials();
327
+ const leftLegacy = (): boolean => beganOnOwn && !onOwnCredentials();
328
+ return {
329
+ isCurrent: () => isRuntimeCurrent() && (!beganOnOwn || onOwnCredentials()),
330
+ leftLegacy,
331
+ needsSweepPass: (runtimeOperational) => leftLegacy() && isRuntimeCurrent() && !runtimeOperational,
332
+ };
333
+ }
334
+
335
+ /**
336
+ * ADR-154: a runtime's recovery on the runner's credentials as they are.
337
+ * Off its own, the boot sweep of every task's credential cleanup from the
338
+ * last process comes first (once per process), so no surviving agent is
339
+ * reattached before its task's cleanup; a failed sweep fails the recovery,
340
+ * and the runner stays pending. On its own, the recovery stops where
341
+ * leaving them begins and reports stale, so the next pass sweeps first.
342
+ */
343
+ export async function recoverRuntimeOnCredentials(
344
+ recover: (isCurrent: () => boolean) => Promise<boolean>,
345
+ isCurrent: () => boolean,
346
+ sweep: () => Promise<void> = recoverCloudCredentials,
347
+ ): Promise<boolean> {
348
+ if (!onOwnCredentials()) {
349
+ try {
350
+ await sweep();
351
+ } catch (error) {
352
+ console.warn(
353
+ `[credentials] boot recovery failed; the runner stays pending: ${error instanceof Error ? error.message : String(error)}`,
354
+ );
355
+ return false;
356
+ }
357
+ if (!isCurrent()) return false;
358
+ return recover(isCurrent);
359
+ }
360
+ const recovered = await recover(() => isCurrent() && onOwnCredentials());
361
+ return recovered && onOwnCredentials();
362
+ }
363
+
364
+ type RuntimeRecovery = (isCurrent: () => boolean) => Promise<boolean>;
365
+
366
+ /**
367
+ * ADR-154: one runtime activation pass on the runner's credentials as they
368
+ * change. `pass` runs the activation under the fence (its currency through
369
+ * publication) with the credentials-aware recovery. A pass that leaving the
370
+ * runner's own credentials cut short, however it ended (an exception
371
+ * included, reported to `onError` as the tail reports any other), asks for
372
+ * the sweep pass (`rerun`). `recover` is a one-shot boot barrier: it
373
+ * answers only the first recovery a pass asks for, before leaving begins.
374
+ * Once asked (whatever its outcome: false, or a throw) or once leaving has
375
+ * begun, every pass recovers afresh (`freshRecover`), never through a result
376
+ * that settled before a retry or before the sweep.
377
+ */
378
+ export async function activationPassOnCredentials(options: {
379
+ isRuntimeCurrent: () => boolean;
380
+ runtimeOperational: () => boolean;
381
+ recover: RuntimeRecovery;
382
+ freshRecover: RuntimeRecovery;
383
+ pass: (isCurrent: () => boolean, recover: RuntimeRecovery) => Promise<"complete" | "retry" | "rerun">;
384
+ onError?: (error: unknown) => void;
385
+ }): Promise<"complete" | "retry" | "rerun"> {
386
+ const fence = credentialsActivationFence(options.isRuntimeCurrent);
387
+ const recover: RuntimeRecovery = recoverAfresh
388
+ ? options.freshRecover
389
+ : (isCurrent) => {
390
+ recoverAfresh = true;
391
+ return options.recover(isCurrent);
392
+ };
393
+ const cutShort = (): boolean => {
394
+ if (!fence.needsSweepPass(options.runtimeOperational())) return false;
395
+ recoverAfresh = true;
396
+ return true;
397
+ };
398
+ let result: "complete" | "retry" | "rerun";
399
+ try {
400
+ result = await options.pass(
401
+ () => fence.isCurrent(),
402
+ (isCurrent) => recoverRuntimeOnCredentials(recover, isCurrent),
403
+ );
404
+ } catch (error) {
405
+ if (!cutShort()) throw error;
406
+ try {
407
+ options.onError?.(error);
408
+ } catch {
409
+ // A diagnostic must never cost the sweep pass.
410
+ }
411
+ return "rerun";
412
+ }
413
+ return cutShort() ? "rerun" : result;
414
+ }
415
+
416
+ /** Boot recovery has succeeded in this process. */
417
+ export function cloudCredentialsSwept(): boolean {
418
+ return recovered;
419
+ }
420
+
421
+ /** Resolves once boot recovery has succeeded in this process. */
422
+ export function cloudCredentialsRecovered(): Promise<void> {
423
+ return recoveredSignal;
424
+ }
425
+
426
+ let activated = false;
427
+ /** The runs `cloudCredentialsActivated` has asked for again, by task and run. */
428
+ const resumedRuns = new Set<string>();
429
+ let recoveredRuntimeSetup:
430
+ | ((run: { taskId: string; runEpoch: number }, signal: AbortSignal) => Promise<void> | void)
431
+ | null = null;
432
+ const activationFollowers = new Set<() => void>();
433
+
434
+ /** What runs on a recovered task's runtime once its recovered grant opens
435
+ * its gate, however late that grant comes (a task machine: its loader,
436
+ * proxy and editor again); `signal` aborts when its run's recovery ends,
437
+ * and nothing of it may start after. Set once, at boot. */
438
+ export function setRecoveredRuntimeSetup(
439
+ setup: ((run: { taskId: string; runEpoch: number }, signal: AbortSignal) => Promise<void> | void) | null,
440
+ ): void {
441
+ recoveredRuntimeSetup = setup;
442
+ }
443
+
444
+ /** Calls `follower` once, when the runner's tasks first run on cloud
445
+ * credentials in this process. Returns its removal. */
446
+ export function onCloudCredentialsActivated(follower: () => void): () => void {
447
+ activationFollowers.add(follower);
448
+ return () => void activationFollowers.delete(follower);
449
+ }
450
+
451
+ /**
452
+ * The activation authority, the moment the runner's tasks run on cloud
453
+ * credentials (its mode `active`, after recovery): every running task that
454
+ * survived this process's start asks for its run's credentials again, as a
455
+ * recovered start, and its runtime is set up again once its gate opens.
456
+ * Once per process: a later call (a repeated activation) changes nothing,
457
+ * and never touches a grant that is live. Its requests go out once the
458
+ * bridge can send them. Done only once every surviving run was asked for:
459
+ * a call that throws on the way is done again by the next, which asks for
460
+ * each run the first one had not.
461
+ */
462
+ export function cloudCredentialsActivated(): void {
463
+ const composer = activeCloudCredentials();
464
+ if (composer === null || !recovered || activated) return;
465
+ const setup = recoveredRuntimeSetup;
466
+ for (const run of runningTaskRuns()) {
467
+ const key = `${run.taskId}\0${String(run.runEpoch)}`;
468
+ if (resumedRuns.has(key)) continue;
469
+ composer.resumeRecovered(run, setup ? (signal) => setup(run, signal) : undefined);
470
+ resumedRuns.add(key);
471
+ }
472
+ activated = true;
473
+ for (const follower of activationFollowers) {
474
+ try {
475
+ follower();
476
+ } catch (error) {
477
+ console.warn(`[credentials] activation follower failed: ${error instanceof Error ? error.message : String(error)}`);
478
+ }
479
+ }
480
+ }
481
+
482
+ /** Tests: no recovery or activation has run in this process. */
483
+ export function resetCloudCredentialRecoveryForTests(): void {
484
+ recoverAfresh = false;
485
+ recovering = null;
486
+ recovered = false;
487
+ recoveredSignal = new Promise<void>((resolve) => (markRecovered = resolve));
488
+ activated = false;
489
+ resumedRuns.clear();
490
+ recoveredRuntimeSetup = null;
491
+ activationFollowers.clear();
492
+ }
493
+
494
+ /**
495
+ * A local operator's Stop of a task on cloud credentials: the start under
496
+ * way, as it is now, ends before Stop queues behind it (never one found
497
+ * later by task id, which could be a replacement). The run Stop closes has
498
+ * its credentials cleaned up inside the stop, by the composer as it is then.
499
+ * Null on a runner not on cloud credentials.
500
+ */
501
+ export function localStopCredentials(taskId: string): { closeCredentials(runEpoch: number): Promise<void> } | null {
502
+ const credentials = activeCloudCredentials();
503
+ if (credentials === null) return null;
504
+ const start = credentials.currentStart(taskId);
505
+ if (start) credentials.interrupt(start);
506
+ // The task's recovery after a restart ends too.
507
+ credentials.interruptRecovery(taskId);
508
+ return {
509
+ closeCredentials: (runEpoch) =>
510
+ activeCloudCredentials()?.closeAssignment({ taskId, runEpoch }, "stop") ?? Promise.resolve(),
511
+ };
512
+ }