@lsctech/polaris 0.5.8 → 0.5.9
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/medic.js +65 -0
- package/dist/config/defaults.js +3 -0
- package/dist/config/validator.js +141 -0
- package/dist/finalize/index.js +34 -2
- package/dist/finalize/medic-gate.js +42 -0
- package/dist/loop/parent.js +201 -0
- 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/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/package.json +1 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* SOL → run-health bridge.
|
|
4
|
+
*
|
|
5
|
+
* Evaluates SOL score reports against operator-configured thresholds and,
|
|
6
|
+
* when policy enables it, appends run-health symptoms so that Medic can be
|
|
7
|
+
* engaged even when no worker explicitly wrote symptoms.
|
|
8
|
+
*
|
|
9
|
+
* Design rules:
|
|
10
|
+
* - SOL is ADVISORY by default. No symptoms are created unless the operator
|
|
11
|
+
* sets `sol.thresholds.enabled = true` AND at least one policy flag
|
|
12
|
+
* (`createRunHealthReport` or `requireMedic`) is true.
|
|
13
|
+
* - Symptoms reference the SOL evidence file as their source; they do NOT
|
|
14
|
+
* mutate raw metric artifacts.
|
|
15
|
+
* - Each threshold crossing produces exactly one symptom per evaluation.
|
|
16
|
+
* Re-evaluating the same run does not duplicate symptoms because the
|
|
17
|
+
* symptom id encodes the threshold name.
|
|
18
|
+
* - Never throws — threshold evaluation must not block the run.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.detectSolThresholdCrossings = detectSolThresholdCrossings;
|
|
22
|
+
exports.evaluateSolThresholds = evaluateSolThresholds;
|
|
23
|
+
const index_js_1 = require("../run-health/index.js");
|
|
24
|
+
// ── Defaults ─────────────────────────────────────────────────────────────────
|
|
25
|
+
const DEFAULT_LOW_COMPOSITE_SCORE = 0.4;
|
|
26
|
+
const DEFAULT_QC_REPAIR_LOOP_FAILURE_STATUSES = [
|
|
27
|
+
"max-rounds",
|
|
28
|
+
"medic-referral",
|
|
29
|
+
"all-providers-failed",
|
|
30
|
+
];
|
|
31
|
+
const DEFAULT_REPEATED_PROVIDER_FAILURES = 3;
|
|
32
|
+
const DEFAULT_FOREMAN_INTERVENTION_COUNT = 2;
|
|
33
|
+
const DEFAULT_STALE_WRONG_RUN_TELEMETRY = true;
|
|
34
|
+
const DEFAULT_VALIDATION_FAILURES = 2;
|
|
35
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
36
|
+
function makeSolSourceActor() {
|
|
37
|
+
return { role: "sol" };
|
|
38
|
+
}
|
|
39
|
+
function makeSolSymptom(crossing) {
|
|
40
|
+
return {
|
|
41
|
+
// Deterministic id: code only (no UUID) so repeated evaluations of the
|
|
42
|
+
// same run are idempotent — the same threshold crossing produces the
|
|
43
|
+
// same id, and a caller that deduplicates by id will skip the duplicate.
|
|
44
|
+
// ponytail: when run-health supports upsert-by-id, switch to that.
|
|
45
|
+
id: `sol:${crossing.code}`,
|
|
46
|
+
severity: solThresholdSeverity(crossing.code),
|
|
47
|
+
code: crossing.code,
|
|
48
|
+
message: crossing.message,
|
|
49
|
+
source_actor: makeSolSourceActor(),
|
|
50
|
+
evidence_refs: crossing.evidenceRefs,
|
|
51
|
+
occurred_at: new Date().toISOString(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function solThresholdSeverity(code) {
|
|
55
|
+
switch (code) {
|
|
56
|
+
case "sol-qc-repair-loop-failure":
|
|
57
|
+
case "sol-repeated-provider-failures":
|
|
58
|
+
return "high";
|
|
59
|
+
case "sol-low-composite-score":
|
|
60
|
+
case "sol-high-foreman-intervention":
|
|
61
|
+
case "sol-validation-failures":
|
|
62
|
+
return "medium";
|
|
63
|
+
case "sol-stale-wrong-run-telemetry":
|
|
64
|
+
return "high";
|
|
65
|
+
default:
|
|
66
|
+
return "medium";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function upsertSolSymptom(runId, clusterId, symptom, repoRoot) {
|
|
70
|
+
const existing = (0, index_js_1.readRunHealthReport)(runId, repoRoot);
|
|
71
|
+
if (!existing) {
|
|
72
|
+
(0, index_js_1.createRunHealthReport)({
|
|
73
|
+
runId,
|
|
74
|
+
clusterId,
|
|
75
|
+
firstSymptom: symptom,
|
|
76
|
+
sourceActor: makeSolSourceActor(),
|
|
77
|
+
repoRoot,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
// Deduplicate: skip if a symptom with the same id already exists
|
|
82
|
+
if (existing.symptoms.some((s) => s.id === symptom.id)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
(0, index_js_1.appendSymptom)(runId, symptom, repoRoot);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// ── Threshold evaluation ──────────────────────────────────────────────────────
|
|
89
|
+
/**
|
|
90
|
+
* Detect which thresholds are crossed given the SOL score report.
|
|
91
|
+
* Returns an array of crossing descriptors without any I/O side effects.
|
|
92
|
+
*/
|
|
93
|
+
function detectSolThresholdCrossings(scoreReport, thresholdsConfig, evidencePaths) {
|
|
94
|
+
const crossings = [];
|
|
95
|
+
const lowCompositeThreshold = thresholdsConfig.low_composite_score ?? DEFAULT_LOW_COMPOSITE_SCORE;
|
|
96
|
+
const qcFailureStatuses = thresholdsConfig.qc_repair_loop_failure_statuses ?? DEFAULT_QC_REPAIR_LOOP_FAILURE_STATUSES;
|
|
97
|
+
const providerFailureThreshold = thresholdsConfig.repeated_provider_failures ?? DEFAULT_REPEATED_PROVIDER_FAILURES;
|
|
98
|
+
const interventionThreshold = thresholdsConfig.foreman_intervention_count ?? DEFAULT_FOREMAN_INTERVENTION_COUNT;
|
|
99
|
+
const checkStaleWrongRun = thresholdsConfig.stale_wrong_run_telemetry ?? DEFAULT_STALE_WRONG_RUN_TELEMETRY;
|
|
100
|
+
const validationFailureThreshold = thresholdsConfig.validation_failures ?? DEFAULT_VALIDATION_FAILURES;
|
|
101
|
+
// 1. Low composite score
|
|
102
|
+
if (scoreReport.run_composite_score !== null &&
|
|
103
|
+
scoreReport.run_composite_score < lowCompositeThreshold) {
|
|
104
|
+
crossings.push({
|
|
105
|
+
code: "sol-low-composite-score",
|
|
106
|
+
message: `SOL run composite score ${scoreReport.run_composite_score.toFixed(4)} is below ` +
|
|
107
|
+
`configured threshold ${lowCompositeThreshold} — run health may be degraded.`,
|
|
108
|
+
evidenceRefs: evidencePaths,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// 2. QC repair-loop failure state
|
|
112
|
+
const foremanReport = scoreReport.foreman;
|
|
113
|
+
const qcRepairLoopDetail = foremanReport.qc_repair_loop?.detail ?? "";
|
|
114
|
+
for (const status of qcFailureStatuses) {
|
|
115
|
+
if (qcRepairLoopDetail.includes(`status=${status}`)) {
|
|
116
|
+
crossings.push({
|
|
117
|
+
code: "sol-qc-repair-loop-failure",
|
|
118
|
+
message: `SOL evidence shows QC repair loop reached failure status "${status}" — ` +
|
|
119
|
+
`Medic consultation is recommended.`,
|
|
120
|
+
evidenceRefs: evidencePaths,
|
|
121
|
+
});
|
|
122
|
+
break; // One symptom per run for QC repair-loop failures
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Also detect via qc_repair_loop score == 0
|
|
126
|
+
if (foremanReport.qc_repair_loop?.score !== null &&
|
|
127
|
+
foremanReport.qc_repair_loop?.score === 0 &&
|
|
128
|
+
crossings.every((c) => c.code !== "sol-qc-repair-loop-failure")) {
|
|
129
|
+
crossings.push({
|
|
130
|
+
code: "sol-qc-repair-loop-failure",
|
|
131
|
+
message: `SOL evidence shows QC repair loop score 0.0 (worst outcome) — ` +
|
|
132
|
+
`Medic consultation is recommended.`,
|
|
133
|
+
evidenceRefs: evidencePaths,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// 3. Repeated provider failures — inferred from worker composite scores ≤ 0
|
|
137
|
+
// and workers_failed count (available through foreman scores proxy).
|
|
138
|
+
const failedWorkers = Object.values(scoreReport.workers).filter((w) => w.composite_score !== null && w.composite_score <= 0);
|
|
139
|
+
if (failedWorkers.length >= providerFailureThreshold) {
|
|
140
|
+
crossings.push({
|
|
141
|
+
code: "sol-repeated-provider-failures",
|
|
142
|
+
message: `SOL evidence shows ${failedWorkers.length} worker(s) with zero composite score ` +
|
|
143
|
+
`(threshold: ${providerFailureThreshold}) — possible repeated provider failures.`,
|
|
144
|
+
evidenceRefs: evidencePaths,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
// 4. High Foreman intervention count
|
|
148
|
+
// intervention.score: 0.0 = user_intervened, 0.5 = foreman_intervened
|
|
149
|
+
const interventionScore = foremanReport.intervention?.score;
|
|
150
|
+
if (interventionScore !== null && interventionScore !== undefined) {
|
|
151
|
+
// pre_analysis dimension uses escalation_events as a proxy
|
|
152
|
+
// intervention score < 0.5 means foreman_intervened; 0.0 means user_intervened
|
|
153
|
+
// We use the foreman.pre_analysis detail to extract escalation_events
|
|
154
|
+
const preAnalysisDetail = foremanReport.pre_analysis?.detail ?? "";
|
|
155
|
+
const escalationMatch = /escalation_events=(\d+)/.exec(preAnalysisDetail);
|
|
156
|
+
const escalationEvents = escalationMatch ? parseInt(escalationMatch[1], 10) : 0;
|
|
157
|
+
if (escalationEvents > interventionThreshold) {
|
|
158
|
+
crossings.push({
|
|
159
|
+
code: "sol-high-foreman-intervention",
|
|
160
|
+
message: `SOL evidence shows ${escalationEvents} escalation event(s) ` +
|
|
161
|
+
`(threshold: ${interventionThreshold}) — possible Foreman intervention loop.`,
|
|
162
|
+
evidenceRefs: evidencePaths,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// 5. Stale / wrong-run telemetry
|
|
167
|
+
// Detected via foreman.dispatch and foreman.duration dimension details
|
|
168
|
+
if (checkStaleWrongRun) {
|
|
169
|
+
const dispatchDetail = foremanReport.dispatch?.detail ?? "";
|
|
170
|
+
const durationDetail = foremanReport.duration?.detail ?? "";
|
|
171
|
+
const hasRedispatch = /redispatched=\d+\//.exec(dispatchDetail);
|
|
172
|
+
const redispatchCount = hasRedispatch
|
|
173
|
+
? parseInt(/redispatched=(\d+)/.exec(dispatchDetail)?.[1] ?? "0", 10)
|
|
174
|
+
: 0;
|
|
175
|
+
// dispatch_epoch >> continue_epoch suggests wrong-run telemetry
|
|
176
|
+
const dispatchEpochMatch = /dispatch_epoch=(\d+)/.exec(durationDetail);
|
|
177
|
+
const continueEpochMatch = /continue_epoch=(\d+)/.exec(durationDetail);
|
|
178
|
+
const dispatchEpoch = dispatchEpochMatch ? parseInt(dispatchEpochMatch[1], 10) : 0;
|
|
179
|
+
const continueEpoch = continueEpochMatch ? parseInt(continueEpochMatch[1], 10) : 0;
|
|
180
|
+
const epochOverhead = dispatchEpoch > 0 ? dispatchEpoch - (continueEpoch + 1) : 0;
|
|
181
|
+
if (redispatchCount >= providerFailureThreshold || epochOverhead >= 3) {
|
|
182
|
+
crossings.push({
|
|
183
|
+
code: "sol-stale-wrong-run-telemetry",
|
|
184
|
+
message: `SOL evidence suggests stale or wrong-run telemetry: ` +
|
|
185
|
+
`redispatched=${redispatchCount}, epoch_overhead=${epochOverhead}. ` +
|
|
186
|
+
`This pattern is consistent with the POL-509 wrong-run failure mode.`,
|
|
187
|
+
evidenceRefs: evidencePaths,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// 6. Validation failures
|
|
192
|
+
const validationFailedWorkers = Object.values(scoreReport.workers).filter((w) => w.validation?.score !== null && w.validation?.score === 0);
|
|
193
|
+
if (validationFailedWorkers.length >= validationFailureThreshold) {
|
|
194
|
+
crossings.push({
|
|
195
|
+
code: "sol-validation-failures",
|
|
196
|
+
message: `SOL evidence shows ${validationFailedWorkers.length} worker(s) with validation failures ` +
|
|
197
|
+
`(threshold: ${validationFailureThreshold}).`,
|
|
198
|
+
evidenceRefs: evidencePaths,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return crossings;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Evaluate SOL score thresholds and, when policy enables it, append
|
|
205
|
+
* run-health symptoms for each crossing.
|
|
206
|
+
*
|
|
207
|
+
* Advisory only by default. To activate run-health writes:
|
|
208
|
+
* `sol.thresholds.enabled = true` AND one of:
|
|
209
|
+
* `sol.thresholds.policy.createRunHealthReport = true`
|
|
210
|
+
* `sol.thresholds.policy.requireMedic = true`
|
|
211
|
+
*
|
|
212
|
+
* Never throws — threshold evaluation must not block the run.
|
|
213
|
+
*/
|
|
214
|
+
function evaluateSolThresholds(params) {
|
|
215
|
+
const { runId, clusterId, scoreReport, evidencePaths = [], thresholdsConfig, repoRoot } = params;
|
|
216
|
+
const crossings = detectSolThresholdCrossings(scoreReport, thresholdsConfig, evidencePaths);
|
|
217
|
+
const policyEnabled = thresholdsConfig.enabled === true;
|
|
218
|
+
const createReport = thresholdsConfig.policy?.createRunHealthReport === true;
|
|
219
|
+
const requireMedic = thresholdsConfig.policy?.requireMedic === true;
|
|
220
|
+
const shouldWrite = policyEnabled && (createReport || requireMedic);
|
|
221
|
+
if (!shouldWrite || crossings.length === 0) {
|
|
222
|
+
return { crossings, symptomsAppended: 0, medicRequired: false };
|
|
223
|
+
}
|
|
224
|
+
let symptomsAppended = 0;
|
|
225
|
+
let medicDecisionWritten = false;
|
|
226
|
+
try {
|
|
227
|
+
for (const crossing of crossings) {
|
|
228
|
+
upsertSolSymptom(runId, clusterId, makeSolSymptom(crossing), repoRoot);
|
|
229
|
+
symptomsAppended++;
|
|
230
|
+
}
|
|
231
|
+
// If requireMedic is true, set medic_consult to pending so finalize gate fires.
|
|
232
|
+
if (requireMedic && crossings.length > 0) {
|
|
233
|
+
(0, index_js_1.markMedicDecision)(runId, { status: "pending" }, repoRoot);
|
|
234
|
+
medicDecisionWritten = true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// Threshold evaluation must never block the run.
|
|
239
|
+
// ponytail: surface write errors to telemetry in a future pass
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
crossings,
|
|
243
|
+
symptomsAppended,
|
|
244
|
+
medicRequired: medicDecisionWritten,
|
|
245
|
+
};
|
|
246
|
+
}
|
package/dist/cli/medic.js
CHANGED
|
@@ -5,6 +5,10 @@ const commander_1 = require("commander");
|
|
|
5
5
|
const node_path_1 = require("node:path");
|
|
6
6
|
const node_fs_1 = require("node:fs");
|
|
7
7
|
const chart_id_js_1 = require("../medic/chart-id.js");
|
|
8
|
+
const loader_js_1 = require("../config/loader.js");
|
|
9
|
+
const terminal_cli_js_1 = require("../loop/adapters/terminal-cli.js");
|
|
10
|
+
const run_health_consult_js_1 = require("../medic/run-health-consult.js");
|
|
11
|
+
const treatment_packets_js_1 = require("../medic/treatment-packets.js");
|
|
8
12
|
function createMedicCommand(options = {}) {
|
|
9
13
|
const repoRootDefault = options.repoRoot ?? (0, node_path_1.resolve)(process.cwd());
|
|
10
14
|
const medic = new commander_1.Command("medic")
|
|
@@ -34,6 +38,67 @@ function createMedicCommand(options = {}) {
|
|
|
34
38
|
process.exit(1);
|
|
35
39
|
}
|
|
36
40
|
}));
|
|
41
|
+
medic
|
|
42
|
+
.command("run-health-consult")
|
|
43
|
+
.description("Run a Medic run-health consult from a packet file")
|
|
44
|
+
.requiredOption("--packet-file <path>", "Path to MedicRunHealthPacket JSON")
|
|
45
|
+
.option("-r, --repo-root <path>", "Repository root", repoRootDefault)
|
|
46
|
+
.action(async (cmdOptions) => {
|
|
47
|
+
const repoRoot = cmdOptions.repoRoot;
|
|
48
|
+
let packet;
|
|
49
|
+
try {
|
|
50
|
+
packet = JSON.parse((0, node_fs_1.readFileSync)(cmdOptions.packetFile, "utf-8"));
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
process.stderr.write(`medic run-health-consult error: cannot read packet: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (packet.role !== "medic-run-health") {
|
|
58
|
+
process.stderr.write(`medic run-health-consult error: packet role must be \"medic-run-health\", got \"${packet.role}\"\n`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const config = (0, loader_js_1.loadConfig)(repoRoot);
|
|
63
|
+
const adapter = new terminal_cli_js_1.TerminalCliAdapter(config.execution);
|
|
64
|
+
// Use shared provider resolution logic: prefer rotation, fall back to first provider
|
|
65
|
+
const providerName = (config.execution.rotation && config.execution.rotation.length > 0)
|
|
66
|
+
? config.execution.rotation[0]
|
|
67
|
+
: Object.keys(config.execution.providers ?? {})[0] ?? "terminal-cli";
|
|
68
|
+
// Get current branch from git
|
|
69
|
+
let branch = "main";
|
|
70
|
+
try {
|
|
71
|
+
const { execFileSync } = require("node:child_process");
|
|
72
|
+
branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
73
|
+
cwd: repoRoot,
|
|
74
|
+
encoding: "utf-8",
|
|
75
|
+
}).trim();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Fall back to main if git fails
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const result = await (0, run_health_consult_js_1.runMedicRunHealthConsult)({
|
|
82
|
+
packet,
|
|
83
|
+
repoRoot,
|
|
84
|
+
stateFile: packet.cluster_state_path,
|
|
85
|
+
telemetryFile: packet.telemetry_path,
|
|
86
|
+
branch,
|
|
87
|
+
dryRun: false,
|
|
88
|
+
dispatchTreatmentWorkerFn: (input) => (0, treatment_packets_js_1.dispatchTreatmentWorker)({
|
|
89
|
+
...input,
|
|
90
|
+
repoRoot,
|
|
91
|
+
dispatch: (workerPacket) => adapter.dispatch(workerPacket, { provider: providerName }),
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
(0, node_fs_1.writeFileSync)(packet.result_path, JSON.stringify(result, null, 2), "utf-8");
|
|
95
|
+
process.stdout.write(`Medic run-health consult result written to ${packet.result_path}\n`);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
process.stderr.write(`medic run-health-consult error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
37
102
|
return medic;
|
|
38
103
|
}
|
|
39
104
|
function createChart(options) {
|
package/dist/config/defaults.js
CHANGED
package/dist/config/validator.js
CHANGED
|
@@ -548,6 +548,20 @@ function validateConfig(config) {
|
|
|
548
548
|
result.errors.push("finalize.archiveRunSnapshot must be a boolean");
|
|
549
549
|
}
|
|
550
550
|
}
|
|
551
|
+
if ("medic" in config.finalize && config.finalize.medic !== undefined) {
|
|
552
|
+
if (!isPlainObject(config.finalize.medic)) {
|
|
553
|
+
result.valid = false;
|
|
554
|
+
result.errors.push("finalize.medic must be an object");
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
if ("bypassPolicy" in config.finalize.medic && config.finalize.medic.bypassPolicy !== undefined) {
|
|
558
|
+
if (!isString(config.finalize.medic.bypassPolicy) || !["none", "cli"].includes(config.finalize.medic.bypassPolicy)) {
|
|
559
|
+
result.valid = false;
|
|
560
|
+
result.errors.push('finalize.medic.bypassPolicy must be one of "none" or "cli"');
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
551
565
|
}
|
|
552
566
|
}
|
|
553
567
|
// tracker
|
|
@@ -1197,6 +1211,128 @@ function validateConfig(config) {
|
|
|
1197
1211
|
}
|
|
1198
1212
|
}
|
|
1199
1213
|
}
|
|
1214
|
+
// run_health
|
|
1215
|
+
if ("run_health" in config && config.run_health !== undefined) {
|
|
1216
|
+
if (!isPlainObject(config.run_health)) {
|
|
1217
|
+
result.valid = false;
|
|
1218
|
+
result.errors.push("run_health must be an object");
|
|
1219
|
+
}
|
|
1220
|
+
else {
|
|
1221
|
+
if ("foreman_symptoms" in config.run_health && config.run_health.foreman_symptoms !== undefined) {
|
|
1222
|
+
if (!isPlainObject(config.run_health.foreman_symptoms)) {
|
|
1223
|
+
result.valid = false;
|
|
1224
|
+
result.errors.push("run_health.foreman_symptoms must be an object");
|
|
1225
|
+
}
|
|
1226
|
+
else {
|
|
1227
|
+
if ("enabled" in config.run_health.foreman_symptoms && config.run_health.foreman_symptoms.enabled !== undefined) {
|
|
1228
|
+
if (!isBoolean(config.run_health.foreman_symptoms.enabled)) {
|
|
1229
|
+
result.valid = false;
|
|
1230
|
+
result.errors.push("run_health.foreman_symptoms.enabled must be a boolean");
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
// sol
|
|
1238
|
+
if ("sol" in config && config.sol !== undefined) {
|
|
1239
|
+
if (!isPlainObject(config.sol)) {
|
|
1240
|
+
result.valid = false;
|
|
1241
|
+
result.errors.push("sol must be an object");
|
|
1242
|
+
}
|
|
1243
|
+
else {
|
|
1244
|
+
if ("history" in config.sol && config.sol.history !== undefined) {
|
|
1245
|
+
if (!isPlainObject(config.sol.history)) {
|
|
1246
|
+
result.valid = false;
|
|
1247
|
+
result.errors.push("sol.history must be an object");
|
|
1248
|
+
}
|
|
1249
|
+
else {
|
|
1250
|
+
if ("enabled" in config.sol.history && config.sol.history.enabled !== undefined) {
|
|
1251
|
+
if (!isBoolean(config.sol.history.enabled)) {
|
|
1252
|
+
result.valid = false;
|
|
1253
|
+
result.errors.push("sol.history.enabled must be a boolean");
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
if ("path" in config.sol.history && config.sol.history.path !== undefined) {
|
|
1257
|
+
if (!isString(config.sol.history.path)) {
|
|
1258
|
+
result.valid = false;
|
|
1259
|
+
result.errors.push("sol.history.path must be a string");
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if ("thresholds" in config.sol && config.sol.thresholds !== undefined) {
|
|
1265
|
+
if (!isPlainObject(config.sol.thresholds)) {
|
|
1266
|
+
result.valid = false;
|
|
1267
|
+
result.errors.push("sol.thresholds must be an object");
|
|
1268
|
+
}
|
|
1269
|
+
else {
|
|
1270
|
+
if ("enabled" in config.sol.thresholds && config.sol.thresholds.enabled !== undefined) {
|
|
1271
|
+
if (!isBoolean(config.sol.thresholds.enabled)) {
|
|
1272
|
+
result.valid = false;
|
|
1273
|
+
result.errors.push("sol.thresholds.enabled must be a boolean");
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
if ("policy" in config.sol.thresholds && config.sol.thresholds.policy !== undefined) {
|
|
1277
|
+
if (!isPlainObject(config.sol.thresholds.policy)) {
|
|
1278
|
+
result.valid = false;
|
|
1279
|
+
result.errors.push("sol.thresholds.policy must be an object");
|
|
1280
|
+
}
|
|
1281
|
+
else {
|
|
1282
|
+
if ("createRunHealthReport" in config.sol.thresholds.policy && config.sol.thresholds.policy.createRunHealthReport !== undefined) {
|
|
1283
|
+
if (!isBoolean(config.sol.thresholds.policy.createRunHealthReport)) {
|
|
1284
|
+
result.valid = false;
|
|
1285
|
+
result.errors.push("sol.thresholds.policy.createRunHealthReport must be a boolean");
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
if ("requireMedic" in config.sol.thresholds.policy && config.sol.thresholds.policy.requireMedic !== undefined) {
|
|
1289
|
+
if (!isBoolean(config.sol.thresholds.policy.requireMedic)) {
|
|
1290
|
+
result.valid = false;
|
|
1291
|
+
result.errors.push("sol.thresholds.policy.requireMedic must be a boolean");
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if ("low_composite_score" in config.sol.thresholds && config.sol.thresholds.low_composite_score !== undefined) {
|
|
1297
|
+
if (!isNumber(config.sol.thresholds.low_composite_score) || !inRange(config.sol.thresholds.low_composite_score, 0, 1)) {
|
|
1298
|
+
result.valid = false;
|
|
1299
|
+
result.errors.push("sol.thresholds.low_composite_score must be a number between 0 and 1");
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
if ("qc_repair_loop_failure_statuses" in config.sol.thresholds && config.sol.thresholds.qc_repair_loop_failure_statuses !== undefined) {
|
|
1303
|
+
if (!isStringArray(config.sol.thresholds.qc_repair_loop_failure_statuses)) {
|
|
1304
|
+
result.valid = false;
|
|
1305
|
+
result.errors.push("sol.thresholds.qc_repair_loop_failure_statuses must be an array of strings");
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
if ("repeated_provider_failures" in config.sol.thresholds && config.sol.thresholds.repeated_provider_failures !== undefined) {
|
|
1309
|
+
if (!isPositiveInteger(config.sol.thresholds.repeated_provider_failures)) {
|
|
1310
|
+
result.valid = false;
|
|
1311
|
+
result.errors.push("sol.thresholds.repeated_provider_failures must be a positive integer");
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
if ("foreman_intervention_count" in config.sol.thresholds && config.sol.thresholds.foreman_intervention_count !== undefined) {
|
|
1315
|
+
if (!isNonNegativeInteger(config.sol.thresholds.foreman_intervention_count)) {
|
|
1316
|
+
result.valid = false;
|
|
1317
|
+
result.errors.push("sol.thresholds.foreman_intervention_count must be a non-negative integer");
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
if ("stale_wrong_run_telemetry" in config.sol.thresholds && config.sol.thresholds.stale_wrong_run_telemetry !== undefined) {
|
|
1321
|
+
if (!isBoolean(config.sol.thresholds.stale_wrong_run_telemetry)) {
|
|
1322
|
+
result.valid = false;
|
|
1323
|
+
result.errors.push("sol.thresholds.stale_wrong_run_telemetry must be a boolean");
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
if ("validation_failures" in config.sol.thresholds && config.sol.thresholds.validation_failures !== undefined) {
|
|
1327
|
+
if (!isNonNegativeInteger(config.sol.thresholds.validation_failures)) {
|
|
1328
|
+
result.valid = false;
|
|
1329
|
+
result.errors.push("sol.thresholds.validation_failures must be a non-negative integer");
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1200
1336
|
// unknown top-level fields -> warnings
|
|
1201
1337
|
const knownKeys = new Set([
|
|
1202
1338
|
"version",
|
|
@@ -1213,6 +1349,11 @@ function validateConfig(config) {
|
|
|
1213
1349
|
"budget",
|
|
1214
1350
|
"compact",
|
|
1215
1351
|
"qc",
|
|
1352
|
+
"sol",
|
|
1353
|
+
"run_health",
|
|
1354
|
+
"orchestration",
|
|
1355
|
+
"skill_packet",
|
|
1356
|
+
"simplicity",
|
|
1216
1357
|
]);
|
|
1217
1358
|
for (const key of Object.keys(config)) {
|
|
1218
1359
|
if (!knownKeys.has(key)) {
|
package/dist/finalize/index.js
CHANGED
|
@@ -34,6 +34,7 @@ const local_graph_js_1 = require("../tracker/local-graph.js");
|
|
|
34
34
|
const index_js_1 = require("../tracker/sync/index.js");
|
|
35
35
|
const finalize_evidence_js_1 = require("../loop/finalize-evidence.js");
|
|
36
36
|
const delivery_integrity_js_1 = require("./delivery-integrity.js");
|
|
37
|
+
const medic_gate_js_1 = require("./medic-gate.js");
|
|
37
38
|
const index_js_2 = require("../qc/index.js");
|
|
38
39
|
function getBranch(repoRoot) {
|
|
39
40
|
try {
|
|
@@ -368,7 +369,7 @@ async function runQcGate(options) {
|
|
|
368
369
|
process.exit(1);
|
|
369
370
|
}
|
|
370
371
|
async function runFinalize(options) {
|
|
371
|
-
const { repoRoot, stateFile, dryRun, skipDelivery, skipLibrarian } = options;
|
|
372
|
+
const { repoRoot, stateFile, dryRun, skipDelivery, skipLibrarian, bypassMedicReason } = options;
|
|
372
373
|
const config = (0, loader_js_1.loadConfig)(repoRoot);
|
|
373
374
|
// Step 1: polaris map update --changed
|
|
374
375
|
console.log("[1/14] Updating map..."); // Step count updated
|
|
@@ -580,6 +581,29 @@ async function runFinalize(options) {
|
|
|
580
581
|
process.stderr.write(`finalize aborted: authoritative child state mismatch.\n${authChildResult.reason}\n`);
|
|
581
582
|
process.exit(1);
|
|
582
583
|
}
|
|
584
|
+
// Step 5.11: Run-health Medic gate
|
|
585
|
+
// After all worker/QC evidence is available and before any final commit, push,
|
|
586
|
+
// PR creation, or tracker update, require a Medic consultation decision for any
|
|
587
|
+
// run that recorded health symptoms.
|
|
588
|
+
{
|
|
589
|
+
console.log("[5.11/14] Checking run-health Medic gate...");
|
|
590
|
+
const bypassPolicy = config.finalize?.medic?.bypassPolicy ?? "none";
|
|
591
|
+
if (dryRun && bypassMedicReason && bypassPolicy === "cli") {
|
|
592
|
+
console.warn(`Dry run: would write Medic bypass to run-health report for run "${state.run_id}" (reason: ${bypassMedicReason}).`);
|
|
593
|
+
}
|
|
594
|
+
const medicBlocker = (0, medic_gate_js_1.validateMedicGate)({
|
|
595
|
+
runId: state.run_id,
|
|
596
|
+
repoRoot,
|
|
597
|
+
bypassReason: bypassMedicReason,
|
|
598
|
+
bypassPolicy,
|
|
599
|
+
dryRun,
|
|
600
|
+
});
|
|
601
|
+
if (medicBlocker) {
|
|
602
|
+
process.stderr.write(`finalize aborted: run-health Medic gate failed.\n${medicBlocker}\n`);
|
|
603
|
+
process.exit(1);
|
|
604
|
+
}
|
|
605
|
+
console.log("[5.11/14] Run-health Medic gate passed.");
|
|
606
|
+
}
|
|
583
607
|
// Step 6: Tracker Reconciliation
|
|
584
608
|
// LinearAdapter is sync-in only; only McpBridgeAdapter supports full reconciliation.
|
|
585
609
|
const trackerType = config.tracker?.adapter;
|
|
@@ -715,11 +739,19 @@ function createFinalizeCommand(handlers = {}) {
|
|
|
715
739
|
.option("--dry-run", "non-mutating preview: validate and generate report without committing or pushing")
|
|
716
740
|
.option("--skip-delivery", "perform local finalize steps only; skip push/PR/Linear/archive")
|
|
717
741
|
.option("--skip-librarian", "skip the Closeout Librarian gate (backward compatibility only)")
|
|
742
|
+
.option("--bypass-medic <reason>", "bypass the run-health Medic gate (requires finalize.medic.bypassPolicy=cli)")
|
|
718
743
|
.action((options) => {
|
|
719
744
|
const repoRoot = options.repoRoot;
|
|
720
745
|
const stateFile = options.stateFile ??
|
|
721
746
|
(0, node_path_1.join)(repoRoot, ".taskchain_artifacts", "polaris-run", "current-state.json");
|
|
722
|
-
finalizeHandler({
|
|
747
|
+
finalizeHandler({
|
|
748
|
+
repoRoot,
|
|
749
|
+
stateFile,
|
|
750
|
+
dryRun: options.dryRun,
|
|
751
|
+
skipDelivery: options.skipDelivery,
|
|
752
|
+
skipLibrarian: options.skipLibrarian,
|
|
753
|
+
bypassMedicReason: options.bypassMedic,
|
|
754
|
+
})
|
|
723
755
|
.catch((err) => {
|
|
724
756
|
process.stderr.write(`finalize error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
725
757
|
process.exit(1);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateMedicGate = validateMedicGate;
|
|
4
|
+
const index_js_1 = require("../run-health/index.js");
|
|
5
|
+
/**
|
|
6
|
+
* Validates that a run with a run-health report either has a Medic decision or
|
|
7
|
+
* an explicit, allowed policy bypass. Returns `null` when finalize may proceed;
|
|
8
|
+
* returns a human-readable blocker string otherwise.
|
|
9
|
+
*
|
|
10
|
+
* Fails closed: a report that exists but lacks a Medic decision/bypass blocks
|
|
11
|
+
* final commit, push, PR creation, and tracker update.
|
|
12
|
+
*/
|
|
13
|
+
function validateMedicGate(options) {
|
|
14
|
+
const { runId, repoRoot, bypassReason, bypassPolicy = "none", dryRun = false } = options;
|
|
15
|
+
const reportPath = (0, index_js_1.getRunHealthReportPath)(runId, repoRoot);
|
|
16
|
+
const report = (0, index_js_1.readRunHealthReport)(runId, repoRoot);
|
|
17
|
+
// No report means no symptoms were recorded — gate does not apply.
|
|
18
|
+
if (!report)
|
|
19
|
+
return null;
|
|
20
|
+
if ((0, index_js_1.isMedicGateSatisfied)(report))
|
|
21
|
+
return null;
|
|
22
|
+
if (bypassReason) {
|
|
23
|
+
if (bypassPolicy !== "cli") {
|
|
24
|
+
return (`Run-health report at ${reportPath} requires Medic consultation, but a CLI bypass is not allowed ` +
|
|
25
|
+
`by finalize.medic.bypassPolicy (current: "${bypassPolicy}").`);
|
|
26
|
+
}
|
|
27
|
+
if (!dryRun) {
|
|
28
|
+
(0, index_js_1.markBypassed)(runId, {
|
|
29
|
+
reason: bypassReason,
|
|
30
|
+
bypassed_by: process.env.USER ?? process.env.LOGNAME ?? "operator",
|
|
31
|
+
bypassed_at: new Date().toISOString(),
|
|
32
|
+
}, repoRoot);
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const bypassHint = bypassPolicy === "cli"
|
|
37
|
+
? `To bypass this gate, use --bypass-medic "<reason>".`
|
|
38
|
+
: `Bypass is not enabled by finalize.medic.bypassPolicy (current: "${bypassPolicy}").`;
|
|
39
|
+
return (`Run-health report at ${reportPath} has recorded symptoms and requires a Medic consultation decision ` +
|
|
40
|
+
`(medic_consult.status "resolved" or "bypassed", or an explicit policy_bypass) before finalize can commit, push, ` +
|
|
41
|
+
`create a PR, or update the tracker.\n${bypassHint}`);
|
|
42
|
+
}
|