@ran-sh/dsh-crew 0.5.6 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +14 -7
  2. package/README.zh.md +12 -7
  3. package/docs/installation.md +1 -1
  4. package/docs/ui-surfaces.md +65 -46
  5. package/lib/client.js +345 -22
  6. package/official-web-bridge/lib/client.js +345 -22
  7. package/package.json +1 -1
  8. package/src/client/collapsible-sections.mjs +3 -2
  9. package/src/client/index.tsx +227 -51
  10. package/src/config-readiness.mjs +56 -5
  11. package/src/extension-contract.mjs +9 -5
  12. package/src/hub/index.mjs +854 -445
  13. package/src/hub-client.mjs +24 -4
  14. package/src/hub-compatibility.mjs +23 -7
  15. package/src/install/install.mjs +4 -4
  16. package/src/install/npx-lifecycle.mjs +263 -118
  17. package/src/job-contracts.mjs +19 -3
  18. package/src/jobs.mjs +2 -4
  19. package/src/local-request-guard.mjs +60 -0
  20. package/src/mcp-runtime.mjs +7 -5
  21. package/src/model-routing.mjs +141 -49
  22. package/src/multimodal.mjs +0 -0
  23. package/src/official-web-bridge.mjs +242 -84
  24. package/src/policy.mjs +62 -31
  25. package/src/provider-config-scrub.mjs +71 -0
  26. package/src/provider-delete-adapters.mjs +424 -0
  27. package/src/provider-health.mjs +124 -0
  28. package/src/provider-inventory.mjs +132 -0
  29. package/src/provider-lifecycle-state.mjs +81 -0
  30. package/src/provider-lifecycle.mjs +214 -0
  31. package/src/provider-profile-store.mjs +171 -0
  32. package/src/readiness-matrix.mjs +11 -5
  33. package/src/removable-waiter.mjs +29 -0
  34. package/src/runtime-identity.mjs +43 -13
  35. package/src/server.mjs +34 -16
  36. package/src/status-shard.mjs +13 -2
  37. package/src/workflow-runtime.mjs +44 -10
  38. package/windows/start-dsh-crew.cmd +3 -3
  39. package/windows/start-dsh-crew.ps1 +49 -5
@@ -119,7 +119,7 @@ function compactSelectionTrace(attempts) {
119
119
  });
120
120
  }
121
121
 
122
- function changedFilesFromView(view) {
122
+ function changedFilesFromView(view) {
123
123
  if (Array.isArray(view?.candidate?.changed_files)) return view.candidate.changed_files;
124
124
  const changes = view?.workspace_diff?.changes;
125
125
  if (!changes || typeof changes !== 'object') return [];
@@ -129,7 +129,22 @@ function changedFilesFromView(view) {
129
129
  ...(Array.isArray(changes.renamed) ? changes.renamed : []),
130
130
  ...(Array.isArray(changes.untracked) ? changes.untracked : []),
131
131
  ])];
132
- }
132
+ }
133
+
134
+ function boundedExecutionContext(value) {
135
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
136
+ const executionPlane = boundedText(value.execution_plane, 64);
137
+ const profile = boundedText(value.profile, 64);
138
+ const runtimeId = boundedText(value.runtime_id, 128);
139
+ const port = Number.isInteger(value.listen_port) ? value.listen_port : null;
140
+ if (!executionPlane && !profile && !runtimeId && port === null) return null;
141
+ return {
142
+ execution_plane: executionPlane,
143
+ profile,
144
+ listen_port: port,
145
+ runtime_id: runtimeId,
146
+ };
147
+ }
133
148
 
