@moldesk/core 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/cloud/accounts.d.ts +69 -0
  2. package/dist/cloud/accounts.js +113 -0
  3. package/dist/cloud/accounts.test.d.ts +1 -0
  4. package/dist/cloud/accounts.test.js +188 -0
  5. package/dist/cloud/backend.test.d.ts +1 -0
  6. package/dist/cloud/backend.test.js +339 -0
  7. package/dist/cloud/deps.d.ts +15 -0
  8. package/dist/cloud/deps.js +30 -0
  9. package/dist/cloud/manage.d.ts +57 -0
  10. package/dist/cloud/manage.js +210 -0
  11. package/dist/cloud/manage.test.d.ts +1 -0
  12. package/dist/cloud/manage.test.js +105 -0
  13. package/dist/cloud/quote.d.ts +131 -0
  14. package/dist/cloud/quote.js +225 -0
  15. package/dist/cloud/quote.test.d.ts +1 -0
  16. package/dist/cloud/quote.test.js +264 -0
  17. package/dist/cloud/run.d.ts +114 -0
  18. package/dist/cloud/run.js +498 -0
  19. package/dist/cloud/run.test.d.ts +1 -0
  20. package/dist/cloud/run.test.js +597 -0
  21. package/dist/cloud/state.d.ts +159 -0
  22. package/dist/cloud/state.js +395 -0
  23. package/dist/cloud/state.test.d.ts +1 -0
  24. package/dist/cloud/state.test.js +199 -0
  25. package/dist/cloud/worker/cache.d.ts +75 -0
  26. package/dist/cloud/worker/cache.js +152 -0
  27. package/dist/cloud/worker/cache.test.d.ts +1 -0
  28. package/dist/cloud/worker/cache.test.js +123 -0
  29. package/dist/cloud/worker/main.d.ts +2 -0
  30. package/dist/cloud/worker/main.js +34 -0
  31. package/dist/cloud/worker/pipeline.d.ts +26 -0
  32. package/dist/cloud/worker/pipeline.js +160 -0
  33. package/dist/cloud/worker/pipeline.test.d.ts +1 -0
  34. package/dist/cloud/worker/pipeline.test.js +298 -0
  35. package/dist/cloud/worker/server.d.ts +38 -0
  36. package/dist/cloud/worker/server.js +287 -0
  37. package/dist/cloud/worker/server.test.d.ts +1 -0
  38. package/dist/cloud/worker/server.test.js +391 -0
  39. package/dist/cloud/worker/watchdog.d.ts +27 -0
  40. package/dist/cloud/worker/watchdog.js +46 -0
  41. package/dist/cloud/worker-release.d.ts +12 -0
  42. package/dist/cloud/worker-release.js +25 -0
  43. package/dist/cloud/worker-release.test.d.ts +1 -0
  44. package/dist/cloud/worker-release.test.js +21 -0
  45. package/dist/index.d.ts +17 -3
  46. package/dist/index.js +9 -2
  47. package/dist/run-lifecycle.d.ts +74 -0
  48. package/dist/run-lifecycle.js +178 -0
  49. package/dist/run.d.ts +61 -1
  50. package/dist/run.js +54 -125
  51. package/dist/run.test.js +47 -1
  52. package/package.json +10 -6
