@deepthonk/core 0.1.3 → 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,4 +1,4 @@
1
- import pLimit from "p-limit";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { z } from "zod";
@@ -8,11 +8,12 @@ import { planBudget, validateProfile } from "./budget.js";
8
8
  import { BudgetExceededError, CancelledError, ConfigError, DeepThonkError } from "./errors.js";
9
9
  import { runArtifactFiles } from "./artifacts.js";
10
10
  import { parseJsonObject } from "./json.js";
11
- import { claimRunLock, emptyUsage, releaseRunLock } from "./lifecycle.js";
11
+ import { claimRunLockOwnership, emptyUsage, releaseRunLock, verifyRunLockClaim } from "./lifecycle.js";
12
12
  import { makeKRegularPairs } from "./pairScheduler.js";
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,24 +30,42 @@ 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();
40
+ let ownsLock = false;
41
+ let ownedLockClaimId;
42
+ let verifiedPreclaim = false;
37
43
  if (!resumeState) {
38
- await trace.init({ ...config, version: await currentPackageVersion() }, runId);
39
- }
40
- else {
41
- await trace.event({
42
- type: "run.resumed",
43
- run_id: runId,
44
- resumed_at: new Date().toISOString(),
45
- next_phase: resumeState.nextPhase.phase,
46
- generation: resumeState.nextPhase.generation
47
- });
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;
65
+ }
48
66
  }
49
67
  let stopped = false;
