@deepthonk/core 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runner.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash, randomUUID } from "node:crypto";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import { z } from "zod";
@@ -7,12 +8,12 @@ import { planBudget, validateProfile } from "./budget.js";
7
8
  import { BudgetExceededError, CancelledError, ConfigError, DeepThonkError } from "./errors.js";
8
9
  import { runArtifactFiles } from "./artifacts.js";
9
10
  import { parseJsonObject } from "./json.js";
10
- import { claimRunLock, emptyUsage, releaseRunLock } from "./lifecycle.js";
11
+ import { claimRunLockOwnership, emptyUsage, releaseRunLock, verifyRunLockClaim } from "./lifecycle.js";
11
12
  import { makeKRegularPairs } from "./pairScheduler.js";
12
13
  import { runLimitedPhase } from "./phaseRunner.js";
13
14
  import { comparePrompt, finalizePrompt, generatePrompt, mutatePrompt } from "./prompts.js";
14
15
  import { createRng } from "./rng.js";
15
- import { assertNoPruneInProgress, buildPopulationMap, buildResumePlan, groupComparisons, persistPrunedTrace, pruneTraceToPlan, readResumeTrace, reconstructScores, replayBudgetUsage, resolveResumeRunId, resumeConfigError, toResumePlanStatus } from "./resume.js";
16
+ import { assertNoPruneInProgress, assertNoLegacyRedactedBudget, buildPopulationMap, buildResumePlan, groupComparisons, persistPrunedTrace, pruneTraceToPlan, readResumeTrace, reconstructScores, replayBudgetUsage, resolveResumeRunId, resumeConfigError, toResumePlanStatus } from "./resume.js";
16
17
  import { runConfigSchema, builtInProfiles, phaseCompletedEventSchema } from "./schemas.js";
17
18
  import { aggregateCritiques } from "./critique.js";
18
19
  import { TraceStore } from "./traceStore.js";
@@ -29,22 +30,39 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
29
30
  const config = runConfigSchema.parse(configInput);
30
31
  validateProfile(config.profile);
31
32
  enforceBudget(config);
32
- const runId = resumeState?.runId ?? `run_${new Date().toISOString().replace(/[:.]/g, "-")}_${Math.abs(config.seed)}`;
33
+ const runId = resumeState?.runId ??
34
+ config.runId ??
35
+ `run_${new Date().toISOString().replace(/[:.]/g, "-")}_${randomUUID()}`;
33
36
  const trace = new TraceStore(config.runDir);
34
37
  const rng = createRng(config.seed);
35
38
  const tracker = resumeState?.tracker ?? new BudgetTracker(config);
36
39
  const startedAt = resumeState?.startedAt ?? new Date().toISOString();
37
40
  let ownsLock = false;
