@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.
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1837 -224
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +650 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1061 -139
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- 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
|
+
]);
|