@lsctech/polaris 0.5.8 → 0.5.10
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.
- package/dist/autoresearch/sol-run-health-bridge.js +246 -0
- package/dist/cli/adopt-canon.js +16 -5
- package/dist/cli/adopt-cognition.js +23 -8
- package/dist/cli/medic.js +65 -0
- package/dist/cognition/closeout-librarian-types.js +53 -0
- package/dist/cognition/librarian-packet.js +25 -1
- package/dist/cognition/validate.js +85 -3
- package/dist/config/defaults.js +4 -0
- package/dist/config/validator.js +141 -0
- package/dist/finalize/index.js +224 -5
- package/dist/finalize/medic-gate.js +42 -0
- package/dist/loop/adapters/terminal-cli.js +3 -2
- package/dist/loop/parent.js +208 -1
- package/dist/loop/worker-packet.js +19 -1
- package/dist/medic/run-health-consult.js +381 -0
- package/dist/medic/treatment-packets.js +169 -0
- package/dist/qc/providers/coderabbit.js +90 -18
- package/dist/qc/repair-loop.js +108 -28
- package/dist/qc/runner.js +7 -1
- package/dist/run-health/foreman-symptoms.js +100 -0
- package/dist/run-health/index.js +301 -0
- package/dist/run-health/qc-escalation.js +155 -0
- package/dist/run-health/schema.js +123 -0
- package/dist/smartdocs-engine/validate-instructions.js +60 -2
- package/package.json +1 -1
|
@@ -17,18 +17,20 @@ function pickString(...candidates) {
|
|
|
17
17
|
return undefined;
|
|
18
18
|
}
|
|
19
19
|
const FINDING_LOCATION_KEYS = ["file", "filePath", "path"];
|
|
20
|
-
const
|
|
21
|
-
"severity",
|
|
20
|
+
const FINDING_CONTENT_KEYS = [
|
|
22
21
|
"message",
|
|
23
22
|
"title",
|
|
24
23
|
"summary",
|
|
25
24
|
"description",
|
|
26
25
|
"body",
|
|
27
|
-
"rule",
|
|
28
|
-
"category",
|
|
29
26
|
"suggestion",
|
|
30
27
|
"suggestedAction",
|
|
31
28
|
"fix",
|
|
29
|
+
];
|
|
30
|
+
const FINDING_BOOKKEEPING_KEYS = [
|
|
31
|
+
"severity",
|
|
32
|
+
"category",
|
|
33
|
+
"rule",
|
|
32
34
|
"providerFindingId",
|
|
33
35
|
"id",
|
|
34
36
|
"findingId",
|
|
@@ -56,8 +58,37 @@ const PROGRESS_STATUS_VALUES = new Set([
|
|
|
56
58
|
function hasFindingLocation(record) {
|
|
57
59
|
return FINDING_LOCATION_KEYS.some((key) => record[key] !== undefined);
|
|
58
60
|
}
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
+
function isGenuineTitle(record) {
|
|
62
|
+
const title = record.title;
|
|
63
|
+
if (typeof title !== "string" || title.trim().length === 0) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const trimmedTitle = title.trim().toLowerCase();
|
|
67
|
+
const fallbackSources = [record.category, record.type, record.rule];
|
|
68
|
+
for (const source of fallbackSources) {
|
|
69
|
+
if (typeof source === "string" && source.trim().toLowerCase() === trimmedTitle) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
function hasFindingContent(record) {
|
|
76
|
+
for (const key of FINDING_CONTENT_KEYS) {
|
|
77
|
+
if (key === "title") {
|
|
78
|
+
if (isGenuineTitle(record))
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
else if (record[key] !== undefined) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
function hasFindingBookkeeping(record) {
|
|
88
|
+
return FINDING_BOOKKEEPING_KEYS.some((key) => record[key] !== undefined);
|
|
89
|
+
}
|
|
90
|
+
function hasFindingShape(record) {
|
|
91
|
+
return hasFindingLocation(record) || hasFindingContent(record) || hasFindingBookkeeping(record);
|
|
61
92
|
}
|
|
62
93
|
function isProgressRecord(record) {
|
|
63
94
|
// Check progress/status indicators FIRST before the generic finding-content guard
|
|
@@ -73,8 +104,8 @@ function isProgressRecord(record) {
|
|
|
73
104
|
// Status-only records with category="status" are progress records even if they have message/title fields
|
|
74
105
|
if (typeof record.category === "string" && record.category.toLowerCase() === "status")
|
|
75
106
|
return true;
|
|
76
|
-
// Only reject as progress if it has both location AND
|
|
77
|
-
if (hasFindingLocation(record) &&
|
|
107
|
+
// Only reject as progress if it has both location AND content (true finding shape)
|
|
108
|
+
if (hasFindingLocation(record) && hasFindingContent(record)) {
|
|
78
109
|
return false;
|
|
79
110
|
}
|
|
80
111
|
return false;
|
|
@@ -82,7 +113,10 @@ function isProgressRecord(record) {
|
|
|
82
113
|
function isActionableFinding(record) {
|
|
83
114
|
if (isProgressRecord(record))
|
|
84
115
|
return false;
|
|
85
|
-
return hasFindingLocation(record) ||
|
|
116
|
+
return hasFindingLocation(record) || hasFindingContent(record);
|
|
117
|
+
}
|
|
118
|
+
function isUnusableFindingRecord(record) {
|
|
119
|
+
return !isActionableFinding(record) && (isProgressRecord(record) || hasFindingShape(record));
|
|
86
120
|
}
|
|
87
121
|
function makeUnusableOutputError(message) {
|
|
88
122
|
const err = new Error(message);
|
|
@@ -136,6 +170,21 @@ function parseReport(output, format, parser) {
|
|
|
136
170
|
if (data !== undefined && typeof data === "object" && data !== null) {
|
|
137
171
|
const findings = parseFindingsFromPayload(data);
|
|
138
172
|
const record = data;
|
|
173
|
+
if (findings.length === 0) {
|
|
174
|
+
if (isProgressRecord(record)) {
|
|
175
|
+
throw makeUnusableOutputError("CodeRabbit output was a progress/status/heartbeat record");
|
|
176
|
+
}
|
|
177
|
+
if (isUnusableFindingRecord(record)) {
|
|
178
|
+
throw makeUnusableOutputError("CodeRabbit output contained only bookkeeping records");
|
|
179
|
+
}
|
|
180
|
+
const rawFindings = record.findings ?? record.issues ?? record.results;
|
|
181
|
+
if (Array.isArray(rawFindings) && rawFindings.length > 0) {
|
|
182
|
+
const unusableCount = rawFindings.filter((item) => typeof item === "object" && item !== null && isUnusableFindingRecord(item)).length;
|
|
183
|
+
if (unusableCount > 0) {
|
|
184
|
+
throw makeUnusableOutputError(`CodeRabbit output contained only bookkeeping/progress records (${unusableCount} items)`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
139
188
|
return {
|
|
140
189
|
findings,
|
|
141
190
|
...(typeof record.prUrl === "string" ? { prUrl: record.prUrl } : {}),
|
|
@@ -150,16 +199,29 @@ function parseReport(output, format, parser) {
|
|
|
150
199
|
if (Array.isArray(parsed)) {
|
|
151
200
|
const findings = parsed.filter((item) => typeof item === "object" && item !== null && isActionableFinding(item));
|
|
152
201
|
if (findings.length === 0 && parsed.length > 0) {
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
155
|
-
throw makeUnusableOutputError(`CodeRabbit output contained only progress/status/heartbeat records (${
|
|
202
|
+
const unusableCount = parsed.filter((item) => typeof item === "object" && item !== null && isUnusableFindingRecord(item)).length;
|
|
203
|
+
if (unusableCount > 0) {
|
|
204
|
+
throw makeUnusableOutputError(`CodeRabbit output contained only progress/status/heartbeat and bookkeeping records (${unusableCount} items)`);
|
|
156
205
|
}
|
|
157
206
|
}
|
|
158
207
|
return { findings };
|
|
159
208
|
}
|
|
160
209
|
const findings = parseFindingsFromPayload(parsed);
|
|
161
|
-
if (findings.length === 0
|
|
162
|
-
|
|
210
|
+
if (findings.length === 0) {
|
|
211
|
+
const parsedRecord = parsed;
|
|
212
|
+
if (isProgressRecord(parsedRecord)) {
|
|
213
|
+
throw makeUnusableOutputError("CodeRabbit output was a progress/status/heartbeat record");
|
|
214
|
+
}
|
|
215
|
+
if (isUnusableFindingRecord(parsedRecord)) {
|
|
216
|
+
throw makeUnusableOutputError("CodeRabbit output contained only bookkeeping records");
|
|
217
|
+
}
|
|
218
|
+
const rawFindings = parsedRecord.findings ?? parsedRecord.issues ?? parsedRecord.results;
|
|
219
|
+
if (Array.isArray(rawFindings) && rawFindings.length > 0) {
|
|
220
|
+
const unusableCount = rawFindings.filter((item) => typeof item === "object" && item !== null && isUnusableFindingRecord(item)).length;
|
|
221
|
+
if (unusableCount > 0) {
|
|
222
|
+
throw makeUnusableOutputError(`CodeRabbit output contained only bookkeeping/progress records (${unusableCount} items)`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
163
225
|
}
|
|
164
226
|
return {
|
|
165
227
|
findings,
|
|
@@ -169,8 +231,14 @@ function parseReport(output, format, parser) {
|
|
|
169
231
|
};
|
|
170
232
|
}
|
|
171
233
|
catch (jsonError) {
|
|
172
|
-
//
|
|
173
|
-
//
|
|
234
|
+
// Semantic failures (e.g. unusable-output) surfaced during JSON object/array
|
|
235
|
+
// parsing must not be swallowed; only true JSON parse errors fall through to
|
|
236
|
+
// JSONL line scanning when the format is not explicitly JSON.
|
|
237
|
+
if (typeof jsonError === "object" &&
|
|
238
|
+
jsonError !== null &&
|
|
239
|
+
"qcFailureReason" in jsonError) {
|
|
240
|
+
throw jsonError;
|
|
241
|
+
}
|
|
174
242
|
if (format === "json") {
|
|
175
243
|
throw jsonError;
|
|
176
244
|
}
|
|
@@ -180,6 +248,7 @@ function parseReport(output, format, parser) {
|
|
|
180
248
|
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
181
249
|
const lineFindings = [];
|
|
182
250
|
let progressLineCount = 0;
|
|
251
|
+
let unusableLineCount = 0;
|
|
183
252
|
let parsedLineCount = 0;
|
|
184
253
|
for (const line of lines) {
|
|
185
254
|
try {
|
|
@@ -196,6 +265,9 @@ function parseReport(output, format, parser) {
|
|
|
196
265
|
if (isActionableFinding(record)) {
|
|
197
266
|
lineFindings.push(parsed);
|
|
198
267
|
}
|
|
268
|
+
else if (isUnusableFindingRecord(record)) {
|
|
269
|
+
unusableLineCount++;
|
|
270
|
+
}
|
|
199
271
|
}
|
|
200
272
|
catch {
|
|
201
273
|
// Ignore unparseable lines.
|
|
@@ -204,8 +276,8 @@ function parseReport(output, format, parser) {
|
|
|
204
276
|
if (lineFindings.length > 0) {
|
|
205
277
|
return { findings: lineFindings };
|
|
206
278
|
}
|
|
207
|
-
if (progressLineCount > 0) {
|
|
208
|
-
throw makeUnusableOutputError(`CodeRabbit output contained only progress/status/heartbeat records (${progressLineCount} lines)`);
|
|
279
|
+
if (progressLineCount > 0 || unusableLineCount > 0) {
|
|
280
|
+
throw makeUnusableOutputError(`CodeRabbit output contained only progress/status/heartbeat and bookkeeping records (${progressLineCount + unusableLineCount} lines)`);
|
|
209
281
|
}
|
|
210
282
|
if (parsedLineCount > 0) {
|
|
211
283
|
throw new Error("CodeRabbit output contained no actionable findings");
|
package/dist/qc/repair-loop.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Each repair packet becomes a sealed WorkerPacket with worker_role: "repair".
|
|
17
17
|
*/
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
-
exports.DEFAULT_MAX_REPAIR_ROUNDS = void 0;
|
|
19
|
+
exports.DEFAULT_REPAIR_DISPATCH_TIMEOUT_MS = exports.DEFAULT_MAX_REPAIR_ROUNDS = void 0;
|
|
20
20
|
exports.partitionRepairPackets = partitionRepairPackets;
|
|
21
21
|
exports.initRepairLoopState = initRepairLoopState;
|
|
22
22
|
exports.runQcRepairLoop = runQcRepairLoop;
|
|
@@ -28,11 +28,71 @@ const store_js_1 = require("../cluster-state/store.js");
|
|
|
28
28
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
29
29
|
/** Default maximum repair rounds when not configured. */
|
|
30
30
|
exports.DEFAULT_MAX_REPAIR_ROUNDS = 2;
|
|
31
|
+
/** Default timeout for a single repair worker dispatch (30 minutes). */
|
|
32
|
+
exports.DEFAULT_REPAIR_DISPATCH_TIMEOUT_MS = 1_800_000;
|
|
31
33
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
32
34
|
function appendTelemetry(telemetryFile, event) {
|
|
33
35
|
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(telemetryFile), { recursive: true });
|
|
34
36
|
(0, node_fs_1.appendFileSync)(telemetryFile, JSON.stringify(event) + "\n", "utf-8");
|
|
35
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Emit a pre-dispatch telemetry checkpoint and wrap the dispatch call in a
|
|
40
|
+
* configurable timeout. A timed-out dispatch resolves to a failure/timeout
|
|
41
|
+
* RepairWorkerResult instead of blocking the loop indefinitely.
|
|
42
|
+
* On timeout, aborts the dispatch via AbortSignal to terminate the worker process.
|
|
43
|
+
*/
|
|
44
|
+
async function dispatchRepairWorkerWithTimeout(dispatch, packet, round, manifest, timeoutMs, telemetryFile, runId, clusterId) {
|
|
45
|
+
appendTelemetry(telemetryFile, {
|
|
46
|
+
event: "qc-repair-worker-dispatch-start",
|
|
47
|
+
run_id: runId,
|
|
48
|
+
cluster_id: clusterId,
|
|
49
|
+
round,
|
|
50
|
+
packet_id: packet.packetId,
|
|
51
|
+
medic: packet.medic,
|
|
52
|
+
parallel_group: packet.parallelGroup,
|
|
53
|
+
timestamp: new Date().toISOString(),
|
|
54
|
+
});
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
let timer;
|
|
57
|
+
let timedOut = false;
|
|
58
|
+
try {
|
|
59
|
+
const result = await Promise.race([
|
|
60
|
+
dispatch(packet, round, manifest, controller.signal),
|
|
61
|
+
new Promise((_, reject) => {
|
|
62
|
+
timer = setTimeout(() => {
|
|
63
|
+
timedOut = true;
|
|
64
|
+
controller.abort(new Error(`Repair worker dispatch timed out after ${timeoutMs}ms`));
|
|
65
|
+
reject(new Error(`Repair worker dispatch timed out after ${timeoutMs}ms`));
|
|
66
|
+
}, timeoutMs);
|
|
67
|
+
}),
|
|
68
|
+
]);
|
|
69
|
+
if (timer)
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (timer)
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
77
|
+
if (timedOut) {
|
|
78
|
+
appendTelemetry(telemetryFile, {
|
|
79
|
+
event: "qc-repair-worker-dispatch-timeout",
|
|
80
|
+
run_id: runId,
|
|
81
|
+
cluster_id: clusterId,
|
|
82
|
+
round,
|
|
83
|
+
packet_id: packet.packetId,
|
|
84
|
+
timeout_ms: timeoutMs,
|
|
85
|
+
error: msg,
|
|
86
|
+
timestamp: new Date().toISOString(),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
packetId: packet.packetId,
|
|
91
|
+
status: "failure",
|
|
92
|
+
errorMessage: msg,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
36
96
|
async function persistQcRepairOutcome(clusterId, repoRoot, outcome) {
|
|
37
97
|
try {
|
|
38
98
|
const clusterState = (0, store_js_1.readClusterStateSync)(clusterId, repoRoot);
|
|
@@ -96,8 +156,8 @@ function rerunPassed(results) {
|
|
|
96
156
|
}
|
|
97
157
|
/** Partition packets into safe-to-parallel and must-serialize groups. */
|
|
98
158
|
function partitionRepairPackets(packets) {
|
|
99
|
-
// Medic
|
|
100
|
-
const serialized = packets.filter((p) => p.medic
|
|
159
|
+
// Medic packets are always serialized; operator-review packets are never dispatched.
|
|
160
|
+
const serialized = packets.filter((p) => p.medic);
|
|
101
161
|
const parallelizable = packets.filter((p) => !p.medic && p.routingTarget !== "operator-review");
|
|
102
162
|
// Group by parallelGroup assignment from the compiler.
|
|
103
163
|
const groups = new Map();
|
|
@@ -139,6 +199,7 @@ function initRepairLoopState(opts) {
|
|
|
139
199
|
*/
|
|
140
200
|
async function runQcRepairLoop(options) {
|
|
141
201
|
const { clusterId, runId, branch, repoRoot, telemetryFile, config, registry, initialQcResults, dispatchRepairWorker, validationCommands = [], timeoutMs, maxRounds = exports.DEFAULT_MAX_REPAIR_ROUNDS, priorLoopState, onStateUpdate, } = options;
|
|
202
|
+
const repairDispatchTimeoutMs = config.repairDispatchTimeoutMs ?? exports.DEFAULT_REPAIR_DISPATCH_TIMEOUT_MS;
|
|
142
203
|
if (!config.enabled) {
|
|
143
204
|
const state = initRepairLoopState({
|
|
144
205
|
maxRounds,
|
|
@@ -194,25 +255,6 @@ async function runQcRepairLoop(options) {
|
|
|
194
255
|
summary: `QC repair loop halted: all QC providers failed at round ${round}`,
|
|
195
256
|
};
|
|
196
257
|
}
|
|
197
|
-
if (requiresOperatorReview(currentResults)) {
|
|
198
|
-
loopState = { ...loopState, terminal_outcome: "operator-review", updated_at: new Date().toISOString() };
|
|
199
|
-
onStateUpdate?.(loopState);
|
|
200
|
-
appendTelemetry(telemetryFile, {
|
|
201
|
-
event: "qc-repair-loop-terminal",
|
|
202
|
-
run_id: runId,
|
|
203
|
-
outcome: "operator-review",
|
|
204
|
-
round,
|
|
205
|
-
timestamp: new Date().toISOString(),
|
|
206
|
-
});
|
|
207
|
-
await persistQcRepairOutcome(clusterId, repoRoot, "operator-review");
|
|
208
|
-
return {
|
|
209
|
-
outcome: "operator-review",
|
|
210
|
-
rounds_completed: roundsCompleted,
|
|
211
|
-
final_qc_results: currentResults,
|
|
212
|
-
loop_state: loopState,
|
|
213
|
-
summary: `QC repair loop halted: unresolved operator-review findings at round ${round}`,
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
258
|
// ── Compile / discover repair packets ──────────────────────────────────
|
|
217
259
|
let manifest = null;
|
|
218
260
|
// Try to read an existing manifest for this round first (idempotent re-entry).
|
|
@@ -261,10 +303,29 @@ async function runQcRepairLoop(options) {
|
|
|
261
303
|
await persistQcRepairManifest(clusterId, repoRoot, round, existingManifestPath);
|
|
262
304
|
}
|
|
263
305
|
// ── Check for repairable packets ────────────────────────────────────────
|
|
264
|
-
const
|
|
306
|
+
const dispatchablePackets = manifest.packets.filter((p) => p.routingTarget === "repair-worker" &&
|
|
265
307
|
p.status === "pending" &&
|
|
266
308
|
!loopState.completed_packet_ids.includes(p.packetId));
|
|
267
|
-
if (
|
|
309
|
+
if (dispatchablePackets.length === 0 && requiresOperatorReview(currentResults)) {
|
|
310
|
+
loopState = { ...loopState, terminal_outcome: "operator-review", updated_at: new Date().toISOString() };
|
|
311
|
+
onStateUpdate?.(loopState);
|
|
312
|
+
appendTelemetry(telemetryFile, {
|
|
313
|
+
event: "qc-repair-loop-terminal",
|
|
314
|
+
run_id: runId,
|
|
315
|
+
outcome: "operator-review",
|
|
316
|
+
round,
|
|
317
|
+
timestamp: new Date().toISOString(),
|
|
318
|
+
});
|
|
319
|
+
await persistQcRepairOutcome(clusterId, repoRoot, "operator-review");
|
|
320
|
+
return {
|
|
321
|
+
outcome: "operator-review",
|
|
322
|
+
rounds_completed: roundsCompleted,
|
|
323
|
+
final_qc_results: currentResults,
|
|
324
|
+
loop_state: loopState,
|
|
325
|
+
summary: `QC repair loop halted: unresolved operator-review findings at round ${round}`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (dispatchablePackets.length === 0 && !hasRepairableFindings(currentResults)) {
|
|
268
329
|
loopState = { ...loopState, terminal_outcome: "no-repairable", updated_at: new Date().toISOString() };
|
|
269
330
|
onStateUpdate?.(loopState);
|
|
270
331
|
appendTelemetry(telemetryFile, {
|
|
@@ -284,7 +345,7 @@ async function runQcRepairLoop(options) {
|
|
|
284
345
|
};
|
|
285
346
|
}
|
|
286
347
|
// ── Dispatch repair workers (parallel groups, then serialized) ──────────
|
|
287
|
-
const { parallelGroups, serialized } = partitionRepairPackets(
|
|
348
|
+
const { parallelGroups, serialized } = partitionRepairPackets(dispatchablePackets);
|
|
288
349
|
const allWorkerResults = [];
|
|
289
350
|
let hasMedicReferral = false;
|
|
290
351
|
// Dispatch parallel groups.
|
|
@@ -298,12 +359,12 @@ async function runQcRepairLoop(options) {
|
|
|
298
359
|
timestamp: new Date().toISOString(),
|
|
299
360
|
});
|
|
300
361
|
// Within a group, packets are non-conflicting and can run concurrently.
|
|
301
|
-
const groupResults = await Promise.all(group.map((pkt) => dispatchRepairWorker
|
|
362
|
+
const groupResults = await Promise.all(group.map((pkt) => dispatchRepairWorkerWithTimeout(dispatchRepairWorker, pkt, round, manifest, repairDispatchTimeoutMs, telemetryFile, runId, clusterId)));
|
|
302
363
|
allWorkerResults.push(...groupResults);
|
|
303
364
|
}
|
|
304
|
-
// Dispatch serialized (medic
|
|
365
|
+
// Dispatch serialized (medic) packets sequentially.
|
|
305
366
|
for (const pkt of serialized) {
|
|
306
|
-
const result = await dispatchRepairWorker
|
|
367
|
+
const result = await dispatchRepairWorkerWithTimeout(dispatchRepairWorker, pkt, round, manifest, repairDispatchTimeoutMs, telemetryFile, runId, clusterId);
|
|
307
368
|
allWorkerResults.push(result);
|
|
308
369
|
}
|
|
309
370
|
// Record completed packet IDs.
|
|
@@ -438,6 +499,25 @@ async function runQcRepairLoop(options) {
|
|
|
438
499
|
}
|
|
439
500
|
}
|
|
440
501
|
// ── Max rounds exhausted ──────────────────────────────────────────────────
|
|
502
|
+
if (requiresOperatorReview(currentResults)) {
|
|
503
|
+
loopState = { ...loopState, terminal_outcome: "operator-review", updated_at: new Date().toISOString() };
|
|
504
|
+
onStateUpdate?.(loopState);
|
|
505
|
+
appendTelemetry(telemetryFile, {
|
|
506
|
+
event: "qc-repair-loop-terminal",
|
|
507
|
+
run_id: runId,
|
|
508
|
+
outcome: "operator-review",
|
|
509
|
+
rounds_completed: roundsCompleted,
|
|
510
|
+
timestamp: new Date().toISOString(),
|
|
511
|
+
});
|
|
512
|
+
await persistQcRepairOutcome(clusterId, repoRoot, "operator-review");
|
|
513
|
+
return {
|
|
514
|
+
outcome: "operator-review",
|
|
515
|
+
rounds_completed: roundsCompleted,
|
|
516
|
+
final_qc_results: currentResults,
|
|
517
|
+
loop_state: loopState,
|
|
518
|
+
summary: `QC repair loop halted: unresolved operator-review findings after ${roundsCompleted} round(s)`,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
441
521
|
loopState = { ...loopState, terminal_outcome: "max-rounds", updated_at: new Date().toISOString() };
|
|
442
522
|
onStateUpdate?.(loopState);
|
|
443
523
|
appendTelemetry(telemetryFile, {
|
package/dist/qc/runner.js
CHANGED
|
@@ -76,6 +76,12 @@ function classifyTerminalFailure(error, output) {
|
|
|
76
76
|
if (execError?.killed && execError?.signal === "SIGTERM") {
|
|
77
77
|
return "timeout";
|
|
78
78
|
}
|
|
79
|
+
// Exit code 143 is the standard shell convention for a process killed by SIGTERM
|
|
80
|
+
// (128 + SIGTERM). Some provider CLI wrappers report this directly instead of
|
|
81
|
+
// allowing Node's execFile to surface the signal, so treat it as a timeout.
|
|
82
|
+
if (output.exitCode === 143) {
|
|
83
|
+
return "timeout";
|
|
84
|
+
}
|
|
79
85
|
if (execError?.code === "ENOENT") {
|
|
80
86
|
return "command-not-found";
|
|
81
87
|
}
|
|
@@ -174,8 +180,8 @@ async function runSingleProvider(provider, scope, options, fallbackSource) {
|
|
|
174
180
|
const startedAt = new Date().toISOString();
|
|
175
181
|
const command = provider.buildReviewCommand(scope);
|
|
176
182
|
const execFn = options.execFileImpl ?? node_child_process_1.execFile;
|
|
177
|
-
const timeoutMs = options.timeoutMs ?? 300_000; // 5 minutes default
|
|
178
183
|
const providerConfig = getProviderConfig(provider.name, options.config);
|
|
184
|
+
const timeoutMs = options.timeoutMs ?? providerConfig?.timeoutMs ?? 300_000; // 5 minutes default
|
|
179
185
|
emitProviderAttempted(options.telemetryFile, scope.runId, scope.clusterId, provider.name, fallbackSource);
|
|
180
186
|
if (!isModeSupported(provider, scope)) {
|
|
181
187
|
const output = {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Foreman-side runtime symptom emission.
|
|
4
|
+
*
|
|
5
|
+
* The Foreman appends symptoms ONLY for Polaris-runtime intervention events
|
|
6
|
+
* (state repair, cluster repair, missing packet/result repair, dispatch
|
|
7
|
+
* boundary repair, QC/finalize runtime failure, local/global binary mismatch,
|
|
8
|
+
* and manual intervention).
|
|
9
|
+
*
|
|
10
|
+
* Symptoms are NOT emitted for non-Polaris target repositories unless
|
|
11
|
+
* `run_health.foreman_symptoms.enabled` is explicitly set to true in
|
|
12
|
+
* polaris.config.json.
|
|
13
|
+
*
|
|
14
|
+
* QC providers remain unchanged artifact producers — they never write here.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.isForemanSymptomEnabled = isForemanSymptomEnabled;
|
|
18
|
+
exports.appendForemanSymptom = appendForemanSymptom;
|
|
19
|
+
const node_crypto_1 = require("node:crypto");
|
|
20
|
+
const index_js_1 = require("./index.js");
|
|
21
|
+
// ── Policy ────────────────────────────────────────────────────────────────────
|
|
22
|
+
/**
|
|
23
|
+
* Returns true when Foreman-side symptom emission is enabled.
|
|
24
|
+
*
|
|
25
|
+
* Emission is enabled when `run_health.foreman_symptoms.enabled === true` in
|
|
26
|
+
* the project's polaris.config.json. Foreman symptoms are disabled by default
|
|
27
|
+
* so that non-Polaris target repositories are not automatically marked sick.
|
|
28
|
+
*/
|
|
29
|
+
function isForemanSymptomEnabled(config) {
|
|
30
|
+
return config?.run_health?.foreman_symptoms?.enabled === true;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Append a Foreman-side runtime symptom to the run-health report.
|
|
34
|
+
*
|
|
35
|
+
* Creates the report when one does not yet exist; appends to it when one does.
|
|
36
|
+
* When `config` is provided, checks `isForemanSymptomEnabled(config)` and
|
|
37
|
+
* no-ops when disabled (so the caller does not need to guard).
|
|
38
|
+
* Never throws — symptom emission is advisory and must not block the run.
|
|
39
|
+
*/
|
|
40
|
+
function appendForemanSymptom(params) {
|
|
41
|
+
const { runId, clusterId, code, message, evidenceRefs, repoRoot, config } = params;
|
|
42
|
+
// Policy gate: skip when config is provided but policy is not enabled.
|
|
43
|
+
if (config !== undefined && !isForemanSymptomEnabled(config)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const sourceActor = {
|
|
47
|
+
role: "foreman",
|
|
48
|
+
};
|
|
49
|
+
const symptom = {
|
|
50
|
+
id: `foreman:${code}:${(0, node_crypto_1.randomUUID)()}`,
|
|
51
|
+
severity: foremanCodeSeverity(code),
|
|
52
|
+
code,
|
|
53
|
+
message,
|
|
54
|
+
source_actor: sourceActor,
|
|
55
|
+
evidence_refs: evidenceRefs ?? [],
|
|
56
|
+
occurred_at: new Date().toISOString(),
|
|
57
|
+
};
|
|
58
|
+
try {
|
|
59
|
+
const existing = (0, index_js_1.readRunHealthReport)(runId, repoRoot);
|
|
60
|
+
if (!existing) {
|
|
61
|
+
(0, index_js_1.createRunHealthReport)({
|
|
62
|
+
runId,
|
|
63
|
+
clusterId,
|
|
64
|
+
firstSymptom: symptom,
|
|
65
|
+
sourceActor,
|
|
66
|
+
repoRoot,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
(0, index_js_1.appendSymptom)(runId, symptom, repoRoot);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Symptom emission must never block the run — swallow errors silently.
|
|
75
|
+
// ponytail: surface to telemetry in a future pass
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Maps a Foreman symptom code to its default severity.
|
|
80
|
+
*/
|
|
81
|
+
function foremanCodeSeverity(code) {
|
|
82
|
+
switch (code) {
|
|
83
|
+
case "foreman-dispatch-boundary-repair":
|
|
84
|
+
return "critical";
|
|
85
|
+
case "foreman-state-repair":
|
|
86
|
+
case "foreman-cluster-repair":
|
|
87
|
+
case "foreman-packet-repair":
|
|
88
|
+
case "foreman-qc-runtime-failure":
|
|
89
|
+
case "foreman-medic-runtime-failure":
|
|
90
|
+
case "foreman-binary-mismatch":
|
|
91
|
+
case "foreman-wrong-run-telemetry":
|
|
92
|
+
return "high";
|
|
93
|
+
case "foreman-finalize-recovery":
|
|
94
|
+
return "medium";
|
|
95
|
+
case "foreman-manual-intervention":
|
|
96
|
+
return "low";
|
|
97
|
+
default:
|
|
98
|
+
return "medium";
|
|
99
|
+
}
|
|
100
|
+
}
|