@runuai/host 0.9.152 → 0.9.154
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/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +10 -0
- package/lib/agent.ts +4 -0
- package/lib/browser-testing.ts +8 -3
- package/lib/cloud-credential-leases.ts +24 -4
- package/lib/cloud-credential-runtime.ts +322 -17
- package/lib/credential-cleanup-inventory.ts +2 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +140 -32
- package/lib/credential-consumers/mcp.ts +6 -1
- package/lib/mcp-gateway.ts +576 -122
- package/lib/orchestrator.ts +91 -9
- package/lib/runtime-state.ts +88 -0
- package/lib/shared-files-s3.ts +743 -3
- package/lib/sidecar-resume.ts +9 -0
- package/lib/task-credential-cleanup.ts +36 -5
- package/lib/task-environment/machine-task-up.ts +35 -19
- package/lib/task-files-machine-retirement.ts +88 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-person-credentials.ts +76 -0
- package/package.json +1 -1
- package/src/cloud-credentials.ts +10 -0
- package/src/index.ts +40 -7
- package/src/main.ts +49 -2
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 Files v1 on task machines.
|
|
3
|
+
*
|
|
4
|
+
* A Files session is deliberately unlike the other leased credentials: the
|
|
5
|
+
* Mountpoint process needs a renewable credential_process file for the whole
|
|
6
|
+
* life of the mount. The cloud still remains the only authority. A grant is
|
|
7
|
+
* kept in memory until its exact gate opens; only then may task-up put the
|
|
8
|
+
* session in the task machine's root-only tmpfs and mount the prefixes named
|
|
9
|
+
* by that grant.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
chmodSync,
|
|
13
|
+
closeSync,
|
|
14
|
+
constants,
|
|
15
|
+
existsSync,
|
|
16
|
+
fsyncSync,
|
|
17
|
+
lstatSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
openSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
statfsSync,
|
|
23
|
+
statSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { dirname } from "node:path";
|
|
27
|
+
|
|
28
|
+
import type {
|
|
29
|
+
CredentialInstallRequest,
|
|
30
|
+
TaskAssignment,
|
|
31
|
+
TaskCredentialView,
|
|
32
|
+
TaskLeaseFilesAccess,
|
|
33
|
+
} from "../cloud-credential-leases";
|
|
34
|
+
import {
|
|
35
|
+
AuthoritativeTaskFilesFailure,
|
|
36
|
+
type TaskFilesConsumer,
|
|
37
|
+
} from "../cloud-credential-runtime";
|
|
38
|
+
import {
|
|
39
|
+
listCredentialCleanupPaths,
|
|
40
|
+
rememberCredentialCleanupPath,
|
|
41
|
+
} from "../credential-cleanup-inventory";
|
|
42
|
+
import {
|
|
43
|
+
LEASE_AWS_CONFIG_FILE,
|
|
44
|
+
LEASE_CREDENTIALS_FILE,
|
|
45
|
+
leaseAwsConfig,
|
|
46
|
+
} from "../lease";
|
|
47
|
+
import {
|
|
48
|
+
hideSharedFilesV1Sync,
|
|
49
|
+
mountSharedFilesFromAccess,
|
|
50
|
+
publishSharedFilesV1Sync,
|
|
51
|
+
type SharedFilesMode,
|
|
52
|
+
unmountSharedFilesStrict,
|
|
53
|
+
} from "../shared-files-s3";
|
|
54
|
+
import { reconstructTaskEnvironment } from "../task-environment";
|
|
55
|
+
import {
|
|
56
|
+
parseTaskEnvironmentLocator,
|
|
57
|
+
serializeTaskEnvironmentLocator,
|
|
58
|
+
type TaskEnvironmentHandle,
|
|
59
|
+
type TaskEnvironmentLocator,
|
|
60
|
+
} from "../task-environment/types";
|
|
61
|
+
import type { TaskCredentialCleanupRequest } from "../task-credential-cleanup";
|
|
62
|
+
import type {
|
|
63
|
+
TaskFilesMachineRetirementReason,
|
|
64
|
+
TaskFilesMachineRetirementRequest,
|
|
65
|
+
} from "../task-files-machine-retirement";
|
|
66
|
+
import { TMPFS_MAGIC, taskFilesV1Readiness } from "../task-files-readiness";
|
|
67
|
+
|
|
68
|
+
export const FILES_SESSION_CREDENTIAL_ID = "files-session";
|
|
69
|
+
export const FILES_SESSION_STAGED_SUFFIX = ".uai-tmp";
|
|
70
|
+
export const FILES_SESSION_MANAGED_PATHS = [
|
|
71
|
+
LEASE_CREDENTIALS_FILE,
|
|
72
|
+
`${LEASE_CREDENTIALS_FILE}${FILES_SESSION_STAGED_SUFFIX}`,
|
|
73
|
+
LEASE_AWS_CONFIG_FILE,
|
|
74
|
+
`${LEASE_AWS_CONFIG_FILE}${FILES_SESSION_STAGED_SUFFIX}`,
|
|
75
|
+
] as const;
|
|
76
|
+
|
|
77
|
+
type RuntimeEnvironment = Pick<
|
|
78
|
+
TaskEnvironmentHandle<unknown>,
|
|
79
|
+
"descriptor" | "exec" | "writeWorkspaceFile"
|
|
80
|
+
>;
|
|
81
|
+
|
|
82
|
+
interface FilesCandidate {
|
|
83
|
+
view: TaskCredentialView;
|
|
84
|
+
readonly access: TaskLeaseFilesAccess;
|
|
85
|
+
/** Valid only for the install transaction which created this candidate.
|
|
86
|
+
* After open, the consumer's exact served candidate + abort fence is the
|
|
87
|
+
* authority; the registry deliberately makes this callback false. */
|
|
88
|
+
installCurrent: () => boolean;
|
|
89
|
+
readonly controller: AbortController;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface FilesBinding {
|
|
93
|
+
readonly locator: TaskEnvironmentLocator;
|
|
94
|
+
readonly workspacePath: string;
|
|
95
|
+
readonly mode: SharedFilesMode;
|
|
96
|
+
/** Present in the process which established/recovered the runtime. */
|
|
97
|
+
environment: RuntimeEnvironment | null;
|
|
98
|
+
/** The exact prefixes are mounted below the root-only fixed directory. */
|
|
99
|
+
mounted: boolean;
|
|
100
|
+
/** The workspace link has been atomically published for this run. A
|
|
101
|
+
* replacement grant can stage a remount while this remains false; the
|
|
102
|
+
* registry's synchronous open is the only operation which flips it. */
|
|
103
|
+
published: boolean;
|
|
104
|
+
/** Non-secret identity of the prefixes which are physically mounted. */
|
|
105
|
+
mountIdentity: string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface FilesState {
|
|
109
|
+
readonly assignment: TaskAssignment;
|
|
110
|
+
candidate: FilesCandidate | null;
|
|
111
|
+
served: FilesCandidate | null;
|
|
112
|
+
binding: FilesBinding | null;
|
|
113
|
+
gateOpen: boolean;
|
|
114
|
+
tail: Promise<void>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface TaskFilesV1Consumer extends TaskFilesConsumer {
|
|
118
|
+
/** Put the exact open grant into this runtime and mount its Files roots. */
|
|
119
|
+
runtimeReady(
|
|
120
|
+
assignment: Readonly<TaskAssignment>,
|
|
121
|
+
environment: RuntimeEnvironment,
|
|
122
|
+
mode: SharedFilesMode,
|
|
123
|
+
signal: AbortSignal,
|
|
124
|
+
): Promise<void>;
|
|
125
|
+
/** Forget values and refuse new work at process shutdown; leave mounts and
|
|
126
|
+
* files for the next process's recovery cleanup. */
|
|
127
|
+
shutdown(): void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface TaskFilesV1ConsumerDeps {
|
|
131
|
+
rememberPath?: typeof rememberCredentialCleanupPath;
|
|
132
|
+
listPaths?: typeof listCredentialCleanupPaths;
|
|
133
|
+
writeSession?: (access: TaskLeaseFilesAccess) => void | Promise<void>;
|
|
134
|
+
mount?: typeof mountSharedFilesFromAccess;
|
|
135
|
+
publish?: typeof publishSharedFilesV1Sync;
|
|
136
|
+
hide?: typeof hideSharedFilesV1Sync;
|
|
137
|
+
unmount?: typeof unmountSharedFilesStrict;
|
|
138
|
+
reconstruct?: (
|
|
139
|
+
locator: TaskEnvironmentLocator,
|
|
140
|
+
) => Promise<TaskEnvironmentHandle<unknown>>;
|
|
141
|
+
/** A runtime which survived this process is bound before its first
|
|
142
|
+
* recovered grant installs. This lets install stage the mount while the
|
|
143
|
+
* gate is still closed, rather than exposing a credential-less interval
|
|
144
|
+
* to processes which survived the restart. */
|
|
145
|
+
recoveredBinding?: (
|
|
146
|
+
assignment: Readonly<TaskAssignment>,
|
|
147
|
+
) =>
|
|
148
|
+
| {
|
|
149
|
+
locator: TaskEnvironmentLocator;
|
|
150
|
+
workspacePath: string;
|
|
151
|
+
mode: SharedFilesMode;
|
|
152
|
+
}
|
|
153
|
+
| null;
|
|
154
|
+
ready?: () => boolean;
|
|
155
|
+
/** A task machine whose Files boundary cannot be hidden or retired must
|
|
156
|
+
* leave service. The cutover installs a handler which quarantines the
|
|
157
|
+
* local task and durably asks the cloud to retire this exact run's machine;
|
|
158
|
+
* tests may observe the request and let the original error throw. */
|
|
159
|
+
retireMachine?: (request: TaskFilesMachineRetirementRequest) => void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function sameAssignment(
|
|
163
|
+
left: Readonly<TaskAssignment>,
|
|
164
|
+
right: Readonly<TaskAssignment>,
|
|
165
|
+
): boolean {
|
|
166
|
+
return left.taskId === right.taskId && left.runEpoch === right.runEpoch;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sameFilesAccess(
|
|
170
|
+
left: Readonly<TaskLeaseFilesAccess>,
|
|
171
|
+
right: Readonly<TaskLeaseFilesAccess>,
|
|
172
|
+
): boolean {
|
|
173
|
+
return (
|
|
174
|
+
sameAssignment(left.assignment, right.assignment) &&
|
|
175
|
+
left.region === right.region &&
|
|
176
|
+
left.files.bucket === right.files.bucket &&
|
|
177
|
+
left.files.orgPrefix === right.files.orgPrefix &&
|
|
178
|
+
left.files.userPrefix === right.files.userPrefix &&
|
|
179
|
+
left.credentials.accessKeyId === right.credentials.accessKeyId &&
|
|
180
|
+
left.credentials.secretAccessKey === right.credentials.secretAccessKey &&
|
|
181
|
+
left.credentials.sessionToken === right.credentials.sessionToken &&
|
|
182
|
+
left.credentials.expiresAt === right.credentials.expiresAt
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function filesScopes(access: TaskLeaseFilesAccess): Array<"org" | "me"> {
|
|
187
|
+
return access.files.orgPrefix === null ? ["me"] : ["org", "me"];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function filesMountIdentity(
|
|
191
|
+
mode: SharedFilesMode,
|
|
192
|
+
access: TaskLeaseFilesAccess,
|
|
193
|
+
): string {
|
|
194
|
+
return JSON.stringify({
|
|
195
|
+
mode,
|
|
196
|
+
region: access.region,
|
|
197
|
+
bucket: access.files.bucket,
|
|
198
|
+
orgPrefix: access.files.orgPrefix,
|
|
199
|
+
userPrefix: access.files.userPrefix,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function assertCanonicalTaskMachineRuntime(
|
|
204
|
+
assignment: Readonly<TaskAssignment>,
|
|
205
|
+
environment: RuntimeEnvironment,
|
|
206
|
+
): void {
|
|
207
|
+
const { descriptor } = environment;
|
|
208
|
+
const locator = descriptor.locator;
|
|
209
|
+
const value = locator.value;
|
|
210
|
+
if (
|
|
211
|
+
descriptor.taskId !== assignment.taskId ||
|
|
212
|
+
descriptor.workspacePath !== "/workspace" ||
|
|
213
|
+
locator.provider !== "self" ||
|
|
214
|
+
typeof value !== "object" ||
|
|
215
|
+
value === null ||
|
|
216
|
+
Array.isArray(value) ||
|
|
217
|
+
(value as Record<string, unknown>).taskId !== assignment.taskId ||
|
|
218
|
+
(value as Record<string, unknown>).workspacePath !== "/workspace"
|
|
219
|
+
) {
|
|
220
|
+
throw new Error("the Files session requires the exact canonical task-machine runtime");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function filesCredentialProcessOutput(access: TaskLeaseFilesAccess): string {
|
|
225
|
+
return JSON.stringify({
|
|
226
|
+
Version: 1,
|
|
227
|
+
AccessKeyId: access.credentials.accessKeyId,
|
|
228
|
+
SecretAccessKey: access.credentials.secretAccessKey,
|
|
229
|
+
SessionToken: access.credentials.sessionToken,
|
|
230
|
+
Expiration: new Date(access.credentials.expiresAt).toISOString(),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function ensurePrivateRunDirectory(): void {
|
|
235
|
+
const root = dirname(LEASE_CREDENTIALS_FILE);
|
|
236
|
+
if (!existsSync(root)) mkdirSync(root, { mode: 0o700 });
|
|
237
|
+
const info = lstatSync(root);
|
|
238
|
+
if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== 0) {
|
|
239
|
+
throw new Error("the Files session directory is not a root-owned directory");
|
|
240
|
+
}
|
|
241
|
+
chmodSync(root, 0o700);
|
|
242
|
+
// `/run` being tmpfs is not enough: a pre-existing `/run/uai` could be a
|
|
243
|
+
// bind mount onto persistent storage. Prove the directory which will hold
|
|
244
|
+
// the bytes immediately before every atomic write pass.
|
|
245
|
+
if (Number(statfsSync(root).type) !== TMPFS_MAGIC) {
|
|
246
|
+
throw new Error("the Files session directory is not memory-backed");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function atomicRootFile(path: string, contents: string): void {
|
|
251
|
+
const staged = `${path}${FILES_SESSION_STAGED_SUFFIX}`;
|
|
252
|
+
// An interrupted older write is itself inventoried. Removing its directory
|
|
253
|
+
// entry cannot follow it if it was somehow replaced by a symlink.
|
|
254
|
+
rmSync(staged, { force: true });
|
|
255
|
+
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
256
|
+
const descriptor = openSync(
|
|
257
|
+
staged,
|
|
258
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow,
|
|
259
|
+
0o600,
|
|
260
|
+
);
|
|
261
|
+
try {
|
|
262
|
+
writeFileSync(descriptor, contents, { encoding: "utf8" });
|
|
263
|
+
fsyncSync(descriptor);
|
|
264
|
+
} finally {
|
|
265
|
+
closeSync(descriptor);
|
|
266
|
+
}
|
|
267
|
+
renameSync(staged, path);
|
|
268
|
+
chmodSync(path, 0o600);
|
|
269
|
+
const written = statSync(path);
|
|
270
|
+
if (!written.isFile() || written.uid !== 0 || (written.mode & 0o777) !== 0o600) {
|
|
271
|
+
throw new Error("a Files session file is not a root-owned regular file");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Atomic root-only write. The caller has already proved `/run` is tmpfs
|
|
276
|
+
* and inventoried both final and staged names. */
|
|
277
|
+
export function writeTaskFilesSession(access: TaskLeaseFilesAccess): void {
|
|
278
|
+
if (!taskFilesV1Readiness(true).ready) {
|
|
279
|
+
throw new Error("the host cannot safely persist a Files session");
|
|
280
|
+
}
|
|
281
|
+
ensurePrivateRunDirectory();
|
|
282
|
+
atomicRootFile(LEASE_CREDENTIALS_FILE, filesCredentialProcessOutput(access));
|
|
283
|
+
atomicRootFile(LEASE_AWS_CONFIG_FILE, leaseAwsConfig({ region: access.region }));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function abortError(signal?: AbortSignal): Error {
|
|
287
|
+
return signal?.reason instanceof Error
|
|
288
|
+
? signal.reason
|
|
289
|
+
: new Error("the task's Files session was withdrawn");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** One serialized Files consumer per task-machine process. */
|
|
293
|
+
export function createTaskFilesV1Consumer(
|
|
294
|
+
deps: TaskFilesV1ConsumerDeps = {},
|
|
295
|
+
): TaskFilesV1Consumer {
|
|
296
|
+
const states = new Map<string, FilesState>();
|
|
297
|
+
let stopped = false;
|
|
298
|
+
const remember = deps.rememberPath ?? rememberCredentialCleanupPath;
|
|
299
|
+
const list = deps.listPaths ?? listCredentialCleanupPaths;
|
|
300
|
+
const writeSession = deps.writeSession ?? writeTaskFilesSession;
|
|
301
|
+
const mount = deps.mount ?? mountSharedFilesFromAccess;
|
|
302
|
+
const publish = deps.publish ?? publishSharedFilesV1Sync;
|
|
303
|
+
const hide = deps.hide ?? hideSharedFilesV1Sync;
|
|
304
|
+
const unmount = deps.unmount ?? unmountSharedFilesStrict;
|
|
305
|
+
const reconstruct =
|
|
306
|
+
deps.reconstruct ??
|
|
307
|
+
((locator: TaskEnvironmentLocator) => reconstructTaskEnvironment(locator, "loader"));
|
|
308
|
+
const recoveredBinding = deps.recoveredBinding ?? (() => null);
|
|
309
|
+
const ready = deps.ready ?? (() => taskFilesV1Readiness(true).ready);
|
|
310
|
+
let retirementRequested = false;
|
|
311
|
+
const retireMachine =
|
|
312
|
+
deps.retireMachine ??
|
|
313
|
+
((request: TaskFilesMachineRetirementRequest): void => {
|
|
314
|
+
throw new Error("task-machine Files retirement is not installed", {
|
|
315
|
+
cause: request.error,
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const terminalFailure = (
|
|
320
|
+
assignment: Readonly<TaskAssignment>,
|
|
321
|
+
reason: TaskFilesMachineRetirementReason,
|
|
322
|
+
error: unknown,
|
|
323
|
+
message: string,
|
|
324
|
+
): Error => {
|
|
325
|
+
const failure = error instanceof Error ? error : new Error(message, { cause: error });
|
|
326
|
+
// Terminal is one-way for this process. No retry or later grant may
|
|
327
|
+
// republish Files while the cutover-owned retirement is pending.
|
|
328
|
+
stopped = true;
|
|
329
|
+
for (const state of states.values()) {
|
|
330
|
+
state.gateOpen = false;
|
|
331
|
+
state.candidate?.controller.abort(failure);
|
|
332
|
+
if (state.served !== state.candidate) state.served?.controller.abort(failure);
|
|
333
|
+
}
|
|
334
|
+
// Re-attempt the local visibility fence before handing off. The handler
|
|
335
|
+
// owns the stronger task-process quarantine and durable cloud request.
|
|
336
|
+
for (const workspacePath of new Set(
|
|
337
|
+
[...states.values()].flatMap((state) =>
|
|
338
|
+
state.binding ? [state.binding.workspacePath] : [],
|
|
339
|
+
),
|
|
340
|
+
)) {
|
|
341
|
+
try {
|
|
342
|
+
hide(workspacePath);
|
|
343
|
+
} catch {
|
|
344
|
+
// The terminal handler receives the original proof failure and must
|
|
345
|
+
// contain the whole task even when HIDE remains unprovable.
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!retirementRequested) {
|
|
349
|
+
retireMachine({ assignment: { ...assignment }, reason, error: failure });
|
|
350
|
+
retirementRequested = true;
|
|
351
|
+
}
|
|
352
|
+
return failure;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const throwAuthoritativeFailure = (
|
|
356
|
+
assignment: Readonly<TaskAssignment>,
|
|
357
|
+
reason: TaskFilesMachineRetirementReason,
|
|
358
|
+
error: unknown,
|
|
359
|
+
message: string,
|
|
360
|
+
): never => {
|
|
361
|
+
try {
|
|
362
|
+
terminalFailure(assignment, reason, error, message);
|
|
363
|
+
} catch (retirementError) {
|
|
364
|
+
throw new AuthoritativeTaskFilesFailure(retirementError);
|
|
365
|
+
}
|
|
366
|
+
throw new AuthoritativeTaskFilesFailure(error);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const publishBoundary = (
|
|
370
|
+
assignment: Readonly<TaskAssignment>,
|
|
371
|
+
binding: FilesBinding,
|
|
372
|
+
access: TaskLeaseFilesAccess,
|
|
373
|
+
afterPublish?: () => void,
|
|
374
|
+
): void => {
|
|
375
|
+
try {
|
|
376
|
+
publish(binding.workspacePath, binding.mode, filesScopes(access));
|
|
377
|
+
binding.published = true;
|
|
378
|
+
afterPublish?.();
|
|
379
|
+
} catch (error) {
|
|
380
|
+
// A lost spawnSync result can mean LINK made the root traversable
|
|
381
|
+
// before its caller saw an error. Hide without relying on the success
|
|
382
|
+
// bit. This also compensates a fence which fails just after LINK.
|
|
383
|
+
try {
|
|
384
|
+
hide(binding.workspacePath);
|
|
385
|
+
binding.published = false;
|
|
386
|
+
} catch (hideError) {
|
|
387
|
+
const failure = new AggregateError(
|
|
388
|
+
[error, hideError],
|
|
389
|
+
"the task's Files boundary could not be hidden after publication failed",
|
|
390
|
+
);
|
|
391
|
+
throwAuthoritativeFailure(
|
|
392
|
+
assignment,
|
|
393
|
+
"visibility-unproven",
|
|
394
|
+
failure,
|
|
395
|
+
"the task's Files boundary could not be hidden after publication failed",
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const assertCandidate = (
|
|
403
|
+
state: FilesState,
|
|
404
|
+
candidate: FilesCandidate,
|
|
405
|
+
signal?: AbortSignal,
|
|
406
|
+
requireOpen = false,
|
|
407
|
+
): void => {
|
|
408
|
+
if (
|
|
409
|
+
stopped ||
|
|
410
|
+
states.get(state.assignment.taskId) !== state ||
|
|
411
|
+
(requireOpen ? state.served !== candidate : state.candidate !== candidate) ||
|
|
412
|
+
candidate.controller.signal.aborted ||
|
|
413
|
+
signal?.aborted ||
|
|
414
|
+
(!requireOpen && !candidate.installCurrent()) ||
|
|
415
|
+
(requireOpen && !state.gateOpen)
|
|
416
|
+
) {
|
|
417
|
+
throw abortError(signal ?? candidate.controller.signal);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const queue = (state: FilesState, work: () => Promise<void>): Promise<void> => {
|
|
422
|
+
const next = state.tail.catch(() => undefined).then(work);
|
|
423
|
+
state.tail = next;
|
|
424
|
+
return next;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const inventory = (state: FilesState, binding: FilesBinding): void => {
|
|
428
|
+
for (const path of FILES_SESSION_MANAGED_PATHS) {
|
|
429
|
+
remember({
|
|
430
|
+
taskId: state.assignment.taskId,
|
|
431
|
+
runEpoch: state.assignment.runEpoch,
|
|
432
|
+
runtime: binding.locator,
|
|
433
|
+
lane: "files-session",
|
|
434
|
+
path,
|
|
435
|
+
credentialId: FILES_SESSION_CREDENTIAL_ID,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const materialize = async (
|
|
441
|
+
state: FilesState,
|
|
442
|
+
candidate: FilesCandidate,
|
|
443
|
+
binding: FilesBinding,
|
|
444
|
+
signal: AbortSignal | undefined,
|
|
445
|
+
requireOpen: boolean,
|
|
446
|
+
): Promise<void> => {
|
|
447
|
+
let environment = binding.environment;
|
|
448
|
+
if (!environment) {
|
|
449
|
+
const recovered = await reconstruct(binding.locator);
|
|
450
|
+
if (
|
|
451
|
+
recovered.descriptor.taskId !== state.assignment.taskId ||
|
|
452
|
+
recovered.descriptor.workspacePath !== binding.workspacePath
|
|
453
|
+
) {
|
|
454
|
+
throw new Error("the recovered Files runtime does not match its assignment");
|
|
455
|
+
}
|
|
456
|
+
binding.environment = recovered;
|
|
457
|
+
environment = recovered;
|
|
458
|
+
}
|
|
459
|
+
assertCandidate(state, candidate, signal, requireOpen);
|
|
460
|
+
// Never accept a healthy-looking mount inherited from v0 or from a
|
|
461
|
+
// crashed process as this grant's. The first pass in this process
|
|
462
|
+
// removes and proves both endpoints before it puts any session on disk.
|
|
463
|
+
const establish = !binding.mounted;
|
|
464
|
+
const mountIdentity = filesMountIdentity(binding.mode, candidate.access);
|
|
465
|
+
if (!establish && binding.mountIdentity !== mountIdentity) {
|
|
466
|
+
throw new Error("the Files mount authority changed and requires full cleanup");
|
|
467
|
+
}
|
|
468
|
+
if (establish) {
|
|
469
|
+
await unmount(environment, binding.workspacePath, {
|
|
470
|
+
admit: () => assertCandidate(state, candidate, signal, requireOpen),
|
|
471
|
+
});
|
|
472
|
+
assertCandidate(state, candidate, signal, requireOpen);
|
|
473
|
+
}
|
|
474
|
+
if (binding.mode === "off") {
|
|
475
|
+
binding.mounted = true;
|
|
476
|
+
binding.published = true;
|
|
477
|
+
binding.mountIdentity = mountIdentity;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
// Durable witnesses precede the first byte of either final or staged
|
|
481
|
+
// file. They remain if anything after this point fails.
|
|
482
|
+
inventory(state, binding);
|
|
483
|
+
assertCandidate(state, candidate, signal, requireOpen);
|
|
484
|
+
await writeSession(candidate.access);
|
|
485
|
+
assertCandidate(state, candidate, signal, requireOpen);
|
|
486
|
+
if (establish) {
|
|
487
|
+
await mount(
|
|
488
|
+
environment as TaskEnvironmentHandle<unknown>,
|
|
489
|
+
binding.workspacePath,
|
|
490
|
+
binding.mode,
|
|
491
|
+
candidate.access,
|
|
492
|
+
{
|
|
493
|
+
admit: () => assertCandidate(state, candidate, signal, requireOpen),
|
|
494
|
+
publish: false,
|
|
495
|
+
},
|
|
496
|
+
);
|
|
497
|
+
binding.mounted = true;
|
|
498
|
+
binding.published = false;
|
|
499
|
+
binding.mountIdentity = mountIdentity;
|
|
500
|
+
}
|
|
501
|
+
assertCandidate(state, candidate, signal, requireOpen);
|
|
502
|
+
if (requireOpen && !binding.published) {
|
|
503
|
+
publishBoundary(state.assignment, binding, candidate.access, () =>
|
|
504
|
+
assertCandidate(state, candidate, signal, requireOpen),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const compensateRuntimeReadyFailure = async (
|
|
510
|
+
state: FilesState,
|
|
511
|
+
candidate: FilesCandidate,
|
|
512
|
+
binding: FilesBinding,
|
|
513
|
+
environment: RuntimeEnvironment,
|
|
514
|
+
signal: AbortSignal,
|
|
515
|
+
error: unknown,
|
|
516
|
+
): Promise<never> => {
|
|
517
|
+
// A helper which already failed its visibility proof has performed the
|
|
518
|
+
// one-way retirement transition. Preserve that authoritative cause.
|
|
519
|
+
if (error instanceof AuthoritativeTaskFilesFailure) throw error;
|
|
520
|
+
// A stale setup must never hide or unmount a winner which took ownership
|
|
521
|
+
// while this one was suspended.
|
|
522
|
+
assertCandidate(state, candidate, signal, true);
|
|
523
|
+
|
|
524
|
+
// LINK or mount(2) may have succeeded before a provider result was lost.
|
|
525
|
+
// Ignore both success bits: hide first, then prove the fixed mounts gone.
|
|
526
|
+
try {
|
|
527
|
+
hide(binding.workspacePath);
|
|
528
|
+
binding.published = false;
|
|
529
|
+
} catch (hideError) {
|
|
530
|
+
throwAuthoritativeFailure(
|
|
531
|
+
state.assignment,
|
|
532
|
+
"visibility-unproven",
|
|
533
|
+
new AggregateError(
|
|
534
|
+
[error, hideError],
|
|
535
|
+
"the task's Files boundary could not be hidden after setup failed",
|
|
536
|
+
),
|
|
537
|
+
"the task's Files boundary could not be hidden after setup failed",
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
await unmount(environment, binding.workspacePath, {
|
|
543
|
+
admit: () => assertCandidate(state, candidate, signal, true),
|
|
544
|
+
});
|
|
545
|
+
} catch (unmountError) {
|
|
546
|
+
// A close or replacement which won while compensation was awaiting the
|
|
547
|
+
// provider owns the remaining cleanup. Never retire it for this stale
|
|
548
|
+
// setup attempt.
|
|
549
|
+
assertCandidate(state, candidate, signal, true);
|
|
550
|
+
throwAuthoritativeFailure(
|
|
551
|
+
state.assignment,
|
|
552
|
+
"mount-retirement-unproven",
|
|
553
|
+
new AggregateError(
|
|
554
|
+
[error, unmountError],
|
|
555
|
+
"the task's Files mounts could not be proven absent after setup failed",
|
|
556
|
+
),
|
|
557
|
+
"the task's Files mounts could not be proven absent after setup failed",
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
binding.mounted = false;
|
|
562
|
+
binding.published = false;
|
|
563
|
+
binding.mountIdentity = null;
|
|
564
|
+
assertCandidate(state, candidate, signal, true);
|
|
565
|
+
throw error;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
async install(request): Promise<void> {
|
|
570
|
+
if (stopped) throw new Error("the Files consumer is shut down");
|
|
571
|
+
if (!ready()) throw new Error("task-machine Files v1 is unavailable on this host");
|
|
572
|
+
if (!request.files || !sameAssignment(request.assignment, request.files.assignment)) {
|
|
573
|
+
throw new Error("a task-machine grant has no Files session for its assignment");
|
|
574
|
+
}
|
|
575
|
+
const existing = states.get(request.assignment.taskId);
|
|
576
|
+
if (existing && existing.assignment.runEpoch > request.assignment.runEpoch) {
|
|
577
|
+
throw new Error("an older run cannot install a Files session");
|
|
578
|
+
}
|
|
579
|
+
// A credential.changed frame changes an engine or MCP record inside the
|
|
580
|
+
// same lease. It does not replace the Files session, remount anything,
|
|
581
|
+
// or abort runtimeReady already under way. Retarget only the view used
|
|
582
|
+
// by a gate which the registry may reopen after a refused credential.
|
|
583
|
+
if (request.reason === "credential-change") {
|
|
584
|
+
const prior = [existing?.candidate, existing?.served].find(
|
|
585
|
+
(candidate): candidate is FilesCandidate => candidate?.view === request.previous,
|
|
586
|
+
);
|
|
587
|
+
if (
|
|
588
|
+
!existing ||
|
|
589
|
+
!sameAssignment(existing.assignment, request.assignment) ||
|
|
590
|
+
!prior ||
|
|
591
|
+
!sameFilesAccess(prior.access, request.files)
|
|
592
|
+
) {
|
|
593
|
+
throw new Error("a Files credential change does not match its grant");
|
|
594
|
+
}
|
|
595
|
+
if (existing.gateOpen && !prior.controller.signal.aborted) {
|
|
596
|
+
// The normal case: preserve the exact candidate so a Files setup
|
|
597
|
+
// already in flight remains authoritative.
|
|
598
|
+
assertCandidate(existing, prior, undefined, prior === existing.served);
|
|
599
|
+
prior.view = request.next;
|
|
600
|
+
prior.installCurrent = request.isCurrent;
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
// A refused credential closes the gate before its selective cleanup.
|
|
604
|
+
// Its candidate was aborted by that close, so give the same Files
|
|
605
|
+
// session a fresh authority which the registry can open afterwards.
|
|
606
|
+
const replacement: FilesCandidate = {
|
|
607
|
+
view: request.next,
|
|
608
|
+
access: request.files,
|
|
609
|
+
installCurrent: request.isCurrent,
|
|
610
|
+
controller: new AbortController(),
|
|
611
|
+
};
|
|
612
|
+
existing.candidate = replacement;
|
|
613
|
+
assertCandidate(existing, replacement);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const controller = new AbortController();
|
|
617
|
+
const candidate: FilesCandidate = {
|
|
618
|
+
view: request.next,
|
|
619
|
+
access: request.files,
|
|
620
|
+
installCurrent: request.isCurrent,
|
|
621
|
+
controller,
|
|
622
|
+
};
|
|
623
|
+
let state: FilesState;
|
|
624
|
+
if (!existing || existing.assignment.runEpoch < request.assignment.runEpoch) {
|
|
625
|
+
if (existing?.candidate && existing.candidate !== existing.served) {
|
|
626
|
+
existing.candidate.controller.abort(abortError());
|
|
627
|
+
}
|
|
628
|
+
let recovered: ReturnType<typeof recoveredBinding>;
|
|
629
|
+
try {
|
|
630
|
+
recovered = recoveredBinding(request.assignment);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
if (request.isCurrent()) {
|
|
633
|
+
terminalFailure(
|
|
634
|
+
request.assignment,
|
|
635
|
+
"setup-unproven",
|
|
636
|
+
error,
|
|
637
|
+
"the recovered task's Files boundary could not be reconstructed",
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
throw error;
|
|
641
|
+
}
|
|
642
|
+
state = {
|
|
643
|
+
assignment: { ...request.assignment },
|
|
644
|
+
candidate,
|
|
645
|
+
served: null,
|
|
646
|
+
binding: recovered
|
|
647
|
+
? {
|
|
648
|
+
...recovered,
|
|
649
|
+
environment: null,
|
|
650
|
+
mounted: false,
|
|
651
|
+
published: false,
|
|
652
|
+
mountIdentity: null,
|
|
653
|
+
}
|
|
654
|
+
: null,
|
|
655
|
+
gateOpen: false,
|
|
656
|
+
tail: Promise.resolve(),
|
|
657
|
+
};
|
|
658
|
+
states.set(request.assignment.taskId, state);
|
|
659
|
+
} else {
|
|
660
|
+
if (existing.candidate && existing.candidate !== existing.served) {
|
|
661
|
+
existing.candidate.controller.abort(abortError());
|
|
662
|
+
}
|
|
663
|
+
existing.candidate = candidate;
|
|
664
|
+
state = existing;
|
|
665
|
+
}
|
|
666
|
+
// A running/recovered runtime refreshes its credential_process session
|
|
667
|
+
// before the replacement grant can open. A first grant has no binding
|
|
668
|
+
// yet and remains memory-only until runtimeReady.
|
|
669
|
+
if (state.binding) {
|
|
670
|
+
const binding = state.binding;
|
|
671
|
+
await queue(state, () => materialize(state, candidate, binding, undefined, false));
|
|
672
|
+
}
|
|
673
|
+
assertCandidate(state, candidate);
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
open(assignment, view): void {
|
|
677
|
+
const state = states.get(assignment.taskId);
|
|
678
|
+
if (
|
|
679
|
+
stopped ||
|
|
680
|
+
!state ||
|
|
681
|
+
!sameAssignment(state.assignment, assignment) ||
|
|
682
|
+
!state.candidate ||
|
|
683
|
+
state.candidate.view !== view ||
|
|
684
|
+
state.candidate.controller.signal.aborted
|
|
685
|
+
) {
|
|
686
|
+
throw new Error("the Files gate cannot open on this grant");
|
|
687
|
+
}
|
|
688
|
+
const next = state.candidate;
|
|
689
|
+
const previous = state.served;
|
|
690
|
+
const binding = state.binding;
|
|
691
|
+
if (binding?.mounted && !binding.published) {
|
|
692
|
+
publishBoundary(state.assignment, binding, next.access);
|
|
693
|
+
}
|
|
694
|
+
state.served = next;
|
|
695
|
+
state.gateOpen = true;
|
|
696
|
+
if (previous && previous !== next) previous.controller.abort(abortError());
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
close(assignment): void {
|
|
700
|
+
const state = states.get(assignment.taskId);
|
|
701
|
+
if (!state || state.assignment.runEpoch > assignment.runEpoch) return;
|
|
702
|
+
state.gateOpen = false;
|
|
703
|
+
const served = state.served;
|
|
704
|
+
served?.controller.abort(abortError());
|
|
705
|
+
if (state.candidate !== served) state.candidate?.controller.abort(abortError());
|
|
706
|
+
if (state.binding) {
|
|
707
|
+
try {
|
|
708
|
+
// Do not trust `published`: a LINK helper may have crossed its
|
|
709
|
+
// final chmod before its caller lost the result. HIDE is
|
|
710
|
+
// idempotent and only removes the exact fixed-root link.
|
|
711
|
+
hide(state.binding.workspacePath);
|
|
712
|
+
state.binding.published = false;
|
|
713
|
+
} catch (error) {
|
|
714
|
+
throw terminalFailure(
|
|
715
|
+
state.assignment,
|
|
716
|
+
"visibility-unproven",
|
|
717
|
+
error,
|
|
718
|
+
"the task's Files boundary could not be hidden while its gate closed",
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
async runtimeReady(assignment, environment, mode, signal): Promise<void> {
|
|
725
|
+
const state = states.get(assignment.taskId);
|
|
726
|
+
if (!state || !sameAssignment(state.assignment, assignment)) {
|
|
727
|
+
throw new Error("the task has no Files session for this run");
|
|
728
|
+
}
|
|
729
|
+
const candidate = state.served;
|
|
730
|
+
if (!candidate || !state.gateOpen) {
|
|
731
|
+
throw new Error("the task's Files session is not open");
|
|
732
|
+
}
|
|
733
|
+
let binding: FilesBinding;
|
|
734
|
+
try {
|
|
735
|
+
assertCanonicalTaskMachineRuntime(assignment, environment);
|
|
736
|
+
const existing = state.binding;
|
|
737
|
+
const locator = serializeTaskEnvironmentLocator(environment.descriptor.locator);
|
|
738
|
+
if (existing) {
|
|
739
|
+
if (
|
|
740
|
+
serializeTaskEnvironmentLocator(existing.locator) !== locator ||
|
|
741
|
+
existing.workspacePath !== environment.descriptor.workspacePath ||
|
|
742
|
+
existing.mode !== mode
|
|
743
|
+
) {
|
|
744
|
+
throw new Error("the run's Files runtime changed without cleanup");
|
|
745
|
+
}
|
|
746
|
+
// A lost-response task-up retry may hand over a newly fenced wrapper
|
|
747
|
+
// for the same runtime. Keep the established mount; only refresh the
|
|
748
|
+
// exact session and replace the callable handle.
|
|
749
|
+
existing.environment = environment;
|
|
750
|
+
binding = existing;
|
|
751
|
+
} else {
|
|
752
|
+
binding = {
|
|
753
|
+
locator: environment.descriptor.locator,
|
|
754
|
+
workspacePath: environment.descriptor.workspacePath,
|
|
755
|
+
mode,
|
|
756
|
+
environment,
|
|
757
|
+
mounted: false,
|
|
758
|
+
published: false,
|
|
759
|
+
mountIdentity: null,
|
|
760
|
+
};
|
|
761
|
+
state.binding = binding;
|
|
762
|
+
}
|
|
763
|
+
} catch (error) {
|
|
764
|
+
// This check and callback are deliberately synchronous. A lost-response
|
|
765
|
+
// retry may abort an older start for this same run, or a renewal may
|
|
766
|
+
// replace the candidate while setup is suspended; neither stale
|
|
767
|
+
// rejection is allowed to close the winner's grant.
|
|
768
|
+
let authoritative = false;
|
|
769
|
+
try {
|
|
770
|
+
assertCandidate(state, candidate, signal, true);
|
|
771
|
+
authoritative = true;
|
|
772
|
+
} catch {
|
|
773
|
+
// Stale/withdrawn setup: its owner performs no broader cleanup.
|
|
774
|
+
}
|
|
775
|
+
if (!authoritative) throw error;
|
|
776
|
+
// A runtime which did not pass the canonical identity checks cannot
|
|
777
|
+
// safely be used to prove the fixed mounts absent. This is an
|
|
778
|
+
// invariant failure rather than an ordinary provider/setup error.
|
|
779
|
+
throwAuthoritativeFailure(
|
|
780
|
+
assignment,
|
|
781
|
+
"setup-unproven",
|
|
782
|
+
error,
|
|
783
|
+
"the task's Files boundary could not be established",
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
await queue(state, async () => {
|
|
787
|
+
try {
|
|
788
|
+
await materialize(state, candidate, binding, signal, true);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
// Compensation belongs to the serialized operation too. A retry
|
|
791
|
+
// queued behind this failure must not begin its mount while the
|
|
792
|
+
// failed attempt is still hiding or unmounting.
|
|
793
|
+
await compensateRuntimeReadyFailure(
|
|
794
|
+
state,
|
|
795
|
+
candidate,
|
|
796
|
+
binding,
|
|
797
|
+
environment,
|
|
798
|
+
signal,
|
|
799
|
+
error,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
},
|
|
804
|
+
|
|
805
|
+
retire(assignment, error): void {
|
|
806
|
+
terminalFailure(
|
|
807
|
+
assignment,
|
|
808
|
+
"setup-unproven",
|
|
809
|
+
error,
|
|
810
|
+
"the task's Files boundary could not be established",
|
|
811
|
+
);
|
|
812
|
+
},
|
|
813
|
+
|
|
814
|
+
async closeConsumers(request: TaskCredentialCleanupRequest): Promise<void> {
|
|
815
|
+
const state = states.get(request.taskId);
|
|
816
|
+
// A selective credential cleanup never owns the Files session, but it
|
|
817
|
+
// still joins a write admitted before its gate closed.
|
|
818
|
+
if (request.credentialIds !== undefined) {
|
|
819
|
+
if (state && state.assignment.runEpoch <= request.runEpoch) {
|
|
820
|
+
await state.tail.catch(() => undefined);
|
|
821
|
+
}
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (state && state.assignment.runEpoch <= request.runEpoch) {
|
|
825
|
+
state.gateOpen = false;
|
|
826
|
+
const served = state.served;
|
|
827
|
+
served?.controller.abort(abortError());
|
|
828
|
+
if (state.candidate !== served) state.candidate?.controller.abort(abortError());
|
|
829
|
+
await state.tail.catch(() => undefined);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const rows = list(request.taskId).filter(
|
|
833
|
+
(row) => row.lane === "files-session" && row.runEpoch <= request.runEpoch,
|
|
834
|
+
);
|
|
835
|
+
const newer = list(request.taskId).filter(
|
|
836
|
+
(row) => row.lane === "files-session" && row.runEpoch > request.runEpoch,
|
|
837
|
+
);
|
|
838
|
+
const newerLocators = new Set(newer.map((row) => row.runtimeLocator));
|
|
839
|
+
const locators = new Map<string, TaskEnvironmentLocator>();
|
|
840
|
+
for (const row of rows) {
|
|
841
|
+
if (!newerLocators.has(row.runtimeLocator)) {
|
|
842
|
+
locators.set(row.runtimeLocator, parseTaskEnvironmentLocator(row.runtimeLocator));
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (state?.binding && state.assignment.runEpoch <= request.runEpoch) {
|
|
846
|
+
const serialized = rows.find(
|
|
847
|
+
(row) => row.runtimeLocator === serializeTaskEnvironmentLocator(state.binding!.locator),
|
|
848
|
+
);
|
|
849
|
+
// The inventory normally supplies it. Keep the live binding as a
|
|
850
|
+
// fallback for injected test stores that intentionally omit rows.
|
|
851
|
+
if (!serialized && newerLocators.size === 0) {
|
|
852
|
+
locators.set(serializeTaskEnvironmentLocator(state.binding.locator), state.binding.locator);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
const failures: unknown[] = [];
|
|
856
|
+
for (const [serialized, locator] of locators) {
|
|
857
|
+
const stillOwnedByCleanup = (): boolean => {
|
|
858
|
+
const current = states.get(request.taskId);
|
|
859
|
+
if (current && current.assignment.runEpoch > request.runEpoch) return false;
|
|
860
|
+
return !list(request.taskId).some(
|
|
861
|
+
(row) =>
|
|
862
|
+
row.lane === "files-session" &&
|
|
863
|
+
row.runEpoch > request.runEpoch &&
|
|
864
|
+
row.runtimeLocator === serialized,
|
|
865
|
+
);
|
|
866
|
+
};
|
|
867
|
+
if (!stillOwnedByCleanup()) continue;
|
|
868
|
+
try {
|
|
869
|
+
// The runtime-ready handle is fenced by the task-up hold and is
|
|
870
|
+
// deliberately unusable once this cleanup closes the gate. Rebuild
|
|
871
|
+
// an unfenced cleanup handle from the durable locator instead.
|
|
872
|
+
const environment = await reconstruct(locator);
|
|
873
|
+
if (environment.descriptor.taskId !== request.taskId) {
|
|
874
|
+
throw new Error("the Files cleanup runtime belongs to another task");
|
|
875
|
+
}
|
|
876
|
+
if (!stillOwnedByCleanup()) continue;
|
|
877
|
+
await unmount(environment, environment.descriptor.workspacePath, {
|
|
878
|
+
admit: () => {
|
|
879
|
+
if (!stillOwnedByCleanup()) {
|
|
880
|
+
throw new Error("a newer run owns the task's Files mounts");
|
|
881
|
+
}
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
} catch (error) {
|
|
885
|
+
// Ownership may transfer while the provider resolves the runtime.
|
|
886
|
+
// In that case the newer run's own setup/cleanup owns both fixed
|
|
887
|
+
// mountpoints; the obsolete cleanup must neither touch nor block it.
|
|
888
|
+
if (stillOwnedByCleanup()) failures.push(error);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (failures.length > 0) {
|
|
892
|
+
throw terminalFailure(
|
|
893
|
+
{ taskId: request.taskId, runEpoch: request.runEpoch },
|
|
894
|
+
"mount-retirement-unproven",
|
|
895
|
+
new AggregateError(failures, "the task's Files mounts could not be proven absent"),
|
|
896
|
+
"the task's Files mounts could not be proven absent",
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
if (state && state.assignment.runEpoch <= request.runEpoch) {
|
|
900
|
+
const continuingReason = request.lease?.reason;
|
|
901
|
+
const continues =
|
|
902
|
+
state.assignment.runEpoch === request.runEpoch &&
|
|
903
|
+
(continuingReason === "denied" ||
|
|
904
|
+
continuingReason === "expired" ||
|
|
905
|
+
continuingReason === "minimum-revision" ||
|
|
906
|
+
continuingReason === "manifest-narrowed" ||
|
|
907
|
+
continuingReason === "cleanup-failed" ||
|
|
908
|
+
continuingReason === "fence-failed");
|
|
909
|
+
if (continues && state.binding) {
|
|
910
|
+
// The registry asks for another grant after these cleanups. Keep
|
|
911
|
+
// only non-secret runtime identity, and force its next install to
|
|
912
|
+
// reconstruct and establish the fixed mounts before the gate opens.
|
|
913
|
+
state.binding.environment = null;
|
|
914
|
+
state.binding.mounted = false;
|
|
915
|
+
state.binding.published = false;
|
|
916
|
+
state.binding.mountIdentity = null;
|
|
917
|
+
state.candidate = null;
|
|
918
|
+
state.served = null;
|
|
919
|
+
} else {
|
|
920
|
+
states.delete(request.taskId);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
},
|
|
924
|
+
|
|
925
|
+
shutdown(): void {
|
|
926
|
+
stopped = true;
|
|
927
|
+
for (const state of states.values()) {
|
|
928
|
+
state.candidate?.controller.abort(abortError());
|
|
929
|
+
if (state.served !== state.candidate) state.served?.controller.abort(abortError());
|
|
930
|
+
}
|
|
931
|
+
states.clear();
|
|
932
|
+
},
|
|
933
|
+
};
|
|
934
|
+
}
|