134
149
  /**
135
150
  * Build the machine-first Result Contract for a workflow.
@@ -162,7 +177,8 @@ export function buildEvidenceEnvelope(view = {}) {
162
177
  delivery_complete: outcome.delivery?.complete === true,
163
178
  review_verdict: review?.verdict ?? null,
164
179
  },
165
- selection_trace: compactSelectionTrace(selectionAttempts),
180
+ selection_trace: compactSelectionTrace(selectionAttempts),
181
+ execution_context: boundedExecutionContext(view.execution_context ?? selectionAttempts[0]?.execution_context),
166
182
  changed_files: changedFiles,
167
183
  changes: boundedStrings(outcome.changes),
168
184
  tests: boundedTests(outcome.tests),
package/src/jobs.mjs CHANGED
@@ -12,6 +12,7 @@ import { createRequire } from 'node:module';
12
12
  import { appendDeliveryInstructions, parseDeliveryReport, formatDeliveryMetadata } from './delivery.mjs';
13
13
  import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from './workspace-audit.mjs';
14
14
  import { buildOutcome, JOB_PHASES } from './workflow.mjs';
15
+ import { raceWaiters } from './removable-waiter.mjs';
15
16
  import { buildDirectSelectionTrace } from './model-routing.mjs';
16
17
 
17
18
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -255,10 +256,7 @@ export async function waitJob(id, timeoutMs) {
255
256
  const job = jobs.get(id);
256
257
  if (!job) throw new Error(`no such job: ${id}`);
257
258
  if (job.status !== 'running') return job;
258
- await Promise.race([
259
- new Promise((res) => job.waiters.push(res)),
260
- timeoutMs ? new Promise((res) => setTimeout(res, timeoutMs)) : new Promise(() => {}),
261
- ]);
259
+ await raceWaiters(job.waiters, { timeoutMs });
262
260
  return job;
263
261
  }
264
262
 
@@ -0,0 +1,60 @@
1
+ // Shared local-request trust primitives for the two loopback surfaces:
2
+ // the isolated 3210 hub API (src/hub/index.mjs) and the 3080→3210
3
+ // official-web bridge (src/official-web-bridge.mjs). One implementation keeps
4
+ // the two guards from drifting apart again.
5
+ //
6
+ // Trust model (see docs/ui-surfaces.md): both surfaces are loopback-only and
7
+ // have no bearer token by design — every process running as the local user is
8
+ // inside the trust boundary. The guards exist to keep off-machine browsers,
9
+ // DNS-rebinding pages, and cross-machine proxies out, not to authenticate
10
+ // local users. `localRequestCore` proves the peer is this machine (loopback
11
+ // socket, local Host header, browser fetch-metadata pass); each surface then
12
+ // layers its own Origin policy on top — the hub accepts any loopback origin
13
+ // because the 3080 panel calls it cross-port, while the bridge requires an
14
+ // exact authority match because it only serves its own same-origin client.
15
+
16
+ function isIpv4Loopback(a) { return /^127(\.\d{1,3}){3}$/.test(a); }
17
+
18
+ export function isLoopbackAddress(address) {
19
+ if (!address) return false;
20
+ const n = address.toLowerCase().split('%', 1)[0];
21
+ if (n === '::1' || isIpv4Loopback(n)) return true;
22
+ if (!n.startsWith('::ffff:')) return false;
23
+ return isIpv4Loopback(n.slice(7));
24
+ }
25
+
26
+ export function isLocalHostname(hostname) {
27
+ return hostname === 'localhost' || hostname === '::1' || hostname === '[::1]' || isIpv4Loopback(hostname);
28
+ }
29
+
30
+ /**
31
+ * Core of both guards: loopback peer, loopback Host header, and (when the
32
+ * client is a browser) a same-origin/none fetch-metadata request. Returns
33
+ * false for anything that cannot be proven local.
34
+ */
35
+ export function localRequestCore(req) {
36
+ if (!isLoopbackAddress(req?.socket?.remoteAddress)) return false;
37
+ const host = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '';
38
+ if (!host) return false;
39
+ let authority;
40
+ try { authority = new URL(`http://${host}`); } catch { return false; }
41
+ if (!isLocalHostname(authority.hostname.toLowerCase())) return false;
42
+ const fetchSite = String(req?.headers?.['sec-fetch-site'] ?? '').toLowerCase();
43
+ if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false;
44
+ return true;
45
+ }
46
+
47
+ /** Hub Origin policy: an Origin, when present, must name a loopback host. */
48
+ export function originLoopback(originHeader) {
49
+ if (typeof originHeader !== 'string') return false;
50
+ let parsed;
51
+ try { parsed = new URL(originHeader); } catch { return false; }
52
+ return isLocalHostname(parsed.hostname.toLowerCase());
53
+ }
54
+
55
+ /** Bridge Origin policy: the Origin authority must equal the request authority. */
56
+ export function originAuthorityMatches(originHeader, host) {
57
+ if (!originLoopback(originHeader)) return false;
58
+ const parsed = new URL(originHeader);
59
+ return parsed.host.toLowerCase() === String(host).trim().toLowerCase();
60
+ }
@@ -69,8 +69,9 @@ export function attemptFromView(view, spec) {
69
69
  modelClassHint: spec.model_class_hint ?? null,
70
70
  strategy: source ?? 'transport-selection',
71
71
  candidateSet: logicalAttempt > 0 ? 'escalation' : 'primary',
72
- provider: view?.provider ?? null,
73
- model: view?.model ?? null,
72
+ provider: view?.provider ?? null,
73
+ model: view?.model ?? null,
74
+ execution_context: view?.execution_context ?? null,
74
75
  source: source ?? 'transport-selection',
75
76
  escalationReason: spec.escalation_reason ?? null,
76
77
  });
