@tuned-tensor/local 0.2.7 → 0.2.8

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 (60) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +139 -0
  3. package/dist/artifacts.d.ts +92 -0
  4. package/dist/artifacts.js +591 -3
  5. package/dist/artifacts.js.map +1 -1
  6. package/dist/contracts.d.ts +5 -2
  7. package/dist/contracts.js +15 -10
  8. package/dist/contracts.js.map +1 -1
  9. package/dist/dataset.d.ts +3 -0
  10. package/dist/dataset.js +87 -5
  11. package/dist/dataset.js.map +1 -1
  12. package/dist/doctor.d.ts +13 -2
  13. package/dist/doctor.js +372 -49
  14. package/dist/doctor.js.map +1 -1
  15. package/dist/evaluation.d.ts +15 -0
  16. package/dist/evaluation.js +120 -16
  17. package/dist/evaluation.js.map +1 -1
  18. package/dist/huggingface-cache.d.ts +26 -0
  19. package/dist/huggingface-cache.js +68 -0
  20. package/dist/huggingface-cache.js.map +1 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.js +741 -75
  23. package/dist/index.js.map +1 -1
  24. package/dist/local-project.d.ts +11 -0
  25. package/dist/local-project.js +96 -3
  26. package/dist/local-project.js.map +1 -1
  27. package/dist/model-registry.d.ts +21 -0
  28. package/dist/model-registry.js +198 -0
  29. package/dist/model-registry.js.map +1 -1
  30. package/dist/model-server.d.ts +32 -0
  31. package/dist/model-server.js +158 -0
  32. package/dist/model-server.js.map +1 -0
  33. package/dist/orchestrator.d.ts +3 -0
  34. package/dist/orchestrator.js +1001 -142
  35. package/dist/orchestrator.js.map +1 -1
  36. package/dist/prefetch.d.ts +13 -0
  37. package/dist/prefetch.js +107 -19
  38. package/dist/prefetch.js.map +1 -1
  39. package/dist/process-runner.d.ts +7 -0
  40. package/dist/process-runner.js +171 -16
  41. package/dist/process-runner.js.map +1 -1
  42. package/dist/process-training.d.ts +5 -1
  43. package/dist/process-training.js +32 -16
  44. package/dist/process-training.js.map +1 -1
  45. package/dist/run-reporter.d.ts +2 -0
  46. package/dist/run-reporter.js +10 -0
  47. package/dist/run-reporter.js.map +1 -1
  48. package/dist/store.d.ts +16 -2
  49. package/dist/store.js +189 -24
  50. package/dist/store.js.map +1 -1
  51. package/docs/architecture.md +32 -0
  52. package/docs/local-workflow-remediation-2026-07-13.md +130 -0
  53. package/docs/local-workflow-ux-review-2026-07-13.md +458 -0
  54. package/package.json +3 -1
  55. package/training/local-runner/src/evaluate.py +80 -20
  56. package/training/local-runner/src/prefetch.py +107 -8
  57. package/training/local-runner/src/serve.py +329 -0
  58. package/training/local-runner/src/train.py +47 -22
  59. package/training/local-runner/src/train_dpo.py +41 -25
  60. package/training/local-runner/uv.lock +2401 -0