38
- if (!resumeState && !control.lockClaimed) {
39
- const claimed = await claimRunLock(config.runDir, control.jobId ?? runId);
40
- if (!claimed) {
41
- throw new ConfigError(`Run directory is already claimed: ${config.runDir}`, {
42
- code: "run.directory_locked",
43
- retryable: false,
44
- fix: "Wait for the active run to finish, or choose a fresh run directory."
45
- });
41
+ let ownedLockClaimId;
42
+ let verifiedPreclaim = false;
43
+ if (!resumeState) {
44
+ if (control.lockClaim) {
45
+ verifiedPreclaim = await verifyRunLockClaim(config.runDir, control.lockClaim, control.jobId);
46
+ if (!verifiedPreclaim) {
47
+ throw new ConfigError("The supplied run lock claim does not own the current run.lock.", {
48
+ code: "run.lock_not_owned",
49
+ retryable: false,
50
+ fix: "Claim the run directory with claimRunLockOwnership and pass that exact claim to the worker."
51
+ });
52
+ }
53
+ }
54
+ else {
55
+ const claimed = await claimRunLockOwnership(config.runDir, control.jobId ?? runId);
56
+ if (!claimed) {
57
+ throw new ConfigError(`Run directory is already claimed: ${config.runDir}`, {
58
+ code: "run.directory_locked",
59
+ retryable: false,
60
+ fix: "Wait for the active run to finish, or choose a fresh run directory."
61
+ });
62
+ }
63
+ ownsLock = true;
64
+ ownedLockClaimId = claimed.claimId;
46
65
  }
47
- ownsLock = true;
48
66
  }
49
67
  let stopped = false;
50
68
  let caughtError;
@@ -89,6 +107,7 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
89
107
  throw new BudgetExceededError(`Run already stopped before ${phase}.`, { code: "budget.stopped" });
90
108
  await assertNotCancelled(phase);
91
109
  await assertBudget(phase);
110
+ return tracker.reserveCall(phase);
92
111
  },
93
112
  async afterCall(phase) {
94
113
  await assertNotCancelled(phase);
@@ -97,7 +116,7 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
97
116
  };
98
117
  try {
99
118
  if (!resumeState) {
100
- await trace.init({ ...config, version: await currentPackageVersion() }, runId);
119
+ await trace.init({ ...config, version: await currentPackageVersion() }, runId, { allowOwnedLock: ownsLock || verifiedPreclaim, pendingJobId: verifiedPreclaim ? control.jobId : undefined });
101
120
  }
102
121
  else {
103
122
  await trace.event({
@@ -261,7 +280,7 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
261
280
  finally {
262
281
  if (ownsLock) {
263
282
  try {
264
- await releaseRunLock(config.runDir);
283
+ await releaseRunLock(config.runDir, ownedLockClaimId);
265
284
  }
266
285
  catch (error) {
267
286
  if (caughtError !== undefined) {
@@ -280,6 +299,7 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
280
299
  resumeConfigError("Run already has summary.json; nothing to resume.", "resume.already_complete", "Use deepthonk inspect/result to read the completed run.");
281
300
  }
282
301
  const rawConfig = await readRequiredConfig(runDir);
302
+ assertNoLegacyRedactedBudget(rawConfig);
283
303
  const currentVersion = await currentPackageVersion();
284
304
  if (!sameMajorMinor(typeof rawConfig.version === "string" ? rawConfig.version : undefined, currentVersion)) {
285
305
  resumeConfigError(`Cannot resume run from version ${String(rawConfig.version ?? "missing")}; current package version is ${currentVersion}. Resume requires matching major.minor.`, "resume.version_mismatch", "Start a fresh run with the current DeepThonk version, or resume with a package version whose major.minor matches the trace.");
@@ -292,7 +312,7 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
292
312
  assertResumeProviderMatches(rawConfig, config, driver, options);
293
313
  assertNoPruneInProgress(runDir);
294
314
  const lockRunId = resolveResumeRunId(config, undefined, []);
295
- const claimed = await claimRunLock(runDir, lockRunId);
315
+ const claimed = await claimRunLockOwnership(runDir, lockRunId);
296
316
  if (!claimed) {
297
317
  throw new ConfigError(`Run directory is already claimed: ${runDir}`, {
298
318
  code: "run.directory_locked",
@@ -312,12 +332,13 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
312
332
  resumeConfigError("Trace says finalizing completed, but summary.json is missing.", "resume.inconsistent_trace", "Inspect the run directory and restore summary.json, or start a fresh run.");
313
333
  }
314
334
  const planStatus = toResumePlanStatus(runDir, runId, plan);
315
- if (options.dryRun)
316
- return planStatus;
317
335
  const pruned = pruneTraceToPlan(trace, plan);
336
+ assertResumeRunIdsAgree(runId, config, status, pruned);
318
337
  const populationByGeneration = buildPopulationMap(config, pruned.populations, pruned.candidates, plan);
319
338
  const comparisonsByGeneration = groupComparisons(pruned.comparisons);
320
- const scoresByGeneration = reconstructScores(config, populationByGeneration, comparisonsByGeneration, pruned.scores, plan);
339
+ const scoresByGeneration = reconstructScores(config, populationByGeneration, comparisonsByGeneration, pruned.scores, plan, runId);
340
+ if (options.dryRun)
341
+ return planStatus;
321
342
  const tracker = replayBudgetUsage(config, pruned.usage);
322
343
  const startedAt = status?.started_at ?? new Date().toISOString();
323
344
  await new TraceStore(runDir).writeStatus({
@@ -343,7 +364,26 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
343
364
  });
344
365
  }
345
366
  finally {
346
- await releaseRunLock(runDir);
367
+ await releaseRunLock(runDir, claimed?.claimId);
368
+ }
369
+ }
370
+ function assertResumeRunIdsAgree(resolvedRunId, config, status, trace) {
371
+ const observed = new Set();
372
+ if (config.runId)
373
+ observed.add(config.runId);
374
+ if (status?.run_id)
375
+ observed.add(status.run_id);
376
+ for (const event of trace.events) {
377
+ if (typeof event.run_id === "string")
378
+ observed.add(event.run_id);
379
+ }
380
+ for (const comparison of trace.comparisons) {
381
+ if (comparison.runId)
382
+ observed.add(comparison.runId);
383
+ }
384
+ observed.add(resolvedRunId);
385
+ if (observed.size > 1) {
386
+ resumeConfigError(`Resume artifacts disagree on run ID: ${[...observed].sort().join(", ")}.`, "resume.run_id_mismatch", "Restore artifacts from one run; do not combine trace files from different run directories.");
347
387
  }
348
388
  }
349
389
  async function markPhaseCompleted(trace, phase, generation) {
@@ -425,10 +465,19 @@ function assertStoredOutputConfigComplete(rawConfig) {
425
465
  }
426
466
  }
427
467
  function assertResumeProviderMatches(rawConfig, config, driver, options) {
468
+ assertProviderReplayInternalConsistency(config);
428
469
  const runtimeProvider = options.provider ?? providerLabel(driver) ?? config.provider;
429
470
  if (runtimeProvider !== config.provider) {
430
471
  providerMismatch(`Cannot resume provider ${config.provider} with runtime provider ${runtimeProvider}.`);
431
472
  }
473
+ const expectedFingerprint = config.providerReplay?.routeFingerprint;
474
+ if (expectedFingerprint !== undefined && providerRouteFingerprint(driver) !== expectedFingerprint) {
475
+ providerMismatch("Cannot resume because the provider route fingerprint changed.");
476
+ }
477
+ if (config.providerReplay?.baseUrl !== undefined &&
478
+ normalizeBaseUrl(providerBaseUrl(driver)) !== normalizeBaseUrl(config.providerReplay.baseUrl)) {
479
+ providerMismatch(`Cannot resume base URL ${config.providerReplay.baseUrl} with runtime base URL ${providerBaseUrl(driver) ?? "missing"}.`);
480
+ }
432
481
  const expectedRoutes = expectedProviderRoutes(rawConfig, config);
433
482
  const actualRoutes = runtimeProviderRoutes(driver);
434
483
  for (const role of providerRoles) {
@@ -450,8 +499,92 @@ function assertResumeProviderMatches(rawConfig, config, driver, options) {
450
499
  }
451
500
  }
452
501
  }
502
+ function assertProviderReplayInternalConsistency(config) {
503
+ const replay = config.providerReplay;
504
+ if (!replay)
505
+ return;
506
+ const expectedModels = {
507
+ generator: config.generatorModel,
508
+ mutator: config.mutatorModel,
509
+ judge: config.judgeModel,
510
+ finalizer: config.finalizerModel
511
+ };
512
+ if (replay.provider !== config.provider ||
513
+ replay.models.generator !== expectedModels.generator ||
514
+ replay.models.mutator !== expectedModels.mutator ||
515
+ replay.models.judge !== expectedModels.judge ||
516
+ replay.models.finalizer !== expectedModels.finalizer) {
517
+ resumeConfigError("Stored run provider/model fields disagree with providerReplay.", "resume.provider_replay_mismatch", "Restore config.json from the original run; do not edit provider or model fields independently.");
518
+ }
519
+ if (replay.routeFingerprint !== undefined && replay.routeFingerprint !== coreProviderReplayFingerprint(replay)) {
520
+ resumeConfigError("Stored providerReplay fingerprint does not match its route fields.", "resume.provider_replay_mismatch", "Restore the original providerReplay block instead of editing route fields.");
521
+ }
522
+ }
523
+ function coreProviderReplayFingerprint(replay) {
524
+ const roles = Object.fromEntries(providerRoles.flatMap((role) => {
525
+ const route = replay.roleProviders?.[role];
526
+ return route
527
+ ? [[role, routeFingerprintValue(route.provider, route.baseUrl, route.apiKeyEnv, route.model, route.supportsJsonMode)]]
528
+ : [];
529
+ }));
530
+ const value = {
531
+ base: routeFingerprintValue(replay.provider, replay.baseUrl, replay.apiKeyEnv, replay.models, replay.supportsJsonMode),
532
+ roles,
533
+ samplingPreferences: replay.samplingPreferences ?? null
534
+ };
535
+ return `sha256:${createHash("sha256").update(stableJson(value)).digest("hex")}`;
536
+ }
537
+ function routeFingerprintValue(provider, baseUrl, apiKeyEnv, model, supportsJsonMode) {
538
+ return Object.fromEntries(Object.entries({
539
+ provider,
540
+ baseUrl: normalizeFingerprintUrl(baseUrl),
541
+ apiKeyEnv,
542
+ model,
543
+ supportsJsonMode
544
+ }).filter(([, value]) => value !== undefined));
545
+ }
546
+ function normalizeFingerprintUrl(baseUrl) {
547
+ if (!baseUrl)
548
+ return undefined;
549
+ try {
550
+ const parsed = new URL(baseUrl);
551
+ parsed.hash = "";
552
+ parsed.hostname = parsed.hostname.toLowerCase();
553
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "");
554
+ return parsed.toString().replace(/\/$/, "");
555
+ }
556
+ catch {
557
+ return baseUrl.replace(/\/+$/, "");
558
+ }
559
+ }
560
+ function stableJson(value) {
561
+ if (Array.isArray(value))
562
+ return `[${value.map(stableJson).join(",")}]`;
563
+ if (value && typeof value === "object") {
564
+ const entries = Object.entries(value)
565
+ .sort(([left], [right]) => left.localeCompare(right))
566
+ .map(([key, inner]) => `${JSON.stringify(key)}:${stableJson(inner)}`);
567
+ return `{${entries.join(",")}}`;
568
+ }
569
+ return JSON.stringify(value) ?? "null";
570
+ }
453
571
  const providerRoles = ["generator", "mutator", "judge", "finalizer"];
454
572
  function expectedProviderRoutes(rawConfig, config) {
573
+ const replayRoutes = config.providerReplay?.roleProviders;
574
+ if (replayRoutes) {
575
+ const routes = {};
576
+ for (const role of providerRoles) {
577
+ const route = replayRoutes[role];
578
+ if (!route)
579
+ continue;
580
+ routes[role] = {
581
+ provider: route.provider,
582
+ baseUrl: route.baseUrl,
583
+ model: route.model
584
+ };
585
+ }
586
+ return routes;
587
+ }
455
588
  const providers = rawConfig.providers;
456
589
  if (!isRecord(providers))
457
590
  return {};
@@ -486,6 +619,16 @@ function runtimeProviderRoutes(driver) {
486
619
  }
487
620
  return routes;
488
621
  }
622
+ function providerRouteFingerprint(driver) {
623
+ const value = driver;
624
+ if (typeof value.routeFingerprint === "string")
625
+ return value.routeFingerprint;
626
+ if (typeof value.config?.routeFingerprint === "string")
627
+ return value.config.routeFingerprint;
628
+ if (value.baseDriver)
629
+ return providerRouteFingerprint(value.baseDriver);
630
+ return undefined;
631
+ }
489
632
  function providerBaseUrl(driver) {
490
633
  const value = driver;
491
634
  if (typeof value.baseUrl === "string")
@@ -544,8 +687,8 @@ function isLiveWorker(pid) {
544
687
  process.kill(pid, 0);
545
688
  return true;
546
689
  }
547
- catch {
548
- return false;
690
+ catch (error) {
691
+ return error.code !== "ESRCH";
549
692
  }
550
693
  }
551
694
  export function rankPopulation(population, scores) {
@@ -601,16 +744,32 @@ async function generateInitialPopulation(config, driver, trace, rng, runId, trac
601
744
  prompt: generatePrompt(config.task, config.rubric, config.promptStyle, config.promptOverrides?.generate)
602
745
  }));
603
746
  const candidates = await runLimitedPhase(jobs.map((job) => async () => {
604
- await guards.beforeCall("initial population call");
605
- const result = await driver.generate({
606
- task: config.task,
607
- rubric: config.rubric,
608
- model: config.generatorModel,
609
- temperature: config.profile.sampleTemperature,
610
- candidateIndex: job.index,
611
- prompt: job.prompt
612
- });
613
- const delta = tracker.record(result, { provider: config.provider, model: config.generatorModel });
747
+ const reservation = await guards.beforeCall("initial population call");
748
+ let result;
749
+ try {
750
+ result = await driver.generate({
751
+ task: config.task,
752
+ rubric: config.rubric,
753
+ model: config.generatorModel,
754
+ temperature: config.profile.sampleTemperature,
755
+ candidateIndex: job.index,
756
+ prompt: job.prompt
757
+ });
758
+ }
759
+ catch (error) {
760
+ await recordFailedInvocation({
761
+ tracker,
762
+ reservation,
763
+ trace,
764
+ phase: "gen0_initial",
765
+ role: "generator",
766
+ provider: config.provider,
767
+ model: config.generatorModel,
768
+ error
769
+ });
770
+ throw error;
771
+ }
772
+ const delta = tracker.record(result, { provider: config.provider, model: config.generatorModel }, reservation);
614
773
  await trace.writeUsage(buildUsageRecord({
615
774
  phase: "gen0_initial",
616
775
  role: "generator",
@@ -668,17 +827,33 @@ async function judgePairs(args) {
668
827
  let outputTokens = 0;
669
828
  let totalTokens = 0;
670
829
  for (let attempt = 0; attempt <= args.config.retry.invalidJsonRetries; attempt += 1) {
671
- await args.guards.beforeCall(`${args.generation} comparison call`);
672
- const result = await args.driver.compare({
673
- task: args.config.task,
674
- rubric: args.config.rubric,
675
- model: args.config.judgeModel,
676
- temperature: args.config.profile.judgeTemperature,
677
- candidateA: job.presentedA,
678
- candidateB: job.presentedB,
679
- prompt: job.prompt
680
- });
681
- const delta = args.tracker.record(result, { provider: args.config.provider, model: args.config.judgeModel });
830
+ const reservation = await args.guards.beforeCall(`${args.generation} comparison call`);
831
+ let result;
832
+ try {
833
+ result = await args.driver.compare({
834
+ task: args.config.task,
835
+ rubric: args.config.rubric,
836
+ model: args.config.judgeModel,
837
+ temperature: args.config.profile.judgeTemperature,
838
+ candidateA: job.presentedA,
839
+ candidateB: job.presentedB,
840
+ prompt: job.prompt
841
+ });
842
+ }
843
+ catch (error) {
844
+ await recordFailedInvocation({
845
+ tracker: args.tracker,
846
+ reservation,
847
+ trace: args.trace,
848
+ phase: args.generation === "final" ? "final_judge" : `gen${args.generation}_judge`,
849
+ role: "judge",
850
+ provider: args.config.provider,
851
+ model: args.config.judgeModel,
852
+ error
853
+ });
854
+ throw error;
855
+ }
856
+ const delta = args.tracker.record(result, { provider: args.config.provider, model: args.config.judgeModel }, reservation);
682
857
  await args.trace.writeUsage(buildUsageRecord({
683
858
  phase: args.generation === "final" ? "final_judge" : `gen${args.generation}_judge`,
684
859
  role: "judge",
@@ -722,11 +897,11 @@ async function judgePairs(args) {
722
897
  candidateBId: job.presentedB.id,
723
898
  presentedAOriginalId: job.presentedA.id,
724
899
  presentedBOriginalId: job.presentedB.id,
725
- winner: parsed?.winner ?? "tie",
726
- confidence: parsed?.confidence,
727
- critiqueForA: parsed?.feedback_a ?? parsed?.critique_for_A ?? "Invalid comparison JSON; recorded as tie.",
728
- critiqueForB: parsed?.feedback_b ?? parsed?.critique_for_B ?? "Invalid comparison JSON; recorded as tie.",
729
- selectionReason: parsed?.selection_reason ?? "invalid_json_tie",
900
+ winner: parsed.winner,
901
+ confidence: parsed.confidence,
902
+ critiqueForA: parsed.feedback_a ?? parsed.critique_for_A ?? "",
903
+ critiqueForB: parsed.feedback_b ?? parsed.critique_for_B ?? "",
904
+ selectionReason: parsed.selection_reason ?? "",
730
905
  rawOutput,
731
906
  model: resultModel,
732
907
  provider: resultProvider,
@@ -760,17 +935,33 @@ async function mutateSurvivors(args) {
760
935
  const mutants = await runLimitedPhase(args.survivors.map((candidate, index) => async () => {
761
936
  const critique = args.critiquesByCandidate.get(candidate.id) ?? "";
762
937
  const prompt = mutatePrompt(args.config.task, candidate, critique, args.config.rubric, args.config.promptStyle, args.config.promptOverrides?.mutate);
763
- await args.guards.beforeCall(`generation ${args.generation} mutation call`);
764
- const result = await args.driver.mutate({
765
- task: args.config.task,
766
- rubric: args.config.rubric,
767
- model: args.config.mutatorModel,
768
- temperature: args.config.profile.mutateTemperature,
769
- candidate,
770
- critique,
771
- prompt
772
- });
773
- const delta = args.tracker.record(result, { provider: args.config.provider, model: args.config.mutatorModel });
938
+ const reservation = await args.guards.beforeCall(`generation ${args.generation} mutation call`);
939
+ let result;
940
+ try {
941
+ result = await args.driver.mutate({
942
+ task: args.config.task,
943
+ rubric: args.config.rubric,
944
+ model: args.config.mutatorModel,
945
+ temperature: args.config.profile.mutateTemperature,
946
+ candidate,
947
+ critique,
948
+ prompt
949
+ });
950
+ }
951
+ catch (error) {
952
+ await recordFailedInvocation({
953
+ tracker: args.tracker,
954
+ reservation,
955
+ trace: args.trace,
956
+ phase: `gen${args.generation}_mutate`,
957
+ role: "mutator",
958
+ provider: args.config.provider,
959
+ model: args.config.mutatorModel,
960
+ error
961
+ });
962
+ throw error;
963
+ }
964
+ const delta = args.tracker.record(result, { provider: args.config.provider, model: args.config.mutatorModel }, reservation);
774
965
  await args.trace.writeUsage(buildUsageRecord({
775
966
  phase: `gen${args.generation}_mutate`,
776
967
  role: "mutator",
@@ -798,15 +989,31 @@ async function mutateSurvivors(args) {
798
989
  async function maybeFinalize(winner, config, driver, trace, tracker, guards) {
799
990
  if (!config.finalizerModel || !driver.finalize)
800
991
  return winner.content;
801
- await guards.beforeCall("finalization call");
802
- const result = await driver.finalize({
803
- task: config.task,
804
- rubric: config.rubric,
805
- model: config.finalizerModel,
806
- candidate: winner,
807
- prompt: finalizePrompt(config.task, winner, config.rubric, config.promptStyle, config.promptOverrides?.finalize)
808
- });
809
- const delta = tracker.record(result, { provider: config.provider, model: config.finalizerModel });
992
+ const reservation = await guards.beforeCall("finalization call");
993
+ let result;
994
+ try {
995
+ result = await driver.finalize({
996
+ task: config.task,
997
+ rubric: config.rubric,
998
+ model: config.finalizerModel,
999
+ candidate: winner,
1000
+ prompt: finalizePrompt(config.task, winner, config.rubric, config.promptStyle, config.promptOverrides?.finalize)
1001
+ });
1002
+ }
1003
+ catch (error) {
1004
+ await recordFailedInvocation({
1005
+ tracker,
1006
+ reservation,
1007
+ trace,
1008
+ phase: "finalize",
1009
+ role: "finalizer",
1010
+ provider: config.provider,
1011
+ model: config.finalizerModel,
1012
+ error
1013
+ });
1014
+ throw error;
1015
+ }
1016
+ const delta = tracker.record(result, { provider: config.provider, model: config.finalizerModel }, reservation);
810
1017
  await trace.writeUsage(buildUsageRecord({
811
1018
  phase: "finalize",
812
1019
  role: "finalizer",
@@ -892,18 +1099,30 @@ function buildUsageRecord(args) {
892
1099
  output_usd: args.delta.outputUsd,
893
1100
  total_usd: args.delta.usd,
894
1101
  latency_ms: args.result.latencyMs,
895
- retry_count: args.result.retryCount
1102
+ retry_count: args.result.retryCount,
1103
+ outcome: args.outcome,
1104
+ error_code: args.errorCode
896
1105
  };
897
1106
  }
1107
+ async function recordFailedInvocation(args) {
1108
+ const delta = args.tracker.failCall(args.reservation);
1109
+ await bestEffortTraceWrite("failed call usage", () => args.trace.writeUsage(buildUsageRecord({
1110
+ phase: args.phase,
1111
+ role: args.role,
1112
+ result: { text: "" },
1113
+ delta,
1114
+ provider: args.provider,
1115
+ model: args.model,
1116
+ outcome: "failed",
1117
+ errorCode: args.error instanceof DeepThonkError ? args.error.code : args.error instanceof Error ? args.error.name : "unknown_error"
1118
+ })));
1119
+ }
898
1120
  function enforceBudget(config) {
899
- const plan = planBudget(config.profile);
900
- // Each comparison call may fire up to invalidJsonRetries extra requests before the loop gives up.
901
- // Include that worst-case headroom so a user who sets --max-calls = plan.calls isn't tripped by
902
- // a single retry mid-run.
903
- const judgeCalls = plan.per_generation_judge_calls * config.profile.t + plan.final_judge_calls;
904
- const retryHeadroom = judgeCalls * config.retry.invalidJsonRetries;
905
- const finalizerCalls = config.finalizerModel ? 1 : 0;
906
- const plannedCalls = plan.calls + finalizerCalls + retryHeadroom;
1121
+ const plan = planBudget(config.profile, {
1122
+ invalidJsonRetries: config.retry.invalidJsonRetries,
1123
+ includeFinalizer: Boolean(config.finalizerModel)
1124
+ });
1125
+ const plannedCalls = plan.calls + plan.finalizer_calls;
907
1126
  if (config.budget?.maxCalls !== undefined && plannedCalls > config.budget.maxCalls) {
908
1127
  throw new BudgetExceededError(`Planned call count ${plannedCalls} exceeds maxCalls ${config.budget.maxCalls}.`, {
909
1128
  code: "budget.planned_calls_exceeded",