@@ -86,9 +87,10 @@ export function attemptFromView(view, spec) {
86
87
  // `view.attempt` may be an adapter routing-attempt (see below); the
87
88
  // workflow's logical attempt number is always the spec value.
88
89
  attempt: logicalAttempt,
89
- provider: view?.provider ?? null,
90
- model: view?.model ?? null,
91
- selection_source: source,
90
+ provider: view?.provider ?? null,
91
+ model: view?.model ?? null,
92
+ execution_context: view?.execution_context ?? null,
93
+ selection_source: source,
92
94
  selection_trace: selectionTrace,
93
95
  status: view?.status ?? 'failed',
94
96
  result: view?.result ?? null,
@@ -26,13 +26,36 @@ export const MODEL_SELECTION_REASON_CODES = Object.freeze({
26
26
  PROVIDER_UNAVAILABLE: 'PROVIDER_UNAVAILABLE',
27
27
  PREFERRED_MODEL_UNAVAILABLE: 'PREFERRED_MODEL_UNAVAILABLE',
28
28
  PREFERRED_MODEL_AMBIGUOUS: 'PREFERRED_MODEL_AMBIGUOUS',
29
- ADAPTIVE_DEPRIORITIZED: 'ADAPTIVE_DEPRIORITIZED',
30
- HARNESS_DEFAULT_INVALID: 'HARNESS_DEFAULT_INVALID',
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',
31
37
  HARNESS_DEFAULT_PROVIDER_UNAVAILABLE: 'HARNESS_DEFAULT_PROVIDER_UNAVAILABLE',
32
38
  PRIMARY_CANDIDATES_EXHAUSTED: 'PRIMARY_CANDIDATES_EXHAUSTED',
33
39
  ESCALATION_CANDIDATES_EXHAUSTED: 'ESCALATION_CANDIDATES_EXHAUSTED',
34
- NO_AVAILABLE_MODEL: 'NO_AVAILABLE_MODEL',
35
- });
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
+ });
36
59
 