@@ -0,0 +1,498 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { getAdapter } from "@moldesk/adapters";
4
+ import { isCloudEligible, listAvailableModels } from "@moldesk/registry";
5
+ import { getMoldeskPaths, } from "@moldesk/runtime";
6
+ import { MoldeskError } from "../errors.js";
7
+ import { cloudCacheKey } from "./worker/cache.js";
8
+ import { digest, sha256File } from "../hash.js";
9
+ import { allocateRunDirectory, collectRunOutputs, createRunSnapshot, finalizeRun, prepareRunParams, toErrorInfo, writeRunRecord, } from "../run-lifecycle.js";
10
+ import { assertApprovalValid, buildQuote } from "./quote.js";
11
+ import { CloudJournal } from "./state.js";
12
+ const REMOTE_PLATFORM = "linux-x64";
13
+ /** Structural stand-in for "the Linux runtime the worker will install"; never a fake local installation. */
14
+ const REMOTE_INSTALLED = { runtime: { kind: "python", python: { platform: REMOTE_PLATFORM } } };
15
+ function failure(code, message, remediation, details) {
16
+ return new MoldeskError({ code, message, remediation, details });
17
+ }
18
+ export function defaultResolveModelForRecovery(name) {
19
+ return defaultResolveModel(name);
20
+ }
21
+ function defaultResolveModel(name) {
22
+ const manifest = listAvailableModels().find((entry) => entry.name === name);
23
+ if (!manifest)
24
+ throw failure("UNKNOWN_MODEL", `Unknown model "${name}".`, "Run `moldesk list` to see available models.");
25
+ const adapter = getAdapter(name);
26
+ if (!adapter)
27
+ throw failure("ADAPTER_NOT_FOUND", `No adapter is registered for ${name}.`, "Repair the registry installation.");
28
+ return { manifest, adapter };
29
+ }
30
+ /**
31
+ * Validates everything that can be validated without provisioning and builds the quote. Rejects
32
+ * (before any run directory or resource exists) for unknown/ineligible models, bad input or params,
33
+ * an unaffordable or unavailable quote.
34
+ */
35
+ export async function planCloudRun(modelName, inputPath, options, deps) {
36
+ const { manifest, adapter } = (deps.resolveModel ?? defaultResolveModel)(modelName);
37
+ if (!isCloudEligible(manifest)) {
38
+ throw failure("CLOUD_MODEL_UNSUPPORTED", `${modelName} does not declare cloud execution support.`, "Run it locally, or use a model whose manifest has a validated `cloud` block.");
39
+ }
40
+ const resolvedInputPath = path.resolve(inputPath);
41
+ await adapter.validateInput(resolvedInputPath);
42
+ const companions = (await adapter.resolveCompanionInputs?.(resolvedInputPath)) ?? [];
43
+ if (companions.length > 0) {
44
+ throw failure("CLOUD_COMPANION_INPUTS_UNSUPPORTED", `${modelName} jobs that reference additional input files are not supported in the cloud yet.`, "Use a self-contained job file (for example sequences inline), or run locally.");
45
+ }
46
+ const { suppliedParams, effectiveParams } = prepareRunParams(adapter, manifest.name, REMOTE_INSTALLED, options.params);
47
+ const stat = fs.statSync(resolvedInputPath);
48
+ const sha256 = await sha256File(resolvedInputPath);
49
+ const catalog = await deps.provider.getCatalog();
50
+ let existingVolumeDatacenterId;
51
+ if (options.cloud.cachePolicy === "persistent" && deps.accountScope) {
52
+ const owned = deps.journal ?? CloudJournal.open(deps.paths ?? getMoldeskPaths());
53
+ try {
54
+ existingVolumeDatacenterId = owned
55
+ .listCaches()
56
+ .find((cache) => cache.accountScope === deps.accountScope && cache.model === manifest.name && cache.state === "READY")?.datacenterId;
57
+ }
58
+ finally {
59
+ if (!deps.journal)
60
+ owned.close();
61
+ }
62
+ }
63
+ const quote = buildQuote({
64
+ provider: deps.provider.name,
65
+ manifest,
66
+ cloud: manifest.cloud,
67
+ catalog,
68
+ ...options.cloud,
69
+ inputs: [{ role: "job", sha256, sizeBytes: stat.size }],
70
+ existingVolumeDatacenterId,
71
+ });
72
+ return {
73
+ quote,
74
+ manifest,
75
+ cloud: manifest.cloud,
76
+ adapter,
77
+ resolvedInputPath,
78
+ input: { fileName: path.basename(resolvedInputPath), sha256, sizeBytes: stat.size },
79
+ suppliedParams,
80
+ effectiveParams,
81
+ };
82
+ }
83
+ const STAGE_TO_JOB = {
84
+ provisioning: "provisioning",
85
+ booting: "provisioning",
86
+ uploading: "provisioning",
87
+ preparing_assets: "preparing_assets",
88
+ installing_runtime: "installing_runtime",
89
+ verifying_runtime: "installing_runtime",
90
+ running: "running",
91
+ collecting: "transferring",
92
+ };
93
+ function ledgerFor(journal, runId, deadlineAt) {
94
+ return {
95
+ beginCreate(role) {
96
+ const intent = journal.recordResourceIntent({ ...(role === "volume" ? {} : { runId }), role, deadlineAt: role === "volume" ? undefined : deadlineAt });
97
+ return { intentId: intent.intentId, name: intent.name };
98
+ },
99
+ created: (intentId, providerId) => void journal.recordResourceCreated(intentId, providerId),
100
+ beginDelete: (intentId) => void journal.markResourceDeletePending(intentId),
101
+ deleted: (intentId) => void journal.markResourceDeleted(intentId),
102
+ };
103
+ }
104
+ /**
105
+ * The worker keeps a bounded log tail. When it reports chunks evicted before we fetched them, say so
106
+ * plainly: a marker in the local log, a warning on the run, and `compute.logs.complete = false`.
107
+ */
108
+ export const LOGS_INCOMPLETE_CODE = "CLOUD_LOGS_INCOMPLETE";
109
+ export function recordLostLogs(snapshot, target) {
110
+ const lost = snapshot.logs.lostEntries ?? 0;
111
+ if (lost <= 0)
112
+ return;
113
+ target.onLost?.(lost);
114
+ const marker = `\n[moldesk: ${lost} worker log chunk(s) were dropped before they could be fetched; the worker retains only a bounded tail, so this log is incomplete here]\n`;
115
+ fs.appendFileSync(target.stdoutPath, marker);
116
+ fs.appendFileSync(target.stderrPath, marker);
117
+ const total = (target.compute.logs?.lostChunks ?? 0) + lost;
118
+ target.compute.logs = { complete: false, lostChunks: total };
119
+ const warning = {
120
+ code: LOGS_INCOMPLETE_CODE,
121
+ message: `${total} worker log chunk(s) were dropped before they could be fetched (the worker keeps a bounded tail); the local logs are incomplete.`,
122
+ };
123
+ target.record.warnings = [...(target.record.warnings ?? []).filter((entry) => entry.code !== LOGS_INCOMPLETE_CODE), warning];
124
+ }
125
+ /** Bridges a backend's cache lifecycle onto the journal for one job. */
126
+ export function cacheLedgerFor(journal, params) {
127
+ let current;
128
+ const state = () => (current ? journal.getCache(current.accountScope, current.datacenterId, current.volumeId, current.cacheKey)?.state : undefined);
129
+ return {
130
+ lookup() {
131
+ const hit = journal.listCaches().find((cache) => cache.accountScope === params.accountScope && cache.cacheKey === params.cacheKey && cache.state === "READY");
132
+ if (hit)
133
+ current = { accountScope: hit.accountScope, datacenterId: hit.datacenterId, volumeId: hit.volumeId, cacheKey: hit.cacheKey };
134
+ return hit ? { volumeId: hit.volumeId, datacenterId: hit.datacenterId } : undefined;
135
+ },
136
+ begin(volume) {
137
+ current = { accountScope: params.accountScope, datacenterId: volume.datacenterId, volumeId: volume.volumeId, cacheKey: params.cacheKey };
138
+ journal.upsertCache({
139
+ ...current,
140
+ model: params.manifest.name,
141
+ modelVersion: params.manifest.modelVersion,
142
+ layoutRevision: params.cloud.assetLayoutRevision,
143
+ manifestDigest: params.manifestDigest,
144
+ cachePath: `/workspace/moldesk-cache/${params.cacheKey}`,
145
+ sizeBytes: null,
146
+ checksumManifestDigest: null,
147
+ });
148
+ if (state() === "FAILED")
149
+ journal.setCacheState(current, "PREPARING");
150
+ },
151
+ verifying() {
152
+ if (current && state() === "PREPARING")
153
+ journal.setCacheState(current, "VERIFYING");
154
+ },
155
+ ready(details) {
156
+ if (current && state() === "VERIFYING")
157
+ journal.setCacheState(current, "READY", details);
158
+ },
159
+ failed() {
160
+ if (current && state() !== undefined && state() !== "FAILED")
161
+ journal.setCacheState(current, "FAILED");
162
+ },
163
+ acquireLease(ttlSeconds) {
164
+ return current ? journal.acquireCacheLease(current, params.runId, ttlSeconds) : true;
165
+ },
166
+ releaseLease() {
167
+ if (current)
168
+ journal.releaseCacheLease(current, params.runId);
169
+ },
170
+ };
171
+ }
172
+ function toView(pod) {
173
+ const match = /-(cpu|gpu)$/.exec(pod.name);
174
+ return { providerId: pod.id, name: pod.name, role: match?.[1] ?? "gpu" };
175
+ }
176
+ /**
177
+ * Terminates and independently verifies every Pod this run owns. Uses the journal (never names
178
+ * alone) for ownership, resolves ambiguous creates first, and never touches volumes. Bounded
179
+ * retries; success means the provider shows the Pod gone, not that a call returned.
180
+ */
181
+ export async function confirmRunResourcesGone(runId, deps, journal) {
182
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
183
+ const attempts = deps.cleanupAttempts ?? 3;
184
+ const isPod = (record) => record.role === "cpu" || record.role === "gpu";
185
+ const open = () => journal.listResources({ runId, states: ["create_pending", "active", "delete_pending"] }).filter(isPod);
186
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
187
+ try {
188
+ const listed = await deps.provider.listPods();
189
+ journal.reconcile(listed.filter((pod) => pod.status !== "TERMINATED").map(toView));
190
+ const livePods = new Map(listed.filter((pod) => pod.status !== "TERMINATED").map((pod) => [pod.id, pod]));
191
+ for (const record of open()) {
192
+ if (!record.providerId)
193
+ continue;
194
+ if (!livePods.has(record.providerId)) {
195
+ journal.markResourceDeleted(record.intentId);
196
+ continue;
197
+ }
198
+ journal.markResourceDeletePending(record.intentId);
199
+ await deps.provider.terminatePod(record.providerId);
200
+ const after = await deps.provider.getPod(record.providerId);
201
+ if (after === null || after.status === "TERMINATED")
202
+ journal.markResourceDeleted(record.intentId);
203
+ }
204
+ }
205
+ catch {
206
+ // Provider unreachable this attempt; the journal keeps the debt and we retry.
207
+ }
208
+ if (open().length === 0)
209
+ return { confirmed: true, pending: [] };
210
+ if (attempt < attempts)
211
+ await sleep(1000 * attempt);
212
+ }
213
+ return {
214
+ confirmed: false,
215
+ pending: open().map((record) => ({ role: record.role, providerId: record.providerId ?? record.intentId })),
216
+ };
217
+ }
218
+ /**
219
+ * Runs an approved cloud job end to end. Before allocation it rejects like a local run; from
220
+ * allocation on it always resolves with a finalized `run.json`. Paid machines are terminated and
221
+ * verified before the report is generated, and cleanup failure never discards a valid result.
222
+ */
223
+ export async function runCloudModel(modelName, inputPath, options, deps) {
224
+ const { backend } = deps;
225
+ if (!backend)
226
+ throw failure("CLOUD_BACKEND_MISSING", "Cloud execution is not configured.", "Provide an execution backend.");
227
+ const paths = deps.paths ?? getMoldeskPaths();
228
+ const now = deps.now ?? (() => new Date());
229
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
230
+ const pollMs = deps.pollIntervalMs ?? 5000;
231
+ // --- Pre-allocation: validate, re-quote, verify consent. Failures reject; nothing exists yet. ---
232
+ const plan = await planCloudRun(modelName, inputPath, { params: options.params, cloud: options.cloud }, { ...deps, paths });
233
+ assertApprovalValid(options.cloud.quote, options.cloud.approval, plan.quote, now());
234
+ const quote = plan.quote;
235
+ const ownJournal = deps.journal === undefined;
236
+ const journal = deps.journal ?? CloudJournal.open(paths);
237
+ try {
238
+ // --- Before new paid work: reconcile the journal with the provider. Unresolved cost blocks it. ---
239
+ let listed;
240
+ try {
241
+ listed = await deps.provider.listPods();
242
+ }
243
+ catch (error) {
244
+ throw failure("CLOUD_UNAVAILABLE", `Could not verify existing cloud resources: ${toErrorInfo(error, "CLOUD_UNAVAILABLE").message}`, "Retry when the provider is reachable; new work is not started blind.");
245
+ }
246
+ const preflight = journal.reconcile(listed.filter((pod) => pod.status !== "TERMINATED").map(toView));
247
+ if (preflight.orphaned.length > 0 || preflight.cleanupOwed.length > 0) {
248
+ const names = [...preflight.orphaned.map((pod) => `orphaned ${pod.providerId}`), ...preflight.cleanupOwed.map((record) => `cleanup owed ${record.providerId ?? record.intentId}`)];
249
+ throw failure("CLOUD_UNRESOLVED_RESOURCES", `Existing cloud resources need attention before new paid work starts: ${names.join(", ")}.`, "Run `moldesk cloud resources`, then `moldesk cloud recover <run-id>` for owed cleanup. Orphans are never deleted automatically; remove them in the provider console if they are yours.", { orphaned: preflight.orphaned.map((pod) => pod.providerId), cleanupOwed: preflight.cleanupOwed.map((record) => record.providerId ?? record.intentId) });
250
+ }
251
+ // --- Allocate and snapshot. A failure here finalizes run.json; nothing paid exists yet. ---
252
+ const dirs = allocateRunDirectory(paths);
253
+ const snapshot = await createRunSnapshot({
254
+ dirs,
255
+ manifest: plan.manifest,
256
+ adapter: plan.adapter,
257
+ runtime: "python",
258
+ installationFingerprint: "remote-pending",
259
+ resolvedInputPath: plan.resolvedInputPath,
260
+ suppliedParams: options.params ?? {},
261
+ effectiveParams: plan.effectiveParams,
262
+ signal: options.signal,
263
+ });
264
+ if (!snapshot.ok)
265
+ return { status: snapshot.record.status, record: snapshot.record };
266
+ const { record, startedAt, storedInputPath } = snapshot;
267
+ const { runDirPath } = dirs;
268
+ const deadlineAt = new Date(now().getTime() + quote.maxLifetimeSeconds * 1000).toISOString();
269
+ const compute = {
270
+ provider: deps.provider.name,
271
+ cachePolicy: quote.cachePolicy,
272
+ quoteId: quote.id,
273
+ stage: "approved",
274
+ datacenterId: quote.datacenterId,
275
+ gpuId: quote.gpu.id,
276
+ resources: [],
277
+ cleanup: "confirmed",
278
+ acknowledgedRisks: [...options.cloud.approval.acknowledged],
279
+ };
280
+ record.compute = compute;
281
+ const persist = () => writeRunRecord(runDirPath, record);
282
+ // Worker logs are persisted locally as they arrive, independent of any terminal tee or --json,
283
+ // so they outlive the machine they came from. (The worker keeps only a bounded tail.)
284
+ const stdoutPath = path.join(runDirPath, "stdout.log");
285
+ const stderrPath = path.join(runDirPath, "stderr.log");
286
+ fs.writeFileSync(stdoutPath, "", { flag: "a", mode: 0o600 });
287
+ fs.writeFileSync(stderrPath, "", { flag: "a", mode: 0o600 });
288
+ record.logs = { stdout: stdoutPath, stderr: stderrPath };
289
+ let lostChunks = 0;
290
+ const saveLogs = (snapshot) => {
291
+ recordLostLogs(snapshot, { stdoutPath, stderrPath, compute, record, onLost: (n) => (lostChunks += n) });
292
+ if (snapshot.logs.stdout)
293
+ fs.appendFileSync(stdoutPath, snapshot.logs.stdout);
294
+ if (snapshot.logs.stderr)
295
+ fs.appendFileSync(stderrPath, snapshot.logs.stderr);
296
+ };
297
+ const localContext = {
298
+ manifestName: plan.manifest.name,
299
+ inputPath: storedInputPath,
300
+ outputDir: dirs.outputDir,
301
+ modelDir: path.join(runDirPath, "remote-model"),
302
+ assetsDir: path.join(runDirPath, "remote-assets"),
303
+ params: plan.effectiveParams,
304
+ runtimeExecutable: "",
305
+ platform: REMOTE_PLATFORM,
306
+ };
307
+ const finish = () => finalizeRun({ adapter: plan.adapter, context: localContext, runDirPath, record, startedAt, signal: options.signal, visualize: options.visualize, outputDir: options.outputDir });
308
+ const abort = (code, message, status = "failed") => {
309
+ record.status = status;
310
+ record.error = { code, message };
311
+ };
312
+ // The primary input must be exactly what was quoted.
313
+ if (record.input[0]?.sha256 !== plan.input.sha256) {
314
+ abort("CLOUD_INPUT_CHANGED", "The input file changed after the quote was produced.");
315
+ return finish();
316
+ }
317
+ const accountScope = deps.accountScope ?? "unscoped";
318
+ journal.createJob({
319
+ runId: dirs.runId,
320
+ accountScope,
321
+ quoteDigest: quote.id,
322
+ cachePolicy: quote.cachePolicy,
323
+ gpuId: quote.gpu.id,
324
+ datacenterId: quote.datacenterId,
325
+ deadlineAt,
326
+ manifestDigest: digest(plan.manifest),
327
+ });
328
+ const job = {
329
+ version: 1,
330
+ runId: dirs.runId,
331
+ model: plan.manifest.name,
332
+ modelVersion: plan.manifest.modelVersion,
333
+ manifestDigest: digest(plan.manifest),
334
+ cachePolicy: quote.cachePolicy,
335
+ gpuId: quote.gpu.id,
336
+ datacenterId: quote.datacenterId,
337
+ images: plan.cloud.images,
338
+ containerDiskGb: plan.cloud.ephemeralDiskGb,
339
+ volumeGb: plan.cloud.volumeGb,
340
+ assetLayoutRevision: plan.cloud.assetLayoutRevision,
341
+ cacheKey: cloudCacheKey(plan.manifest),
342
+ cpuAssetPreparation: plan.cloud.cpuAssetPreparation,
343
+ params: plan.effectiveParams,
344
+ inputs: [{ role: "job", relativePath: plan.input.fileName, sha256: plan.input.sha256, sizeBytes: plan.input.sizeBytes, sourcePath: storedInputPath }],
345
+ deadlineAt,
346
+ };
347
+ const ledger = ledgerFor(journal, dirs.runId, deadlineAt);
348
+ const progress = (stage, message) => {
349
+ compute.stage = stage;
350
+ journal.updateJob(dirs.runId, { stage: STAGE_TO_JOB[stage] });
351
+ persist();
352
+ options.onProgress?.({ stage, message });
353
+ };
354
+ let handle;
355
+ let inventory;
356
+ let snapshotLast;
357
+ let cursor = 0;
358
+ try {
359
+ handle = await backend.start(job, { ledger, ...(quote.cachePolicy === "persistent" ? { cache: cacheLedgerFor(journal, { accountScope, runId: dirs.runId, manifest: plan.manifest, cloud: plan.cloud, cacheKey: job.cacheKey, manifestDigest: job.manifestDigest }) } : {}), signal: options.signal, onProgress: (event) => progress(event.stage, event.message) });
360
+ record.status = "running";
361
+ persist();
362
+ // --- Observe until the worker reports a terminal state. Bounded by the approved deadline. ---
363
+ let consecutiveObserveFailures = 0;
364
+ for (;;) {
365
+ if (options.signal?.aborted) {
366
+ await backend.cancel(handle).catch(() => undefined);
367
+ abort("RUN_CANCELLED", "Run was cancelled.", "cancelled");
368
+ break;
369
+ }
370
+ if (now().getTime() > Date.parse(deadlineAt)) {
371
+ await backend.cancel(handle).catch(() => undefined);
372
+ abort("CLOUD_DEADLINE_EXCEEDED", `The ${quote.maxLifetimeSeconds}s lifetime limit was reached before the run finished.`);
373
+ break;
374
+ }
375
+ try {
376
+ snapshotLast = await backend.observe(handle, cursor);
377
+ consecutiveObserveFailures = 0;
378
+ }
379
+ catch (error) {
380
+ consecutiveObserveFailures += 1;
381
+ if (consecutiveObserveFailures >= 3) {
382
+ const info = toErrorInfo(error, "CLOUD_WORKER_UNREACHABLE");
383
+ abort("CLOUD_WORKER_UNREACHABLE", `The worker stopped responding: ${info.message}`);
384
+ break;
385
+ }
386
+ await sleep(pollMs);
387
+ continue;
388
+ }
389
+ saveLogs(snapshotLast);
390
+ cursor = snapshotLast.logs.cursor;
391
+ if (compute.workerLogCursor !== cursor) {
392
+ compute.workerLogCursor = cursor;
393
+ persist();
394
+ }
395
+ if (snapshotLast.logs.stdout)
396
+ options.onLog?.("stdout", snapshotLast.logs.stdout);
397
+ if (snapshotLast.logs.stderr)
398
+ options.onLog?.("stderr", snapshotLast.logs.stderr);
399
+ if (snapshotLast.stage !== compute.stage)
400
+ progress(snapshotLast.stage);
401
+ if (snapshotLast.state === "succeeded" || snapshotLast.state === "failed" || snapshotLast.state === "cancelled")
402
+ break;
403
+ await sleep(pollMs);
404
+ }
405
+ if (snapshotLast?.runtime) {
406
+ const runtime = snapshotLast.runtime;
407
+ compute.hardware = runtime.gpu;
408
+ compute.remoteCommand = runtime.command;
409
+ compute.pythonVersion = runtime.pythonVersion;
410
+ compute.dependencyFreeze = runtime.dependencyFreeze;
411
+ compute.sourceRevision = runtime.sourceRevision;
412
+ record.installationFingerprint = runtime.installationFingerprint ?? digest(runtime);
413
+ }
414
+ record.process = { exitCode: snapshotLast?.exitCode };
415
+ if (record.status === "running") {
416
+ if (snapshotLast?.state === "cancelled")
417
+ abort("RUN_CANCELLED", "Run was cancelled.", "cancelled");
418
+ else if (snapshotLast?.state === "failed") {
419
+ const exit = snapshotLast.exitCode;
420
+ abort(snapshotLast.error?.code ?? "RUN_EXECUTION_FAILED", snapshotLast.error?.message ?? `${modelName} exited with code ${exit ?? "unknown"}.`);
421
+ }
422
+ else {
423
+ // --- Succeeded on the worker: download and verify. Retried only inside a bounded window. ---
424
+ progress("collecting");
425
+ journal.updateJob(dirs.runId, { transferState: "downloading" });
426
+ const windowEnd = Math.min(Date.parse(deadlineAt), now().getTime() + (deps.recoveryWindowSeconds ?? 600) * 1000);
427
+ let lastError;
428
+ for (let attempt = 1; !inventory; attempt += 1) {
429
+ try {
430
+ inventory = await backend.collect(handle, dirs.outputDir, { signal: options.signal });
431
+ }
432
+ catch (error) {
433
+ lastError = error;
434
+ if (options.signal?.aborted || now().getTime() + 2000 * attempt > windowEnd)
435
+ break;
436
+ await sleep(2000 * attempt);
437
+ }
438
+ }
439
+ if (!inventory) {
440
+ const info = toErrorInfo(lastError, "CLOUD_TRANSFER_FAILED");
441
+ journal.updateJob(dirs.runId, { transferState: "failed" });
442
+ if (options.signal?.aborted || info.code === "RUN_CANCELLED")
443
+ abort("RUN_CANCELLED", "Run was cancelled.", "cancelled");
444
+ else
445
+ abort("CLOUD_TRANSFER_FAILED", `Results could not be downloaded: ${info.message}`);
446
+ }
447
+ else {
448
+ journal.updateJob(dirs.runId, { transferState: "verified" });
449
+ // Existing local collection: a missing required output is a failure even after exit 0.
450
+ record.outputs = await collectRunOutputs(plan.adapter, localContext);
451
+ record.status = "succeeded";
452
+ }
453
+ }
454
+ }
455
+ }
456
+ catch (error) {
457
+ const info = toErrorInfo(error, "CLOUD_RUN_FAILED");
458
+ if (options.signal?.aborted)
459
+ abort("RUN_CANCELLED", "Run was cancelled.", "cancelled");
460
+ else
461
+ abort(info.code, info.message);
462
+ }
463
+ // Best-effort final drain of any log tail not yet saved (cancel, deadline, unreachable worker paths).
464
+ if (handle && !(snapshotLast && snapshotLast.state !== "running" && snapshotLast.state !== "pending")) {
465
+ const tail = await backend.observe(handle, cursor).catch(() => undefined);
466
+ if (tail) {
467
+ saveLogs(tail);
468
+ compute.workerLogCursor = tail.logs.cursor;
469
+ }
470
+ }
471
+ // --- Cleanup first (separate from the possibly-aborted signal), then report generation. ---
472
+ options.onProgress?.({ stage: "cleanup" });
473
+ journal.updateJob(dirs.runId, { stage: "cleaning_up" });
474
+ if (handle)
475
+ await backend.release(handle, { ledger }).catch(() => undefined);
476
+ else
477
+ await backend.release({ runId: dirs.runId }, { ledger }).catch(() => undefined);
478
+ const cleanup = await confirmRunResourcesGone(dirs.runId, deps, journal);
479
+ compute.resources = journal
480
+ .listResources({ runId: dirs.runId })
481
+ .filter((resource) => resource.providerId)
482
+ .map((resource) => ({ role: resource.role, providerId: resource.providerId }));
483
+ compute.cleanup = cleanup.confirmed ? "confirmed" : "pending";
484
+ journal.updateJob(dirs.runId, {
485
+ stage: "done",
486
+ status: cleanup.confirmed ? (record.status === "succeeded" ? "succeeded" : record.status === "cancelled" ? "cancelled" : "failed") : "cleanup_pending",
487
+ error: cleanup.confirmed ? record.error ?? null : { code: "CLOUD_CLEANUP_PENDING", message: "Paid resources could not be confirmed deleted." },
488
+ });
489
+ const result = await finish();
490
+ if (!cleanup.confirmed)
491
+ result.cleanupPending = { code: "CLOUD_CLEANUP_PENDING", resources: cleanup.pending };
492
+ return result;
493
+ }
494
+ finally {
495
+ if (ownJournal)
496
+ journal.close();
497
+ }
498
+ }
@@ -0,0 +1 @@
1
+ export {};