@lsctech/polaris 0.4.9 → 0.5.2

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 (42) hide show
  1. package/dist/autoresearch/gates.js +47 -0
  2. package/dist/autoresearch/proposal.js +183 -1
  3. package/dist/autoresearch/score.js +276 -0
  4. package/dist/cli/init.js +17 -0
  5. package/dist/cluster-state/store.js +70 -1
  6. package/dist/config/defaults.js +25 -0
  7. package/dist/config/validator.js +390 -0
  8. package/dist/finalize/artifact-policy.js +3 -1
  9. package/dist/finalize/index.js +73 -17
  10. package/dist/finalize/linear.js +45 -0
  11. package/dist/finalize/run-report.js +68 -12
  12. package/dist/loop/adapters/agent-subtask.js +19 -0
  13. package/dist/loop/adapters/cli-subtask-bridge.js +0 -1
  14. package/dist/loop/adapters/terminal-cli.js +194 -14
  15. package/dist/loop/checkpoint.js +40 -0
  16. package/dist/loop/dispatch-boundary.js +29 -0
  17. package/dist/loop/dispatch.js +193 -22
  18. package/dist/loop/orphan-recovery.js +56 -0
  19. package/dist/loop/parent.js +197 -22
  20. package/dist/loop/router/engine.js +229 -0
  21. package/dist/loop/router/index.js +5 -0
  22. package/dist/loop/router/types.js +2 -0
  23. package/dist/loop/worker-packet.js +16 -0
  24. package/dist/qc/artifacts.js +117 -0
  25. package/dist/qc/attribution.js +266 -0
  26. package/dist/qc/autofix.js +77 -0
  27. package/dist/qc/index.js +30 -0
  28. package/dist/qc/orchestration.js +150 -0
  29. package/dist/qc/policy.js +70 -0
  30. package/dist/qc/provider.js +28 -0
  31. package/dist/qc/providers/coderabbit.js +214 -0
  32. package/dist/qc/providers/index.js +5 -0
  33. package/dist/qc/registry.js +16 -0
  34. package/dist/qc/routing.js +55 -0
  35. package/dist/qc/runner.js +137 -0
  36. package/dist/qc/schemas.js +110 -0
  37. package/dist/qc/security-category.js +11 -0
  38. package/dist/qc/severity.js +78 -0
  39. package/dist/qc/triggers.js +92 -0
  40. package/dist/qc/types.js +9 -0
  41. package/dist/runtime/scheduling/child-selector.js +81 -0
  42. package/package.json +1 -1
@@ -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) {
@@ -40,8 +40,10 @@ exports.validateTransition = validateTransition;
40
40
  exports.initialDispatchBoundary = initialDispatchBoundary;
41
41
  exports.advanceDispatchEpoch = advanceDispatchEpoch;
42
42
  exports.advanceContinueEpoch = advanceContinueEpoch;
43
+ exports.assertChildQcSelectionAllowed = assertChildQcSelectionAllowed;
43
44
  const node_fs_1 = require("node:fs");
44
45
  const node_path_1 = require("node:path");
46
+ const triggers_js_1 = require("../qc/triggers.js");
45
47
  // ──────────────────────────────────────────────────────────────────────────────
46
48
  // Error constants
47
49
  // ──────────────────────────────────────────────────────────────────────────────
@@ -333,3 +335,30 @@ function advanceContinueEpoch(current) {
333
335
  last_dispatched_child: current.last_dispatched_child,
334
336
  };
335
337
  }