37
60
  export function normalizeModelRef(raw) {
38
61
  if (!raw || typeof raw !== 'object') return null;
@@ -101,7 +124,7 @@ function traceBase({
101
124
  };
102
125
  }
103
126
 
104
- function candidateDecision(ref, source, status, { reasonCode, advertised } = {}) {
127
+ function candidateDecision(ref, source, status, { reasonCode, advertised } = {}) {
105
128
  return {
106
129
  provider: ref?.provider ?? null,
107
130
  model: ref?.model ?? null,
@@ -110,7 +133,25 @@ function candidateDecision(ref, source, status, { reasonCode, advertised } = {})
110
133
  ...(reasonCode ? { reason_code: reasonCode } : {}),
111
134
  ...(advertised === false ? { advertised: false } : {}),
112
135
  };
113
- }
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
+ }
114
155
 
115
156
  function selectTrace(trace, ref, source, { advertised, fallbackReason } = {}) {
116
157
  trace.ordered_candidates.push(candidateDecision(ref, source, 'selected', { advertised }));
@@ -198,7 +239,7 @@ export function enrichSelectionTrace(trace, {
198
239
  return base;
199
240
  }
200
241
 
201
- export function resolveWorkerModel({
242
+ export function resolveWorkerModel({
202
243
  tier,
203
244
  priority,
204
245
  priorityConfigured = false,
@@ -207,9 +248,13 @@ export function resolveWorkerModel({
207
248
  fallback = 'harness-default',
208
249
  preferredModelId = DEFAULT_TIER_MODEL_PREFERENCES[tier],
209
250
  traceContext = {},
210
- adaptive,
211
- adaptiveHealth,
212
- } = {}) {
251
+ adaptive,
252
+ adaptiveHealth,
253
+ healthStore,
254
+ healthGate,
255
+ allowFallback = true,
256
+ tombstones,
257
+ } = {}) {
213
258
  const providers = providerMap(catalog);
214
259
  const normalizedPriority = normalizeModelPriority(priority);
215
260
  const prioritySource = typeof traceContext.prioritySource === 'string' && traceContext.prioritySource
@@ -230,17 +275,25 @@ export function resolveWorkerModel({
230
275
  rankForTrace(trace, normalizedPriority, { adaptive, adaptiveHealth, explicitPriority: true });
231
276
  }
232
277
 
233
- for (let index = 0; index < normalizedPriority.length; index++) {
234
- const ref = normalizedPriority[index];
235
- const provider = providers.get(ref.provider);
278
+ for (let index = 0; index < normalizedPriority.length; index++) {
279
+ const ref = normalizedPriority[index];
280
+ const provider = providers.get(ref.provider);
236
281
  if (!provider) {
237
282
  trace.ordered_candidates.push(candidateDecision(ref, prioritySource, 'skipped', {
238
283
  reasonCode: MODEL_SELECTION_REASON_CODES.PROVIDER_UNAVAILABLE,
239
- }));
240
- continue;
241
- }
242
- const advertised = (provider.models ?? []).some((model) => model?.id === ref.model);
243
- selectTrace(trace, ref, prioritySource, { advertised });
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 });
244
297
  return {
245
298
  ok: true,
246
299
  ...ref,
@@ -259,24 +312,47 @@ export function resolveWorkerModel({
259
312
  if ((provider.models ?? []).some((model) => model?.id === preferredModelId)) {
260
313
  matches.push({ provider: provider.id, model: preferredModelId });
261
314
  }
262
- }
263
- if (matches.length === 1) {
264
- rankForTrace(trace, matches, { adaptive, adaptiveHealth });
265
- selectTrace(trace, matches[0], 'preferred-default');
266
- return { ok: true, ...matches[0], source: 'preferred-default', selection_trace: trace };
267
- }
268
- if (matches.length > 1) {
269
- const preferredProvider = normalizeModelRef(harnessDefault)?.provider;
270
- const deterministicMatch = matches.find((candidate) => candidate.provider === preferredProvider) ?? null;
271
- const baseline = deterministicMatch
272
- ? [deterministicMatch, ...matches.filter((candidate) => candidate.provider !== deterministicMatch.provider)]
273
- : matches;
274
- const ranked = rankForTrace(trace, baseline, { adaptive, adaptiveHealth });
275
- const adaptiveChoice = ranked.trace.decision_supported ? ranked.candidates[0] : null;
276
- const match = adaptiveChoice ?? deterministicMatch;
277
- if (match) {
278
- const decisionOrder = adaptiveChoice ? ranked.candidates : matches;
279
- for (const candidate of decisionOrder) {
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) {
280
356
  if (candidate.provider === match.provider && candidate.model === match.model) continue;
281
357
  trace.ordered_candidates.push(candidateDecision(candidate, 'preferred-default', 'skipped', {
282
358
  reasonCode: adaptiveChoice
@@ -318,20 +394,28 @@ export function resolveWorkerModel({
318
394
  reasonCode: MODEL_SELECTION_REASON_CODES.HARNESS_DEFAULT_PROVIDER_UNAVAILABLE,
319
395
  }));
320
396
  } else {
321
- const fallbackReason = traceContext.candidateSet === 'escalation'
322
- ? MODEL_SELECTION_REASON_CODES.ESCALATION_CANDIDATES_EXHAUSTED
323
- : MODEL_SELECTION_REASON_CODES.PRIMARY_CANDIDATES_EXHAUSTED;
324
- selectTrace(trace, defaultRef, 'harness-default', { fallbackReason });
325
- return {
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 {
326
409
  ok: true,
327
410
  ...defaultRef,
328
411
  source: 'harness-default',
329
412
  ...(typeof harnessDefault.reasoningEffort === 'string' && harnessDefault.reasoningEffort
330
413
  ? { reasoningEffort: harnessDefault.reasoningEffort }
331
414
  : {}),
332
- selection_trace: trace,
333
- };
334
- }
415
+ selection_trace: trace,
416
+ };
417
+ }
418
+ }
335
419
  }
336
420
  trace.fallback_reason = MODEL_SELECTION_REASON_CODES.NO_AVAILABLE_MODEL;
337
421
  return {
@@ -351,14 +435,18 @@ export function resolveWorkerModel({
351
435
  * The output shape matches resolveWorkerModel (provider/model/source/...) plus
352
436
  * role + attempt so selection provenance lands in job metadata.
353
437
  */
354
- export function resolveModel({
438
+ export function resolveModel({
355
439
  role = 'worker',
356
440
  attempt = 0,
357
441
  policy,
358
442
  catalog,
359
443
  harnessDefault,
360
- adaptiveHealth,
361
- } = {}) {
444
+ adaptiveHealth,
445
+ healthStore,
446
+ healthGate,
447
+ allowFallback = true,
448
+ tombstones,
449
+ } = {}) {
362
450
  const p = policy && typeof policy === 'object' ? policy : {};
363
451
  const escalated = Number.isInteger(attempt) && attempt > 0;
364
452
  const candidates = escalated ? p.escalation_priority : p.priority;
@@ -376,8 +464,12 @@ export function resolveModel({
376
464
  harnessDefault,
377
465
  fallback: p.fallback === 'harness-default' ? 'harness-default' : 'harness-default',
378
466
  preferredModelId,
379
- adaptive: p.adaptive,
380
- adaptiveHealth,
467
+ adaptive: p.adaptive,
468
+ adaptiveHealth,
469
+ healthStore,
470
+ healthGate,
471
+ allowFallback,
472
+ tombstones,
381
473
  traceContext: {
382
474
  role,
383
475
  logicalAttempt: attempt,
Binary file