dsh-harbor-evolution 0.7.3 → 0.8.0

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/lib/dashboard.js CHANGED
@@ -5,6 +5,7 @@ import path from 'node:path'
5
5
  import { resolveWithin } from './evolution.js'
6
6
 
7
7
  const SUMMARY_NAME = 'evaluation-summary.json'
8
+ const HISTORICAL_COMPLETION_NAME = 'historical-evaluation-complete.json'
8
9
  const DEFAULT_JOB_PAGE_SIZE = 20
9
10
  const MAX_JOB_PAGE_SIZE = 100
10
11
  const MAX_JSON_BYTES = 2 * 1024 * 1024
@@ -107,19 +108,65 @@ function isObject(value) {
107
108
  return value !== null && typeof value === 'object' && !Array.isArray(value)
108
109
  }
109
110
 
111
+ const CANDIDATE_JOB_KIND = 'candidate-evaluation'
112
+ const HISTORICAL_JOB_KIND = 'historical-generation-evaluation'
113
+
114
+ function normalizedJobKind(summary, context) {
115
+ const declared = summary?.job_kind ?? context?.job_kind
116
+ if (typeof declared === 'string' && declared) return declared
117
+ if (context?.protocol === 'historical-generation-evaluation-context/v1') return HISTORICAL_JOB_KIND
118
+ return CANDIDATE_JOB_KIND
119
+ }
120
+
121
+ function coverageView(summary) {
122
+ if (isObject(summary?.coverage)) return summary.coverage
123
+ const total = Number(summary?.n_trials ?? 0)
124
+ const scored = Number(summary?.scored_trial_count ?? summary?.n_valid_scores ?? 0)
125
+ const unscored = Number(
126
+ summary?.unscored_trial_count
127
+ ?? summary?.status_counts?.['completed-unscored']
128
+ ?? 0,
129
+ )
130
+ return {
131
+ scored_trials: scored,
132
+ unscored_trials: unscored,
133
+ total_trials: total,
134
+ trial_rate: total ? scored / total : undefined,
135
+ }
136
+ }
137
+
138
+ function evaluatorMetaEvaluation(summary, context) {
139
+ return summary?.evaluator_meta_evaluation
140
+ ?? context?.downstream_analysis?.evaluator_meta_evaluation
141
+ ?? (normalizedJobKind(summary, context) === HISTORICAL_JOB_KIND
142
+ ? { status: 'not-run', validation_report_ref: null }
143
+ : undefined)
144
+ }
145
+
110
146
  function capabilityMap(summary, context, lifecycle, registry, stack) {
147
+ const jobKind = normalizedJobKind(summary, context)
148
+ const historicalGeneration = jobKind === HISTORICAL_JOB_KIND
111
149
  const contextV2 = context?.schema_version === 2
150
+ const historicalContext = context?.schema_version === 1
151
+ && context?.protocol === 'historical-generation-evaluation-context/v1'
112
152
  const scoreValidity = summary?.schema_version === 3
153
+ || summary?.schema_version === 4
113
154
  return {
155
+ jobKind,
114
156
  contextV2,
157
+ contextSupported: contextV2 || historicalContext,
158
+ historicalGeneration,
159
+ candidateEvaluation: !historicalGeneration,
115
160
  trialLifecycle: lifecycle?.schema_version === 1,
116
161
  scoreValidity,
117
162
  evidenceProvenance: scoreValidity,
118
- artifactRegistry: registry?.schema_version === 1,
119
- compare: contextV2,
163
+ artifactRegistry: [1, 2].includes(registry?.schema_version),
164
+ source: historicalGeneration,
165
+ compare: contextV2 && !historicalGeneration,
120
166
  evaluatorGovernance: stack?.schema_version === 1,
121
- gate: contextV2 && summary?.mode === 'promotion-eligible',
122
- readOnlyLegacy: !contextV2,
167
+ evaluatorMetaEvaluation: evaluatorMetaEvaluation(summary, context),
168
+ gate: contextV2 && !historicalGeneration && summary?.mode === 'promotion-eligible',
169
+ readOnlyLegacy: !contextV2 && !historicalContext,
123
170
  }
124
171
  }
