@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.
@@ -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 = {
@@ -39,8 +39,11 @@ const dispatch_boundary_js_1 = require("./dispatch-boundary.js");
39
39
  const run_bootstrap_js_1 = require("./run-bootstrap.js");
40
40
  const ledger_js_1 = require("./ledger.js");
41
41
  const dispatch_js_1 = require("./dispatch.js");
42
- const index_js_1 = require("../tracker/index.js");
42
+ const index_js_1 = require("./router/index.js");
43
+ const child_selector_js_1 = require("../runtime/scheduling/child-selector.js");
44
+ const index_js_2 = require("../tracker/index.js");
43
45
  const lifecycle_transition_js_1 = require("../tracker/lifecycle-transition.js");
46
+ const local_graph_js_1 = require("../tracker/local-graph.js");
44
47
  const CLAIM_TTL_MS = 30 * 60 * 1000;
45
48
  /**
46
49
  * Returns the list of files touched by a git commit, or null when the commit
@@ -130,13 +133,6 @@ function emitBootstrapContextSize(telemetryFile, runId, childId, stateFile, pack
130
133
  timestamp: new Date().toISOString(),
131
134
  });
132
135
  }
133
- /**
134
- * Select the next open child that is not Done/blocked.
135
- * Returns null when all children are completed.
136
- */
137
- function selectNextChild(state) {
138
- return state.open_children[0] ?? null;
139
- }
140
136
  /**
141
137
  * Returns true when the given child is detected as an analyze issue.
142
138
  * Detection is intentionally conservative: title prefix or label match only.
@@ -212,7 +208,7 @@ function appendRunStartedLedgerEvent(repoRoot, state) {
212
208
  timestamp: new Date().toISOString(),
213
209
  });
214
210
  }
215
- function appendChildCompletedLedgerEvent(repoRoot, state, completedChild, lastCommit, validation) {
211
+ function appendChildCompletedLedgerEvent(repoRoot, state, completedChild, lastCommit, validation, completion) {
216
212
  const writer = new ledger_js_1.LedgerWriter((0, node_path_1.join)(repoRoot, ledger_js_1.DEFAULT_LEDGER_PATH));
217
213
  const base = {
218
214
  schema_version: 1,
@@ -237,6 +233,18 @@ function appendChildCompletedLedgerEvent(repoRoot, state, completedChild, lastCo
237
233
  validation: typeof validation === "object" && validation !== null
238
234
  ? { status: "complete", ...validation }
239
235
  : { status: "complete" },
236
+ ...(completion?.dispatchId ? { dispatch_id: completion.dispatchId } : {}),
237
+ provider: completion?.provider ?? null,
238
+ model: completion?.model ?? null,
239
+ ...(completion?.elapsedSeconds !== undefined ? { elapsed_seconds: completion.elapsedSeconds } : {}),
240
+ ...(completion?.commitFiles !== undefined ? { commit_files: completion.commitFiles } : {}),
241
+ ...(completion?.completionStatus ? { completion_status: completion.completionStatus } : {}),
242
+ ...(completion?.routerSelectionReason
243
+ ? { router_selection_reason: completion.routerSelectionReason }
244
+ : {}),
245
+ ...(completion?.providersTried?.length
246
+ ? { providers_tried: completion.providersTried }
247
+ : {}),
240
248
  });
241
249
  }
242
250
  function appendClusterCompletedLedgerEvent(repoRoot, state) {
@@ -275,7 +283,7 @@ function appendClusterCompletedLedgerEvent(repoRoot, state) {
275
283
  * @param resultFile - Optional override path where the worker should write its result
276
284
  * @returns The compiled WorkerPacket configured for `activeChild`
277
285
  */
278
- function buildPacket(state, activeChild, stateFile, telemetryFile, repoRoot, resultFile) {
286
+ function buildPacket(state, activeChild, stateFile, telemetryFile, repoRoot, resultFile, maxConcurrentWorkers) {
279
287
  const branch = state.branch ?? getCurrentBranch(repoRoot);
280
288
  const childMeta = state.open_children_meta?.[activeChild];
281
289
  // Hydrate body from cluster snapshot when runtime state lacks it.
@@ -312,7 +320,7 @@ function buildPacket(state, activeChild, stateFile, telemetryFile, repoRoot, res
312
320
  telemetryFile,
313
321
  issueContext,
314
322
  allowedScope: resolvedScope.length > 0 ? resolvedScope : undefined,
315
- maxConcurrentWorkers: 1,
323
+ maxConcurrentWorkers,
316
324
  promptMode,
317
325
  resultFile,
318
326
  });
@@ -416,6 +424,8 @@ function buildParentDispatchRecord(args) {
416
424
  packet_path: args.packetPath,
417
425
  expected_result_path: args.resultPath,
418
426
  provider: args.provider,
427
+ provider_selection_reason: args.providerSelectionReason,
428
+ providers_tried: args.providersTried,
419
429
  dispatched_at: args.dispatchedAt,
420
430
  status: "dispatched",
421
431
  dispatch_mode: "direct-worker",
@@ -487,6 +497,26 @@ function advanceState(state, completedChild, lastCommit) {
487
497
  },
488
498
  };
489
499
  }
500
+ function withWorkerPoolState(state, maxConcurrentWorkers, slotClaims) {
501
+ return {
502
+ ...state,
503
+ worker_pool_state: {
504
+ max_concurrent: maxConcurrentWorkers,
505
+ slot_claims: slotClaims,
506
+ },
507
+ };
508
+ }
509
+ function pruneWorkerPoolClaimsForChild(state, childId) {
510
+ if (!state.worker_pool_state)
511
+ return state;
512
+ return {
513
+ ...state,
514
+ worker_pool_state: {
515
+ ...state.worker_pool_state,
516
+ slot_claims: state.worker_pool_state.slot_claims.filter((claim) => claim.child_id !== childId),
517
+ },
518
+ };
519
+ }
490
520
  async function syncClusterCompletion(args) {
491
521
  const clusterState = await (0, store_js_1.readClusterState)(args.clusterId, args.repoRoot);
492
522
  if (!clusterState) {
@@ -651,8 +681,12 @@ async function runParentLoop(options) {
651
681
  const notificationFormat = config.orchestration?.notification_format ?? (orchestrationMode === 'auto' ? 'terse' : 'verbose');
652
682
  const adapterName = legacyEphemeralMode ? "agent-subtask" : (options.adapter ?? config.execution?.adapter ?? "terminal-cli");
653
683
  let providerName;
684
+ let providerSelectionReason;
685
+ let providersTried;
654
686
  if (adapterName === "agent-subtask") {
655
687
  providerName = "agent-subtask";
688
+ providerSelectionReason = "agent-subtask-adapter";
689
+ providersTried = ["agent-subtask"];
656
690
  }
657
691
  else {
658
692
  let evidence;
@@ -668,6 +702,8 @@ async function runParentLoop(options) {
668
702
  };
669
703
  }
670
704
  providerName = evidence.provider ?? "default";
705
+ providerSelectionReason = evidence.selectionReason;
706
+ providersTried = evidence.providersTried;
671
707
  }
672
708
  const executionConfig = adapterName === "agent-subtask"
673
709
  ? { ...(config.execution ?? { providers: {} }), adapter: "agent-subtask" }
@@ -675,6 +711,7 @@ async function runParentLoop(options) {
675
711
  const adapter = (0, registry_js_1.createAdapter)(adapterName, executionConfig);
676
712
  const budgetPolicy = (0, budget_js_1.policyFromConfig)(state.context_budget, config.budget);
677
713
  const allowAnalyzeChildren = allowAnalyzeChildrenFlag || (config.budget?.allow_analyze_children === true);
714
+ const maxConcurrentWorkers = config.execution?.routerPolicy?.defaultWorkerPool?.maxActiveWorkers ?? 1;
678
715
  const telemetryFile = resolveTelemetryFile(state, repoRoot);
679
716
  // Enforce provider policy for explicit --provider flag before entering the loop.
680
717
  // resolveProviderAndMode handles policy-filtered rotation; this gate blocks an
@@ -725,18 +762,80 @@ async function runParentLoop(options) {
725
762
  if (!dryRun) {
726
763
  flushBodiesToClusterSnapshot(state, repoRoot);
727
764
  }
765
+ let localGraph = null;
766
+ try {
767
+ localGraph = await local_graph_js_1.LocalGraph.load(state.cluster_id, repoRoot);
768
+ }
769
+ catch {
770
+ localGraph = null;
771
+ }
728
772
  // ── Lifecycle manager: enforce one-active-worker policy ─────────────────
729
773
  // forceReleaseAll() clears any orphaned registrations from a previous
730
774
  // crashed session. Registrations are session-memory only; they are not
731
775
  // persisted to disk, so a fresh loop start always begins clean.
732
- const lifecycle = new lifecycle_js_1.WorkerLifecycleManager(1);
776
+ const lifecycle = new lifecycle_js_1.WorkerLifecycleManager(maxConcurrentWorkers);
733
777
  lifecycle.forceReleaseAll();
734
778
  // ── Main dispatch loop ───────────────────────────────────────────────────
735
779
  // eslint-disable-next-line no-constant-condition
736
780
  while (true) {
737
781
  // ── Step 02: Select next open child ─────────────────────────────────
738
- const nextChild = selectNextChild(state);
782
+ const existingSlotClaims = state.worker_pool_state?.slot_claims ?? [];
783
+ if (!dryRun) {
784
+ const clusterState = await (0, store_js_1.readClusterState)(state.cluster_id, repoRoot);
785
+ if (clusterState) {
786
+ const pruned = (0, store_js_1.pruneExpiredClaims)(clusterState);
787
+ if (pruned.expiredChildIds.length > 0) {
788
+ await (0, store_js_1.writeClusterState)(state.cluster_id, pruned.state, repoRoot);
789
+ }
790
+ }
791
+ }
792
+ const selection = (0, child_selector_js_1.selectChildSlotClaims)({
793
+ open_children: state.open_children,
794
+ completed_children: state.completed_children,
795
+ active_child: state.active_child || null,
796
+ existing_claims: existingSlotClaims,
797
+ max_concurrent: maxConcurrentWorkers,
798
+ claim_ttl_ms: CLAIM_TTL_MS,
799
+ get_dependencies: (childId) => localGraph?.getDependencies(childId) ?? [],
800
+ decide_route: ({ activeSlotsByProvider }) => (0, index_js_1.decideWorkerRoute)({
801
+ role: "worker",
802
+ taskType: "impl",
803
+ adapter: adapterName,
804
+ providerOverride: options.provider,
805
+ providers: Object.keys(config.execution?.providers ?? {}),
806
+ rotation: config.execution?.rotation ?? [],
807
+ rolePolicy: config.execution?.providerPolicy?.worker,
808
+ roleConfiguredProvider: config.execution?.roles?.worker?.provider,
809
+ routerPolicy: config.execution?.routerPolicy,
810
+ constraints: {
811
+ requiredCapabilities: ["implementation"],
812
+ },
813
+ runtime: {
814
+ activeSlotsByProvider,
815
+ },
816
+ compatibilityMode: false,
817
+ }),
818
+ });
819
+ const nextSlotClaims = selection.slot_claims;
820
+ const nextChild = selection.selected_child;
739
821
  if (nextChild === null) {
822
+ if (state.open_children.length > 0) {
823
+ if (!dryRun) {
824
+ appendTelemetry(telemetryFile, {
825
+ event: "scheduler-no-eligible-child",
826
+ run_id: state.run_id,
827
+ open_children: state.open_children,
828
+ rejected_children: selection.rejected_children,
829
+ slot_claims: nextSlotClaims,
830
+ timestamp: new Date().toISOString(),
831
+ });
832
+ }
833
+ return {
834
+ haltReason: "blocked",
835
+ childrenDispatched,
836
+ message: "No schedulable child matched dependency and router slot constraints.",
837
+ };
838
+ }
740
839
  const autoFinalizeRequested = orchestrationMode === "auto" && config.orchestration?.auto_finalize === true;
741
840
  // All children completed — write final state and halt
742
841
  if (!dryRun) {
@@ -979,7 +1078,7 @@ async function runParentLoop(options) {
979
1078
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(resultFile), { recursive: true });
980
1079
  const statePreDispatch = state;
981
1080
  let stateBeforeDispatch = state;
982
- const packet = buildPacket(state, nextChild, stateFile, telemetryFile, repoRoot, resultFile);
1081
+ const packet = buildPacket(state, nextChild, stateFile, telemetryFile, repoRoot, resultFile, maxConcurrentWorkers);
983
1082
  if (!dryRun) {
984
1083
  try {
985
1084
  writePacketArtifact(packetPath, packet);
@@ -1009,11 +1108,13 @@ async function runParentLoop(options) {
1009
1108
  packetPath,
1010
1109
  resultPath: resultFile,
1011
1110
  provider: providerName,
1111
+ providerSelectionReason,
1112
+ providersTried,
1012
1113
  workerId,
1013
1114
  dispatchedAt,
1014
1115
  });
1015
1116
  const stateWithDispatch = {
1016
- ...withChildDispatchMetadata(state, nextChild, resultFile, dispatchRecord),
1117
+ ...withWorkerPoolState(withChildDispatchMetadata(state, nextChild, resultFile, dispatchRecord), maxConcurrentWorkers, nextSlotClaims),
1017
1118
  active_child: nextChild,
1018
1119
  step_cursor: "dispatch",
1019
1120
  dispatch_boundary: (0, dispatch_boundary_js_1.advanceDispatchEpoch)(state.dispatch_boundary, nextChild),
@@ -1066,6 +1167,7 @@ async function runParentLoop(options) {
1066
1167
  if (!dryRun) {
1067
1168
  const totalChildren = state.open_children.length + state.completed_children.length;
1068
1169
  const childIndex = state.completed_children.length + 1;
1170
+ const selectedSlotClaim = nextSlotClaims.find((claim) => claim.child_id === nextChild);
1069
1171
  logStatus(notificationFormat, `RUNNING ${nextChild} (${childIndex}/${totalChildren})`);
1070
1172
  appendTelemetry(telemetryFile, {
1071
1173
  event: "child-dispatched",
@@ -1080,6 +1182,8 @@ async function runParentLoop(options) {
1080
1182
  packet_path: packetPath,
1081
1183
  expected_result_path: resultFile,
1082
1184
  dry_run: dryRun,
1185
+ selected_slot_claim: selectedSlotClaim ?? null,
1186
+ slot_claims: nextSlotClaims,
1083
1187
  timestamp: new Date().toISOString(),
1084
1188
  });
1085
1189
  emitBootstrapContextSize(telemetryFile, state.run_id, nextChild, stateFile, packet);
@@ -1229,7 +1333,7 @@ async function runParentLoop(options) {
1229
1333
  // Errors are logged to telemetry but do not fail the halt.
1230
1334
  let adapter;
1231
1335
  try {
1232
- adapter = (0, index_js_1.loadTrackerAdapter)(config);
1336
+ adapter = (0, index_js_2.loadTrackerAdapter)(config);
1233
1337
  }
1234
1338
  catch (err) {
1235
1339
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -1423,6 +1527,14 @@ async function runParentLoop(options) {
1423
1527
  finalWorkerSummary?.['commit_hash'];
1424
1528
  const validationSummary = finalWorkerSummary?.['validation'] ??
1425
1529
  finalWorkerSummary?.['validation_summary'];
1530
+ const dispatchRecord = state.open_children_meta?.[nextChild]?.dispatch_record;
1531
+ const providerUsed = finalWorkerSummary?.['provider'] ??
1532
+ finalWorkerSummary?.['provider_used'] ??
1533
+ dispatchRecord?.provider ??
1534
+ null;
1535
+ const modelUsed = finalWorkerSummary?.['model'] ??
1536
+ finalWorkerSummary?.['model_used'] ??
1537
+ null;
1426
1538
  if (!dryRun && workerStatus === 'done' && (!lastCommit || lastCommit.trim().length === 0)) {
1427
1539
  const errMsg = `Worker reported done for ${nextChild} without commit evidence`;
1428
1540
  appendTelemetry(telemetryFile, {
@@ -1561,14 +1673,14 @@ async function runParentLoop(options) {
1561
1673
  }
1562
1674
  // Advance state, including continue_epoch to match the consumed dispatch
1563
1675
  const advanced = advanceState(state, nextChild, lastCommit);
1564
- state = {
1676
+ state = pruneWorkerPoolClaimsForChild({
1565
1677
  ...advanced,
1566
1678
  dispatch_boundary: (0, dispatch_boundary_js_1.advanceContinueEpoch)(state.dispatch_boundary),
1567
1679
  completed_children_results: {
1568
1680
  ...advanced.completed_children_results,
1569
1681
  [nextChild]: workerResult,
1570
1682
  },
1571
- };
1683
+ }, nextChild);
1572
1684
  childrenDispatched += 1;
1573
1685
  // Worker did not write its own completion — orchestrator fills the gap.
1574
1686
  if (!dryRun) {
@@ -1578,14 +1690,14 @@ async function runParentLoop(options) {
1578
1690
  else {
1579
1691
  // Worker wrote its own completion — advance continue_epoch to stay in sync.
1580
1692
  // The worker does not manage dispatch_boundary, so the parent must do it.
1581
- state = {
1693
+ state = pruneWorkerPoolClaimsForChild({
1582
1694
  ...state,
1583
1695
  dispatch_boundary: (0, dispatch_boundary_js_1.advanceContinueEpoch)(state.dispatch_boundary),
1584
1696
  completed_children_results: {
1585
1697
  ...state.completed_children_results,
1586
1698
  [nextChild]: workerResult,
1587
1699
  },
1588
- };
1700
+ }, nextChild);
1589
1701
  childrenDispatched += 1;
1590
1702
  if (!dryRun) {
1591
1703
  (0, checkpoint_js_1.writeStateAtomic)(stateFile, state);
@@ -1641,15 +1753,30 @@ async function runParentLoop(options) {
1641
1753
  child_id: nextChild,
1642
1754
  children_completed: state.context_budget.children_completed,
1643
1755
  validation_summary: validationSummary,
1756
+ completion_status: workerStatus,
1644
1757
  commit_hash: lastCommit,
1645
1758
  commit_files: commitFiles,
1759
+ dispatch_id: dispatchRecord?.dispatch_id,
1760
+ provider: providerUsed,
1761
+ model: modelUsed,
1762
+ router_selection_reason: dispatchRecord?.provider_selection_reason,
1763
+ providers_tried: dispatchRecord?.providers_tried,
1646
1764
  timestamp: new Date().toISOString(),
1647
1765
  };
1648
1766
  if (elapsedSeconds !== undefined) {
1649
1767
  telemetryEvent.elapsed_seconds = elapsedSeconds;
1650
1768
  }
1651
1769
  appendTelemetry(telemetryFile, telemetryEvent);
1652
- appendChildCompletedLedgerEvent(repoRoot, state, nextChild, lastCommit ?? null, validationSummary);
1770
+ appendChildCompletedLedgerEvent(repoRoot, state, nextChild, lastCommit ?? null, validationSummary, {
1771
+ dispatchId: dispatchRecord?.dispatch_id,
1772
+ provider: providerUsed,
1773
+ model: modelUsed,
1774
+ elapsedSeconds,
1775
+ commitFiles,
1776
+ completionStatus: workerStatus,
1777
+ routerSelectionReason: dispatchRecord?.provider_selection_reason,
1778
+ providersTried: dispatchRecord?.providers_tried,
1779
+ });
1653
1780
  }
1654
1781
  if (orchestrationMode === 'supervised') {
1655
1782
  return {
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decideWorkerRoute = decideWorkerRoute;
4
+ const TRUST_RANK = {
5
+ sandbox: 0,
6
+ standard: 1,
7
+ trusted: 2,
8
+ };
9
+ const COST_RANK = {
10
+ low: 0,
11
+ medium: 1,
12
+ high: 2,
13
+ };
14
+ function defaultCapabilitiesForTask(taskType) {
15
+ switch (taskType) {
16
+ case "startup":
17
+ return ["orchestration"];
18
+ case "analyze":
19
+ return ["analysis"];
20
+ case "impl":
21
+ return ["implementation"];
22
+ case "repair":
23
+ return ["repair"];
24
+ case "docs":
25
+ return ["docs"];
26
+ case "finalize":
27
+ return ["finalization"];
28
+ default:
29
+ return [];
30
+ }
31
+ }
32
+ function orderedProviders(input) {
33
+ if (input.providerOverride) {
34
+ return [input.providerOverride];
35
+ }
36
+ const roleProviders = input.rolePolicy?.providers ?? [];
37
+ const rotation = input.rotation ?? [];
38
+ if (input.compatibilityMode !== false) {
39
+ if (roleProviders.length > 0) {
40
+ const filteredRotation = rotation.filter((provider) => roleProviders.includes(provider));
41
+ if (filteredRotation.length > 0) {
42
+ return filteredRotation;
43
+ }
44
+ const configuredProviders = new Set(input.providers);
45
+ return roleProviders.filter((provider) => configuredProviders.has(provider));
46
+ }
47
+ if (input.roleConfiguredProvider) {
48
+ return [input.roleConfiguredProvider];
49
+ }
50
+ if (rotation.length > 0) {
51
+ return rotation;
52
+ }
53
+ return input.providers;
54
+ }
55
+ const preferred = rotation.length > 0 ? rotation : input.providers;
56
+ if (roleProviders.length === 0) {
57
+ return preferred;
58
+ }
59
+ const configuredAndAllowed = new Set(roleProviders.filter((provider) => input.providers.includes(provider)));
60
+ const ordered = preferred.filter((provider) => configuredAndAllowed.has(provider));
61
+ if (ordered.length > 0) {
62
+ return ordered;
63
+ }
64
+ return roleProviders.filter((provider) => input.providers.includes(provider));
65
+ }
66
+ function compatibilitySelectionReason(input, selectedProvider) {
67
+ if (input.providerOverride)
68
+ return "cli-provider-override";
69
+ if (!selectedProvider)
70
+ return "delegated-no-provider";
71
+ const roleProviders = input.rolePolicy?.providers ?? [];
72
+ if (roleProviders.length > 0) {
73
+ const rotation = input.rotation ?? [];
74
+ if (rotation.filter((provider) => roleProviders.includes(provider))[0] === selectedProvider) {
75
+ return "policy-filtered-rotation";
76
+ }
77
+ return "role-policy";
78
+ }
79
+ if (input.roleConfiguredProvider === selectedProvider)
80
+ return "role-config";
81
+ if ((input.rotation ?? [])[0] === selectedProvider)
82
+ return "config-rotation";
83
+ return "config-first-provider";
84
+ }
85
+ function compatibilityExhaustedReason(input) {
86
+ const roleProviders = input.rolePolicy?.providers ?? [];
87
+ if (roleProviders.length > 0)
88
+ return "role-policy-no-configured-provider";
89
+ return "no-configured-provider";
90
+ }
91
+ function topRejectionReason(candidates) {
92
+ const orderedReasons = [
93
+ "role-disabled",
94
+ "not-in-policy",
95
+ "quota-exhausted",
96
+ "trust-too-low",
97
+ "capability-mismatch",
98
+ "cost-policy",
99
+ "no-slot",
100
+ ];
101
+ for (const reason of orderedReasons) {
102
+ if (candidates.some((candidate) => candidate.rejectionReasons.includes(reason))) {
103
+ return reason;
104
+ }
105
+ }
106
+ return undefined;
107
+ }
108
+ function decideWorkerRoute(input) {
109
+ const compatibilityMode = input.compatibilityMode !== false;
110
+ const providerRegistry = input.routerPolicy?.providerRegistry ?? {};
111
+ const roleProviders = input.rolePolicy?.providers ?? [];
112
+ const requiredCapabilities = input.constraints?.requiredCapabilities ?? defaultCapabilitiesForTask(input.taskType);
113
+ const ordered = orderedProviders(input);
114
+ const activeSlotsByProvider = input.runtime?.activeSlotsByProvider ?? {};
115
+ const quotaAvailableByProvider = input.runtime?.quotaAvailableByProvider ?? {};
116
+ const globalSlotLimit = input.routerPolicy?.defaultWorkerPool?.maxActiveSlots;
117
+ const candidates = [];
118
+ if (roleProviders.length === 0 && input.rolePolicy) {
119
+ return {
120
+ selectedProvider: undefined,
121
+ selectedWorker: { role: input.role, taskType: input.taskType },
122
+ mode: "delegated",
123
+ selectionReason: compatibilityMode ? "delegated-no-provider" : "router-role-disabled",
124
+ exhaustedReason: "role-disabled",
125
+ compatibilityMode,
126
+ providersTried: [],
127
+ candidates: [],
128
+ };
129
+ }
130
+ for (const [orderIndex, provider] of ordered.entries()) {
131
+ const policy = providerRegistry[provider];
132
+ const rejectionReasons = [];
133
+ const eligibleRoleSet = new Set(policy?.eligibleRoles ?? []);
134
+ const policyMatched = !policy || eligibleRoleSet.size === 0 || eligibleRoleSet.has(input.role);
135
+ if (!policyMatched) {
136
+ rejectionReasons.push("role-disabled");
137
+ }
138
+ if (roleProviders.length > 0 && !roleProviders.includes(provider)) {
139
+ rejectionReasons.push("not-in-policy");
140
+ }
141
+ const capabilitySet = new Set(policy?.capabilities ?? []);
142
+ const allowedTaskSet = new Set(policy?.taskTypes ?? []);
143
+ if ((capabilitySet.size > 0 && requiredCapabilities.some((capability) => !capabilitySet.has(capability))) ||
144
+ (allowedTaskSet.size > 0 && !allowedTaskSet.has(input.taskType))) {
145
+ rejectionReasons.push("capability-mismatch");
146
+ }
147
+ const providerTrust = policy?.trustTier;
148
+ const minTrust = input.constraints?.minTrustTier;
149
+ if (providerTrust && minTrust && TRUST_RANK[providerTrust] < TRUST_RANK[minTrust]) {
150
+ rejectionReasons.push("trust-too-low");
151
+ }
152
+ const providerCost = policy?.costTier;
153
+ const maxCost = input.constraints?.maxCostTier;
154
+ if (providerCost && maxCost && COST_RANK[providerCost] > COST_RANK[maxCost]) {
155
+ rejectionReasons.push("cost-policy");
156
+ }
157
+ const disallowedQuota = new Set(input.constraints?.disallowedQuotaPolicies ?? []);
158
+ if (policy?.quotaPolicy && disallowedQuota.has(policy.quotaPolicy)) {
159
+ rejectionReasons.push("cost-policy");
160
+ }
161
+ if (policy?.quotaPolicy && quotaAvailableByProvider[provider] === false) {
162
+ rejectionReasons.push("quota-exhausted");
163
+ }
164
+ const activeSlots = activeSlotsByProvider[provider] ?? 0;
165
+ const providerSlotLimit = policy?.maxActiveSlots;
166
+ // Provider-specific maxActiveSlots overrides the global default when present.
167
+ const effectiveSlotLimit = providerSlotLimit ?? globalSlotLimit;
168
+ if (effectiveSlotLimit !== undefined && activeSlots >= effectiveSlotLimit) {
169
+ rejectionReasons.push("no-slot");
170
+ }
171
+ const score = {
172
+ orderScore: Math.max(0, 100 - orderIndex),
173
+ trustScore: providerTrust ? TRUST_RANK[providerTrust] * 1_000 : 0,
174
+ costScore: providerCost ? (3 - COST_RANK[providerCost]) * 10 : 0,
175
+ total: 0,
176
+ };
177
+ score.total = score.orderScore + score.trustScore + score.costScore;
178
+ candidates.push({
179
+ provider,
180
+ eligible: rejectionReasons.length === 0,
181
+ score,
182
+ rejectionReasons,
183
+ evidence: {
184
+ orderIndex,
185
+ trustTier: providerTrust,
186
+ costTier: providerCost,
187
+ quotaPolicy: policy?.quotaPolicy,
188
+ activeSlots,
189
+ slotLimit: providerSlotLimit ?? globalSlotLimit,
190
+ policyMatched,
191
+ },
192
+ });
193
+ }
194
+ const eligibleCandidates = candidates.filter((candidate) => candidate.eligible);
195
+ const selected = eligibleCandidates
196
+ .slice()
197
+ .sort((a, b) => {
198
+ if (a.score.total !== b.score.total)
199
+ return b.score.total - a.score.total;
200
+ if (a.evidence.orderIndex !== b.evidence.orderIndex)
201
+ return a.evidence.orderIndex - b.evidence.orderIndex;
202
+ return a.provider.localeCompare(b.provider);
203
+ })[0];
204
+ const selectedProvider = selected?.provider;
205
+ if (!selectedProvider) {
206
+ const rejection = topRejectionReason(candidates);
207
+ const exhaustedReason = rejection ?? (compatibilityMode ? compatibilityExhaustedReason(input) : "no-eligible-provider");
208
+ const selectionReason = compatibilityMode ? "delegated-no-provider" : "router-no-eligible-provider";
209
+ return {
210
+ selectedProvider: undefined,
211
+ selectedWorker: { role: input.role, taskType: input.taskType },
212
+ mode: "delegated",
213
+ selectionReason,
214
+ exhaustedReason,
215
+ compatibilityMode,
216
+ providersTried: ordered,
217
+ candidates,
218
+ };
219
+ }
220
+ return {
221
+ selectedProvider,
222
+ selectedWorker: { role: input.role, taskType: input.taskType },
223
+ mode: "direct-worker",
224
+ selectionReason: compatibilityMode ? compatibilitySelectionReason(input, selectedProvider) : "policy-router",
225
+ compatibilityMode,
226
+ providersTried: ordered,
227
+ candidates,
228
+ };
229
+ }
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decideWorkerRoute = void 0;
4
+ var engine_js_1 = require("./engine.js");
5
+ Object.defineProperty(exports, "decideWorkerRoute", { enumerable: true, get: function () { return engine_js_1.decideWorkerRoute; } });
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });