@lsctech/polaris 0.4.9 → 0.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.
@@ -46,6 +46,71 @@ function resolveCommand(cmd) {
46
46
  return false;
47
47
  }
48
48
  }
49
+ function isQuotaExhaustedSignal(...parts) {
50
+ const text = parts.filter((value) => typeof value === "string" && value.length > 0).join("\n").toLowerCase();
51
+ if (!text)
52
+ return false;
53
+ return (text.includes("quota") ||
54
+ text.includes("rate limit") ||
55
+ text.includes("429") ||
56
+ text.includes("resource exhausted") ||
57
+ text.includes("insufficient_quota"));
58
+ }
59
+ function hasWorkerExecutionEvidence(packet, resultFilePath) {
60
+ if (resultFilePath && node_fs_1.default.existsSync(resultFilePath))
61
+ return true;
62
+ if (!packet.telemetry_file || !packet.active_child || !node_fs_1.default.existsSync(packet.telemetry_file))
63
+ return false;
64
+ try {
65
+ const telemetry = node_fs_1.default.readFileSync(packet.telemetry_file, "utf-8").trim();
66
+ if (!telemetry)
67
+ return false;
68
+ const lines = telemetry.split("\n");
69
+ const packetDispatchId = packet.dispatch_id;
70
+ // Scan backwards: when both the packet and the parsed event carry a dispatch_id,
71
+ // use that as the primary scope gate so stale events from a previous attempt are
72
+ // never attributed to the current dispatch. Fall back to the child-dispatched
73
+ // boundary for older telemetry that lacks dispatch_id on individual events.
74
+ for (let i = lines.length - 1; i >= 0; i--) {
75
+ const line = lines[i]?.trim();
76
+ if (!line)
77
+ continue;
78
+ try {
79
+ const parsed = JSON.parse(line);
80
+ if (parsed.child_id !== packet.active_child)
81
+ continue;
82
+ if (parsed.event === "worker-acknowledged" ||
83
+ parsed.event === "worker-heartbeat" ||
84
+ parsed.event === "worker-result") {
85
+ const eventDispatchId = parsed.dispatch_id;
86
+ if (packetDispatchId && eventDispatchId) {
87
+ // Primary gate: dispatch_id available on both sides — match determines
88
+ // whether this event belongs to the current dispatch.
89
+ if (eventDispatchId === packetDispatchId)
90
+ return true;
91
+ // Mismatch — event belongs to a different dispatch; keep scanning.
92
+ continue;
93
+ }
94
+ // Fallback: dispatch_id not available on one or both sides — accept the
95
+ // event as evidence for the current dispatch (scoped by the
96
+ // child-dispatched boundary below).
97
+ return true;
98
+ }
99
+ // Boundary fallback: stop at the child-dispatched event so events from a
100
+ // prior attempt are not counted when dispatch_id matching is unavailable.
101
+ if (parsed.event === "child-dispatched")
102
+ break;
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ }
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ return false;
113
+ }
49
114
  class TerminalCliAdapter {
50
115
  config;
51
116
  name = 'terminal-cli';
@@ -102,6 +167,8 @@ class TerminalCliAdapter {
102
167
  }
103
168
  async dispatch(packet, options) {
104
169
  const primaryProvider = options.provider || "terminal-cli";
170
+ const routerEvidence = options.routerDecision;
171
+ const providerAttempts = [];
105
172
  if ((0, worker_packet_js_1.isWorkerPacket)(packet) && packet.worker_role === "impl") {
106
173
  const allowed = Array.isArray(packet.instructions?.allowed_scope) ? packet.instructions.allowed_scope : [];
107
174
  if (allowed.length === 0) {
@@ -119,6 +186,20 @@ class TerminalCliAdapter {
119
186
  }),
120
187
  stderr: blockedMsg,
121
188
  pre_dispatch_failure: true,
189
+ failure_origin: "provider-launch",
190
+ failure_category: "launch-error",
191
+ fallback_eligible: false,
192
+ router_evidence: routerEvidence,
193
+ provider_attempts: [
194
+ {
195
+ provider: primaryProvider,
196
+ failure_origin: "provider-launch",
197
+ failure_category: "launch-error",
198
+ pre_dispatch_failure: true,
199
+ fallback_eligible: false,
200
+ message: blockedMsg,
201
+ },
202
+ ],
122
203
  };
123
204
  }
124
205
  }
@@ -132,11 +213,15 @@ class TerminalCliAdapter {
132
213
  const policyProviders = (canFallback && Array.isArray(workerPolicy?.providers))
133
214
  ? workerPolicy.providers
134
215
  : [];
216
+ const routerProviders = canFallback && Array.isArray(routerEvidence?.providersTried)
217
+ ? routerEvidence.providersTried
218
+ : [];
219
+ const fallbackOrder = routerProviders.length > 0 ? routerProviders : policyProviders;
135
220
  const providersToTry = [
136
221
  { name: primaryProvider, cfg: primaryCfg, isPrimary: true },
137
222
  ];
138
223
  if (canFallback) {
139
- for (const p of policyProviders) {
224
+ for (const p of fallbackOrder) {
140
225
  if (p !== primaryProvider && p in (this.config.providers ?? {})) {
141
226
  try {
142
227
  providersToTry.push({ name: p, cfg: this.getProvider(p), isPrimary: false });
@@ -167,12 +252,30 @@ class TerminalCliAdapter {
167
252
  provider_used: provider,
168
253
  command_run: provider,
169
254
  stderr: err instanceof Error ? err.message : String(err),
255
+ pre_dispatch_failure: true,
256
+ failure_origin: "provider-launch",
257
+ failure_category: "launch-error",
258
+ fallback_eligible: true,
259
+ router_evidence: routerEvidence,
170
260
  };
261
+ providerAttempts.push({
262
+ provider,
263
+ failure_origin: "provider-launch",
264
+ failure_category: "launch-error",
265
+ pre_dispatch_failure: true,
266
+ fallback_eligible: true,
267
+ message: lastResult.stderr,
268
+ });
171
269
  continue;
172
270
  }
173
271
  const commandLine = formatCommandLine(command, args);
174
272
  if (options.dryRun) {
175
- return this.dryRun(provider, command, args, commandLine, packet, packetFile, workerPrompt);
273
+ const dryRun = this.dryRun(provider, command, args, commandLine, packet, packetFile, workerPrompt);
274
+ return {
275
+ ...dryRun,
276
+ router_evidence: routerEvidence,
277
+ provider_attempts: providerAttempts,
278
+ };
176
279
  }
177
280
  if (!resolveCommand(command)) {
178
281
  lastResult = {
@@ -181,28 +284,108 @@ class TerminalCliAdapter {
181
284
  command_run: commandLine,
182
285
  stderr: `Provider command "${command}" not found on PATH. ` +
183
286
  `Install it or update the "command" field for provider "${provider}" in polaris.config.json.`,
287
+ pre_dispatch_failure: true,
288
+ failure_origin: "provider-launch",
289
+ failure_category: "provider-unavailable",
290
+ fallback_eligible: true,
291
+ router_evidence: routerEvidence,
292
+ };
293
+ providerAttempts.push({
294
+ provider,
295
+ failure_origin: "provider-launch",
296
+ failure_category: "provider-unavailable",
297
+ pre_dispatch_failure: true,
298
+ fallback_eligible: true,
299
+ message: lastResult.stderr,
300
+ });
301
+ continue;
302
+ }
303
+ let result;
304
+ try {
305
+ result = await this.runProcess(command, args, commandLine, packet, packetFile, provider, workerPrompt);
306
+ }
307
+ catch (err) {
308
+ const message = err instanceof Error ? err.message : String(err);
309
+ lastResult = {
310
+ exit_code: 1,
311
+ provider_used: provider,
312
+ command_run: commandLine,
313
+ stderr: message,
314
+ pre_dispatch_failure: true,
315
+ failure_origin: "provider-launch",
316
+ failure_category: "provider-unavailable",
317
+ fallback_eligible: true,
318
+ router_evidence: routerEvidence,
184
319
  };
320
+ providerAttempts.push({
321
+ provider,
322
+ failure_origin: "provider-launch",
323
+ failure_category: "provider-unavailable",
324
+ pre_dispatch_failure: true,
325
+ fallback_eligible: true,
326
+ message,
327
+ });
185
328
  continue;
186
329
  }
187
- const result = await this.runProcess(command, args, commandLine, packet, packetFile, provider, workerPrompt);
188
330
  // Worker succeeded — return immediately.
189
331
  if (result.exit_code === 0) {
190
- return result;
332
+ return {
333
+ ...result,
334
+ router_evidence: routerEvidence,
335
+ provider_attempts: providerAttempts,
336
+ };
191
337
  }
192
- // If the worker wrote a result file it actually ran (and failed) — don't
193
- // retry with another provider; that would double-dispatch the same child.
194
338
  const resultFilePath = (0, worker_packet_js_1.isWorkerPacket)(packet) ? packet.result_file_contract?.result_file : undefined;
195
- if (resultFilePath && node_fs_1.default.existsSync(resultFilePath)) {
196
- return result;
339
+ const workerStarted = hasWorkerExecutionEvidence(packet, resultFilePath);
340
+ if (workerStarted) {
341
+ return {
342
+ ...result,
343
+ failure_origin: "worker-execution",
344
+ failure_category: "worker-failure",
345
+ fallback_eligible: false,
346
+ router_evidence: routerEvidence,
347
+ provider_attempts: providerAttempts,
348
+ };
197
349
  }
198
- // Worker did not start (wrong args, command not found, etc.) — try next.
199
- lastResult = result;
350
+ const category = isQuotaExhaustedSignal(result.stderr, result.stdout, result.summary)
351
+ ? "quota-exhausted"
352
+ : "provider-unavailable";
353
+ const classified = {
354
+ ...result,
355
+ pre_dispatch_failure: true,
356
+ failure_origin: "provider-launch",
357
+ failure_category: category,
358
+ fallback_eligible: true,
359
+ router_evidence: routerEvidence,
360
+ };
361
+ providerAttempts.push({
362
+ provider,
363
+ failure_origin: "provider-launch",
364
+ failure_category: category,
365
+ pre_dispatch_failure: true,
366
+ fallback_eligible: true,
367
+ message: result.stderr ?? result.summary,
368
+ });
369
+ lastResult = classified;
200
370
  }
201
- return lastResult ?? {
371
+ const exhausted = lastResult ?? {
202
372
  exit_code: 1,
203
373
  provider_used: primaryProvider,
204
374
  command_run: '',
205
375
  stderr: 'All configured providers exhausted without starting a worker',
376
+ pre_dispatch_failure: true,
377
+ failure_origin: "provider-launch",
378
+ failure_category: "provider-unavailable",
379
+ fallback_eligible: false,
380
+ router_evidence: routerEvidence,
381
+ provider_attempts: providerAttempts,
382
+ };
383
+ return {
384
+ ...exhausted,
385
+ // All providers have been exhausted — no further fallback is possible.
386
+ fallback_eligible: false,
387
+ router_evidence: exhausted.router_evidence ?? routerEvidence,
388
+ provider_attempts: exhausted.provider_attempts ?? providerAttempts,
206
389
  };
207
390
  }
208
391
  finally {
@@ -269,6 +269,46 @@ function validateState(state) {
269
269
  }
270
270
  }
271
271
  }
272
+ if ("worker_pool_state" in s && s["worker_pool_state"] !== undefined) {
273
+ if (typeof s["worker_pool_state"] !== "object" || s["worker_pool_state"] === null || Array.isArray(s["worker_pool_state"])) {
274
+ errors.push("worker_pool_state must be an object");
275
+ }
276
+ else {
277
+ const pool = s["worker_pool_state"];
278
+ if (typeof pool["max_concurrent"] !== "number" ||
279
+ !Number.isInteger(pool["max_concurrent"]) ||
280
+ pool["max_concurrent"] < 1) {
281
+ errors.push("worker_pool_state.max_concurrent must be a positive integer");
282
+ }
283
+ if (!Array.isArray(pool["slot_claims"])) {
284
+ errors.push("worker_pool_state.slot_claims must be an array");
285
+ }
286
+ else {
287
+ for (const [index, claim] of pool["slot_claims"].entries()) {
288
+ if (typeof claim !== "object" || claim === null || Array.isArray(claim)) {
289
+ errors.push(`worker_pool_state.slot_claims[${index}] must be an object`);
290
+ continue;
291
+ }
292
+ const typed = claim;
293
+ if (typeof typed["child_id"] !== "string" || typed["child_id"].trim().length === 0) {
294
+ errors.push(`worker_pool_state.slot_claims[${index}].child_id must be a non-empty string`);
295
+ }
296
+ if (typed["provider"] !== null && typeof typed["provider"] !== "string") {
297
+ errors.push(`worker_pool_state.slot_claims[${index}].provider must be a string or null`);
298
+ }
299
+ if (typeof typed["claimed_at"] !== "string") {
300
+ errors.push(`worker_pool_state.slot_claims[${index}].claimed_at must be a string`);
301
+ }
302
+ if (typeof typed["expires_at"] !== "string") {
303
+ errors.push(`worker_pool_state.slot_claims[${index}].expires_at must be a string`);
304
+ }
305
+ if (typeof typed["selection_reason"] !== "string") {
306
+ errors.push(`worker_pool_state.slot_claims[${index}].selection_reason must be a string`);
307
+ }
308
+ }
309
+ }
310
+ }
311
+ }
272
312
  return errors;
273
313
  }
274
314
  function writeStateAtomic(stateFile, state) {
@@ -15,6 +15,7 @@ const body_parser_js_1 = require("./body-parser.js");
15
15
  const loader_js_1 = require("../config/loader.js");
16
16
  const store_js_1 = require("../cluster-state/store.js");
17
17
  const worker_prompt_js_1 = require("./worker-prompt.js");
18
+ const index_js_1 = require("./router/index.js");
18
19
  function resolveSimplicityMode(state, config) {
19
20
  if (state.simplicity_bypass)
20
21
  return "off";
@@ -23,7 +24,7 @@ function resolveSimplicityMode(state, config) {
23
24
  const dispatch_boundary_js_1 = require("./dispatch-boundary.js");
24
25
  const run_bootstrap_js_1 = require("./run-bootstrap.js");
25
26
  const ledger_js_1 = require("./ledger.js");
26
- const index_js_1 = require("../tracker/index.js");
27
+ const index_js_2 = require("../tracker/index.js");
27
28
  const lifecycle_transition_js_1 = require("../tracker/lifecycle-transition.js");
28
29
  const CLAIM_TTL_MS = 30 * 60 * 1000;
29
30
  /**
@@ -46,18 +47,51 @@ function roleContextToExecutionRole(role) {
46
47
  return "worker";
47
48
  }
48
49
  }
49
- function resolveProviderAndMode(options, role, config) {
50
- const defaultAdapter = "terminal-cli";
51
- if (options.provider) {
52
- return {
53
- provider: options.provider,
54
- mode: "direct-worker",
55
- adapter: defaultAdapter,
56
- selectionReason: "cli-provider-override",
57
- overrideSource: "dispatch-flag",
58
- providersTried: [options.provider],
59
- };
50
+ function roleContextToTaskType(roleContext) {
51
+ switch (roleContext?.role) {
52
+ case "analyst":
53
+ return "analyze";
54
+ case "librarian":
55
+ return "docs";
56
+ case "foreman":
57
+ return "startup";
58
+ case "worker":
59
+ default:
60
+ return "impl";
60
61
  }
62
+ }
63
+ function requiredCapabilitiesForTask(taskType) {
64
+ switch (taskType) {
65
+ case "startup":
66
+ return ["orchestration"];
67
+ case "analyze":
68
+ return ["analysis"];
69
+ case "impl":
70
+ return ["implementation"];
71
+ case "repair":
72
+ return ["repair"];
73
+ case "docs":
74
+ return ["docs"];
75
+ case "finalize":
76
+ return ["finalization"];
77
+ default:
78
+ return [];
79
+ }
80
+ }
81
+ function collectActiveProviderSlots(state) {
82
+ const counts = {};
83
+ for (const meta of Object.values(state.open_children_meta ?? {})) {
84
+ const dispatch = meta?.dispatch_record;
85
+ if (!dispatch?.provider)
86
+ continue;
87
+ if (dispatch.status !== "dispatched")
88
+ continue;
89
+ counts[dispatch.provider] = (counts[dispatch.provider] ?? 0) + 1;
90
+ }
91
+ return counts;
92
+ }
93
+ function resolveProviderAndMode(options, role, config, routerContext) {
94
+ const defaultAdapter = "terminal-cli";
61
95
  // Only catch config-load failures; policy violations must propagate to the caller.
62
96
  let loaded;
63
97
  let adapter = defaultAdapter;
@@ -78,6 +112,64 @@ function resolveProviderAndMode(options, role, config) {
78
112
  };
79
113
  }
80
114
  const exec = loaded.execution;
115
+ const providerRegistry = exec?.routerPolicy?.providerRegistry ?? {};
116
+ const compatibilityMode = Object.keys(providerRegistry).length === 0;
117
+ if (compatibilityMode) {
118
+ return resolveProviderAndModeLegacy(options, role, adapter, exec);
119
+ }
120
+ const decision = (0, index_js_1.decideWorkerRoute)({
121
+ role,
122
+ taskType: routerContext?.taskType ?? "impl",
123
+ adapter,
124
+ providerOverride: options.provider,
125
+ providers: Object.keys(exec?.providers ?? {}),
126
+ rotation: exec?.rotation ?? [],
127
+ rolePolicy: exec?.providerPolicy?.[role],
128
+ roleConfiguredProvider: exec?.roles?.[role]?.provider,
129
+ routerPolicy: exec?.routerPolicy,
130
+ constraints: {
131
+ minTrustTier: routerContext?.minTrustTier,
132
+ maxCostTier: routerContext?.maxCostTier,
133
+ disallowedQuotaPolicies: routerContext?.disallowedQuotaPolicies,
134
+ requiredCapabilities: routerContext?.requiredCapabilities,
135
+ },
136
+ runtime: {
137
+ activeSlotsByProvider: routerContext?.activeSlotsByProvider,
138
+ quotaAvailableByProvider: routerContext?.quotaAvailableByProvider,
139
+ },
140
+ compatibilityMode: false,
141
+ });
142
+ return {
143
+ provider: decision.selectedProvider,
144
+ mode: decision.mode,
145
+ adapter,
146
+ selectionReason: decision.selectionReason,
147
+ overrideSource: options.provider ? "dispatch-flag" : undefined,
148
+ providersTried: decision.providersTried,
149
+ exhaustedReason: decision.exhaustedReason,
150
+ routerEvidence: decision,
151
+ routerContextSnapshot: {
152
+ taskType: routerContext?.taskType ?? "impl",
153
+ requiredCapabilities: routerContext?.requiredCapabilities,
154
+ minTrustTier: routerContext?.minTrustTier,
155
+ maxCostTier: routerContext?.maxCostTier,
156
+ disallowedQuotaPolicies: routerContext?.disallowedQuotaPolicies,
157
+ activeSlotsByProvider: routerContext?.activeSlotsByProvider,
158
+ quotaAvailableByProvider: routerContext?.quotaAvailableByProvider,
159
+ },
160
+ };
161
+ }
162
+ function resolveProviderAndModeLegacy(options, role, adapter, exec) {
163
+ if (options.provider) {
164
+ return {
165
+ provider: options.provider,
166
+ mode: "direct-worker",
167
+ adapter,
168
+ selectionReason: "cli-provider-override",
169
+ overrideSource: "dispatch-flag",
170
+ providersTried: [options.provider],
171
+ };
172
+ }
81
173
  const rolePolicy = exec?.providerPolicy?.[role];
82
174
  const roleProviders = rolePolicy?.providers ?? [];
83
175
  const rotation = exec?.rotation ?? [];
@@ -326,6 +418,15 @@ function determineRuntimeState(mode) {
326
418
  return mode === "delegated" ? "delegated" : "packet-created";
327
419
  }
328
420
  function emitProviderSelected(telemetryFile, dispatchId, runId, childId, decision) {
421
+ const fallbackAttempts = decision.providersTried.map((provider, index) => {
422
+ const candidate = decision.routerEvidence?.candidates.find((entry) => entry.provider === provider);
423
+ return {
424
+ provider,
425
+ attempt_index: index + 1,
426
+ outcome: decision.provider === provider ? "selected" : "rejected",
427
+ rejection_reasons: candidate?.rejectionReasons ?? [],
428
+ };
429
+ });
329
430
  appendTelemetry(telemetryFile, {
330
431
  event: "provider-selected",
331
432
  event_id: (0, node_crypto_1.randomUUID)(),
@@ -336,10 +437,55 @@ function emitProviderSelected(telemetryFile, dispatchId, runId, childId, decisio
336
437
  selected_provider: decision.provider ?? null,
337
438
  selected_adapter: decision.adapter,
338
439
  selection_reason: decision.selectionReason,
440
+ ...(decision.routerEvidence
441
+ ? {
442
+ router_mode: decision.routerEvidence.mode,
443
+ router_task_type: decision.routerEvidence.selectedWorker.taskType,
444
+ router_compatibility_mode: decision.routerEvidence.compatibilityMode,
445
+ }
446
+ : {}),
447
+ ...(decision.routerContextSnapshot
448
+ ? {
449
+ router_score_inputs: {
450
+ required_capabilities: decision.routerContextSnapshot.requiredCapabilities,
451
+ min_trust_tier: decision.routerContextSnapshot.minTrustTier,
452
+ max_cost_tier: decision.routerContextSnapshot.maxCostTier,
453
+ disallowed_quota_policies: decision.routerContextSnapshot.disallowedQuotaPolicies,
454
+ active_slots_by_provider: decision.routerContextSnapshot.activeSlotsByProvider,
455
+ quota_available_by_provider: decision.routerContextSnapshot.quotaAvailableByProvider,
456
+ },
457
+ }
458
+ : {}),
459
+ ...(fallbackAttempts.length > 0 ? { fallback_attempts: fallbackAttempts } : {}),
339
460
  ...(decision.overrideSource ? { override_source: decision.overrideSource } : {}),
340
461
  ...(decision.fallbackFrom ? { fallback_from: decision.fallbackFrom } : {}),
341
462
  ...(decision.fallbackReason ? { fallback_reason: decision.fallbackReason } : {}),
342
463
  ...(decision.providersTried.length > 0 ? { providers_tried: decision.providersTried } : {}),
464
+ ...(decision.exhaustedReason ? { router_exhausted_reason: decision.exhaustedReason } : {}),
465
+ ...(decision.routerEvidence?.candidates?.length
466
+ ? {
467
+ router_candidates: decision.routerEvidence.candidates.map((candidate) => ({
468
+ provider: candidate.provider,
469
+ eligible: candidate.eligible,
470
+ rejection_reasons: candidate.rejectionReasons,
471
+ score: {
472
+ order: candidate.score.orderScore,
473
+ trust: candidate.score.trustScore,
474
+ cost: candidate.score.costScore,
475
+ total: candidate.score.total,
476
+ },
477
+ inputs: {
478
+ order_index: candidate.evidence.orderIndex,
479
+ trust_tier: candidate.evidence.trustTier,
480
+ cost_tier: candidate.evidence.costTier,
481
+ quota_policy: candidate.evidence.quotaPolicy,
482
+ active_slots: candidate.evidence.activeSlots,
483
+ slot_limit: candidate.evidence.slotLimit,
484
+ policy_matched: candidate.evidence.policyMatched,
485
+ },
486
+ })),
487
+ }
488
+ : {}),
343
489
  timestamp: new Date().toISOString(),
344
490
  });
345
491
  }
@@ -355,6 +501,10 @@ function emitProviderFallbackAttempted(telemetryFile, dispatchId, runId, childId
355
501
  requested_role: "worker",
356
502
  fallback_from: decision.fallbackFrom,
357
503
  fallback_reason: decision.fallbackReason,
504
+ ...(decision.provider ? { fallback_to: decision.provider } : {}),
505
+ ...(decision.providersTried.length > 0
506
+ ? { fallback_attempt_index: decision.providersTried.length }
507
+ : {}),
358
508
  ...(decision.providersTried.length > 0 ? { providers_tried: decision.providersTried } : {}),
359
509
  timestamp: new Date().toISOString(),
360
510
  });
@@ -664,7 +814,7 @@ function selectChild(state, requestedChild) {
664
814
  * @param resultFile - Optional path for the expected result file; non-absolute paths are resolved against `repoRoot`
665
815
  * @returns The constructed WorkerPacket ready for dispatch
666
816
  */
667
- function buildPacket(state, childId, stateFile, telemetryFile, repoRoot, resultFile, config) {
817
+ function buildPacket(state, childId, stateFile, telemetryFile, repoRoot, resultFile, maxConcurrentWorkers, config) {
668
818
  const childMeta = state.open_children_meta?.[childId];
669
819
  // Hydrate body from cluster snapshot when runtime state lacks it.
670
820
  const cachedBody = childMeta?.body;
@@ -697,7 +847,7 @@ function buildPacket(state, childId, stateFile, telemetryFile, repoRoot, resultF
697
847
  telemetryFile,
698
848
  issueContext,
699
849
  allowedScope: resolvedScope.length > 0 ? resolvedScope : undefined,
700
- maxConcurrentWorkers: 1,
850
+ maxConcurrentWorkers,
701
851
  promptMode: (0, worker_prompt_js_1.selectPromptMode)(childId, state),
702
852
  simplicityMode: resolveSimplicityMode(state, config),
703
853
  resultFile: canonicalPath(absoluteResultFile(repoRoot, resultFile)),
@@ -918,7 +1068,7 @@ function runLoopDispatch(options) {
918
1068
  step_cursor: "dispatch",
919
1069
  dispatch_boundary: (0, dispatch_boundary_js_1.advanceDispatchEpoch)(state.dispatch_boundary, childId),
920
1070
  };
921
- const packet = buildPacket(preliminaryState, childId, options.stateFile, telemetryFile, options.repoRoot, canonicalResultFile, loadedConfig);
1071
+ const packet = buildPacket(preliminaryState, childId, options.stateFile, telemetryFile, options.repoRoot, canonicalResultFile, loadedConfig?.execution?.routerPolicy?.defaultWorkerPool?.maxActiveWorkers ?? 1, loadedConfig);
922
1072
  const packetFailure = detectPacketGenerationFailure(packet);
923
1073
  if (packetFailure) {
924
1074
  appendTelemetry(telemetryFile, {
@@ -932,22 +1082,38 @@ function runLoopDispatch(options) {
932
1082
  fail(`${packetFailure.message} (missing_field=${packetFailure.missingField}, child_id=${childId})`);
933
1083
  }
934
1084
  const targetRole = roleContextToExecutionRole(packet.role_context.role);
1085
+ const taskType = roleContextToTaskType(packet.role_context);
1086
+ const activeSlotsByProvider = collectActiveProviderSlots(state);
935
1087
  // ── Resolve provider and dispatch mode ─────────────────────────────────────
936
1088
  // loadedConfig was loaded earlier (above bootstrap seal check)
937
1089
  let providerDecision;
938
1090
  try {
939
- providerDecision = resolveProviderAndMode(options, targetRole, loadedConfig);
1091
+ providerDecision = resolveProviderAndMode(options, targetRole, loadedConfig, {
1092
+ taskType,
1093
+ requiredCapabilities: requiredCapabilitiesForTask(taskType),
1094
+ activeSlotsByProvider,
1095
+ });
940
1096
  }
941
1097
  catch (err) {
942
1098
  fail(err instanceof Error ? err.message : String(err));
943
1099
  }
1100
+ // ── Handle hard-exhaustion: emit telemetry before failing ──────────────────
1101
+ // resolveProviderAndMode returns (not throws) when all providers are rejected so
1102
+ // emitProviderExhausted/emitProviderSelected can record the candidate breakdown.
1103
+ if (!providerDecision.provider && providerDecision.exhaustedReason && providerDecision.routerEvidence) {
1104
+ emitProviderExhausted(telemetryFile, dispatchId, state.run_id, childId, providerDecision);
1105
+ emitProviderSelected(telemetryFile, dispatchId, state.run_id, childId, providerDecision);
1106
+ fail(`provider dispatch forbidden: router rejected all providers (reason=${providerDecision.exhaustedReason})`);
1107
+ }
944
1108
  const { provider: resolvedProvider } = providerDecision;
945
1109
  const providerPolicy = loadedConfig?.execution.providerPolicy ?? getProviderPolicy(options.repoRoot);
946
- try {
947
- assertProviderAllowedForRole(targetRole, resolvedProvider, providerPolicy, telemetryFile, dispatchId, state.run_id, childId);
948
- }
949
- catch (err) {
950
- fail(err instanceof Error ? err.message : String(err));
1110
+ if (!providerDecision.routerEvidence) {
1111
+ try {
1112
+ assertProviderAllowedForRole(targetRole, resolvedProvider, providerPolicy, telemetryFile, dispatchId, state.run_id, childId);
1113
+ }
1114
+ catch (err) {
1115
+ fail(err instanceof Error ? err.message : String(err));
1116
+ }
951
1117
  }
952
1118
  // ── Provider probe on first dispatch ──────────────────────────────────────
953
1119
  // Catches provider configuration failures before packet commit. Only runs on
@@ -962,6 +1128,11 @@ function runLoopDispatch(options) {
962
1128
  run_id: state.run_id,
963
1129
  child_id: childId,
964
1130
  provider: resolvedProvider,
1131
+ failure_origin: "provider-launch",
1132
+ failure_category: "provider-unavailable",
1133
+ pre_dispatch_failure: true,
1134
+ fallback_eligible: true,
1135
+ providers_tried: providerDecision.providersTried,
965
1136
  error: probeResult.error,
966
1137
  timestamp: new Date().toISOString(),
967
1138
  });
@@ -1080,7 +1251,7 @@ function runLoopDispatch(options) {
1080
1251
  if (loadedConfig) {
1081
1252
  let adapter;
1082
1253
  try {
1083
- adapter = (0, index_js_1.loadTrackerAdapter)(loadedConfig);
1254
+ adapter = (0, index_js_2.loadTrackerAdapter)(loadedConfig);
1084
1255
  }
1085
1256
  catch (err) {
1086
1257
  const errorMsg = err instanceof Error ? err.message : String(err);