125
172
 
@@ -131,7 +178,7 @@ function primaryMetric(summary, contract) {
131
178
  }
132
179
 
133
180
  function progressView(summary, lifecycle, updatedAt) {
134
- const total = Number(lifecycle?.dataset_total ?? summary?.n_trials ?? 0)
181
+ const total = Number(lifecycle?.dataset_total ?? summary?.n_trials ?? summary?.coverage?.total_trials ?? 0)
135
182
  const lifecycleTrials = selectedLifecycleTrials(lifecycle)
136
183
  const completed = lifecycle
137
184
  ? lifecycleTrials.filter(item => item.terminal).length
@@ -151,19 +198,53 @@ function progressView(summary, lifecycle, updatedAt) {
151
198
  }
152
199
  }
153
200
 
154
- function jobStatus(summary, lifecycle, progress) {
201
+ const HISTORICAL_COVERAGE_KEYS = [
202
+ 'scored_trials', 'unscored_trials', 'total_trials', 'trial_rate',
203
+ 'criterion_scored', 'criterion_total', 'criterion_rate',
204
+ ]
205
+
206
+ function historicalCompletionValid(summary, completion, jobName) {
207
+ return (
208
+ summary?.schema_version === 4
209
+ && summary?.job === jobName
210
+ && summary?.job_kind === HISTORICAL_JOB_KIND
211
+ && summary?.mode === 'diagnostic'
212
+ && summary?.execution_mode === 'observe-existing'
213
+ && summary?.artifact_validation?.valid === true
214
+ && summary?.candidate === undefined
215
+ && completion?.schema_version === 1
216
+ && completion?.job_kind === HISTORICAL_JOB_KIND
217
+ && completion?.status === 'completed'
218
+ && completion?.valid === true
219
+ && completion?.job === jobName
220
+ && completion?.summary_path === SUMMARY_NAME
221
+ && completion?.artifact_registry_path === 'artifact-registry.json'
222
+ && HISTORICAL_COVERAGE_KEYS.every(key => (
223
+ typeof summary?.coverage?.[key] === 'number'
224
+ && summary.coverage[key] === completion?.coverage?.[key]
225
+ ))
226
+ )
227
+ }
228
+
229
+ function jobStatus(summary, lifecycle, progress, jobKind, completion, jobName) {
155
230
  if (summary?.__readError) return 'failed'
156
231
  if (!summary && !lifecycle) return 'pending'
157
232
  if (progress.active) return 'running'
158
233
  if (!summary && lifecycle) return 'running'
234
+ if (summary?.artifact_validation?.valid === false) return 'failed'
235
+ if (
236
+ jobKind === HISTORICAL_JOB_KIND
237
+ && !historicalCompletionValid(summary, completion, jobName)
238
+ ) return 'failed'
159
239
  if (Number(summary.n_infrastructure_exceptions ?? summary.n_exceptions ?? 0) > 0 || Number(summary.n_evaluation_exceptions ?? 0) > 0) return 'partial'
160
- if (Number(summary.n_invalid_scores ?? 0) > 0) return 'attention'
240
+ const invalidScores = Number(summary.n_invalid_scores ?? 0)
241
+ if (invalidScores > 0) return 'attention'
161
242
  return 'completed'
162
243
  }
163
244
 
164
245
  async function readJob(jobsDir, entry, details) {
165
246
  const directory = path.join(jobsDir, entry.name)
166
- const [summary, contextFile, promotion, contract, lifecycle, registry, stack] = await Promise.all([
247
+ const [summary, contextFile, promotion, contract, lifecycle, registry, stack, completion] = await Promise.all([
167
248
  readJson(path.join(directory, SUMMARY_NAME)),
168
249
  readJson(path.join(directory, 'evaluation-context.json')),
169
250
  readJson(path.join(directory, 'promotion-report.json')),
@@ -171,25 +252,38 @@ async function readJob(jobsDir, entry, details) {
171
252
  readJson(path.join(directory, 'trial-lifecycle.json')),
172
253
  readJson(path.join(directory, 'artifact-registry.json')),
173
254
  readJson(path.join(directory, 'evaluation-stack-manifest.json')),
255
+ readJson(path.join(directory, HISTORICAL_COMPLETION_NAME)),
174
256
  ])
175
257
  const evaluationContext = summary?.evaluation_context ?? contextFile
176
258
  if (!evaluationContext && !summary && !lifecycle) return undefined
177
259
  const updatedAt = details.mtime.toISOString()
178
260
  const progress = progressView(summary, lifecycle, updatedAt)
261
+ const jobKind = normalizedJobKind(summary, evaluationContext)
179
262
  const capabilities = capabilityMap(summary, evaluationContext, lifecycle, registry, stack)
263
+ const evaluationTarget = summary?.evaluation_target ?? evaluationContext?.evaluation_target
264
+ const generationSource = summary?.generation_source ?? evaluationContext?.generation_source
265
+ const coverage = coverageView(summary)
180
266
  return {
181
267
  name: entry.name,
182
268
  updatedAt,
183
- status: jobStatus(summary, lifecycle, progress),
269
+ status: jobStatus(summary, lifecycle, progress, jobKind, completion, entry.name),
270
+ jobKind,
184
271
  mode: summary?.mode ?? evaluationContext?.mode,
272
+ executionMode: summary?.execution_mode ?? evaluationContext?.execution_mode,
185
273
  nTrials: progress.total,
186
274
  nDiscoveredTrials: Number(summary?.n_discovered_trials ?? lifecycle?.attempt_count ?? 0),
187
275
  nValidScores: summary?.n_valid_scores,
188
276
  nInvalidScores: summary?.n_invalid_scores,
277
+ nUnscoredTrials: Number(coverage.unscored_trials ?? 0),
189
278
  nExceptions: Number(summary?.n_exceptions ?? 0),
190
279
  primaryMetric: primaryMetric(summary, contract),
191
280
  metrics: summary?.metrics ?? {},
192
281
  candidate: summary?.candidate ?? evaluationContext?.candidate,
282
+ evaluationTarget,
283
+ generationSource,
284
+ generatorPopulation: evaluationTarget?.generator_population,
285
+ coverage,
286
+ evaluatorMetaEvaluation: evaluatorMetaEvaluation(summary, evaluationContext),
193
287
  dataset: evaluationContext?.dataset,
194
288
  evaluationContext,
195
289
  progress,
@@ -353,6 +447,7 @@ const DETAIL_ARTIFACTS = {
353
447
  diagnosis: 'diagnosis-report.json',
354
448
  optimization: 'optimization-report.json',
355
449
  promotion: 'promotion-report.json',
450
+ completion: HISTORICAL_COMPLETION_NAME,
356
451
  }
357
452
 
358
453
  function schemaIssue(key, value) {
@@ -360,8 +455,8 @@ function schemaIssue(key, value) {
360
455
  if (value?.__readError) return value.__readError
361
456
  if (!isObject(value)) return 'artifact must be an object'
362
457
  const versions = {
363
- summary: [2, 3], candidate: [1], dataset: [1], datasetPreview: [1], stack: [1], stackSources: [1], context: [1, 2], contract: [1],
364
- doctor: [1], population: [1, 2], lifecycle: [1], registry: [1], diagnosis: [1], optimization: [1, 2], promotion: [2],
458
+ summary: [2, 3, 4], candidate: [1], dataset: [1], datasetPreview: [1], stack: [1], stackSources: [1], context: [1, 2], contract: [1],
459
+ doctor: [1], population: [1, 2, 3], lifecycle: [1], registry: [1, 2], diagnosis: [1, 2], optimization: [1, 2, 3], promotion: [2], completion: [1],
365
460
  }[key]
366
461
  if (versions && !versions.includes(value.schema_version)) return `schema_version must be one of ${versions.join(', ')}`
367
462
  const required = {
@@ -369,8 +464,12 @@ function schemaIssue(key, value) {
369
464
  stack: ['stack_id', 'version', 'digest', 'components', 'judge'], stackSources: ['stack_digest', 'components'], context: ['digest'], contract: ['contract_id', 'version', 'primary_metric', 'metrics'],
370
465
  doctor: ['promotion_ready', 'findings'], population: ['population_size', 'groups', 'metrics'], lifecycle: ['dataset_total', 'trials'],
371
466
  registry: ['artifacts'], diagnosis: ['diagnoses'], optimization: ['hypotheses'], promotion: ['decision', 'reasons', 'policy_digest'],
467
+ completion: ['job_kind', 'status', 'valid', 'job', 'summary_path', 'artifact_registry_path', 'coverage'],
372
468
  }[key] ?? []
373
- const missing = required.filter(field => value[field] === undefined)
469
+ const requiredFields = key === 'population' && value.schema_version === 3
470
+ ? ['population_size', 'coverage', 'metrics']
471
+ : required
472
+ const missing = requiredFields.filter(field => value[field] === undefined)
374
473
  return missing.length ? `missing fields: ${missing.join(', ')}` : undefined
375
474
  }
376
475
 
@@ -390,10 +489,35 @@ export async function readJobDetail(config, args) {
390
489
  return [key, value === undefined ? { status: 'unavailable', reason: 'capability-not-produced' } : issue ? { status: 'invalid', error: issue } : { status: 'valid' }]
391
490
  }))
392
491
  const context = artifacts.context ?? values[Object.keys(DETAIL_ARTIFACTS).indexOf('context')]
492
+ const summary = values[Object.keys(DETAIL_ARTIFACTS).indexOf('summary')]
493
+ const jobKind = normalizedJobKind(summary, context)
494
+ if (
495
+ jobKind === HISTORICAL_JOB_KIND
496
+ && !historicalCompletionValid(summary, artifacts.completion, job)
497
+ ) {
498
+ validation.completion = {
499
+ status: 'invalid',
500
+ error: 'Historical completion sentinel is missing, stale, or inconsistent with the Summary',
501
+ }
502
+ }
393
503
  const capabilities = capabilityMap(
394
- values[Object.keys(DETAIL_ARTIFACTS).indexOf('summary')], context, artifacts.lifecycle, artifacts.registry, artifacts.stack,
504
+ summary, context, artifacts.lifecycle, artifacts.registry, artifacts.stack,
395
505
  )
396
- return { schemaVersion: 2, job, capabilities, artifacts, validation }
506
+ const evaluationTarget = summary?.evaluation_target ?? context?.evaluation_target
507
+ return {
508
+ schemaVersion: 3,
509
+ job,
510
+ jobKind,
511
+ evaluationTarget,
512
+ generationSource: summary?.generation_source ?? context?.generation_source,
513
+ generatorPopulation: evaluationTarget?.generator_population,
514
+ executionMode: summary?.execution_mode ?? context?.execution_mode,
515
+ coverage: coverageView(summary),
516
+ evaluatorMetaEvaluation: evaluatorMetaEvaluation(summary, context),
517
+ capabilities,
518
+ artifacts,
519
+ validation,
520
+ }
397
521
  }
398
522
 
399
523
  function selectedLifecycleTrials(lifecycle) {
@@ -408,13 +532,15 @@ function selectedLifecycleTrials(lifecycle) {
408
532
  function normalizeTrial(trial, order) {
409
533
  const score = trial.score ?? { value: undefined, valid: trial.exception ? false : true, invalid_reasons: trial.exception ? ['infrastructure-error'] : [] }
410
534
  const datasetOrder = Number(trial.datasetOrder ?? trial.dataset_order ?? order)
535
+ const status = trial.status ?? trial.phase ?? (trial.exception ? 'infrastructure-error' : 'completed')
411
536
  return {
412
537
  id: trial.id ?? trial.execution_id ?? `dataset-${datasetOrder}`,
413
538
  name: trial.name ?? trial.trial_name ?? trial.dataset_trial ?? trial.trial,
414
539
  datasetTrial: trial.datasetTrial ?? trial.dataset_trial ?? trial.trial,
415
540
  datasetOrder,
416
541
  attempt: Number(trial.attempt ?? 1),
417
- status: trial.status ?? trial.phase ?? (trial.exception ? 'infrastructure-error' : 'completed'),
542
+ status,
543
+ scoringStatus: status === 'completed-unscored' ? 'unscored' : score.valid ? 'scored' : 'invalid',
418
544
  terminal: trial.terminal ?? true,
419
545
  updatedAt: trial.updatedAt ?? trial.updated_at,
420
546
  score,
@@ -737,6 +863,32 @@ export async function readComparison(config, args) {
737
863
  if (!baseline || baseline.__readError || !candidate || candidate.__readError) throw new Error('Both Job summaries are required')
738
864
  const baselineContext = baseline.evaluation_context ?? await readJson(path.join(jobDirectory(config, baselineJob), 'evaluation-context.json'))
739
865
  const candidateContext = candidate.evaluation_context ?? await readJson(path.join(jobDirectory(config, candidateJob), 'evaluation-context.json'))
866
+ const baselineKind = normalizedJobKind(baseline, baselineContext)
867
+ const candidateKind = normalizedJobKind(candidate, candidateContext)
868
+ if (baselineKind !== CANDIDATE_JOB_KIND || candidateKind !== CANDIDATE_JOB_KIND) {
869
+ const error = {
870
+ code: 'UNSUPPORTED_JOB_KIND_FOR_PROMOTION',
871
+ message: 'Historical Generation Evaluation Jobs are diagnostic evidence and cannot be used as a Candidate baseline, comparison, or Promotion Gate input.',
872
+ }
873
+ return {
874
+ schemaVersion: 1,
875
+ baselineJob,
876
+ candidateJob,
877
+ baselineJobKind: baselineKind,
878
+ candidateJobKind: candidateKind,
879
+ comparable: false,
880
+ comparabilityReasons: [error],
881
+ metrics: {},
882
+ population: {},
883
+ improvedTrials: [],
884
+ regressedTrials: [],
885
+ newExceptions: [],
886
+ artifactRegressions: [],
887
+ gateEligibility: 'not-applicable',
888
+ error,
889
+ note: 'Convert reviewed badcases into a fixed regression Dataset before running Candidate comparison or Gate.',
890
+ }
891
+ }
740
892
  const reasons = []
741
893
  if (baselineContext?.schema_version !== 2 || candidateContext?.schema_version !== 2) reasons.push('Context v2 is required')
742
894
  if (!baselineContext?.digest || baselineContext.digest !== candidateContext?.digest) reasons.push('Evaluation Context differs; establish a fresh baseline')
package/lib/evolution.js CHANGED
@@ -66,6 +66,73 @@ export function makeJobName(manifest, now = new Date()) {
66
66
  return `${identity}-${suffix}`
67
67
  }
68
68
 
69
+ export function makeHistoricalJobName(batch, now = new Date()) {
70
+ const timestamp = now.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '')
71
+ const digest = String(batch?.digest ?? '').replace(/^sha256:/, '').slice(0, 8) || 'historical'
72
+ return `dsh-session-history-${timestamp}-${digest}`
73
+ }
74
+
75
+ const HISTORICAL_COVERAGE_KEYS = [
76
+ 'scored_trials',
77
+ 'unscored_trials',
78
+ 'total_trials',
79
+ 'trial_rate',
80
+ 'criterion_scored',
81
+ 'criterion_total',
82
+ 'criterion_rate',
83
+ ]
84
+
85
+ function historicalCoverageMatches(summaryCoverage, completionCoverage) {
86
+ if (!summaryCoverage || !completionCoverage) return false
87
+ return HISTORICAL_COVERAGE_KEYS.every(key => (
88
+ typeof summaryCoverage[key] === 'number'
89
+ && summaryCoverage[key] === completionCoverage[key]
90
+ ))
91
+ }
92
+
93
+ /**
94
+ * Harbor 0.21 treats Job-plugin finalization failures as warnings, so a zero
95
+ * process exit is not proof that Historical artifacts completed. Validate the
96
+ * fresh plugin-owned sentinel and its core Summary cross-links fail-closed.
97
+ */
98
+ export function assertHistoricalCompletion(summary, completion, {
99
+ jobName,
100
+ batchDigest,
101
+ batchId,
102
+ recordCount,
103
+ }) {
104
+ const summaryValid = (
105
+ summary?.schema_version === 4
106
+ && summary?.job === jobName
107
+ && summary?.job_kind === 'historical-generation-evaluation'
108
+ && summary?.mode === 'diagnostic'
109
+ && summary?.execution_mode === 'observe-existing'
110
+ && summary?.evaluation_target?.digest === batchDigest
111
+ && summary?.evaluation_target?.batch_id === batchId
112
+ && summary?.evaluation_target?.record_count === recordCount
113
+ && summary?.n_trials === recordCount
114
+ && summary?.coverage?.total_trials === recordCount
115
+ && summary?.artifact_validation?.valid === true
116
+ && summary?.candidate === undefined
117
+ )
118
+ if (!summaryValid) {
119
+ throw new Error('HISTORICAL_JOB_IDENTITY_INVALID: the Summary is not a validated, Candidate-free Historical Generation Evaluation for this Batch')
120
+ }
121
+ const completionValid = (
122
+ completion?.schema_version === 1
123
+ && completion?.job_kind === 'historical-generation-evaluation'
124
+ && completion?.status === 'completed'
125
+ && completion?.valid === true
126
+ && completion?.job === jobName
127
+ && completion?.summary_path === 'evaluation-summary.json'
128
+ && completion?.artifact_registry_path === 'artifact-registry.json'
129
+ && historicalCoverageMatches(summary.coverage, completion.coverage)
130
+ )
131
+ if (!completionValid) {
132
+ throw new Error('HISTORICAL_JOB_ARTIFACT_VALIDATION_FAILED: the completion sentinel is missing, stale, or inconsistent with the validated Summary')
133
+ }
134
+ }
135
+
69
136
  async function cliJson(config, args, { allowedExitCodes = [0], input } = {}) {
70
137
  let result
71
138
  try {
@@ -374,6 +441,128 @@ export async function runEvaluation(config, args, modelRuntime) {
374
441
  }
375
442
  }
376
443
 
444
+ /**
445
+ * Materialize an immutable Historical Generation Batch before Harbor creates
446
+ * its Job, then run a deterministic Observation Adapter. This path never
447
+ * snapshots or executes a Candidate.
448
+ */
449
+ export async function runHistoricalEvaluation(config, args, modelRuntime) {
450
+ const projectRoot = path.resolve(config.projectRoot)
451
+ const batchPath = resolveWithin(projectRoot, args.batchPath, 'batchPath')
452
+ const batchDir = resolveWithin(projectRoot, args.batchDir ?? path.dirname(batchPath), 'batchDir')
453
+ const output = resolveWithin(projectRoot, path.join(batchDir, 'dataset'), 'historicalDataset')
454
+ if (!args.judgeBinding?.provider || !args.judgeBinding?.model) {
455
+ throw new Error('Historical Judge model binding is required before materialization')
456
+ }
457
+ const materialized = await cliJson(config, [
458
+ 'historical', 'materialize',
459
+ '--project-root', projectRoot,
460
+ '--batch', batchPath,
461
+ '--output', output,
462
+ '--judge-provider', args.judgeBinding.provider,
463
+ '--judge-model', args.judgeBinding.model,
464
+ ...(args.judgeBinding.reasoning_effort === undefined
465
+ ? []
466
+ : ['--judge-reasoning-effort', args.judgeBinding.reasoning_effort]),
467
+ ])
468
+ const dataset = resolveWithin(projectRoot, materialized.dataset_path ?? output, 'historicalDataset')
469
+ const stack = resolveWithin(projectRoot, materialized.stack_path, 'historicalStack')
470
+ const batch = JSON.parse(await readFile(batchPath, 'utf8'))
471
+ const jobs = resolveWithin(projectRoot, config.jobsDir, 'jobsDir')
472
+ const jobName = args.jobName ?? makeHistoricalJobName(batch)
473
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(jobName)) {
474
+ throw new Error('jobName contains unsupported characters')
475
+ }
476
+ const agentImportPath = config.historicalAgentImportPath
477
+ ?? 'harbor_dsh_evolution.session_agent:SessionObservationAgent'
478
+ const pluginImportPath = config.historicalPluginImportPath
479
+ ?? 'dsh-historical-evaluation'
480
+ const harborArgs = [
481
+ 'run', '-y', '-p', dataset,
482
+ '-a', agentImportPath,
483
+ '--job-name', jobName,
484
+ '--jobs-dir', jobs,
485
+ '--plugin', pluginImportPath,
486
+ '--plugin-kwarg', `batch_path=${batchPath}`,
487
+ '--plugin-kwarg', `dataset_path=${dataset}`,
488
+ '--plugin-kwarg', `stack_path=${stack}`,
489
+ '--plugin-kwarg', `project_root=${projectRoot}`,
490
+ '--plugin-kwarg', 'mode=diagnostic',
491
+ ]
492
+ const lease = await modelRuntime.openLease(args.judgeBinding, {
493
+ candidateDigest: batch.digest,
494
+ jobName,
495
+ })
496
+ const jobDir = path.join(jobs, jobName)
497
+ try {
498
+ let processResult
499
+ try {
500
+ processResult = await runProcess(config.harborBin, harborArgs, {
501
+ cwd: projectRoot,
502
+ timeoutMs: config.timeoutMs,
503
+ env: {
504
+ ...process.env,
505
+ ...(config.pythonPath ? { PYTHONPATH: config.pythonPath } : {}),
506
+ HSE_JUDGE_GATEWAY_URL: lease.endpoint,
507
+ HSE_JUDGE_GATEWAY_TOKEN: lease.token,
508
+ HSE_JUDGE_GATEWAY_PROVIDER: lease.candidateProvider,
509
+ HSE_JUDGE_GATEWAY_INFO: JSON.stringify({
510
+ protocol: lease.protocol,
511
+ candidate_digest: batch.digest,
512
+ job: jobName,
513
+ binding: {
514
+ provider: args.judgeBinding.provider,
515
+ model: args.judgeBinding.model,
516
+ ...(args.judgeBinding.reasoning_effort === undefined
517
+ ? {}
518
+ : { reasoning_effort: args.judgeBinding.reasoning_effort }),
519
+ },
520
+ model_info: lease.modelInfo,
521
+ }),
522
+ HSE_JUDGE_GATEWAY_PROTOCOL: lease.protocol,
523
+ },
524
+ })
525
+ } catch (error) {
526
+ throw await explainHarborFailure(error, jobDir)
527
+ }
528
+ let summary
529
+ let completion
530
+ try {
531
+ summary = JSON.parse(await readFile(path.join(jobDir, 'evaluation-summary.json'), 'utf8'))
532
+ completion = JSON.parse(await readFile(path.join(jobDir, 'historical-evaluation-complete.json'), 'utf8'))
533
+ } catch (error) {
534
+ throw new Error(
535
+ `HISTORICAL_JOB_INCOMPLETE: Harbor exited successfully but the Historical plugin did not write its summary/completion sentinel (${error.message})`,
536
+ )
537
+ }
538
+ assertHistoricalCompletion(summary, completion, {
539
+ jobName,
540
+ batchDigest: batch.digest,
541
+ batchId: batch.batch_id,
542
+ recordCount: Array.isArray(batch.records) ? batch.records.length : undefined,
543
+ })
544
+ return {
545
+ judgeModelBinding: {
546
+ provider: args.judgeBinding.provider,
547
+ model: args.judgeBinding.model,
548
+ ...(args.judgeBinding.reasoning_effort === undefined
549
+ ? {}
550
+ : { reasoning_effort: args.judgeBinding.reasoning_effort }),
551
+ },
552
+ job: path.relative(projectRoot, jobDir).split(path.sep).join('/'),
553
+ summary,
554
+ completion,
555
+ materialized: {
556
+ dataset: path.relative(projectRoot, dataset).split(path.sep).join('/'),
557
+ stack: path.relative(projectRoot, stack).split(path.sep).join('/'),
558
+ },
559
+ process: { code: processResult.code },
560
+ }
561
+ } finally {
562
+ await lease.close()
563
+ }
564
+ }
565
+
377
566
  export async function readEvaluation(config, args) {
378
567
  const jobDir = resolveWithin(config.projectRoot, args.jobPath, 'jobPath')
379
568
  return JSON.parse(await readFile(path.join(jobDir, 'evaluation-summary.json'), 'utf8'))
@@ -74,13 +74,17 @@ export class CandidateModelRuntime {
74
74
  this.config = config
75
75
  }
76
76
 
77
- async currentBinding() {
77
+ async resolveCurrent() {
78
78
  const inherited = this.ctx.agentDefaultModel.currentSelection()
79
- const binding = await this.resolve({
79
+ return this.resolve({
80
80
  candidateProvider: inherited.provider,
81
81
  candidateModel: inherited.model,
82
82
  candidateReasoningEffort: inherited.reasoningEffort,
83
- })
83
+ }, undefined, { ignoreConfigured: true })
84
+ }
85
+
86
+ async currentBinding() {
87
+ const binding = await this.resolveCurrent()
84
88
  return {
85
89
  schema_version: 1,
86
90
  source: 'skill-agent-default',
@@ -92,14 +96,14 @@ export class CandidateModelRuntime {
92
96
  }
93
97
  }
94
98
 
95
- async resolve(args = {}, pinnedBinding) {
99
+ async resolve(args = {}, pinnedBinding, { ignoreConfigured = false } = {}) {
96
100
  const explicitProvider = nonBlank(args.candidateProvider)
97
101
  const explicitModel = nonBlank(args.candidateModel)
98
102
  if (Boolean(explicitProvider) !== Boolean(explicitModel)) {
99
103
  throw new Error('candidateProvider and candidateModel must be supplied together')
100
104
  }
101
- const configuredProvider = nonBlank(this.config.candidateProvider)
102
- const configuredModel = nonBlank(this.config.candidateModel)
105
+ const configuredProvider = ignoreConfigured ? undefined : nonBlank(this.config.candidateProvider)
106
+ const configuredModel = ignoreConfigured ? undefined : nonBlank(this.config.candidateModel)
103
107
  if (Boolean(configuredProvider) !== Boolean(configuredModel)) {
104
108
  throw new Error('Harbor candidateProvider and candidateModel configuration must be supplied together')
105
109
  }
@@ -129,7 +133,7 @@ export class CandidateModelRuntime {
129
133
  const provider = pinnedProvider ?? explicitProvider ?? configuredProvider ?? inherited.provider
130
134
  const model = pinnedModel ?? explicitModel ?? configuredModel ?? inherited.model
131
135
  const explicitReasoning = nonBlank(args.candidateReasoningEffort)
132
- const configuredReasoning = nonBlank(this.config.candidateReasoningEffort)
136
+ const configuredReasoning = ignoreConfigured ? undefined : nonBlank(this.config.candidateReasoningEffort)
133
137
  const canInheritReasoning = provider === inherited.provider && model === inherited.model
134
138
  const reasoningEffort = pinnedProvider
135
139
  ? pinnedReasoning