68
+ let caughtError;
50
69
  const writeStatus = async (state, phase, extra = {}) => {
51
70
  await trace.writeStatus({
52
71
  job_id: control.jobId,
@@ -77,7 +96,7 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
77
96
  catch (error) {
78
97
  if (error instanceof BudgetExceededError) {
79
98
  stopped = true;
80
- await trace.event({ type: "budget.exceeded", phase, usage: cloneUsage(tracker.usage), error: serializeRunError(error) });
99
+ await bestEffortTraceWrite("budget exceeded event", () => trace.event({ type: "budget.exceeded", phase, usage: cloneUsage(tracker.usage), error: serializeRunError(error) }));
81
100
  }
82
101
  throw error;
83
102
  }
@@ -88,6 +107,7 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
88
107
  throw new BudgetExceededError(`Run already stopped before ${phase}.`, { code: "budget.stopped" });
89
108
  await assertNotCancelled(phase);
90
109
  await assertBudget(phase);
110
+ return tracker.reserveCall(phase);
91
111
  },
92
112
  async afterCall(phase) {
93
113
  await assertNotCancelled(phase);
@@ -95,6 +115,18 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
95
115
  }
96
116
  };
97
117
  try {
118
+ if (!resumeState) {
119
+ await trace.init({ ...config, version: await currentPackageVersion() }, runId, { allowOwnedLock: ownsLock || verifiedPreclaim, pendingJobId: verifiedPreclaim ? control.jobId : undefined });
120
+ }
121
+ else {
122
+ await trace.event({
123
+ type: "run.resumed",
124
+ run_id: runId,
125
+ resumed_at: new Date().toISOString(),
126
+ next_phase: resumeState.nextPhase.phase,
127
+ generation: resumeState.nextPhase.generation
128
+ });
129
+ }
98
130
  await writeStatus("running", resumeState ? "resume_replay" : "initialized");
99
131
  let population;
100
132
  if (isResumePhaseCompleted(resumeState, "initial_generation")) {
@@ -238,12 +270,28 @@ export async function runDeepThonk(configInput, driver, control = {}, resumeStat
238
270
  };
239
271
  }
240
272
  catch (error) {
273
+ caughtError = error;
241
274
  const serialized = serializeRunError(error);
242
275
  const state = error instanceof CancelledError ? "cancelled" : error instanceof BudgetExceededError ? "budget_exceeded" : "failed";
243
- await trace.event({ type: `run.${state}`, run_id: runId, error: serialized, updated_at: new Date().toISOString() });
244
- await writeStatus(state, state, { error: serialized });
276
+ await bestEffortTraceWrite("run failure event", () => trace.event({ type: `run.${state}`, run_id: runId, error: serialized, updated_at: new Date().toISOString() }));
277
+ await bestEffortTraceWrite("run failure status", () => writeStatus(state, state, { error: serialized }));
245
278
  throw error;
246
279
  }
280
+ finally {
281
+ if (ownsLock) {
282
+ try {
283
+ await releaseRunLock(config.runDir, ownedLockClaimId);
284
+ }
285
+ catch (error) {
286
+ if (caughtError !== undefined) {
287
+ process.stderr.write(`deepthonk: failed to release run lock: ${error.message}\n`);
288
+ }
289
+ else {
290
+ throw error;
291
+ }
292
+ }
293
+ }
294
+ }
247
295
  }
248
296
  export async function resumeDeepThonk(runDir, driver, options = {}) {
249
297
  const existingSummary = await readOptionalJson(runDir, runArtifactFiles.summary);
@@ -251,6 +299,7 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
251
299
  resumeConfigError("Run already has summary.json; nothing to resume.", "resume.already_complete", "Use deepthonk inspect/result to read the completed run.");
252
300
  }
253
301
  const rawConfig = await readRequiredConfig(runDir);
302
+ assertNoLegacyRedactedBudget(rawConfig);
254
303
  const currentVersion = await currentPackageVersion();
255
304
  if (!sameMajorMinor(typeof rawConfig.version === "string" ? rawConfig.version : undefined, currentVersion)) {
256
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.");
@@ -263,7 +312,7 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
263
312
  assertResumeProviderMatches(rawConfig, config, driver, options);
264
313
  assertNoPruneInProgress(runDir);
265
314
  const lockRunId = resolveResumeRunId(config, undefined, []);
266
- const claimed = await claimRunLock(runDir, lockRunId);
315
+ const claimed = await claimRunLockOwnership(runDir, lockRunId);
267
316
  if (!claimed) {
268
317
  throw new ConfigError(`Run directory is already claimed: ${runDir}`, {
269
318
  code: "run.directory_locked",
@@ -283,12 +332,13 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
283
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.");
284
333
  }
285
334
  const planStatus = toResumePlanStatus(runDir, runId, plan);
286
- if (options.dryRun)
287
- return planStatus;
288
335
  const pruned = pruneTraceToPlan(trace, plan);
336
+ assertResumeRunIdsAgree(runId, config, status, pruned);
289
337
  const populationByGeneration = buildPopulationMap(config, pruned.populations, pruned.candidates, plan);
290
338
  const comparisonsByGeneration = groupComparisons(pruned.comparisons);
291
- 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;
292
342
  const tracker = replayBudgetUsage(config, pruned.usage);
293
343
  const startedAt = status?.started_at ?? new Date().toISOString();
294
344
  await new TraceStore(runDir).writeStatus({
@@ -314,7 +364,26 @@ export async function resumeDeepThonk(runDir, driver, options = {}) {
314
364
  });
315
365
  }
316
366
  finally {
317
- 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.");
318
387
  }
319
388
  }
320
389
  async function markPhaseCompleted(trace, phase, generation) {
@@ -396,10 +465,19 @@ function assertStoredOutputConfigComplete(rawConfig) {
396
465
  }
397
466
  }
398
467
  function assertResumeProviderMatches(rawConfig, config, driver, options) {
468
+ assertProviderReplayInternalConsistency(config);
399
469
  const runtimeProvider = options.provider ?? providerLabel(driver) ?? config.provider;
400
470
  if (runtimeProvider !== config.provider) {
401
471
  providerMismatch(`Cannot resume provider ${config.provider} with runtime provider ${runtimeProvider}.`);
402
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
+ }
403
481
  const expectedRoutes = expectedProviderRoutes(rawConfig, config);
404
482
  const actualRoutes = runtimeProviderRoutes(driver);
405
483
  for (const role of providerRoles) {
@@ -421,8 +499,92 @@ function assertResumeProviderMatches(rawConfig, config, driver, options) {
421
499
  }
422
500
  }
423
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
+ }
424
571
  const providerRoles = ["generator", "mutator", "judge", "finalizer"];
425
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
+ }
426
588
  const providers = rawConfig.providers;
427
589
  if (!isRecord(providers))
428
590
  return {};
@@ -457,6 +619,16 @@ function runtimeProviderRoutes(driver) {
457
619
  }
458
620
  return routes;
459
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
+ }
460
632
  function providerBaseUrl(driver) {
461
633
  const value = driver;
462
634
  if (typeof value.baseUrl === "string")
@@ -515,8 +687,8 @@ function isLiveWorker(pid) {
515
687
  process.kill(pid, 0);
516
688
  return true;
517
689
  }
518
- catch {
519
- return false;
690
+ catch (error) {
691
+ return error.code !== "ESRCH";
520
692
  }
521
693
  }
522
694
  export function rankPopulation(population, scores) {
@@ -566,23 +738,38 @@ async function traceDiscardedCandidates(trace, ranked, carriedIds, bottomQuartil
566
738
  }
567
739
  }
568
740
  async function generateInitialPopulation(config, driver, trace, rng, runId, tracker, guards) {
569
- const limit = pLimit(config.concurrency.generate);
570
741
  const jobs = Array.from({ length: config.profile.n }, (_, index) => ({
571
742
  index,
572
743
  id: `g0_c${index}_${rng.id("c")}`,
573
744
  prompt: generatePrompt(config.task, config.rubric, config.promptStyle, config.promptOverrides?.generate)
574
745
  }));
575
- const candidates = await Promise.all(jobs.map((job) => limit(async () => {
576
- await guards.beforeCall("initial population call");
577
- const result = await driver.generate({
578
- task: config.task,
579
- rubric: config.rubric,
580
- model: config.generatorModel,
581
- temperature: config.profile.sampleTemperature,
582
- candidateIndex: job.index,
583
- prompt: job.prompt
584
- });
585
- const delta = tracker.record(result, { provider: config.provider, model: config.generatorModel });
746
+ const candidates = await runLimitedPhase(jobs.map((job) => async () => {
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);
586
773
  await trace.writeUsage(buildUsageRecord({
587
774
  phase: "gen0_initial",
588
775
  role: "generator",
@@ -603,12 +790,11 @@ async function generateInitialPopulation(config, driver, trace, rng, runId, trac
603
790
  await trace.writeCandidate(candidate);
604
791
  await trace.event({ type: "candidate.generated", run_id: runId, candidate_id: candidate.id });
605
792
  return candidate;
606
- })));
793
+ }), config.concurrency.generate);
607
794
  return candidates;
608
795
  }
609
796
  async function judgePairs(args) {
610
797
  const byId = new Map(args.population.map((candidate) => [candidate.id, candidate]));
611
- const limit = pLimit(args.config.concurrency.judge);
612
798
  const jobs = args.pairs.map((pair, pairIndex) => {
613
799
  const originalA = byId.get(pair.a);
614
800
  const originalB = byId.get(pair.b);
@@ -627,7 +813,7 @@ async function judgePairs(args) {
627
813
  prompt: comparePrompt(args.config.task, presentedA, presentedB, args.config.rubric, args.config.promptStyle, args.config.promptOverrides?.compare)
628
814
  };
629
815
  });
630
- const comparisons = await Promise.all(jobs.map((job) => limit(async () => {
816
+ const comparisons = await runLimitedPhase(jobs.map((job) => async () => {
631
817
  let parsed;
632
818
  let rawOutput;
633
819
  let invalidJson = false;
@@ -641,17 +827,33 @@ async function judgePairs(args) {
641
827
  let outputTokens = 0;
642
828
  let totalTokens = 0;
643
829
  for (let attempt = 0; attempt <= args.config.retry.invalidJsonRetries; attempt += 1) {
644
- await args.guards.beforeCall(`${args.generation} comparison call`);
645
- const result = await args.driver.compare({
646
- task: args.config.task,
647
- rubric: args.config.rubric,
648
- model: args.config.judgeModel,
649
- temperature: args.config.profile.judgeTemperature,
650
- candidateA: job.presentedA,
651
- candidateB: job.presentedB,
652
- prompt: job.prompt
653
- });
654
- 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);
655
857
  await args.trace.writeUsage(buildUsageRecord({
656
858
  phase: args.generation === "final" ? "final_judge" : `gen${args.generation}_judge`,
657
859
  role: "judge",
@@ -695,11 +897,11 @@ async function judgePairs(args) {
695
897
  candidateBId: job.presentedB.id,
696
898
  presentedAOriginalId: job.presentedA.id,
697
899
  presentedBOriginalId: job.presentedB.id,
698
- winner: parsed?.winner ?? "tie",
699
- confidence: parsed?.confidence,
700
- critiqueForA: parsed?.feedback_a ?? parsed?.critique_for_A ?? "Invalid comparison JSON; recorded as tie.",
701
- critiqueForB: parsed?.feedback_b ?? parsed?.critique_for_B ?? "Invalid comparison JSON; recorded as tie.",
702
- 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 ?? "",
703
905
  rawOutput,
704
906
  model: resultModel,
705
907
  provider: resultProvider,
@@ -726,25 +928,40 @@ async function judgePairs(args) {
726
928
  generation: args.generation
727
929
  });
728
930
  return comparison;
729
- })));
931
+ }), args.config.concurrency.judge);
730
932
  return comparisons;
731
933
  }
732
934
  async function mutateSurvivors(args) {
733
- const limit = pLimit(args.config.concurrency.mutate);
734
- const mutants = await Promise.all(args.survivors.map((candidate, index) => limit(async () => {
935
+ const mutants = await runLimitedPhase(args.survivors.map((candidate, index) => async () => {
735
936
  const critique = args.critiquesByCandidate.get(candidate.id) ?? "";
736
937
  const prompt = mutatePrompt(args.config.task, candidate, critique, args.config.rubric, args.config.promptStyle, args.config.promptOverrides?.mutate);
737
- await args.guards.beforeCall(`generation ${args.generation} mutation call`);
738
- const result = await args.driver.mutate({
739
- task: args.config.task,
740
- rubric: args.config.rubric,
741
- model: args.config.mutatorModel,
742
- temperature: args.config.profile.mutateTemperature,
743
- candidate,
744
- critique,
745
- prompt
746
- });
747
- 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);
748
965
  await args.trace.writeUsage(buildUsageRecord({
749
966
  phase: `gen${args.generation}_mutate`,
750
967
  role: "mutator",
@@ -766,21 +983,37 @@ async function mutateSurvivors(args) {
766
983
  await args.trace.writeCandidate(mutant);
767
984
  await args.trace.event({ type: "candidate.mutated", candidate_id: mutant.id, parent_id: mutant.parentId });
768
985
  return mutant;
769
- })));
986
+ }), args.config.concurrency.mutate);
770
987
  return mutants;
771
988
  }
772
989
  async function maybeFinalize(winner, config, driver, trace, tracker, guards) {
773
990
  if (!config.finalizerModel || !driver.finalize)
774
991
  return winner.content;
775
- await guards.beforeCall("finalization call");
776
- const result = await driver.finalize({
777
- task: config.task,
778
- rubric: config.rubric,
779
- model: config.finalizerModel,
780
- candidate: winner,
781
- prompt: finalizePrompt(config.task, winner, config.rubric, config.promptStyle, config.promptOverrides?.finalize)
782
- });
783
- 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);
784
1017
  await trace.writeUsage(buildUsageRecord({
785
1018
  phase: "finalize",
786
1019
  role: "finalizer",
@@ -866,18 +1099,30 @@ function buildUsageRecord(args) {
866
1099
  output_usd: args.delta.outputUsd,
867
1100
  total_usd: args.delta.usd,
868
1101
  latency_ms: args.result.latencyMs,
869
- retry_count: args.result.retryCount
1102
+ retry_count: args.result.retryCount,
1103
+ outcome: args.outcome,
1104
+ error_code: args.errorCode
870
1105
  };
871
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
+ }
872
1120
  function enforceBudget(config) {
873
- const plan = planBudget(config.profile);
874
- // Each comparison call may fire up to invalidJsonRetries extra requests before the loop gives up.
875
- // Include that worst-case headroom so a user who sets --max-calls = plan.calls isn't tripped by
876
- // a single retry mid-run.
877
- const judgeCalls = plan.per_generation_judge_calls * config.profile.t + plan.final_judge_calls;
878
- const retryHeadroom = judgeCalls * config.retry.invalidJsonRetries;
879
- const finalizerCalls = config.finalizerModel ? 1 : 0;
880
- 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;
881
1126
  if (config.budget?.maxCalls !== undefined && plannedCalls > config.budget.maxCalls) {
882
1127
  throw new BudgetExceededError(`Planned call count ${plannedCalls} exceeds maxCalls ${config.budget.maxCalls}.`, {
883
1128
  code: "budget.planned_calls_exceeded",
@@ -891,6 +1136,14 @@ function cloneUsage(usage) {
891
1136
  ...usage
892
1137
  };
893
1138
  }
1139
+ async function bestEffortTraceWrite(label, action) {
1140
+ try {
1141
+ await action();
1142
+ }
1143
+ catch (error) {
1144
+ process.stderr.write(`deepthonk: failed to persist ${label}: ${error.message}\n`);
1145
+ }
1146
+ }
894
1147
  function serializeRunError(error) {
895
1148
  if (error instanceof DeepThonkError) {
896
1149
  return {