338
+ /**
339
+ * Validate that a child-level QC trigger is only selected when permitted.
340
+ *
341
+ * Child-level QC is opt-in: high-risk scope, route policy, or explicit operator
342
+ * request. If a packet carries child-level QC without meeting one of those
343
+ * conditions, the dispatch boundary is violated.
344
+ */
345
+ function assertChildQcSelectionAllowed(state, childId, config, allowedScope, labels, telemetryFile) {
346
+ const meta = state.open_children_meta?.[childId];
347
+ if (meta?.qc_trigger !== "child")
348
+ return;
349
+ const routeName = childId; // route-specific overrides are keyed by child/route identifier
350
+ if ((0, triggers_js_1.isChildQcSelected)(config, childId, allowedScope, labels, routeName)) {
351
+ return;
352
+ }
353
+ const reason = `Child-level QC selected for ${childId} but no opt-in condition is met (high-risk scope, route policy, or "qc-child" label)`;
354
+ appendDispatchViolationEvent(telemetryFile, {
355
+ event: "illegal-state-transition",
356
+ run_id: state.run_id,
357
+ child_id: childId,
358
+ from_state: getMachineState(state),
359
+ to_state: "dispatched",
360
+ reason,
361
+ timestamp: new Date().toISOString(),
362
+ });
363
+ throw new Error(reason);
364
+ }
@@ -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);
@@ -64,6 +64,42 @@ function hasTelemetryEvent(telemetryFile, eventName, childId) {
64
64
  catch { /* ignore */ }
65
65
  return false;
66
66
  }
67
+ function latestProviderFailureReason(telemetryFile, childId, dispatchId) {
68
+ if (!(0, node_fs_1.existsSync)(telemetryFile))
69
+ return null;
70
+ try {
71
+ const lines = (0, node_fs_1.readFileSync)(telemetryFile, "utf-8").trim().split("\n").filter(Boolean);
72
+ for (let i = lines.length - 1; i >= 0; i--) {
73
+ const line = lines[i];
74
+ if (!line)
75
+ continue;
76
+ try {
77
+ const event = JSON.parse(line);
78
+ if (event.child_id !== childId)
79
+ continue;
80
+ if (event.dispatch_id && event.dispatch_id !== dispatchId)
81
+ continue;
82
+ const category = typeof event.failure_category === "string" ? event.failure_category : undefined;
83
+ const reason = typeof event.reason === "string" ? event.reason : undefined;
84
+ if (category === "quota-exhausted" || reason === "quota-exhausted") {
85
+ return "quota-exhausted";
86
+ }
87
+ if (category === "provider-unavailable" ||
88
+ reason === "provider-unavailable" ||
89
+ event.event === "provider-probe-failed") {
90
+ return "provider-unavailable";
91
+ }
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ }
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ return null;
102
+ }
67
103
  // ── Emit recovery telemetry events ───────────────────────────────────────────
68
104
  function emitRecoveryInitiated(telemetryFile, childId, dispatchId, reason) {
69
105
  appendTelemetry(telemetryFile, {
@@ -187,6 +223,26 @@ function checkOrphans(options) {
187
223
  if (dr && dr.runtime_state !== "completed" && dr.runtime_state !== "failed") {
188
224
  const dispatchedAt = new Date(dr.dispatched_at).getTime();
189
225
  const elapsed = now - dispatchedAt;
226
+ const providerFailure = latestProviderFailureReason(telemetryFile, state.active_child, dr.dispatch_id);
227
+ // Provider launch failure detected before worker-start evidence.
228
+ // Safe to requeue because no worker result artifact exists and there is
229
+ // no heartbeat/acknowledgment evidence that execution started.
230
+ if (providerFailure &&
231
+ !safeResultExists(dr.expected_result_path) &&
232
+ !dr.first_heartbeat_at) {
233
+ const detection = {
234
+ childId: state.active_child,
235
+ dispatchId: dr.dispatch_id,
236
+ reason: providerFailure,
237
+ requiresApproval: false,
238
+ };
239
+ detected.push(detection);
240
+ emitRecoveryInitiated(telemetryFile, state.active_child, dr.dispatch_id, providerFailure);
241
+ emitChildOrphaned(telemetryFile, state.active_child, dr.dispatch_id, null);
242
+ const newId = resetForRedispatch(options.stateFile, state, state.active_child);
243
+ emitChildRequeued(telemetryFile, state.active_child, newId, dr.dispatch_id);
244
+ return { detected, checked };
245
+ }
190
246
  // Scenario A: packet written, no worker_id after launch_timeout
191
247
  if (!dr.worker_id && elapsed > timeouts.launchTimeoutMs) {
192
248
  const detection = {