@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
@@ -29,6 +29,7 @@ const local_graph_js_1 = require("../tracker/local-graph.js");
29
29
  const index_js_1 = require("../tracker/sync/index.js");
30
30
  const finalize_evidence_js_1 = require("../loop/finalize-evidence.js");
31
31
  const delivery_integrity_js_1 = require("./delivery-integrity.js");
32
+ const index_js_2 = require("../qc/index.js");
32
33
  function getBranch(repoRoot) {
33
34
  try {
34
35
  return (0, node_child_process_1.execFileSync)("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
@@ -178,6 +179,36 @@ function checkLibrarianGate(repoRoot, clusterId) {
178
179
  return `Librarian result gate error: ${err instanceof Error ? err.message : String(err)}`;
179
180
  }
180
181
  }
182
+ function resolveQcTelemetryFile(state, repoRoot) {
183
+ const artifactDir = state.artifact_dir ?? (0, node_path_1.join)(repoRoot, ".taskchain_artifacts", "polaris-run");
184
+ return (0, node_path_1.join)(artifactDir, "runs", state.run_id, "telemetry.jsonl");
185
+ }
186
+ async function runQcGate(options) {
187
+ const { config, state, repoRoot, branch, trigger, prUrl, stepLabel } = options;
188
+ const registry = (0, index_js_2.createDefaultQcRegistry)();
189
+ const result = await (0, index_js_2.runQcAtTrigger)({
190
+ config,
191
+ registry,
192
+ trigger,
193
+ prUrl,
194
+ repoRoot,
195
+ runId: state.run_id,
196
+ clusterId: state.cluster_id,
197
+ branch,
198
+ telemetryFile: resolveQcTelemetryFile(state, repoRoot),
199
+ state,
200
+ });
201
+ if (result.action === "pass") {
202
+ console.log(`${stepLabel} QC ${trigger} passed: ${result.summary}`);
203
+ return;
204
+ }
205
+ if (result.action === "follow-up") {
206
+ console.warn(`${stepLabel} QC ${trigger} produced follow-up work: ${result.summary}`);
207
+ return;
208
+ }
209
+ process.stderr.write(`${stepLabel} QC ${trigger} blocked finalize: ${result.summary}\n`);
210
+ process.exit(1);
211
+ }
181
212
  async function runFinalize(options) {
182
213
  const { repoRoot, stateFile, dryRun, skipDelivery, skipLibrarian } = options;
183
214
  const config = (0, loader_js_1.loadConfig)(repoRoot);
@@ -356,6 +387,16 @@ async function runFinalize(options) {
356
387
  }
357
388
  }
358
389
  }
390
+ // Step 5.8: Completed-cluster QC trigger (when configured)
391
+ // Runs after all authoritative gates and before final commit/delivery.
392
+ await runQcGate({
393
+ config: config.qc,
394
+ state,
395
+ repoRoot,
396
+ branch,
397
+ trigger: "completed-cluster",
398
+ stepLabel: "[5.8/14]",
399
+ });
359
400
  // Step 6: Tracker Reconciliation
360
401
  // LinearAdapter is sync-in only; only McpBridgeAdapter supports full reconciliation.
361
402
  const trackerType = config.tracker?.adapter;
@@ -392,28 +433,32 @@ async function runFinalize(options) {
392
433
  else {
393
434
  console.warn(`[6/14] Unknown tracker adapter '${trackerType}' — skipping reconciliation.`);
394
435
  }
395
- // Step 7: Single final commit: source changes + durable Polaris artifacts
396
- console.log("[7/14] Committing durable Polaris state + map..."); // Step count updated
436
+ // Step 7: Closeout Librarian gate (pre-commit preflight)
437
+ // Must run before the finalize commit so that a missing or invalid librarian
438
+ // result aborts before any mutating git I/O.
439
+ if (!skipDelivery) {
440
+ if (!skipLibrarian) {
441
+ console.log("[7/14] Checking Closeout Librarian gate...");
442
+ const librarianBlocker = checkLibrarianGate(repoRoot, state.cluster_id);
443
+ if (librarianBlocker) {
444
+ process.stderr.write(`finalize aborted: Closeout Librarian gate failed.\n${librarianBlocker}\n`);
445
+ process.exit(1);
446
+ }
447
+ console.log("[7/14] Closeout Librarian gate passed.");
448
+ }
449
+ else {
450
+ console.log("[7/14] Closeout Librarian gate skipped (--skip-librarian).");
451
+ }
452
+ }
453
+ // Step 8: Single final commit: source changes + durable Polaris artifacts
454
+ console.log("[8/14] Committing durable Polaris state + map..."); // Step count updated
397
455
  const resolvedStateFile = (0, node_path_1.resolve)(stateFile);
398
456
  (0, _06_commit_js_1.stepCommit)(repoRoot, state, resolvedStateFile, reportPath);
399
457
  if (skipDelivery) {
400
- console.log("[8–14/14] Delivery skipped (--skip-delivery).");
401
- console.log("polaris finalize steps 1–7 complete.");
458
+ console.log("[9–14/14] Delivery skipped (--skip-delivery).");
459
+ console.log("polaris finalize steps 1–8 complete.");
402
460
  return;
403
461
  }
404
- // Step 8: Closeout Librarian gate
405
- if (!skipLibrarian) {
406
- console.log("[8/14] Checking Closeout Librarian gate...");
407
- const librarianBlocker = checkLibrarianGate(repoRoot, state.cluster_id);
408
- if (librarianBlocker) {
409
- process.stderr.write(`finalize aborted: Closeout Librarian gate failed.\n${librarianBlocker}\n`);
410
- process.exit(1);
411
- }
412
- console.log("[8/14] Closeout Librarian gate passed.");
413
- }
414
- else {
415
- console.log("[8/14] Closeout Librarian gate skipped (--skip-librarian).");
416
- }
417
462
  // Step 9: git push
418
463
  console.log("[9/14] Pushing branch...");
419
464
  (0, _07_push_js_1.stepPush)(repoRoot, branch);
@@ -421,6 +466,17 @@ async function runFinalize(options) {
421
466
  const prDraft = config.finalize?.prDraft ?? true;
422
467
  console.log("[10/14] Creating draft PR...");
423
468
  const prUrl = (0, _08_create_pr_js_1.stepCreatePr)(repoRoot, branch, state, prDraft);
469
+ // Step 10.5: PR-required QC trigger (when configured)
470
+ // Providers that require a PR URL run here after the PR is created.
471
+ await runQcGate({
472
+ config: config.qc,
473
+ state,
474
+ repoRoot,
475
+ branch,
476
+ trigger: "pr",
477
+ prUrl,
478
+ stepLabel: "[10.5/14]",
479
+ });
424
480
  // Step 11: Write PR URL to current-state.json
425
481
  console.log("[11/14] Writing PR URL to state...");
426
482
  state = (0, _09_update_state_js_1.stepUpdateState)(resolvedStateFile, state, prUrl);
@@ -4,6 +4,8 @@ exports.assertNotDoneState = assertNotDoneState;
4
4
  exports.findReviewState = findReviewState;
5
5
  exports.postLinearComment = postLinearComment;
6
6
  exports.updateLinearIssueAfterFinalize = updateLinearIssueAfterFinalize;
7
+ exports.buildFollowUpIssuePayload = buildFollowUpIssuePayload;
8
+ exports.createLinearFollowUpIssue = createLinearFollowUpIssue;
7
9
  const node_https_1 = require("node:https");
8
10
  const lifecycle_policy_js_1 = require("../tracker/lifecycle-policy.js");
9
11
  // ──────────────────────────────────────────────────────────────────────────────
@@ -211,3 +213,46 @@ async function updateLinearIssueAfterFinalize(options) {
211
213
  throw new Error(`Linear commentCreate failed: ${JSON.stringify(commentData.commentCreate)}`);
212
214
  }
213
215
  }
216
+ async function findIssueTeamId(issueId, apiKey) {
217
+ const issueData = await linearGraphQL(`query GetIssueTeam($id: String!) { issue(id: $id) { team { id } } }`, { id: issueId }, apiKey);
218
+ return issueData.issue?.team?.id ?? null;
219
+ }
220
+ /**
221
+ * Build the GraphQL variables for a Linear follow-up issue.
222
+ * Exported separately so tests can inspect the payload shape.
223
+ */
224
+ function buildFollowUpIssuePayload(options) {
225
+ const input = {
226
+ title: options.title,
227
+ description: options.description,
228
+ parentId: options.parentIssueId,
229
+ teamId: options.teamId,
230
+ };
231
+ if (options.stateId)
232
+ input.stateId = options.stateId;
233
+ if (options.labelIds && options.labelIds.length > 0)
234
+ input.labelIds = options.labelIds;
235
+ return {
236
+ query: `mutation CreateFollowUpIssue($input: IssueCreateInput!) {
237
+ issueCreate(input: $input) { success issue { id identifier url } }
238
+ }`,
239
+ variables: { input },
240
+ };
241
+ }
242
+ /**
243
+ * Create a Linear follow-up issue linked to a parent issue.
244
+ *
245
+ * This is the repair path for QC findings that are routed to follow-up work.
246
+ */
247
+ async function createLinearFollowUpIssue(options) {
248
+ const teamId = options.teamId ?? await findIssueTeamId(options.parentIssueId, options.apiKey);
249
+ if (!teamId) {
250
+ throw new Error(`Unable to resolve Linear team for parent issue ${options.parentIssueId}`);
251
+ }
252
+ const payload = buildFollowUpIssuePayload({ ...options, teamId });
253
+ const data = await linearGraphQL(payload.query, payload.variables, options.apiKey);
254
+ if (data.issueCreate?.success !== true || !data.issueCreate?.issue) {
255
+ throw new Error(`Linear issueCreate failed: ${JSON.stringify(data.issueCreate)}`);
256
+ }
257
+ return data.issueCreate.issue;
258
+ }
@@ -1,8 +1,61 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.generateRunReport = generateRunReport;
4
+ function renderQcSection(qcSummary) {
5
+ if (!qcSummary)
6
+ return "";
7
+ const { total_findings, blocking_findings, autofixed_findings, repaired_findings, waived_findings, unvalidated_findings, open_by_severity, blocks_delivery, qc_run_count, weighted_open_score, qc_penalty, provider_breakdown, routing_breakdown } = qcSummary;
8
+ const openTotal = open_by_severity.critical + open_by_severity.high +
9
+ open_by_severity.medium + open_by_severity.low + open_by_severity.info;
10
+ const deliveryStatus = blocks_delivery
11
+ ? "**BLOCKED** — unresolved critical/high findings must be addressed before delivery"
12
+ : "Not blocking delivery";
13
+ const providers = Object.entries(provider_breakdown)
14
+ .map(([provider, summary]) => `| ${provider} | ${summary.total} | ${summary.blocking} | ${summary.unvalidated} |`)
15
+ .join("\n");
16
+ const routing = `| original-worker | ${routing_breakdown.original_worker} |
17
+ | repair-worker | ${routing_breakdown.repair_worker} |
18
+ | follow-up | ${routing_breakdown.follow_up} |
19
+ | operator-review | ${routing_breakdown.operator_review} |
20
+ | unset | ${routing_breakdown.unset} |`;
21
+ const solImpactValue = qc_penalty > 0
22
+ ? `-${(qc_penalty * 100).toFixed(1)}% (weighted open score ${weighted_open_score.toFixed(2)})`
23
+ : `none (weighted open score ${weighted_open_score.toFixed(2)})`;
24
+ return `
25
+ ## QC summary
26
+
27
+ | Metric | Value |
28
+ |---|---|
29
+ | **QC runs** | ${qc_run_count} |
30
+ | **Delivery status** | ${deliveryStatus} |
31
+ | **Total findings** | ${total_findings} (${unvalidated_findings} unvalidated/provider-noise excluded from scoring) |
32
+ | **SOL score impact** | ${solImpactValue} |
33
+
34
+ | Status | Count |
35
+ |---|---|
36
+ | Blocking (critical/high, open) | ${blocking_findings} |
37
+ | Open (all severities) | ${openTotal} |
38
+ | Autofixed | ${autofixed_findings} |
39
+ | Repaired | ${repaired_findings} |
40
+ | Waived | ${waived_findings} |
41
+
42
+ | **Open by severity** | critical=${open_by_severity.critical} high=${open_by_severity.high} medium=${open_by_severity.medium} low=${open_by_severity.low} info=${open_by_severity.info} |
43
+
44
+ ### Providers
45
+
46
+ | Provider | Total | Blocking | Unvalidated |
47
+ |---|---|---|---|
48
+ ${providers || "| _none_ | — | — | — |"}
49
+
50
+ ### Repair routing
51
+
52
+ | Decision | Count |
53
+ |---|---|
54
+ ${routing}
55
+ `;
56
+ }
4
57
  function generateRunReport(data) {
5
- const { state, branch, validationPassed, prUrl, artifacts, notes } = data;
58
+ const { state, branch, validationPassed, prUrl, artifacts, notes, qcSummary } = data;
6
59
  const total = state.completed_children.length + state.open_children.length;
7
60
  const completedCount = state.completed_children.length;
8
61
  const childRows = state.completed_children
@@ -18,19 +71,22 @@ function generateRunReport(data) {
18
71
  const blockerNote = state.blocker
19
72
  ? `\n**Blocker:** ${state.blocker.reason} (child: ${state.blocker.child_id})\n`
20
73
  : "";
74
+ const qcSection = renderQcSection(qcSummary);
21
75
  return `# Run Report: ${state.run_id}
22
76
 
23
- **Status:** ${state.status}
24
- **Branch:** ${branch}
25
- **PR:** ${prUrl ?? "TBD — set at delivery step 9"}
26
- **Children completed:** ${completedCount} of ${total}
27
- **Validation:** ${validationPassed ? "passed" : "failed"}
28
- ${blockerNote}
29
- ## Children
77
+ | Field | Value |
78
+ |---|---|
79
+ | **Status** | ${state.status} |
80
+ | **Branch** | ${branch} |
81
+ | **PR** | ${prUrl ?? "TBD set at delivery step 9"} |
82
+ | **Children completed** | ${completedCount} of ${total} |
83
+ | **Validation** | ${validationPassed ? "passed" : "failed"} |
84
+ ${blockerNote}
85
+ ## Children
30
86
 
31
- | ID | Title | Commit | Status |
32
- |---|---|---|---|
33
- ${childRows || "_No children recorded_"}
87
+ | ID | Title | Commit | Status |
88
+ |---|---|---|---|
89
+ ${childRows || "_No children recorded_"}
34
90
 
35
91
  ## Artifacts produced
36
92
 
@@ -39,7 +95,7 @@ ${artifactList}
39
95
  ## Validation summary
40
96
 
41
97
  ${validationPassed ? "Map validate passed. Schema validate passed." : "One or more validations failed — see step output above."}
42
-
98
+ ${qcSection}
43
99
  ## Notes
44
100
 
45
101
  ${notesList}
@@ -50,6 +50,7 @@ class AgentSubtaskAdapter {
50
50
  const label = packet.active_child || ((0, worker_packet_js_1.isWorkerPacket)(packet) ? packet.worker_role : 'worker');
51
51
  const commandRun = `agent-subtask:${label}`;
52
52
  const provider = options.provider || "agent-subtask";
53
+ const routerEvidence = options.routerDecision;
53
54
  if (!this.dispatcher) {
54
55
  const error = "Native ephemeral agent subtask dispatch is unavailable in this host environment. " +
55
56
  "Use manual handoff or a configured terminal-cli adapter.";
@@ -60,6 +61,10 @@ class AgentSubtaskAdapter {
60
61
  command_run: commandRun,
61
62
  summary: error,
62
63
  stderr: error,
64
+ failure_origin: "provider-launch",
65
+ failure_category: "provider-unavailable",
66
+ fallback_eligible: true,
67
+ router_evidence: routerEvidence,
63
68
  };
64
69
  }
65
70
  if ((0, worker_packet_js_1.isWorkerPacket)(packet) && packet.worker_role === 'impl') {
@@ -78,6 +83,10 @@ class AgentSubtaskAdapter {
78
83
  warnings: ["empty-allowed-scope"],
79
84
  }),
80
85
  stderr: blockedMsg,
86
+ failure_origin: "provider-launch",
87
+ failure_category: "launch-error",
88
+ fallback_eligible: false,
89
+ router_evidence: routerEvidence,
81
90
  };
82
91
  }
83
92
  }
@@ -94,6 +103,7 @@ class AgentSubtaskAdapter {
94
103
  next_action: "resume-parent",
95
104
  warnings: ["dry-run"],
96
105
  }),
106
+ router_evidence: routerEvidence,
97
107
  };
98
108
  }
99
109
  const instructions = (0, worker_instructions_js_1.buildWorkerInstructions)(packet);
@@ -113,6 +123,10 @@ class AgentSubtaskAdapter {
113
123
  command_run: commandRun,
114
124
  summary: validationError,
115
125
  stderr: validationError,
126
+ failure_origin: "worker-execution",
127
+ failure_category: "worker-failure",
128
+ fallback_eligible: false,
129
+ router_evidence: routerEvidence,
116
130
  };
117
131
  }
118
132
  return {
@@ -120,6 +134,7 @@ class AgentSubtaskAdapter {
120
134
  provider_used: provider,
121
135
  command_run: commandRun,
122
136
  summary,
137
+ router_evidence: routerEvidence,
123
138
  };
124
139
  }
125
140
  catch (err) {
@@ -130,6 +145,10 @@ class AgentSubtaskAdapter {
130
145
  command_run: commandRun,
131
146
  summary: `Native ephemeral agent subtask dispatch failed: ${msg}`,
132
147
  stderr: msg,
148
+ failure_origin: "worker-execution",
149
+ failure_category: "worker-failure",
150
+ fallback_eligible: false,
151
+ router_evidence: routerEvidence,
133
152
  };
134
153
  }
135
154
  }
@@ -65,7 +65,6 @@ function writeSealedResultFromSummary(packet, parsedSummary) {
65
65
  const validation = parsedSummary["validation"] ?? parsedSummary["validation_summary"];
66
66
  const sealedResult = {
67
67
  run_id: packet.run_id,
68
- cluster_id: packet.cluster_id,
69
68
  child_id: packet.active_child,
70
69
  status: normalizeSealedStatus(parsedSummary["status"]),
71
70
  };
@@ -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 {
@@ -318,9 +501,7 @@ class TerminalCliAdapter {
318
501
  // that return minimal status objects instead of proper CompactReturn structs.
319
502
  const effectiveStatus = (exitCode === 0 && isValidCompactReturn) ? "success" : "failure";
320
503
  const sealedResult = {
321
- ...normalized,
322
504
  run_id: packet.run_id,
323
- cluster_id: packet.cluster_id,
324
505
  child_id: String(normalized["child_id"] ?? packet.active_child),
325
506
  status: effectiveStatus,
326
507
  commit: typeof normalized["commit"] === "string"
@@ -336,7 +517,6 @@ class TerminalCliAdapter {
336
517
  ? normalized["error_message"]
337
518
  : stdout || summary)
338
519
  : undefined,
339
- ...(compactReturnErrors.length > 0 ? { compact_return_errors: compactReturnErrors } : {}),
340
520
  };
341
521
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(resultFile), { recursive: true });
342
522
  node_fs_1.default.writeFileSync(resultFile, JSON.stringify(sealedResult, null, 2), "utf-8");