@ran-sh/dsh-crew 1.3.2 → 1.5.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/src/jobs.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // never passes an explicit dshBin that would bypass it. The worker rides the
9
9
  // `sdk-minimal` profile with the Crew overlay applied as a --patch.
10
10
 
11
- import { createStandaloneHarness } from './standalone-sdk.mjs';
11
+ import { createStandaloneHarness } from './standalone-sdk.mjs';
12
12
  import { readFileSync, mkdirSync, existsSync } from 'node:fs';
13
13
  import { createShardWriter } from './status-shard.mjs';
14
14
  import { fileURLToPath } from 'node:url';
@@ -19,6 +19,9 @@ import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY }
19
19
  import { buildOutcome, JOB_PHASES } from './workflow.mjs';
20
20
  import { raceWaiters } from './removable-waiter.mjs';
21
21
  import { buildDirectSelectionTrace } from './model-routing.mjs';
22
+ import { normalizeModelSchedule, scheduleAdmission } from './model-schedule.mjs';
23
+ import { appendSessionOrigin } from './session-origins.mjs';
24
+ import { readGlobalConfig } from './install/install.mjs';
22
25
 
23
26
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
24
27
  const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
@@ -26,6 +29,13 @@ const CREW_DSH_HOME = join(homedir(), '.config', 'dsh-crew', 'harness');
26
29
  const STATUS_FILE = join(CONFIG_DIR, 'status.json');
27
30
  const CORDIS = join(ROOT, 'worker.cordis.yml');
28
31
 
32
+ // Read the operator's schedule once per dispatch. An unreadable config must not
33
+ // block a job: a missing or damaged file means no restriction, matching how the
34
+ // router treats an absent schedule.
35
+ function readModelSchedule() {
36
+ try { return normalizeModelSchedule(readGlobalConfig().model_schedule); } catch { return normalizeModelSchedule(undefined); }
37
+ }
38
+
29
39
  export const TIERS = {
30
40
  flash: { model: 'deepseek-v4-flash', label: 'V4 Flash' },
31
41
  pro: { model: 'deepseek-v4-pro', label: 'V4 Pro' },
@@ -109,6 +119,14 @@ export function startJob({
109
119
  }) {
110
120
  const tierInfo = TIERS[tier];
111
121
  if (!tierInfo) throw new Error(`unknown tier "${tier}" (expected: ${Object.keys(TIERS).join(', ')})`);
122
+ // Standalone dispatch names its provider/model directly rather than walking a
123
+ // catalog, but a peak `block` still applies: without this the schedule would be
124
+ // silently ignored on every standalone job.
125
+ const standaloneRef = { provider: 'deepseek-official', model: tierInfo.model };
126
+ const standaloneAt = new Date();
127
+ if (scheduleAdmission(readModelSchedule(), standaloneRef, standaloneAt)?.mode === 'block') {
128
+ throw Object.assign(new Error('MODEL_BLOCKED_PEAK'), { policyCode: 'MODEL_BLOCKED_PEAK' });
129
+ }
112
130
  if (!ROLES[role]) throw new Error(`unknown role "${role}" (expected: ${Object.keys(ROLES).join(', ')})`);
113
131
  if (!['off', 'high', 'max'].includes(effort)) throw new Error(`unknown effort "${effort}" (expected: off, high, max)`);
114
132
  if (!existsSync(CORDIS)) throw new Error(`worker overlay not found at ${CORDIS}`);
@@ -123,7 +141,7 @@ export function startJob({
123
141
  throw new Error(`DEEPSEEK_API_KEY not found in env or ${join(CONFIG_DIR, '.env')}`);
124
142
  }
125
143
 
126
- const harness = createStandaloneHarness({
144
+ const harness = createStandaloneHarness({
127
145
  profile: 'sdk-minimal',
128
146
  patches: [CORDIS],
129
147
  dshHome: CREW_DSH_HOME,
@@ -178,6 +196,10 @@ export function startJob({
178
196
  .catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
179
197
 
180
198
  const sessionId = id;
199
+ // Standalone dispatches write into the same shared session store as the hub,
200
+ // so they need the same provenance record or a Crew-scoped cleanup would leave
201
+ // them behind.
202
+ appendSessionOrigin({ sessionId, role, jobId: id });
181
203
  const onNotification = (n) => {
182
204
  if (n.method === 'session.status') return;
183
205
  if (n.method !== 'session.event') return;
@@ -5,6 +5,7 @@
5
5
  // routing boundary.
6
6
 
7
7
  import { rankAdaptiveCandidates } from './adaptive-routing.mjs';
8
+ import { scheduleAdmission } from './model-schedule.mjs';
8
9
 
9
10
  export const DEFAULT_TIER_MODEL_PREFERENCES = Object.freeze({
10
11
  flash: 'deepseek-v4-flash',
@@ -26,36 +27,39 @@ export const MODEL_SELECTION_REASON_CODES = Object.freeze({
26
27
  PROVIDER_UNAVAILABLE: 'PROVIDER_UNAVAILABLE',
27
28
  PREFERRED_MODEL_UNAVAILABLE: 'PREFERRED_MODEL_UNAVAILABLE',
28
29
  PREFERRED_MODEL_AMBIGUOUS: 'PREFERRED_MODEL_AMBIGUOUS',
29
- ADAPTIVE_DEPRIORITIZED: 'ADAPTIVE_DEPRIORITIZED',
30
- PROVIDER_TOMBSTONED: 'PROVIDER_TOMBSTONED',
31
- CREDENTIAL_MISSING: 'CREDENTIAL_MISSING',
32
- QUOTA_EXHAUSTED: 'QUOTA_EXHAUSTED',
33
- RATE_LIMITED: 'RATE_LIMITED',
34
- PROBE_TIMEOUT: 'PROBE_TIMEOUT',
35
- PROVIDER_INTERNAL_ERROR: 'PROVIDER_INTERNAL_ERROR',
36
- HARNESS_DEFAULT_INVALID: 'HARNESS_DEFAULT_INVALID',
30
+ ADAPTIVE_DEPRIORITIZED: 'ADAPTIVE_DEPRIORITIZED',
31
+ PROVIDER_TOMBSTONED: 'PROVIDER_TOMBSTONED',
32
+ CREDENTIAL_MISSING: 'CREDENTIAL_MISSING',
33
+ QUOTA_EXHAUSTED: 'QUOTA_EXHAUSTED',
34
+ RATE_LIMITED: 'RATE_LIMITED',
35
+ PEAK_RESTRICTED: 'PEAK_RESTRICTED',
36
+ PEAK_ADVISORY: 'PEAK_ADVISORY',
37
+ PROBE_TIMEOUT: 'PROBE_TIMEOUT',
38
+ PROVIDER_INTERNAL_ERROR: 'PROVIDER_INTERNAL_ERROR',
39
+ HARNESS_DEFAULT_INVALID: 'HARNESS_DEFAULT_INVALID',
37
40
  HARNESS_DEFAULT_PROVIDER_UNAVAILABLE: 'HARNESS_DEFAULT_PROVIDER_UNAVAILABLE',
38
41
  PRIMARY_CANDIDATES_EXHAUSTED: 'PRIMARY_CANDIDATES_EXHAUSTED',
39
42
  ESCALATION_CANDIDATES_EXHAUSTED: 'ESCALATION_CANDIDATES_EXHAUSTED',
40
- NO_AVAILABLE_MODEL: 'NO_AVAILABLE_MODEL',
41
- });
42
-
43
- const HEALTH_BLOCK_REASONS = Object.freeze({
44
- 'credential-missing': MODEL_SELECTION_REASON_CODES.CREDENTIAL_MISSING,
45
- 'quota-exhausted': MODEL_SELECTION_REASON_CODES.QUOTA_EXHAUSTED,
46
- 'rate-limited': MODEL_SELECTION_REASON_CODES.RATE_LIMITED,
47
- timeout: MODEL_SELECTION_REASON_CODES.PROBE_TIMEOUT,
48
- 'internal-error': MODEL_SELECTION_REASON_CODES.PROVIDER_INTERNAL_ERROR,
49
- });
50
-
51
- const BLOCKED_MODEL_CODES = Object.freeze({
52
- [MODEL_SELECTION_REASON_CODES.CREDENTIAL_MISSING]: 'MODEL_BLOCKED_CREDENTIAL',
53
- [MODEL_SELECTION_REASON_CODES.QUOTA_EXHAUSTED]: 'MODEL_BLOCKED_QUOTA',
54
- [MODEL_SELECTION_REASON_CODES.RATE_LIMITED]: 'MODEL_BLOCKED_RATE_LIMIT',
55
- [MODEL_SELECTION_REASON_CODES.PROBE_TIMEOUT]: 'MODEL_BLOCKED_TIMEOUT',
56
- [MODEL_SELECTION_REASON_CODES.PROVIDER_INTERNAL_ERROR]: 'MODEL_BLOCKED_PROVIDER',
57
- [MODEL_SELECTION_REASON_CODES.PROVIDER_TOMBSTONED]: 'MODEL_BLOCKED_TOMBSTONED',
58
- });
43
+ NO_AVAILABLE_MODEL: 'NO_AVAILABLE_MODEL',
44
+ });
45
+
46
+ const HEALTH_BLOCK_REASONS = Object.freeze({
47
+ 'credential-missing': MODEL_SELECTION_REASON_CODES.CREDENTIAL_MISSING,
48
+ 'quota-exhausted': MODEL_SELECTION_REASON_CODES.QUOTA_EXHAUSTED,
49
+ 'rate-limited': MODEL_SELECTION_REASON_CODES.RATE_LIMITED,
50
+ timeout: MODEL_SELECTION_REASON_CODES.PROBE_TIMEOUT,
51
+ 'internal-error': MODEL_SELECTION_REASON_CODES.PROVIDER_INTERNAL_ERROR,
52
+ });
53
+
54
+ const BLOCKED_MODEL_CODES = Object.freeze({
55
+ [MODEL_SELECTION_REASON_CODES.CREDENTIAL_MISSING]: 'MODEL_BLOCKED_CREDENTIAL',
56
+ [MODEL_SELECTION_REASON_CODES.QUOTA_EXHAUSTED]: 'MODEL_BLOCKED_QUOTA',
57
+ [MODEL_SELECTION_REASON_CODES.RATE_LIMITED]: 'MODEL_BLOCKED_RATE_LIMIT',
58
+ [MODEL_SELECTION_REASON_CODES.PROBE_TIMEOUT]: 'MODEL_BLOCKED_TIMEOUT',
59
+ [MODEL_SELECTION_REASON_CODES.PROVIDER_INTERNAL_ERROR]: 'MODEL_BLOCKED_PROVIDER',
60
+ [MODEL_SELECTION_REASON_CODES.PROVIDER_TOMBSTONED]: 'MODEL_BLOCKED_TOMBSTONED',
61
+ [MODEL_SELECTION_REASON_CODES.PEAK_RESTRICTED]: 'MODEL_BLOCKED_PEAK',
62
+ });
59
63
 
60
64
  export function normalizeModelRef(raw) {
61
65
  if (!raw || typeof raw !== 'object') return null;
@@ -124,7 +128,7 @@ function traceBase({
124
128
  };
125
129
  }
126
130
 
127
- function candidateDecision(ref, source, status, { reasonCode, advertised } = {}) {
131
+ function candidateDecision(ref, source, status, { reasonCode, advertised } = {}) {
128
132
  return {
129
133
  provider: ref?.provider ?? null,
130
134
  model: ref?.model ?? null,
@@ -133,28 +137,40 @@ function candidateDecision(ref, source, status, { reasonCode, advertised } = {})
133
137
  ...(reasonCode ? { reason_code: reasonCode } : {}),
134
138
  ...(advertised === false ? { advertised: false } : {}),
135
139
  };
136
- }
137
-
138
- function healthAdmissionReason(ref, { healthStore, healthGate, tombstones } = {}) {
139
- if (tombstones?.[ref?.provider] === 'absent') return MODEL_SELECTION_REASON_CODES.PROVIDER_TOMBSTONED;
140
- if (healthGate !== 'hard-failures' || typeof healthStore?.get !== 'function') return null;
141
- const observation = healthStore.get(ref?.provider, ref?.model);
142
- if (observation?.fresh !== true) return null;
143
- return HEALTH_BLOCK_REASONS[observation.state] ?? null;
144
- }
145
-
146
- function blockedSelection(trace, ref, source, reasonCode) {
147
- trace.fallback_reason = reasonCode;
148
- return {
149
- ok: false,
150
- code: BLOCKED_MODEL_CODES[reasonCode] ?? 'MODEL_BLOCKED_PROVIDER',
151
- message: `Model ${ref?.provider ?? 'unknown'}/${ref?.model ?? 'unknown'} is blocked by ${reasonCode}.`,
152
- selection_trace: trace,
153
- };
154
- }
140
+ }
141
+
142
+ function healthAdmissionReason(ref, { healthStore, healthGate, tombstones } = {}) {
143
+ if (tombstones?.[ref?.provider] === 'absent') return MODEL_SELECTION_REASON_CODES.PROVIDER_TOMBSTONED;
144
+ if (healthGate !== 'hard-failures' || typeof healthStore?.get !== 'function') return null;
145
+ const observation = healthStore.get(ref?.provider, ref?.model);
146
+ if (observation?.fresh !== true) return null;
147
+ return HEALTH_BLOCK_REASONS[observation.state] ?? null;
148
+ }
149
+
150
+ /**
151
+ * Peak/off-peak admission for one candidate.
152
+ *
153
+ * Returns `null` when the model is unrestricted or off-peak; otherwise the
154
+ * schedule's verdict, which the caller applies. A `warn` rule is advisory — the
155
+ * candidate is still selected, and the trace records why.
156
+ */
157
+ function peakAdmission(schedule, ref, at) {
158
+ if (!schedule) return null;
159
+ return scheduleAdmission(schedule, ref, at);
160
+ }
155
161
 
156
- function selectTrace(trace, ref, source, { advertised, fallbackReason } = {}) {
157
- trace.ordered_candidates.push(candidateDecision(ref, source, 'selected', { advertised }));
162
+ function blockedSelection(trace, ref, source, reasonCode) {
163
+ trace.fallback_reason = reasonCode;
164
+ return {
165
+ ok: false,
166
+ code: BLOCKED_MODEL_CODES[reasonCode] ?? 'MODEL_BLOCKED_PROVIDER',
167
+ message: `Model ${ref?.provider ?? 'unknown'}/${ref?.model ?? 'unknown'} is blocked by ${reasonCode}.`,
168
+ selection_trace: trace,
169
+ };
170
+ }
171
+
172
+ function selectTrace(trace, ref, source, { advertised, fallbackReason, reasonCode } = {}) {
173
+ trace.ordered_candidates.push(candidateDecision(ref, source, 'selected', { advertised, reasonCode }));
158
174
  trace.selected = { provider: ref.provider, model: ref.model, source };
159
175
  trace.selection_source = source;
160
176
  trace.fallback_reason = fallbackReason ?? null;
@@ -190,6 +206,7 @@ export function buildDirectSelectionTrace({
190
206
  model,
191
207
  source = 'legacy-strict',
192
208
  escalationReason = null,
209
+ peakAdvisory = false,
193
210
  } = {}) {
194
211
  const trace = traceBase({ role, logicalAttempt, modelClassHint, strategy, candidateSet, escalationReason });
195
212
  const ref = normalizeModelRef({ provider, model });
@@ -197,7 +214,9 @@ export function buildDirectSelectionTrace({
197
214
  trace.fallback_reason = MODEL_SELECTION_REASON_CODES.NO_AVAILABLE_MODEL;
198
215
  return trace;
199
216
  }
200
- return selectTrace(trace, ref, source);
217
+ return selectTrace(trace, ref, source, {
218
+ reasonCode: peakAdvisory ? MODEL_SELECTION_REASON_CODES.PEAK_ADVISORY : null,
219
+ });
201
220
  }
202
221
 
203
222
  /**
@@ -239,7 +258,7 @@ export function enrichSelectionTrace(trace, {
239
258
  return base;
240
259
  }
241
260
 
242
- export function resolveWorkerModel({
261
+ export function resolveWorkerModel({
243
262
  tier,
244
263
  priority,
245
264
  priorityConfigured = false,
@@ -248,13 +267,15 @@ export function resolveWorkerModel({
248
267
  fallback = 'harness-default',
249
268
  preferredModelId = DEFAULT_TIER_MODEL_PREFERENCES[tier],
250
269
  traceContext = {},
251
- adaptive,
252
- adaptiveHealth,
253
- healthStore,
254
- healthGate,
255
- allowFallback = true,
256
- tombstones,
257
- } = {}) {
270
+ adaptive,
271
+ adaptiveHealth,
272
+ healthStore,
273
+ healthGate,
274
+ allowFallback = true,
275
+ tombstones,
276
+ schedule,
277
+ at,
278
+ } = {}) {
258
279
  const providers = providerMap(catalog);
259
280
  const normalizedPriority = normalizeModelPriority(priority);
260
281
  const prioritySource = typeof traceContext.prioritySource === 'string' && traceContext.prioritySource
@@ -269,37 +290,57 @@ export function resolveWorkerModel({
269
290
  escalationReason: traceContext.escalationReason ?? null,
270
291
  });
271
292
 
293
+ // One clock read for the whole resolution. Calling `new Date()` per admission
294
+ // would let a resolution that straddles a peak boundary judge candidates
295
+ // against different instants — the filter and the all-blocked rescan below
296
+ // could then disagree about the same candidate.
297
+ const selectionAt = at ?? new Date();
298
+
272
299
  // Explicit priority — including an intentionally empty configured list — is
273
300
  // authoritative. Adaptive mode records that bypass but never reorders it.
274
301
  if (priorityConfigured || normalizedPriority.length > 0) {
275
302
  rankForTrace(trace, normalizedPriority, { adaptive, adaptiveHealth, explicitPriority: true });
276
303
  }
277
304
 
278
- for (let index = 0; index < normalizedPriority.length; index++) {
279
- const ref = normalizedPriority[index];
280
- const provider = providers.get(ref.provider);
305
+ for (let index = 0; index < normalizedPriority.length; index++) {
306
+ const ref = normalizedPriority[index];
307
+ const provider = providers.get(ref.provider);
281
308
  if (!provider) {
282
309
  trace.ordered_candidates.push(candidateDecision(ref, prioritySource, 'skipped', {
283
310
  reasonCode: MODEL_SELECTION_REASON_CODES.PROVIDER_UNAVAILABLE,
284
- }));
285
- continue;
286
- }
287
- const advertised = (provider.models ?? []).some((model) => model?.id === ref.model);
288
- const healthReason = healthAdmissionReason(ref, { healthStore, healthGate, tombstones });
289
- if (healthReason) {
290
- trace.ordered_candidates.push(candidateDecision(ref, prioritySource, 'skipped', {
291
- reasonCode: healthReason, advertised,
292
- }));
293
- if (allowFallback === false) return blockedSelection(trace, ref, prioritySource, healthReason);
294
- continue;
295
- }
296
- selectTrace(trace, ref, prioritySource, { advertised });
311
+ }));
312
+ continue;
313
+ }
314
+ const advertised = (provider.models ?? []).some((model) => model?.id === ref.model);
315
+ const healthReason = healthAdmissionReason(ref, { healthStore, healthGate, tombstones });
316
+ if (healthReason) {
317
+ trace.ordered_candidates.push(candidateDecision(ref, prioritySource, 'skipped', {
318
+ reasonCode: healthReason, advertised,
319
+ }));
320
+ if (allowFallback === false) return blockedSelection(trace, ref, prioritySource, healthReason);
321
+ continue;
322
+ }
323
+ // A peak-blocked model is skipped so the next candidate can serve the job;
324
+ // only an explicit no-fallback call turns it into a hard failure.
325
+ const peak = peakAdmission(schedule, ref, selectionAt);
326
+ if (peak?.mode === 'block') {
327
+ trace.ordered_candidates.push(candidateDecision(ref, prioritySource, 'skipped', {
328
+ reasonCode: MODEL_SELECTION_REASON_CODES.PEAK_RESTRICTED, advertised,
329
+ }));
330
+ if (allowFallback === false) return blockedSelection(trace, ref, prioritySource, MODEL_SELECTION_REASON_CODES.PEAK_RESTRICTED);
331
+ continue;
332
+ }
333
+ selectTrace(trace, ref, prioritySource, {
334
+ advertised,
335
+ reasonCode: peak?.mode === 'warn' ? MODEL_SELECTION_REASON_CODES.PEAK_ADVISORY : null,
336
+ });
297
337
  return {
298
338
  ok: true,
299
339
  ...ref,
300
340
  source: 'priority',
301
341
  matchedPriorityIndex: index,
302
342
  ...(advertised ? {} : { advertised: false }),
343
+ ...(peak?.mode === 'warn' ? { peak_advisory: true } : {}),
303
344
  selection_trace: trace,
304
345
  };
305
346
  }
@@ -312,47 +353,79 @@ export function resolveWorkerModel({
312
353
  if ((provider.models ?? []).some((model) => model?.id === preferredModelId)) {
313
354
  matches.push({ provider: provider.id, model: preferredModelId });
314
355
  }
315
- }
316
- if (matches.length === 1) {
317
- const preferred = matches[0];
318
- const healthReason = healthAdmissionReason(preferred, { healthStore, healthGate, tombstones });
319
- if (healthReason) {
320
- trace.ordered_candidates.push(candidateDecision(preferred, 'preferred-default', 'skipped', {
321
- reasonCode: healthReason,
322
- }));
323
- if (allowFallback === false) return blockedSelection(trace, preferred, 'preferred-default', healthReason);
324
- } else {
325
- rankForTrace(trace, matches, { adaptive, adaptiveHealth });
326
- selectTrace(trace, preferred, 'preferred-default');
327
- return { ok: true, ...preferred, source: 'preferred-default', selection_trace: trace };
328
- }
329
- }
330
- if (matches.length > 1) {
331
- const availableMatches = matches.filter((candidate) => {
332
- const healthReason = healthAdmissionReason(candidate, { healthStore, healthGate, tombstones });
333
- if (!healthReason) return true;
334
- trace.ordered_candidates.push(candidateDecision(candidate, 'preferred-default', 'skipped', {
335
- reasonCode: healthReason,
336
- }));
337
- return false;
338
- });
339
- if (availableMatches.length === 0) {
340
- const blocked = matches.find((candidate) => healthAdmissionReason(candidate, { healthStore, healthGate, tombstones }));
341
- if (blocked && allowFallback === false) {
342
- return blockedSelection(trace, blocked, 'preferred-default', healthAdmissionReason(blocked, { healthStore, healthGate, tombstones }));
343
- }
344
- }
345
- const preferredProvider = normalizeModelRef(harnessDefault)?.provider;
346
- const deterministicMatch = availableMatches.find((candidate) => candidate.provider === preferredProvider) ?? null;
347
- const baseline = deterministicMatch
348
- ? [deterministicMatch, ...availableMatches.filter((candidate) => candidate.provider !== deterministicMatch.provider)]
349
- : availableMatches;
350
- const ranked = rankForTrace(trace, baseline, { adaptive, adaptiveHealth });
351
- const adaptiveChoice = ranked.trace.decision_supported ? ranked.candidates[0] : null;
352
- const match = adaptiveChoice ?? deterministicMatch;
353
- if (match) {
354
- const decisionOrder = adaptiveChoice ? ranked.candidates : availableMatches;
355
- for (const candidate of decisionOrder) {
356
+ }
357
+ if (matches.length === 1) {
358
+ const preferred = matches[0];
359
+ const healthReason = healthAdmissionReason(preferred, { healthStore, healthGate, tombstones });
360
+ const peak = healthReason ? null : peakAdmission(schedule, preferred, selectionAt);
361
+ const blockReason = healthReason ?? (peak?.mode === 'block' ? MODEL_SELECTION_REASON_CODES.PEAK_RESTRICTED : null);
362
+ if (blockReason) {
363
+ trace.ordered_candidates.push(candidateDecision(preferred, 'preferred-default', 'skipped', {
364
+ reasonCode: blockReason,
365
+ }));
366
+ if (allowFallback === false) return blockedSelection(trace, preferred, 'preferred-default', blockReason);
367
+ } else {
368
+ rankForTrace(trace, matches, { adaptive, adaptiveHealth });
369
+ selectTrace(trace, preferred, 'preferred-default', {
370
+ reasonCode: peak?.mode === 'warn' ? MODEL_SELECTION_REASON_CODES.PEAK_ADVISORY : null,
371
+ });
372
+ return {
373
+ ok: true,
374
+ ...preferred,
375
+ source: 'preferred-default',
376
+ ...(peak?.mode === 'warn' ? { peak_advisory: true } : {}),
377
+ selection_trace: trace,
378
+ };
379
+ }
380
+ }
381
+ if (matches.length > 1) {
382
+ // Keep each admitted candidate's advisory verdict alongside it: a `warn`
383
+ // rule is only advisory if the caller learns about it, and recomputing it
384
+ // after the pick would re-evaluate a clock already read once.
385
+ const verdicts = new Map();
386
+ const availableMatches = matches.filter((candidate) => {
387
+ const healthReason = healthAdmissionReason(candidate, { healthStore, healthGate, tombstones });
388
+ const peak = healthReason ? null : peakAdmission(schedule, candidate, selectionAt);
389
+ const reason = healthReason ?? (peak?.mode === 'block' ? MODEL_SELECTION_REASON_CODES.PEAK_RESTRICTED : null);
390
+ if (!reason) {
391
+ verdicts.set(modelRefKey(candidate), peak ?? null);
392
+ return true;
393
+ }
394
+ trace.ordered_candidates.push(candidateDecision(candidate, 'preferred-default', 'skipped', {
395
+ reasonCode: reason,
396
+ }));
397
+ return false;
398
+ });
399
+ if (availableMatches.length === 0) {
400
+ const blocked = matches.find((candidate) => healthAdmissionReason(candidate, { healthStore, healthGate, tombstones })
401
+ || peakAdmission(schedule, candidate, selectionAt)?.mode === 'block');
402
+ if (blocked && allowFallback === false) {
403
+ const reason = healthAdmissionReason(blocked, { healthStore, healthGate, tombstones })
404
+ ?? MODEL_SELECTION_REASON_CODES.PEAK_RESTRICTED;
405
+ return blockedSelection(trace, blocked, 'preferred-default', reason);
406
+ }
407
+ }
408
+ const preferredProvider = normalizeModelRef(harnessDefault)?.provider;
409
+ const deterministicMatch = availableMatches.find((candidate) => candidate.provider === preferredProvider) ?? null;
410
+ // Filtering can leave exactly one admissible candidate while removing the
411
+ // one that matched the Harness Default provider, which leaves both the
412
+ // deterministic match and the adaptive choice empty. Ambiguity only means
413
+ // something with two or more candidates left, so a lone survivor is picked
414
+ // outright rather than reported as ambiguous and dropped.
415
+ const soleMatch = availableMatches.length === 1 ? availableMatches[0] : null;
416
+ const baseline = deterministicMatch
417
+ ? [deterministicMatch, ...availableMatches.filter((candidate) => candidate.provider !== deterministicMatch.provider)]
418
+ : availableMatches;
419
+ // Ranking still runs for a lone survivor so `trace.adaptive` reflects the
420
+ // configured policy in this shape too; the result is not consulted, because
421
+ // ranking orders candidates rather than admitting them and must never
422
+ // discard the only one that passed admission.
423
+ const ranked = rankForTrace(trace, soleMatch ? [soleMatch] : baseline, { adaptive, adaptiveHealth });
424
+ const adaptiveChoice = !soleMatch && ranked?.trace.decision_supported ? ranked.candidates[0] : null;
425
+ const match = soleMatch ?? adaptiveChoice ?? deterministicMatch;
426
+ if (match) {
427
+ const decisionOrder = soleMatch ? [] : adaptiveChoice ? ranked.candidates : availableMatches;
428
+ for (const candidate of decisionOrder) {
356
429
  if (candidate.provider === match.provider && candidate.model === match.model) continue;
357
430
  trace.ordered_candidates.push(candidateDecision(candidate, 'preferred-default', 'skipped', {
358
431
  reasonCode: adaptiveChoice
@@ -360,10 +433,21 @@ export function resolveWorkerModel({
360
433
  : MODEL_SELECTION_REASON_CODES.PREFERRED_MODEL_AMBIGUOUS,
361
434
  }));
362
435
  }
363
- selectTrace(trace, match, 'preferred-default');
364
- return { ok: true, ...match, source: 'preferred-default', selection_trace: trace };
436
+ const verdict = verdicts.get(modelRefKey(match)) ?? null;
437
+ selectTrace(trace, match, 'preferred-default', {
438
+ reasonCode: verdict?.mode === 'warn' ? MODEL_SELECTION_REASON_CODES.PEAK_ADVISORY : null,
439
+ });
440
+ return {
441
+ ok: true,
442
+ ...match,
443
+ source: 'preferred-default',
444
+ ...(verdict?.mode === 'warn' ? { peak_advisory: true } : {}),
445
+ selection_trace: trace,
446
+ };
365
447
  }
366
- for (const candidate of matches) {
448
+ // Only the candidates still in play can be ambiguous; one already rejected
449
+ // for a concrete reason keeps that reason and appears once.
450
+ for (const candidate of availableMatches) {
367
451
  trace.ordered_candidates.push(candidateDecision(candidate, 'preferred-default', 'skipped', {
368
452
  reasonCode: MODEL_SELECTION_REASON_CODES.PREFERRED_MODEL_AMBIGUOUS,
369
453
  }));
@@ -385,37 +469,52 @@ export function resolveWorkerModel({
385
469
 
386
470
  if (fallback === 'harness-default') {
387
471
  const defaultRef = normalizeModelRef(harnessDefault);
472
+ // The Harness Default is often also a priority or preferred candidate, and
473
+ // that candidate has already been judged. Recording it a second time with a
474
+ // different reason would make the trace contradict itself, so a ref that
475
+ // already has a verdict keeps it.
476
+ const alreadyJudged = defaultRef
477
+ ? trace.ordered_candidates.some((row) => row.provider === defaultRef.provider && row.model === defaultRef.model)
478
+ : false;
388
479
  if (!defaultRef) {
389
480
  trace.ordered_candidates.push(candidateDecision(null, 'harness-default', 'skipped', {
390
481
  reasonCode: MODEL_SELECTION_REASON_CODES.HARNESS_DEFAULT_INVALID,
391
482
  }));
483
+ } else if (alreadyJudged) {
484
+ // Keep its existing verdict; nothing further to record.
392
485
  } else if (!providers.has(defaultRef.provider)) {
393
486
  trace.ordered_candidates.push(candidateDecision(defaultRef, 'harness-default', 'skipped', {
394
487
  reasonCode: MODEL_SELECTION_REASON_CODES.HARNESS_DEFAULT_PROVIDER_UNAVAILABLE,
395
488
  }));
396
489
  } else {
397
- const fallbackReason = traceContext.candidateSet === 'escalation'
398
- ? MODEL_SELECTION_REASON_CODES.ESCALATION_CANDIDATES_EXHAUSTED
399
- : MODEL_SELECTION_REASON_CODES.PRIMARY_CANDIDATES_EXHAUSTED;
400
- const healthReason = healthAdmissionReason(defaultRef, { healthStore, healthGate, tombstones });
401
- if (healthReason) {
402
- trace.ordered_candidates.push(candidateDecision(defaultRef, 'harness-default', 'skipped', {
403
- reasonCode: healthReason,
404
- }));
405
- if (allowFallback === false) return blockedSelection(trace, defaultRef, 'harness-default', healthReason);
406
- } else {
407
- selectTrace(trace, defaultRef, 'harness-default', { fallbackReason });
408
- return {
490
+ const fallbackReason = traceContext.candidateSet === 'escalation'
491
+ ? MODEL_SELECTION_REASON_CODES.ESCALATION_CANDIDATES_EXHAUSTED
492
+ : MODEL_SELECTION_REASON_CODES.PRIMARY_CANDIDATES_EXHAUSTED;
493
+ const healthReason = healthAdmissionReason(defaultRef, { healthStore, healthGate, tombstones });
494
+ const peak = healthReason ? null : peakAdmission(schedule, defaultRef, selectionAt);
495
+ const blockReason = healthReason ?? (peak?.mode === 'block' ? MODEL_SELECTION_REASON_CODES.PEAK_RESTRICTED : null);
496
+ if (blockReason) {
497
+ trace.ordered_candidates.push(candidateDecision(defaultRef, 'harness-default', 'skipped', {
498
+ reasonCode: blockReason,
499
+ }));
500
+ if (allowFallback === false) return blockedSelection(trace, defaultRef, 'harness-default', blockReason);
501
+ } else {
502
+ selectTrace(trace, defaultRef, 'harness-default', {
503
+ fallbackReason,
504
+ reasonCode: peak?.mode === 'warn' ? MODEL_SELECTION_REASON_CODES.PEAK_ADVISORY : null,
505
+ });
506
+ return {
409
507
  ok: true,
410
508
  ...defaultRef,
411
509
  source: 'harness-default',
510
+ ...(peak?.mode === 'warn' ? { peak_advisory: true } : {}),
412
511
  ...(typeof harnessDefault.reasoningEffort === 'string' && harnessDefault.reasoningEffort
413
512
  ? { reasoningEffort: harnessDefault.reasoningEffort }
414
513
  : {}),
415
- selection_trace: trace,
416
- };
417
- }
418
- }
514
+ selection_trace: trace,
515
+ };
516
+ }
517
+ }
419
518
  }
420
519
  trace.fallback_reason = MODEL_SELECTION_REASON_CODES.NO_AVAILABLE_MODEL;
421
520
  return {
@@ -435,18 +534,20 @@ export function resolveWorkerModel({
435
534
  * The output shape matches resolveWorkerModel (provider/model/source/...) plus
436
535
  * role + attempt so selection provenance lands in job metadata.
437
536
  */
438
- export function resolveModel({
537
+ export function resolveModel({
439
538
  role = 'worker',
440
539
  attempt = 0,
441
540
  policy,
442
541
  catalog,
443
542
  harnessDefault,
444
- adaptiveHealth,
445
- healthStore,
446
- healthGate,
447
- allowFallback = true,
448
- tombstones,
449
- } = {}) {
543
+ adaptiveHealth,
544
+ healthStore,
545
+ healthGate,
546
+ allowFallback = true,
547
+ tombstones,
548
+ schedule,
549
+ at,
550
+ } = {}) {
450
551
  const p = policy && typeof policy === 'object' ? policy : {};
451
552
  const escalated = Number.isInteger(attempt) && attempt > 0;
452
553
  const candidates = escalated ? p.escalation_priority : p.priority;
@@ -464,12 +565,14 @@ export function resolveModel({
464
565
  harnessDefault,
465
566
  fallback: p.fallback === 'harness-default' ? 'harness-default' : 'harness-default',
466
567
  preferredModelId,
467
- adaptive: p.adaptive,
468
- adaptiveHealth,
469
- healthStore,
470
- healthGate,
471
- allowFallback,
472
- tombstones,
568
+ adaptive: p.adaptive,
569
+ adaptiveHealth,
570
+ healthStore,
571
+ healthGate,
572
+ allowFallback,
573
+ tombstones,
574
+ schedule,
575
+ at,
473
576
  traceContext: {
474
577
  role,
475
578
  logicalAttempt: attempt,