@@ -1,13 +1,20 @@
1
- import { copyFile, readFile, rm, stat, writeFile } from "node:fs/promises";
2
- import { createHash } from "node:crypto";
1
+ import { copyFile, lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { createReadStream } from "node:fs";
3
+ import { createHash, randomUUID } from "node:crypto";
3
4
  import { performance } from "node:perf_hooks";
4
- import { dirname, resolve } from "node:path";
5
- import { defaultArtifactPrefix, fileUri, prepareRunDirectories, readJson, resolveRunArtifacts, writeJson, } from "./artifacts.js";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { defaultArtifactPrefix, fileUri, assertArtifactManifest, ARTIFACT_WORKFLOW_LOCK_FILE, claimRunArtifactDirectory, prepareRunDirectories, readJson, resolveRunArtifacts, writeArtifactManifest, writeFileAtomic, writeJsonAtomic, } from "./artifacts.js";
6
9
  import { evalReportSchema, fineTuneRunRequestSchema, localRunnerConfigSchema, runReportSchema, trainingReportSchema, } from "./contracts.js";
7
- import { buildSystemMessage, compileSpecToJsonl, examplesFromChatJsonl, examplesFromSpec } from "./dataset.js";
10
+ import { buildSystemMessage, compileSpecToJsonl, examplesFromChatJsonl, examplesFromSpec, localAssetPathsFromChatJsonl, normalizeChatJsonlForRelocation, } from "./dataset.js";
8
11
  import { compareEvalReports, deriveSampleSeed, evaluateExamples, rescoreEvalReport, splitSpecExamples } from "./evaluation.js";
9
12
  import { launchProcessTraining } from "./process-training.js";
13
+ import { assertUsableModelArtifact, localModelArtifactPath } from "./model-registry.js";
14
+ import { ProcessCancelledError } from "./process-runner.js";
10
15
  import { createLocalStore } from "./store.js";
16
+ import { withHuggingFaceCacheEnvironment } from "./huggingface-cache.js";
17
+ import { verifyLocalBaseModel } from "./prefetch.js";
11
18
  export async function loadJsonFile(path) {
12
19
  return JSON.parse(await readFile(path, "utf8"));
13
20
  }
@@ -17,7 +24,45 @@ export async function loadRunRequest(path) {
17
24
  export async function loadLocalRunnerConfig(path) {
18
25
  if (!path)
19
26
  return localRunnerConfigSchema.parse({});
20
- return localRunnerConfigSchema.parse(await loadJsonFile(path));
27
+ const configPath = resolve(path);
28
+ const base = dirname(configPath);
29
+ const config = localRunnerConfigSchema.parse(await loadJsonFile(configPath));
30
+ const configPathValue = (value, preserveBundled = false) => {
31
+ if (!value)
32
+ return undefined;
33
+ if (preserveBundled && (value === "training/local-runner" || value.startsWith("training/local-runner/"))) {
34
+ return value;
35
+ }
36
+ if (value === "~")
37
+ return homedir();
38
+ if (value.startsWith("~/"))
39
+ return resolve(homedir(), value.slice(2));
40
+ return resolve(base, value);
41
+ };
42
+ return {
43
+ ...config,
44
+ artifactRoot: configPathValue(config.artifactRoot),
45
+ storeRoot: configPathValue(config.storeRoot),
46
+ training: {
47
+ ...config.training,
48
+ cwd: configPathValue(config.training.cwd),
49
+ project: configPathValue(config.training.project, true),
50
+ script: configPathValue(config.training.script, true),
51
+ },
52
+ paths: {
53
+ baseModel: configPathValue(config.paths.baseModel),
54
+ modelCache: configPathValue(config.paths.modelCache),
55
+ },
56
+ evaluation: {
57
+ ...config.evaluation,
58
+ inference: {
59
+ ...config.evaluation.inference,
60
+ cwd: configPathValue(config.evaluation.inference.cwd),
61
+ project: configPathValue(config.evaluation.inference.project, true),
62
+ script: configPathValue(config.evaluation.inference.script, true),
63
+ },
64
+ },
65
+ };
21
66
  }
22
67
  function elapsed(started) {
23
68
  const ms = Math.max(0, Math.round(performance.now() - started));
@@ -60,19 +105,82 @@ function artifactPrefix(request) {
60
105
  runId: request.run_id,
61
106
  });
62
107
  }
108
+ function canonicalJson(value) {
109
+ if (value === null || typeof value !== "object")
110
+ return JSON.stringify(value) ?? "null";
111
+ if (Array.isArray(value))
112
+ return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
113
+ const object = value;
114
+ return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
115
+ }
63
116
  function hashJson(value) {
64
- return createHash("sha256").update(JSON.stringify(value)).digest("hex");
117
+ return createHash("sha256").update(canonicalJson(value)).digest("hex");
118
+ }
119
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
120
+ async function hashFileIfPresent(path) {
121
+ try {
122
+ return await hashFile(path);
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ async function packageVersion() {
129
+ try {
130
+ const parsed = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
131
+ return typeof parsed.version === "string" ? parsed.version : "unknown";
132
+ }
133
+ catch {
134
+ return "unknown";
135
+ }
136
+ }
137
+ async function effectiveConfigFingerprint(config) {
138
+ const bundledProject = resolve(packageRoot, "training/local-runner");
139
+ const projectRoots = [...new Set([
140
+ bundledProject,
141
+ config.training.project === "training/local-runner" ? bundledProject : config.training.project,
142
+ config.evaluation.inference.project === "training/local-runner"
143
+ ? bundledProject
144
+ : config.evaluation.inference.project,
145
+ ].filter((value) => Boolean(value)).map((value) => resolve(value)))];
146
+ const projectFingerprints = {};
147
+ for (const project of projectRoots) {
148
+ projectFingerprints[project] = {
149
+ pyproject: await hashFileIfPresent(resolve(project, "pyproject.toml")),
150
+ uv_lock: await hashFileIfPresent(resolve(project, "uv.lock")),
151
+ };
152
+ }
153
+ return hashJson({
154
+ config,
155
+ runtime: {
156
+ tt_local_version: await packageVersion(),
157
+ node_version: process.version,
158
+ platform: process.platform,
159
+ architecture: process.arch,
160
+ project_fingerprints: projectFingerprints,
161
+ },
162
+ });
163
+ }
164
+ async function runtimeFingerprint() {
165
+ const bundledProject = resolve(packageRoot, "training/local-runner");
166
+ return hashJson({
167
+ tt_local_version: await packageVersion(),
168
+ node_version: process.version,
169
+ platform: process.platform,
170
+ architecture: process.arch,
171
+ runner_pyproject: await hashFileIfPresent(resolve(bundledProject, "pyproject.toml")),
172
+ runner_uv_lock: await hashFileIfPresent(resolve(bundledProject, "uv.lock")),
173
+ });
65
174
  }
66
175
  function preparedSourceFingerprint(args) {
67
176
  return hashJson({
68
- training_method: args.request.training_method,
69
- spec_snapshot: args.request.spec_snapshot,
70
- dataset_prebuilt: args.request.dataset_prebuilt ?? null,
177
+ request_fingerprint: args.requestFingerprint,
178
+ runtime_fingerprint: args.runtimeFingerprint,
179
+ preparation_config: args.preparationConfig,
180
+ base_model_revision: args.baseModelRevision ?? null,
181
+ base_model_fingerprint: args.baseModelFingerprint ?? null,
182
+ parent_model_fingerprint: args.parentModelFingerprint ?? null,
71
183
  dataset_fingerprints: args.datasetFingerprints,
72
- base_model_for_evaluation: args.baseModelForEvaluation,
73
- parent_model_artifact: args.parentModelArtifact ?? null,
74
- eval_sample_seed: args.evalSampleSeed,
75
- max_eval_examples: args.maxEvalExamples ?? null,
76
184
  });
77
185
  }
78
186
  async function countJsonlRows(path) {
@@ -80,24 +188,140 @@ async function countJsonlRows(path) {
80
188
  return text.split(/\r?\n/).filter((line) => line.trim()).length;
81
189
  }
82
190
  async function hashFile(path) {
83
- return createHash("sha256").update(await readFile(path)).digest("hex");
191
+ const hash = createHash("sha256");
192
+ for await (const chunk of createReadStream(path))
193
+ hash.update(chunk);
194
+ return hash.digest("hex");
84
195
  }
85
196
  async function datasetFingerprints(request) {
86
197
  const dataset = request.dataset_prebuilt;
87
- if (!dataset)
88
- return {};
89
198
  const entries = [
90
- ["training", dataset.training],
91
- ["validation", dataset.validation],
92
- ["test", dataset.test],
199
+ ["training", dataset?.training],
200
+ ["validation", dataset?.validation],
201
+ ["test", dataset?.test],
93
202
  ];
94
203
  const fingerprints = {};
95
204
  for (const [key, value] of entries) {
96
- if (value)
97
- fingerprints[key] = await hashFile(stripFileUri(value));
205
+ if (!value)
206
+ continue;
207
+ const datasetPath = stripFileUri(value);
208
+ fingerprints[key] = await hashFile(datasetPath);
209
+ for (const assetPath of await localAssetPathsFromChatJsonl(datasetPath)) {
210
+ fingerprints[`${key}_asset:${assetPath}`] = await hashFile(assetPath);
211
+ }
212
+ }
213
+ for (const [exampleIndex, example] of request.spec_snapshot.examples.entries()) {
214
+ for (const [assetIndex, asset] of (example.input_assets ?? []).entries()) {
215
+ const value = asset.image ?? asset.data_uri ?? asset.uri ?? asset.path;
216
+ if (!value
217
+ || value.startsWith("data:")
218
+ || (/^[a-z][a-z0-9+.-]*:/i.test(value) && !value.startsWith("file://"))) {
219
+ continue;
220
+ }
221
+ const assetPath = stripFileUri(value);
222
+ fingerprints[`spec_asset:${exampleIndex}:${assetIndex}:${assetPath}`] = await hashFile(assetPath);
223
+ }
98
224
  }
99
225
  return fingerprints;
100
226
  }
227
+ async function resolveBaseModelRevision(request, config) {
228
+ const explicit = request.hyperparameters.base_model_revision;
229
+ if (explicit)
230
+ return explicit;
231
+ if (config.paths.baseModel) {
232
+ const match = resolve(config.paths.baseModel).match(/[\\/]snapshots[\\/]([^\\/]+)(?:[\\/]|$)/);
233
+ if (match?.[1])
234
+ return match[1];
235
+ }
236
+ if (!request.spec_snapshot.base_model.includes("/"))
237
+ return undefined;
238
+ const repository = `models--${request.spec_snapshot.base_model.replaceAll("/", "--")}`;
239
+ const cacheEnvironment = withHuggingFaceCacheEnvironment(process.env, config.paths.modelCache);
240
+ const refPath = resolve(cacheEnvironment.HF_HUB_CACHE, repository, "refs", "main");
241
+ try {
242
+ const revision = (await readFile(refPath, "utf8")).trim();
243
+ return revision || undefined;
244
+ }
245
+ catch {
246
+ return undefined;
247
+ }
248
+ }
249
+ async function fingerprintLocalArtifact(uri) {
250
+ const root = localModelArtifactPath(uri);
251
+ const metadata = await lstat(root);
252
+ if (metadata.isSymbolicLink()) {
253
+ throw new Error(`Parent model artifact must not be a symbolic link: ${root}`);
254
+ }
255
+ if (metadata.isFile()) {
256
+ return hashJson({ kind: "file", size_bytes: metadata.size, sha256: await hashFile(root) });
257
+ }
258
+ if (!metadata.isDirectory())
259
+ throw new Error(`Parent model artifact is not a file or directory: ${root}`);
260
+ const files = [];
261
+ const visit = async (directory) => {
262
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
263
+ const child = resolve(directory, entry.name);
264
+ if (entry.isSymbolicLink()) {
265
+ throw new Error(`Parent model artifact must not contain symbolic links: ${child}`);
266
+ }
267
+ if (entry.isDirectory()) {
268
+ await visit(child);
269
+ continue;
270
+ }
271
+ const childMetadata = await stat(child);
272
+ if (!childMetadata.isFile())
273
+ continue;
274
+ files.push({
275
+ path: relative(root, child).split("\\").join("/"),
276
+ size_bytes: childMetadata.size,
277
+ sha256: await hashFile(child),
278
+ });
279
+ }
280
+ };
281
+ await visit(root);
282
+ files.sort((left, right) => left.path.localeCompare(right.path));
283
+ if (files.length === 0)
284
+ throw new Error(`Parent model artifact directory is empty: ${root}`);
285
+ return hashJson({ kind: "directory", files });
286
+ }
287
+ /** Hash a local base model while permitting Hugging Face snapshot file links. */
288
+ export async function fingerprintLocalBaseModel(uri) {
289
+ const root = localModelArtifactPath(uri);
290
+ const rootMetadata = await lstat(root);
291
+ if (rootMetadata.isSymbolicLink()) {
292
+ throw new Error(`Local base model path must not itself be a symbolic link: ${root}`);
293
+ }
294
+ if (!rootMetadata.isDirectory()) {
295
+ throw new Error(`Local base model must be a Hugging Face snapshot directory: ${root}`);
296
+ }
297
+ await verifyLocalBaseModel(root);
298
+ const files = [];
299
+ const visit = async (directory) => {
300
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
301
+ const child = resolve(directory, entry.name);
302
+ if (entry.isDirectory()) {
303
+ await visit(child);
304
+ continue;
305
+ }
306
+ const childMetadata = await stat(child);
307
+ if (entry.isSymbolicLink() && !childMetadata.isFile()) {
308
+ throw new Error(`Local base model contains a non-file symbolic link: ${child}`);
309
+ }
310
+ if (!childMetadata.isFile())
311
+ continue;
312
+ files.push({
313
+ path: relative(root, child).split("\\").join("/"),
314
+ size_bytes: childMetadata.size,
315
+ sha256: await hashFile(child),
316
+ });
317
+ }
318
+ };
319
+ await visit(root);
320
+ files.sort((left, right) => left.path.localeCompare(right.path));
321
+ if (files.length === 0)
322
+ throw new Error(`Local base model directory is empty: ${root}`);
323
+ return hashJson({ kind: "directory", files });
324
+ }
101
325
  function statusForProgressStage(stage) {
102
326
  if (stage === "evaluating_baseline")
103
327
  return "evaluating_baseline";
@@ -125,24 +349,338 @@ async function readStageMetadata(path) {
125
349
  }
126
350
  }
127
351
  async function clearDependentStageArtifacts(artifacts) {
352
+ await cleanupStageArtifacts(artifacts, "prepare");
353
+ }
354
+ async function removePrefixedArtifacts(path) {
355
+ const directory = dirname(path);
356
+ const prefix = `${basename(path)}.`;
357
+ const names = await readdir(directory).catch(() => []);
128
358
  await Promise.all([
129
- rm(artifacts.baselineEvalJson, { force: true }),
130
- rm(artifacts.candidateEvalJson, { force: true }),
131
- rm(artifacts.trainingReportJson, { force: true }),
132
- rm(artifacts.runReportJson, { force: true }),
359
+ rm(path, { force: true }),
360
+ ...names.filter((name) => name.startsWith(prefix)).map((name) => rm(resolve(directory, name), { recursive: true, force: true })),
133
361
  ]);
134
362
  }
363
+ async function cleanupStageArtifacts(artifacts, stage) {
364
+ const removeReport = async () => rm(artifacts.runReportJson, { force: true });
365
+ if (stage === "prepare") {
366
+ await Promise.all([
367
+ removePrefixedArtifacts(artifacts.baselineEvalJson),
368
+ removePrefixedArtifacts(artifacts.candidateEvalJson),
369
+ rm(artifacts.trainingDir, { recursive: true, force: true }),
370
+ removePrefixedArtifacts(artifacts.trainingReportJson),
371
+ rm(resolve(artifacts.runDir, "model.tar.gz"), { force: true }),
372
+ removeReport(),
373
+ rm(artifacts.artifactManifestJson, { force: true }),
374
+ ]);
375
+ return;
376
+ }
377
+ if (stage === "baseline") {
378
+ await Promise.all([
379
+ removePrefixedArtifacts(artifacts.baselineEvalJson),
380
+ removeReport(),
381
+ ]);
382
+ return;
383
+ }
384
+ if (stage === "train") {
385
+ await Promise.all([
386
+ rm(artifacts.trainingDir, { recursive: true, force: true }),
387
+ removePrefixedArtifacts(artifacts.trainingReportJson),
388
+ rm(resolve(artifacts.runDir, "model.tar.gz"), { force: true }),
389
+ removePrefixedArtifacts(artifacts.candidateEvalJson),
390
+ removeReport(),
391
+ ]);
392
+ await prepareRunDirectories(artifacts);
393
+ return;
394
+ }
395
+ if (stage === "candidate") {
396
+ await Promise.all([
397
+ removePrefixedArtifacts(artifacts.candidateEvalJson),
398
+ removeReport(),
399
+ ]);
400
+ return;
401
+ }
402
+ await removeReport();
403
+ }
404
+ function manifestRelativePath(artifacts, path) {
405
+ return relative(artifacts.runDir, path).split("\\").join("/");
406
+ }
407
+ async function verifyReusableArtifacts(artifacts, paths, options = {}) {
408
+ if (!await pathExists(artifacts.artifactManifestJson))
409
+ return false;
410
+ await assertArtifactManifest(artifacts.artifactManifestJson, {
411
+ requiredPaths: paths.map((path) => manifestRelativePath(artifacts, path)),
412
+ scopeToRequired: true,
413
+ verifyModel: options.verifyModel,
414
+ });
415
+ return true;
416
+ }
417
+ async function throwIfCancelled(store, request) {
418
+ if (await store.isCancellationRequested(request.run_id)) {
419
+ throw new ProcessCancelledError(`Run ${request.run_id} was cancelled.`);
420
+ }
421
+ }
422
+ async function acquireWorkflowLock(lockPath, description) {
423
+ await mkdir(dirname(lockPath), { recursive: true });
424
+ const token = randomUUID();
425
+ for (let attempt = 0; attempt < 3; attempt += 1) {
426
+ try {
427
+ const handle = await open(lockPath, "wx", 0o600);
428
+ try {
429
+ await handle.writeFile(`${JSON.stringify({ token, pid: process.pid, created_at: new Date().toISOString() })}\n`);
430
+ }
431
+ finally {
432
+ await handle.close();
433
+ }
434
+ return async () => {
435
+ try {
436
+ const owner = JSON.parse(await readFile(lockPath, "utf8"));
437
+ if (owner.token === token)
438
+ await rm(lockPath, { force: true });
439
+ }
440
+ catch {
441
+ // The lock may already have been removed during process shutdown.
442
+ }
443
+ };
444
+ }
445
+ catch (error) {
446
+ if (error.code !== "EEXIST")
447
+ throw error;
448
+ const metadata = await stat(lockPath).catch(() => null);
449
+ let owner = null;
450
+ try {
451
+ owner = JSON.parse(await readFile(lockPath, "utf8"));
452
+ }
453
+ catch {
454
+ owner = null;
455
+ }
456
+ let ownerAlive = false;
457
+ if (typeof owner?.pid === "number" && Number.isSafeInteger(owner.pid) && owner.pid > 0) {
458
+ try {
459
+ process.kill(owner.pid, 0);
460
+ ownerAlive = true;
461
+ }
462
+ catch (killError) {
463
+ ownerAlive = killError.code !== "ESRCH";
464
+ }
465
+ }
466
+ const recent = metadata ? Date.now() - metadata.mtimeMs < 10_000 : true;
467
+ if (ownerAlive || (!owner && recent)) {
468
+ throw new Error(`${description} already has an active local workflow (lock: ${lockPath}).`);
469
+ }
470
+ const stalePath = `${lockPath}.stale.${randomUUID()}`;
471
+ try {
472
+ await rename(lockPath, stalePath);
473
+ await rm(stalePath, { force: true });
474
+ }
475
+ catch (renameError) {
476
+ if (renameError.code !== "ENOENT")
477
+ throw renameError;
478
+ }
479
+ }
480
+ }
481
+ throw new Error(`Unable to acquire local workflow lock for ${description}.`);
482
+ }
483
+ async function acquireRunLock(store, runId) {
484
+ return acquireWorkflowLock(resolve(store.paths.runsDir, runId, "workflow.lock"), `Run ${runId}`);
485
+ }
486
+ async function acquireArtifactLock(artifacts) {
487
+ return acquireWorkflowLock(join(artifacts.runDir, ARTIFACT_WORKFLOW_LOCK_FILE), `Artifact directory ${artifacts.runDir}`);
488
+ }
489
+ function isDryTraining(training) {
490
+ return training.metrics?.dry_run === true;
491
+ }
492
+ function stringMetadata(value, key) {
493
+ const entry = value?.[key];
494
+ return typeof entry === "string" && entry.length > 0 ? entry : undefined;
495
+ }
496
+ async function modelManifestContract(prepared, training) {
497
+ if (isDryTraining(training) || !training.model_artifact_uri)
498
+ return undefined;
499
+ const metadata = training.artifact_metadata;
500
+ const explicitContract = Boolean(metadata?.framework && metadata?.format);
501
+ const inspection = await assertUsableModelArtifact(training.model_artifact_uri, {
502
+ allowUnrecognizedPayload: explicitContract,
503
+ });
504
+ const adapterWeights = inspection.adapter_weight_file_count > 0
505
+ && inspection.adapter_weight_bytes > 0;
506
+ const fullModelWeights = inspection.full_model_weight_file_count > 0
507
+ && inspection.full_model_weight_bytes > 0;
508
+ if (!adapterWeights && !fullModelWeights && !explicitContract) {
509
+ throw new Error(`Model artifact ${inspection.path} has no loadable adapter or Transformers full-model weights. `
510
+ + "Custom trainers must set training.artifact.framework and training.artifact.format.");
511
+ }
512
+ if ((metadata?.framework === "transformers-peft" || metadata?.servable === true)
513
+ && (!adapterWeights || !inspection.has_adapter_config)) {
514
+ throw new Error(`Model artifact ${inspection.path} requires adapter_model.safetensors or adapter_model.bin and a non-empty `
515
+ + "adapter_config.json for a PEFT/servable contract.");
516
+ }
517
+ if (metadata?.framework === "transformers-full" && !fullModelWeights) {
518
+ throw new Error(`Model artifact ${inspection.path} has no Transformers full-model weights.`);
519
+ }
520
+ const implicitPeftAdapter = adapterWeights && inspection.has_adapter_config;
521
+ const format = metadata?.format
522
+ ?? (inspection.kind === "file" && inspection.path.endsWith(".tar.gz")
523
+ ? "tar.gz"
524
+ : inspection.kind === "directory" ? "huggingface-directory" : "file");
525
+ return {
526
+ artifact_kind: inspection.kind,
527
+ format,
528
+ framework: metadata?.framework ?? (implicitPeftAdapter ? "transformers-peft" : fullModelWeights ? "transformers-full" : "custom"),
529
+ base_model: prepared.request.spec_snapshot.base_model,
530
+ base_model_revision: stringMetadata(metadata, "base_model_revision")
531
+ ?? stringMetadata(training.metrics, "base_model_revision")
532
+ ?? prepared.metadata.base_model_revision
533
+ ?? prepared.request.hyperparameters.base_model_revision,
534
+ base_model_artifact_uri: training.base_model_artifact_uri,
535
+ base_model_fingerprint: prepared.metadata.base_model_fingerprint ?? undefined,
536
+ parent_model_artifact: prepared.request.hyperparameters.parent_model_artifact,
537
+ artifact_uri: training.model_artifact_uri,
538
+ artifact_root: inspection.path,
539
+ servable: metadata?.servable ?? implicitPeftAdapter,
540
+ };
541
+ }
542
+ async function refreshArtifactManifest(prepared) {
543
+ let model;
544
+ if (await pathExists(prepared.artifacts.trainingReportJson)) {
545
+ const training = trainingReportSchema.parse(await readJson(prepared.artifacts.trainingReportJson));
546
+ model = await modelManifestContract(prepared, training);
547
+ }
548
+ await writeArtifactManifest(prepared.artifacts, { model });
549
+ }
550
+ function stageOutputPath(prepared, stage) {
551
+ if (stage === "baseline")
552
+ return prepared.artifacts.baselineEvalJson;
553
+ if (stage === "train")
554
+ return prepared.artifacts.trainingReportJson;
555
+ return prepared.artifacts.candidateEvalJson;
556
+ }
557
+ function stageFingerprintPath(prepared, stage) {
558
+ return `${stageOutputPath(prepared, stage)}.stage.json`;
559
+ }
560
+ function configuredPath(path, cwd) {
561
+ if (path === "training/local-runner" || path.startsWith("training/local-runner/")) {
562
+ return resolve(packageRoot, path);
563
+ }
564
+ if (path.startsWith("file://"))
565
+ return stripFileUri(path);
566
+ if (isAbsolute(path))
567
+ return path;
568
+ const fileLike = path.startsWith("./")
569
+ || path.startsWith("../")
570
+ || path.includes("/")
571
+ || path.includes("\\")
572
+ || /\.(?:py|js|mjs|cjs|ts|tsx|sh|bash|zsh|pl|rb)$/i.test(path);
573
+ return fileLike ? resolve(cwd ?? process.cwd(), path) : null;
574
+ }
575
+ async function entrypointFileFingerprints(args) {
576
+ const candidates = new Set();
577
+ for (const value of args.values) {
578
+ if (!value)
579
+ continue;
580
+ const path = configuredPath(value, args.cwd);
581
+ if (path)
582
+ candidates.add(path);
583
+ }
584
+ if (args.project) {
585
+ const project = configuredPath(args.project, args.cwd) ?? resolve(args.cwd ?? process.cwd(), args.project);
586
+ candidates.add(resolve(project, "pyproject.toml"));
587
+ candidates.add(resolve(project, "uv.lock"));
588
+ }
589
+ const fingerprints = {};
590
+ for (const path of [...candidates].sort())
591
+ fingerprints[path] = await hashFileIfPresent(path);
592
+ return fingerprints;
593
+ }
594
+ async function stageFingerprint(args) {
595
+ const common = {
596
+ source_fingerprint: args.prepared.metadata.source_fingerprint,
597
+ runtime_fingerprint: args.prepared.metadata.runtime_fingerprint,
598
+ dry_run: args.config.dryRun,
599
+ };
600
+ if (args.stage === "train") {
601
+ const entrypointFiles = await entrypointFileFingerprints({
602
+ values: [
603
+ args.config.training.script
604
+ ?? (args.prepared.request.training_method === "dpo"
605
+ ? "training/local-runner/src/train_dpo.py"
606
+ : "training/local-runner/src/train.py"),
607
+ ...(args.config.training.command ?? []),
608
+ ],
609
+ cwd: args.config.training.cwd,
610
+ project: args.config.training.project,
611
+ });
612
+ return hashJson({
613
+ ...common,
614
+ training: args.config.training,
615
+ paths: args.config.paths,
616
+ entrypoint_files: entrypointFiles,
617
+ });
618
+ }
619
+ const selectedCommand = args.stage === "baseline"
620
+ ? args.config.evaluation.baselineCommand
621
+ : args.config.evaluation.candidateCommand;
622
+ const entrypointFiles = await entrypointFileFingerprints({
623
+ values: [
624
+ args.config.evaluation.inference.script,
625
+ ...(args.config.evaluation.inference.command ?? []),
626
+ ...(selectedCommand ?? []),
627
+ ],
628
+ cwd: args.config.evaluation.inference.cwd,
629
+ project: args.config.evaluation.inference.project,
630
+ });
631
+ const evaluation = {
632
+ command: args.stage === "baseline"
633
+ ? args.config.evaluation.baselineCommand
634
+ : args.config.evaluation.candidateCommand,
635
+ inference: args.config.evaluation.inference,
636
+ scoring: args.config.evaluation.scoring,
637
+ timeout_ms: args.config.evaluation.timeoutMs,
638
+ baseline_cache: args.config.evaluation.baselineCache,
639
+ llm: args.config.llm,
640
+ model_cache: args.config.paths.modelCache,
641
+ };
642
+ return hashJson({
643
+ ...common,
644
+ evaluation,
645
+ entrypoint_files: entrypointFiles,
646
+ });
647
+ }
648
+ async function writeStageFingerprint(args) {
649
+ await writeJsonAtomic(stageFingerprintPath(args.prepared, args.stage), {
650
+ schema_version: 1,
651
+ stage: args.stage,
652
+ fingerprint: await stageFingerprint(args),
653
+ written_at: new Date().toISOString(),
654
+ });
655
+ }
656
+ async function hasCurrentStageFingerprint(args) {
657
+ try {
658
+ const record = await readJson(stageFingerprintPath(args.prepared, args.stage));
659
+ return record.fingerprint === await stageFingerprint(args);
660
+ }
661
+ catch {
662
+ return false;
663
+ }
664
+ }
665
+ async function canReuseStageArtifact(args) {
666
+ const output = stageOutputPath(args.prepared, args.stage);
667
+ if (!await pathExists(output) || !await hasCurrentStageFingerprint(args))
668
+ return false;
669
+ return verifyReusableArtifacts(args.prepared.artifacts, [output, stageFingerprintPath(args.prepared, args.stage), ...(args.additionalPaths ?? [])], { verifyModel: args.verifyModel });
670
+ }
135
671
  function createStoreReporter(input) {
136
672
  return {
137
673
  verbose: input.reporter?.verbose,
138
674
  async onEvent(event) {
139
- await input.store.updateRun({
675
+ const state = await input.store.updateRun({
140
676
  runId: input.request.run_id,
141
677
  status: statusForProgressStage(event.stage),
142
678
  stage: event.stage,
143
679
  message: event.message,
144
680
  details: event.details,
145
681
  });
682
+ if (state.status === "cancelled")
683
+ throw new ProcessCancelledError(`Run ${input.request.run_id} was cancelled.`);
146
684
  await input.reporter?.onEvent?.(event);
147
685
  },
148
686
  async onLog(log) {
@@ -158,6 +696,8 @@ async function updateRun(input) {
158
696
  message: input.message,
159
697
  details: input.details,
160
698
  });
699
+ if (state.status === "cancelled")
700
+ throw new ProcessCancelledError(`Run ${input.request.run_id} was cancelled.`);
161
701
  await input.reporter?.onEvent?.({
162
702
  stage: input.stage,
163
703
  status: input.status,
@@ -168,11 +708,26 @@ async function updateRun(input) {
168
708
  }
169
709
  async function ensureRunRecord(args) {
170
710
  await prepareRunDirectories(args.artifacts);
171
- await writeJson(resolve(args.artifacts.runDir, "request.json"), args.request);
711
+ let existing = null;
172
712
  try {
173
- await args.store.getRun(args.request.run_id);
713
+ existing = await args.store.getRun(args.request.run_id);
174
714
  }
175
- catch {
715
+ catch (error) {
716
+ if (!(error instanceof Error) || !error.message.startsWith("Run not found:"))
717
+ throw error;
718
+ }
719
+ if (existing) {
720
+ if (existing.behavior_spec_id !== args.request.behavior_spec_id
721
+ || existing.user_id !== args.request.user_id
722
+ || existing.run_number !== args.request.run_number) {
723
+ throw new Error(`Run ${args.request.run_id} cannot be reused with different user, behavior spec, or run number identity.`);
724
+ }
725
+ if (resolve(existing.artifact_dir) !== resolve(args.artifacts.runDir)) {
726
+ throw new Error(`Run ${args.request.run_id} is already bound to artifact directory ${existing.artifact_dir}; `
727
+ + `it cannot be resumed with ${args.artifacts.runDir}.`);
728
+ }
729
+ }
730
+ else {
176
731
  await args.store.startRun({ request: args.request, artifactDir: args.artifacts.runDir });
177
732
  await args.reporter?.onEvent?.({
178
733
  stage: "queued",
@@ -210,8 +765,10 @@ async function computePreparedRun(args) {
210
765
  + "overstate improvement. Provide dataset_prebuilt.test or dataset_prebuilt.validation, or set "
211
766
  + "evaluation.allowPrebuiltTrainingEval=true to evaluate on the training split anyway.");
212
767
  }
213
- if (args.writeArtifacts)
214
- await copyFile(trainingPath, artifacts.trainingJsonl);
768
+ if (args.writeArtifacts) {
769
+ const normalizedTraining = await normalizeChatJsonlForRelocation(trainingPath);
770
+ await writeFile(artifacts.trainingJsonl, `${normalizedTraining}\n`, "utf8");
771
+ }
215
772
  examples = await examplesFromChatJsonl(evaluation.path);
216
773
  trainingExampleCount = null;
217
774
  }
@@ -232,20 +789,40 @@ async function computePreparedRun(args) {
232
789
  const system = buildSystemMessage(request.spec_snapshot);
233
790
  const baseModelForEvaluation = config.paths.baseModel ?? request.spec_snapshot.base_model;
234
791
  const parentModelArtifact = request.hyperparameters.parent_model_artifact;
792
+ const parentModelFingerprint = parentModelArtifact
793
+ ? await fingerprintLocalArtifact(parentModelArtifact)
794
+ : undefined;
235
795
  const maxEvalExamples = config.evaluation.maxExamples ?? request.hyperparameters.max_eval_examples;
236
796
  const evalExamplesUsed = Math.min(maxEvalExamples ?? examples.length, examples.length);
237
797
  const fingerprints = await datasetFingerprints(request);
798
+ const requestFingerprint = hashJson(request);
799
+ const effectiveConfigFingerprintValue = await effectiveConfigFingerprint(config);
800
+ const runtimeFingerprintValue = await runtimeFingerprint();
801
+ const baseModelRevision = await resolveBaseModelRevision(request, config);
802
+ const baseModelFingerprint = config.paths.baseModel
803
+ ? await fingerprintLocalBaseModel(config.paths.baseModel)
804
+ : undefined;
238
805
  const metadata = {
239
806
  run_id: request.run_id,
240
807
  behavior_spec_id: request.behavior_spec_id,
241
808
  user_id: request.user_id,
242
809
  training_method: request.training_method,
810
+ request_fingerprint: requestFingerprint,
811
+ effective_config_fingerprint: effectiveConfigFingerprintValue,
812
+ runtime_fingerprint: runtimeFingerprintValue,
243
813
  source_fingerprint: preparedSourceFingerprint({
244
- request,
245
- baseModelForEvaluation,
246
- parentModelArtifact,
247
- evalSampleSeed,
248
- maxEvalExamples,
814
+ requestFingerprint,
815
+ runtimeFingerprint: runtimeFingerprintValue,
816
+ preparationConfig: {
817
+ dry_run: config.dryRun,
818
+ base_model_path: config.paths.baseModel,
819
+ max_eval_examples: config.evaluation.maxExamples,
820
+ eval_sample_seed: config.evaluation.sampleSeed,
821
+ allow_prebuilt_training_eval: config.evaluation.allowPrebuiltTrainingEval,
822
+ },
823
+ baseModelRevision,
824
+ baseModelFingerprint,
825
+ parentModelFingerprint,
249
826
  datasetFingerprints: fingerprints,
250
827
  }),
251
828
  eval_split: evalSplit,
@@ -259,12 +836,15 @@ async function computePreparedRun(args) {
259
836
  dataset_fingerprints: fingerprints,
260
837
  dataset_uri: fileUri(artifacts.trainingJsonl),
261
838
  base_model_for_evaluation: baseModelForEvaluation,
839
+ base_model_revision: baseModelRevision ?? null,
840
+ base_model_fingerprint: baseModelFingerprint ?? null,
262
841
  parent_model_artifact: parentModelArtifact ?? null,
842
+ parent_model_fingerprint: parentModelFingerprint ?? null,
263
843
  system_prompt_sha256: createHash("sha256").update(system).digest("hex"),
264
844
  prepared_at: new Date().toISOString(),
265
845
  };
266
846
  if (args.writeArtifacts)
267
- await writeJson(artifacts.stageMetadataJson, metadata);
847
+ await writeJsonAtomic(artifacts.stageMetadataJson, metadata);
268
848
  return {
269
849
  request,
270
850
  artifacts,
@@ -279,13 +859,22 @@ async function prepareStage(args) {
279
859
  const preparedExists = await pathExists(args.artifacts.stageMetadataJson)
280
860
  && await pathExists(args.artifacts.trainingJsonl);
281
861
  const prepared = await computePreparedRun({ ...args, writeArtifacts: false });
862
+ await args.store.syncRunRequest(args.request, args.artifacts.runDir);
863
+ await writeJsonAtomic(resolve(args.artifacts.runDir, "request.json"), args.request);
282
864
  const existingMetadata = preparedExists
283
865
  ? await readStageMetadata(args.artifacts.stageMetadataJson)
284
866
  : null;
285
- const canReuse = preparedExists
867
+ let canReuse = preparedExists
286
868
  && !args.force
287
869
  && existingMetadata?.source_fingerprint === prepared.metadata.source_fingerprint;
288
870
  if (canReuse) {
871
+ canReuse = await verifyReusableArtifacts(args.artifacts, [
872
+ args.artifacts.stageMetadataJson,
873
+ args.artifacts.trainingJsonl,
874
+ ]);
875
+ }
876
+ if (canReuse) {
877
+ await throwIfCancelled(args.store, args.request);
289
878
  await updateRun({
290
879
  ...args,
291
880
  status: "preparing",
@@ -302,8 +891,12 @@ async function prepareStage(args) {
302
891
  message: "Preparing local run artifacts.",
303
892
  details: { artifact_dir: args.artifacts.runDir },
304
893
  });
894
+ await throwIfCancelled(args.store, args.request);
895
+ await args.store.invalidateRunOutputs(args.request.run_id, { report: true, model: true });
305
896
  await clearDependentStageArtifacts(args.artifacts);
306
- return computePreparedRun({ ...args, writeArtifacts: true });
897
+ const refreshed = await computePreparedRun({ ...args, writeArtifacts: true });
898
+ await refreshArtifactManifest(refreshed);
899
+ return refreshed;
307
900
  }
308
901
  async function ensurePrepared(args) {
309
902
  return prepareStage({
@@ -316,7 +909,13 @@ async function ensurePrepared(args) {
316
909
  });
317
910
  }
318
911
  async function runBaselineStage(args) {
319
- if (!args.force && await pathExists(args.prepared.artifacts.baselineEvalJson)) {
912
+ if (!args.force
913
+ && await canReuseStageArtifact({
914
+ stage: "baseline",
915
+ prepared: args.prepared,
916
+ config: args.config,
917
+ })) {
918
+ await throwIfCancelled(args.store, args.prepared.request);
320
919
  await updateRun({
321
920
  store: args.store,
322
921
  reporter: args.reporter,
@@ -326,8 +925,13 @@ async function runBaselineStage(args) {
326
925
  message: "Reusing existing baseline evaluation.",
327
926
  details: { path: args.prepared.artifacts.baselineEvalJson },
328
927
  });
329
- return evalReportSchema.parse(await readJson(args.prepared.artifacts.baselineEvalJson));
928
+ const report = evalReportSchema.parse(await readJson(args.prepared.artifacts.baselineEvalJson));
929
+ await throwIfCancelled(args.store, args.prepared.request);
930
+ return report;
330
931
  }
932
+ await throwIfCancelled(args.store, args.prepared.request);
933
+ await args.store.invalidateRunOutputs(args.prepared.request.run_id, { report: true });
934
+ await cleanupStageArtifacts(args.prepared.artifacts, "baseline");
331
935
  await updateRun({
332
936
  store: args.store,
333
937
  reporter: args.reporter,
@@ -343,10 +947,14 @@ async function runBaselineStage(args) {
343
947
  parent_model_artifact: args.prepared.metadata.parent_model_artifact,
344
948
  },
345
949
  });
346
- return evaluateExamples({
950
+ const report = await evaluateExamples({
347
951
  kind: "baseline",
348
952
  modelId: args.prepared.metadata.parent_model_artifact ?? args.prepared.baseModelForEvaluation,
349
953
  baseModelId: args.prepared.baseModelForEvaluation,
954
+ baseModelRevision: args.config.paths.baseModel
955
+ ? undefined
956
+ : args.prepared.metadata.base_model_revision ?? undefined,
957
+ sourceFingerprint: args.prepared.metadata.source_fingerprint,
350
958
  adapterPath: args.prepared.metadata.parent_model_artifact ?? undefined,
351
959
  examples: args.prepared.examples,
352
960
  system: args.prepared.system,
@@ -356,10 +964,21 @@ async function runBaselineStage(args) {
356
964
  maxExamples: args.prepared.maxEvalExamples,
357
965
  evalSplit: args.prepared.metadata.eval_split,
358
966
  sampleSeed: args.prepared.metadata.eval_sample_seed,
967
+ shouldCancel: () => args.store.isCancellationRequested(args.prepared.request.run_id),
359
968
  });
969
+ await throwIfCancelled(args.store, args.prepared.request);
970
+ await writeStageFingerprint({ stage: "baseline", prepared: args.prepared, config: args.config });
971
+ return report;
360
972
  }
361
973
  async function runTrainStage(args) {
362
- if (!args.force && await pathExists(args.prepared.artifacts.trainingReportJson)) {
974
+ if (!args.force
975
+ && await canReuseStageArtifact({
976
+ stage: "train",
977
+ prepared: args.prepared,
978
+ config: args.config,
979
+ verifyModel: true,
980
+ })) {
981
+ await throwIfCancelled(args.store, args.prepared.request);
363
982
  await updateRun({
364
983
  store: args.store,
365
984
  reporter: args.reporter,
@@ -369,8 +988,22 @@ async function runTrainStage(args) {
369
988
  message: "Reusing existing training result.",
370
989
  details: { path: args.prepared.artifacts.trainingReportJson },
371
990
  });
372
- return trainingReportSchema.parse(await readJson(args.prepared.artifacts.trainingReportJson));
991
+ const training = trainingReportSchema.parse(await readJson(args.prepared.artifacts.trainingReportJson));
992
+ if (!isDryTraining(training)) {
993
+ await modelManifestContract(args.prepared, training);
994
+ await throwIfCancelled(args.store, args.prepared.request);
995
+ await args.store.registerModel({
996
+ request: args.prepared.request,
997
+ training,
998
+ artifactDir: args.prepared.artifacts.runDir,
999
+ });
1000
+ }
1001
+ await throwIfCancelled(args.store, args.prepared.request);
1002
+ return training;
373
1003
  }
1004
+ await throwIfCancelled(args.store, args.prepared.request);
1005
+ await args.store.invalidateRunOutputs(args.prepared.request.run_id, { report: true, model: true });
1006
+ await cleanupStageArtifacts(args.prepared.artifacts, "train");
374
1007
  await updateRun({
375
1008
  store: args.store,
376
1009
  reporter: args.reporter,
@@ -384,16 +1017,37 @@ async function runTrainStage(args) {
384
1017
  request: args.prepared.request,
385
1018
  artifacts: args.prepared.artifacts,
386
1019
  config: args.config,
1020
+ baseModelRevision: args.config.paths.baseModel
1021
+ ? undefined
1022
+ : args.prepared.metadata.base_model_revision ?? undefined,
387
1023
  reporter: args.runReporter,
1024
+ shouldCancel: () => args.store.isCancellationRequested(args.prepared.request.run_id),
388
1025
  });
389
- await writeJson(args.prepared.artifacts.trainingReportJson, training);
1026
+ await throwIfCancelled(args.store, args.prepared.request);
1027
+ if (!isDryTraining(training)) {
1028
+ if (!training.model_artifact_uri) {
1029
+ throw new Error("Training process completed without a model_artifact_uri.");
1030
+ }
1031
+ await modelManifestContract(args.prepared, training);
1032
+ }
1033
+ await writeJsonAtomic(args.prepared.artifacts.trainingReportJson, training);
1034
+ await writeStageFingerprint({ stage: "train", prepared: args.prepared, config: args.config });
1035
+ await refreshArtifactManifest(args.prepared);
1036
+ await throwIfCancelled(args.store, args.prepared.request);
1037
+ if (!isDryTraining(training)) {
1038
+ await args.store.registerModel({
1039
+ request: args.prepared.request,
1040
+ training,
1041
+ artifactDir: args.prepared.artifacts.runDir,
1042
+ });
1043
+ }
390
1044
  return training;
391
1045
  }
392
1046
  async function writeExternalTrainingReport(args) {
393
1047
  const training = trainingReportSchema.parse({
394
1048
  provider: args.config.training.backend === "command" ? "local-command" : "local-uv",
395
1049
  training_job_name: `external-${args.prepared.request.run_id}`,
396
- model_artifact_uri: args.modelArtifact,
1050
+ model_artifact_uri: fileUri(localModelArtifactPath(args.modelArtifact)),
397
1051
  base_model_artifact_uri: args.config.paths.baseModel ? fileUri(args.config.paths.baseModel) : undefined,
398
1052
  parent_model_artifact_uri: args.prepared.metadata.parent_model_artifact ?? undefined,
399
1053
  artifact_metadata: {
@@ -404,11 +1058,36 @@ async function writeExternalTrainingReport(args) {
404
1058
  exit_code: null,
405
1059
  log_uri: fileUri(args.prepared.artifacts.trainingReportJson),
406
1060
  });
407
- await writeJson(args.prepared.artifacts.trainingReportJson, training);
1061
+ await writeJsonAtomic(args.prepared.artifacts.trainingReportJson, training);
408
1062
  return training;
409
1063
  }
410
1064
  async function runCandidateStage(args) {
411
- if (!args.force && !args.modelArtifact && await pathExists(args.prepared.artifacts.candidateEvalJson)) {
1065
+ let verifiedTraining;
1066
+ if (!args.modelArtifact && await pathExists(args.prepared.artifacts.trainingReportJson)) {
1067
+ const trainingCurrent = await canReuseStageArtifact({
1068
+ stage: "train",
1069
+ prepared: args.prepared,
1070
+ config: args.config,
1071
+ verifyModel: true,
1072
+ });
1073
+ if (trainingCurrent) {
1074
+ verifiedTraining = trainingReportSchema.parse(await readJson(args.prepared.artifacts.trainingReportJson));
1075
+ }
1076
+ }
1077
+ if (!args.force
1078
+ && !args.modelArtifact
1079
+ && verifiedTraining
1080
+ && await canReuseStageArtifact({
1081
+ stage: "candidate",
1082
+ prepared: args.prepared,
1083
+ config: args.config,
1084
+ verifyModel: true,
1085
+ additionalPaths: [
1086
+ args.prepared.artifacts.trainingReportJson,
1087
+ stageFingerprintPath(args.prepared, "train"),
1088
+ ],
1089
+ })) {
1090
+ await throwIfCancelled(args.store, args.prepared.request);
412
1091
  await updateRun({
413
1092
  store: args.store,
414
1093
  reporter: args.reporter,
@@ -418,8 +1097,28 @@ async function runCandidateStage(args) {
418
1097
  message: "Reusing existing candidate evaluation.",
419
1098
  details: { path: args.prepared.artifacts.candidateEvalJson },
420
1099
  });
421
- return evalReportSchema.parse(await readJson(args.prepared.artifacts.candidateEvalJson));
1100
+ const training = verifiedTraining;
1101
+ if (!isDryTraining(training)) {
1102
+ await throwIfCancelled(args.store, args.prepared.request);
1103
+ await args.store.registerModel({
1104
+ request: args.prepared.request,
1105
+ training,
1106
+ artifactDir: args.prepared.artifacts.runDir,
1107
+ });
1108
+ }
1109
+ const report = evalReportSchema.parse(await readJson(args.prepared.artifacts.candidateEvalJson));
1110
+ await throwIfCancelled(args.store, args.prepared.request);
1111
+ return report;
1112
+ }
1113
+ if (!args.modelArtifact && !verifiedTraining) {
1114
+ throw new Error("candidate stage requires current verified training output. Run --stage train first.");
422
1115
  }
1116
+ await throwIfCancelled(args.store, args.prepared.request);
1117
+ await args.store.invalidateRunOutputs(args.prepared.request.run_id, {
1118
+ report: true,
1119
+ model: Boolean(args.modelArtifact),
1120
+ });
1121
+ await cleanupStageArtifacts(args.prepared.artifacts, "candidate");
423
1122
  let training;
424
1123
  if (args.modelArtifact) {
425
1124
  training = await writeExternalTrainingReport({
@@ -427,9 +1126,10 @@ async function runCandidateStage(args) {
427
1126
  config: args.config,
428
1127
  modelArtifact: args.modelArtifact,
429
1128
  });
1129
+ await writeStageFingerprint({ stage: "train", prepared: args.prepared, config: args.config });
430
1130
  }
431
- else if (await pathExists(args.prepared.artifacts.trainingReportJson)) {
432
- training = trainingReportSchema.parse(await readJson(args.prepared.artifacts.trainingReportJson));
1131
+ else if (verifiedTraining) {
1132
+ training = verifiedTraining;
433
1133
  }
434
1134
  else {
435
1135
  throw new Error("candidate stage requires training output or --model-artifact.");
@@ -437,6 +1137,16 @@ async function runCandidateStage(args) {
437
1137
  const modelArtifact = training.model_artifact_uri;
438
1138
  if (!modelArtifact)
439
1139
  throw new Error("candidate stage requires a model_artifact_uri in training-report.json or --model-artifact.");
1140
+ if (!isDryTraining(training)) {
1141
+ await modelManifestContract(args.prepared, training);
1142
+ await refreshArtifactManifest(args.prepared);
1143
+ await throwIfCancelled(args.store, args.prepared.request);
1144
+ await args.store.registerModel({
1145
+ request: args.prepared.request,
1146
+ training,
1147
+ artifactDir: args.prepared.artifacts.runDir,
1148
+ });
1149
+ }
440
1150
  await updateRun({
441
1151
  store: args.store,
442
1152
  reporter: args.reporter,
@@ -446,10 +1156,13 @@ async function runCandidateStage(args) {
446
1156
  message: "Running candidate evaluation.",
447
1157
  details: { model_artifact_uri: modelArtifact },
448
1158
  });
449
- return evaluateExamples({
1159
+ const report = await evaluateExamples({
450
1160
  kind: "candidate",
451
1161
  modelId: modelArtifact,
452
1162
  baseModelId: args.prepared.baseModelForEvaluation,
1163
+ baseModelRevision: args.config.paths.baseModel
1164
+ ? undefined
1165
+ : args.prepared.metadata.base_model_revision ?? undefined,
453
1166
  adapterPath: modelArtifact,
454
1167
  examples: args.prepared.examples,
455
1168
  system: args.prepared.system,
@@ -459,7 +1172,16 @@ async function runCandidateStage(args) {
459
1172
  maxExamples: args.prepared.maxEvalExamples,
460
1173
  evalSplit: args.prepared.metadata.eval_split,
461
1174
  sampleSeed: args.prepared.metadata.eval_sample_seed,
1175
+ shouldCancel: () => args.store.isCancellationRequested(args.prepared.request.run_id),
1176
+ });
1177
+ await throwIfCancelled(args.store, args.prepared.request);
1178
+ await writeStageFingerprint({
1179
+ stage: "candidate",
1180
+ prepared: args.prepared,
1181
+ config: args.config,
1182
+ training,
462
1183
  });
1184
+ return report;
463
1185
  }
464
1186
  async function runScoreStage(args) {
465
1187
  if (!await pathExists(args.prepared.artifacts.baselineEvalJson)) {
@@ -468,6 +1190,15 @@ async function runScoreStage(args) {
468
1190
  if (!await pathExists(args.prepared.artifacts.candidateEvalJson)) {
469
1191
  throw new Error("score stage requires candidate-eval.json. Run --stage candidate first.");
470
1192
  }
1193
+ await verifyReusableArtifacts(args.prepared.artifacts, [
1194
+ args.prepared.artifacts.baselineEvalJson,
1195
+ stageFingerprintPath(args.prepared, "baseline"),
1196
+ args.prepared.artifacts.candidateEvalJson,
1197
+ stageFingerprintPath(args.prepared, "candidate"),
1198
+ ]);
1199
+ await throwIfCancelled(args.store, args.prepared.request);
1200
+ await args.store.invalidateRunOutputs(args.prepared.request.run_id, { report: true });
1201
+ await cleanupStageArtifacts(args.prepared.artifacts, "score");
471
1202
  await updateRun({
472
1203
  store: args.store,
473
1204
  reporter: args.reporter,
@@ -477,21 +1208,47 @@ async function runScoreStage(args) {
477
1208
  message: "Rescoring existing baseline and candidate outputs.",
478
1209
  details: { scoring_mode: args.config.evaluation.scoring.mode },
479
1210
  });
480
- const baseline = await rescoreEvalReport({
481
- report: evalReportSchema.parse(await readJson(args.prepared.artifacts.baselineEvalJson)),
482
- config: args.config,
483
- outputPath: args.prepared.artifacts.baselineEvalJson,
484
- system: args.prepared.system,
485
- reporter: args.runReporter,
486
- });
487
- const candidate = await rescoreEvalReport({
488
- report: evalReportSchema.parse(await readJson(args.prepared.artifacts.candidateEvalJson)),
489
- config: args.config,
490
- outputPath: args.prepared.artifacts.candidateEvalJson,
491
- system: args.prepared.system,
492
- reporter: args.runReporter,
493
- });
494
- return { baseline, candidate };
1211
+ const rollbackPaths = [
1212
+ args.prepared.artifacts.baselineEvalJson,
1213
+ stageFingerprintPath(args.prepared, "baseline"),
1214
+ args.prepared.artifacts.candidateEvalJson,
1215
+ stageFingerprintPath(args.prepared, "candidate"),
1216
+ ];
1217
+ const originals = new Map();
1218
+ for (const path of rollbackPaths)
1219
+ originals.set(path, await readFile(path));
1220
+ try {
1221
+ const baseline = await rescoreEvalReport({
1222
+ report: evalReportSchema.parse(await readJson(args.prepared.artifacts.baselineEvalJson)),
1223
+ config: args.config,
1224
+ outputPath: args.prepared.artifacts.baselineEvalJson,
1225
+ system: args.prepared.system,
1226
+ reporter: args.runReporter,
1227
+ });
1228
+ const candidate = await rescoreEvalReport({
1229
+ report: evalReportSchema.parse(await readJson(args.prepared.artifacts.candidateEvalJson)),
1230
+ config: args.config,
1231
+ outputPath: args.prepared.artifacts.candidateEvalJson,
1232
+ system: args.prepared.system,
1233
+ reporter: args.runReporter,
1234
+ });
1235
+ await throwIfCancelled(args.store, args.prepared.request);
1236
+ await writeStageFingerprint({ stage: "baseline", prepared: args.prepared, config: args.config });
1237
+ const training = await pathExists(args.prepared.artifacts.trainingReportJson)
1238
+ ? trainingReportSchema.parse(await readJson(args.prepared.artifacts.trainingReportJson))
1239
+ : undefined;
1240
+ await writeStageFingerprint({
1241
+ stage: "candidate",
1242
+ prepared: args.prepared,
1243
+ config: args.config,
1244
+ training,
1245
+ });
1246
+ return { baseline, candidate };
1247
+ }
1248
+ catch (error) {
1249
+ await Promise.all([...originals].map(([path, contents]) => writeFileAtomic(path, contents)));
1250
+ throw error;
1251
+ }
495
1252
  }
496
1253
  async function runReportStage(args) {
497
1254
  if (!await pathExists(args.prepared.artifacts.baselineEvalJson)) {
@@ -503,6 +1260,37 @@ async function runReportStage(args) {
503
1260
  if (!await pathExists(args.prepared.artifacts.trainingReportJson)) {
504
1261
  throw new Error("report stage requires training-report.json. Run --stage train first, or --stage candidate --model-artifact <path>.");
505
1262
  }
1263
+ const currentTraining = await canReuseStageArtifact({
1264
+ stage: "train",
1265
+ prepared: args.prepared,
1266
+ config: args.config,
1267
+ verifyModel: true,
1268
+ });
1269
+ const currentBaseline = await canReuseStageArtifact({
1270
+ stage: "baseline",
1271
+ prepared: args.prepared,
1272
+ config: args.config,
1273
+ });
1274
+ const currentCandidate = await canReuseStageArtifact({
1275
+ stage: "candidate",
1276
+ prepared: args.prepared,
1277
+ config: args.config,
1278
+ verifyModel: true,
1279
+ });
1280
+ if (!currentTraining || !currentBaseline || !currentCandidate) {
1281
+ throw new Error("report stage inputs are stale for the current request/config. Re-run baseline, train, and candidate as needed.");
1282
+ }
1283
+ await verifyReusableArtifacts(args.prepared.artifacts, [
1284
+ args.prepared.artifacts.baselineEvalJson,
1285
+ stageFingerprintPath(args.prepared, "baseline"),
1286
+ args.prepared.artifacts.candidateEvalJson,
1287
+ stageFingerprintPath(args.prepared, "candidate"),
1288
+ args.prepared.artifacts.trainingReportJson,
1289
+ stageFingerprintPath(args.prepared, "train"),
1290
+ ], { verifyModel: true });
1291
+ await throwIfCancelled(args.store, args.prepared.request);
1292
+ await args.store.invalidateRunOutputs(args.prepared.request.run_id, { report: true });
1293
+ await cleanupStageArtifacts(args.prepared.artifacts, "report");
506
1294
  if (args.emitReportingEvent) {
507
1295
  await updateRun({
508
1296
  store: args.store,
@@ -559,15 +1347,20 @@ async function runReportStage(args) {
559
1347
  },
560
1348
  created_at: completedAt,
561
1349
  });
562
- await writeJson(args.prepared.artifacts.runReportJson, report);
563
- await args.store.completeRun(report, args.prepared.artifacts.runDir, args.prepared.artifacts.runReportJson);
1350
+ await writeJsonAtomic(args.prepared.artifacts.runReportJson, report);
1351
+ await refreshArtifactManifest(args.prepared);
1352
+ await throwIfCancelled(args.store, args.prepared.request);
1353
+ const completedState = await args.store.completeRun(report, args.prepared.artifacts.runDir, args.prepared.artifacts.runReportJson);
1354
+ if (completedState.status === "cancelled") {
1355
+ throw new ProcessCancelledError(`Run ${args.prepared.request.run_id} was cancelled.`);
1356
+ }
564
1357
  await args.reporter?.onEvent?.({
565
1358
  stage: "completed",
566
1359
  status: "completed",
567
1360
  message: "Run completed successfully.",
568
1361
  details: {
569
1362
  report_path: args.prepared.artifacts.runReportJson,
570
- model_id: `local-${args.prepared.request.run_id}`,
1363
+ ...(!isDryTraining(training) ? { model_id: `local-${args.prepared.request.run_id}` } : {}),
571
1364
  avg_score_delta: comparison.avg_score_delta,
572
1365
  elapsed_seconds: duration.seconds,
573
1366
  },
@@ -581,89 +1374,154 @@ export async function runLocalFineTuneStage(input) {
581
1374
  const prefix = artifactPrefix(input.request);
582
1375
  const artifacts = resolveRunArtifacts({ artifactRoot: input.config.artifactRoot, prefix });
583
1376
  const store = createLocalStore(input.config.storeRoot);
584
- await ensureRunRecord({ request: input.request, artifacts, store, reporter: input.reporter });
585
- const runReporter = createStoreReporter({ request: input.request, store, reporter: input.reporter });
1377
+ const releaseRunLock = await acquireRunLock(store, input.request.run_id);
1378
+ let releaseArtifactLock;
586
1379
  try {
587
- const prepared = await ensurePrepared({
588
- request: input.request,
589
- config: input.config,
1380
+ await claimRunArtifactDirectory({
590
1381
  artifacts,
591
- store,
592
- reporter: input.reporter,
593
- forcePrepare: Boolean(input.force && (stage === "prepare" || stage === "all")),
1382
+ runId: input.request.run_id,
1383
+ userId: input.request.user_id,
1384
+ behaviorSpecId: input.request.behavior_spec_id,
594
1385
  });
595
- let report;
596
- if (stage === "prepare") {
597
- return stageResult({ request: input.request, stage, artifacts });
598
- }
599
- if (stage === "baseline" || stage === "all") {
600
- await runBaselineStage({
601
- prepared,
1386
+ releaseArtifactLock = await acquireArtifactLock(artifacts);
1387
+ await ensureRunRecord({ request: input.request, artifacts, store, reporter: input.reporter });
1388
+ const runReporter = createStoreReporter({ request: input.request, store, reporter: input.reporter });
1389
+ let prepared;
1390
+ try {
1391
+ await throwIfCancelled(store, input.request);
1392
+ prepared = await ensurePrepared({
1393
+ request: input.request,
602
1394
  config: input.config,
1395
+ artifacts,
603
1396
  store,
604
1397
  reporter: input.reporter,
605
- runReporter,
606
- force: Boolean(input.force),
1398
+ forcePrepare: Boolean(input.force && (stage === "prepare" || stage === "all")),
607
1399
  });
608
- if (stage === "baseline")
1400
+ await throwIfCancelled(store, input.request);
1401
+ let report;
1402
+ const completeRequestedStage = async (completedStage) => {
1403
+ await throwIfCancelled(store, input.request);
1404
+ const message = `${completedStage} stage completed.`;
1405
+ const completedState = await store.updateRun({
1406
+ runId: input.request.run_id,
1407
+ status: "stage_completed",
1408
+ stage: `${completedStage}_completed`,
1409
+ message,
1410
+ details: { requested_stage: completedStage },
1411
+ });
1412
+ if (completedState.status === "cancelled") {
1413
+ throw new ProcessCancelledError(`Run ${input.request.run_id} was cancelled.`);
1414
+ }
1415
+ await input.reporter?.onEvent?.({
1416
+ stage: `${completedStage}_completed`,
1417
+ status: "completed",
1418
+ message,
1419
+ details: { requested_stage: completedStage },
1420
+ });
1421
+ };
1422
+ if (stage === "prepare") {
1423
+ await completeRequestedStage(stage);
609
1424
  return stageResult({ request: input.request, stage, artifacts });
610
- }
611
- if (stage === "train" || stage === "all") {
612
- await runTrainStage({
613
- prepared,
614
- config: input.config,
615
- store,
616
- reporter: input.reporter,
617
- runReporter,
618
- force: Boolean(input.force),
619
- });
620
- if (stage === "train")
1425
+ }
1426
+ if (stage === "baseline" || stage === "all") {
1427
+ await runBaselineStage({
1428
+ prepared,
1429
+ config: input.config,
1430
+ store,
1431
+ reporter: input.reporter,
1432
+ runReporter,
1433
+ force: Boolean(input.force),
1434
+ });
1435
+ await refreshArtifactManifest(prepared);
1436
+ if (stage === "baseline") {
1437
+ await completeRequestedStage(stage);
1438
+ return stageResult({ request: input.request, stage, artifacts });
1439
+ }
1440
+ }
1441
+ if (stage === "train" || stage === "all") {
1442
+ await runTrainStage({
1443
+ prepared,
1444
+ config: input.config,
1445
+ store,
1446
+ reporter: input.reporter,
1447
+ runReporter,
1448
+ force: Boolean(input.force),
1449
+ });
1450
+ if (stage === "train") {
1451
+ await completeRequestedStage(stage);
1452
+ return stageResult({ request: input.request, stage, artifacts });
1453
+ }
1454
+ }
1455
+ if (stage === "candidate" || stage === "all") {
1456
+ await runCandidateStage({
1457
+ prepared,
1458
+ config: input.config,
1459
+ store,
1460
+ reporter: input.reporter,
1461
+ runReporter,
1462
+ force: Boolean(input.force),
1463
+ modelArtifact: input.modelArtifact,
1464
+ });
1465
+ await refreshArtifactManifest(prepared);
1466
+ if (stage === "candidate") {
1467
+ await completeRequestedStage(stage);
1468
+ return stageResult({ request: input.request, stage, artifacts });
1469
+ }
1470
+ }
1471
+ if (stage === "score") {
1472
+ await runScoreStage({
1473
+ prepared,
1474
+ config: input.config,
1475
+ store,
1476
+ reporter: input.reporter,
1477
+ runReporter,
1478
+ });
1479
+ await refreshArtifactManifest(prepared);
1480
+ await completeRequestedStage(stage);
621
1481
  return stageResult({ request: input.request, stage, artifacts });
1482
+ }
1483
+ if (stage === "report" || stage === "all") {
1484
+ report = await runReportStage({
1485
+ prepared,
1486
+ config: input.config,
1487
+ store,
1488
+ reporter: input.reporter,
1489
+ startedAt,
1490
+ startedPerf,
1491
+ emitReportingEvent: stage === "report",
1492
+ });
1493
+ return stageResult({ request: input.request, stage, artifacts, report });
1494
+ }
1495
+ throw new Error(`Unknown run stage: ${stage}`);
622
1496
  }
623
- if (stage === "candidate" || stage === "all") {
624
- await runCandidateStage({
625
- prepared,
626
- config: input.config,
627
- store,
628
- reporter: input.reporter,
629
- runReporter,
630
- force: Boolean(input.force),
631
- modelArtifact: input.modelArtifact,
632
- });
633
- if (stage === "candidate")
634
- return stageResult({ request: input.request, stage, artifacts });
1497
+ catch (error) {
1498
+ const cancelled = error instanceof ProcessCancelledError
1499
+ || await store.isCancellationRequested(input.request.run_id).catch(() => false);
1500
+ if (cancelled) {
1501
+ const state = await store.getRun(input.request.run_id).catch(() => null);
1502
+ if (state?.status !== "cancelled")
1503
+ await store.cancelRun(input.request.run_id).catch(() => undefined);
1504
+ await store.finalizeCancellation(input.request.run_id).catch(() => undefined);
1505
+ await input.reporter?.onEvent?.({
1506
+ stage: "cancelled",
1507
+ status: "cancelled",
1508
+ message: "Run cancelled.",
1509
+ });
1510
+ }
1511
+ else {
1512
+ await store.failRun(input.request.run_id, error instanceof Error ? error.message : String(error)).catch(() => undefined);
1513
+ await input.reporter?.onEvent?.({
1514
+ stage: "failed",
1515
+ status: "failed",
1516
+ message: error instanceof Error ? error.message : String(error),
1517
+ });
1518
+ }
1519
+ throw error;
635
1520
  }
636
- if (stage === "score") {
637
- await runScoreStage({
638
- prepared,
639
- config: input.config,
640
- store,
641
- reporter: input.reporter,
642
- runReporter,
643
- });
644
- return stageResult({ request: input.request, stage, artifacts });
645
- }
646
- if (stage === "report" || stage === "all") {
647
- report = await runReportStage({
648
- prepared,
649
- store,
650
- reporter: input.reporter,
651
- startedAt,
652
- startedPerf,
653
- emitReportingEvent: stage === "report",
654
- });
655
- return stageResult({ request: input.request, stage, artifacts, report });
656
- }
657
- throw new Error(`Unknown run stage: ${stage}`);
658
1521
  }
659
- catch (error) {
660
- await store.failRun(input.request.run_id, error instanceof Error ? error.message : String(error)).catch(() => undefined);
661
- await input.reporter?.onEvent?.({
662
- stage: "failed",
663
- status: "failed",
664
- message: error instanceof Error ? error.message : String(error),
665
- });
666
- throw error;
1522
+ finally {
1523
+ await releaseArtifactLock?.();
1524
+ await releaseRunLock();
667
1525
  }
668
1526
  }
669
1527
  function stageResult(args) {
@@ -680,6 +1538,7 @@ function stageResult(args) {
680
1538
  baseline_eval: args.artifacts.baselineEvalJson,
681
1539
  candidate_eval: args.artifacts.candidateEvalJson,
682
1540
  report: args.artifacts.runReportJson,
1541
+ artifact_manifest: args.artifacts.artifactManifestJson,
683
1542
  },
684
1543
  };
685
1544
  }