forgedock 1.37.0 → 1.38.1
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/bin/engine/outcome-codes.mjs +12 -3
- package/bin/engine/telemetry-seam.mjs +5 -0
- package/bin/engine-cli.mjs +97 -18
- package/bin/forgedock.mjs +13 -2
- package/bin/lean/activation.mjs +76 -0
- package/bin/lean/activation.test.mjs +75 -0
- package/bin/lean/admission.mjs +17 -1
- package/bin/lean/batch.mjs +51 -21
- package/bin/lean/batch.test.mjs +95 -5
- package/bin/lean/conformance.mjs +53 -4
- package/bin/lean/conformance.test.mjs +24 -1
- package/bin/lean/decision-engine.mjs +217 -0
- package/bin/lean/decision-engine.test.mjs +159 -0
- package/bin/lean/effects.mjs +39 -13
- package/bin/lean/execution.mjs +74 -5
- package/bin/lean/execution.test.mjs +64 -1
- package/bin/lean/explore.test.mjs +1 -1
- package/bin/lean/guards.mjs +3 -2
- package/bin/lean/guards.test.mjs +24 -3
- package/bin/lean/heavy.mjs +15 -2
- package/bin/lean/heavy.test.mjs +31 -2
- package/bin/lean/issuewatch-release.mjs +245 -0
- package/bin/lean/issuewatch-release.test.mjs +244 -0
- package/bin/lean/issuewatch.mjs +386 -0
- package/bin/lean/issuewatch.test.mjs +280 -0
- package/bin/lean/lane.mjs +4 -3
- package/bin/lean/lane.test.mjs +12 -0
- package/bin/lean/lean.mjs +103 -16
- package/bin/lean/lean.test.mjs +127 -5
- package/bin/lean/needs-human-premise.mjs +177 -0
- package/bin/lean/needs-human-premise.test.mjs +136 -0
- package/bin/lean/orchestrate-blocked.test.mjs +71 -0
- package/bin/lean/orchestrate.mjs +93 -21
- package/bin/lean/postdeploy-check.mjs +88 -2
- package/bin/lean/postdeploy-check.test.mjs +78 -0
- package/bin/lean/prodwatch-sources.mjs +165 -0
- package/bin/lean/prodwatch-sources.test.mjs +108 -0
- package/bin/lean/prompts.mjs +9 -3
- package/bin/lean/recovery.test.mjs +14 -0
- package/bin/lean/relay.mjs +182 -0
- package/bin/lean/relay.test.mjs +214 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +13 -1
- package/bin/lean/sandbox-remote.mjs +53 -9
- package/bin/lean/sched.test.mjs +30 -0
- package/bin/lean/schedd-health.mjs +69 -0
- package/bin/lean/schedd-health.test.mjs +51 -0
- package/bin/lean/schedd.mjs +29 -12
- package/bin/lean/schedd.test.mjs +40 -4
- package/bin/lean/sequence.mjs +57 -24
- package/bin/lean/sequence.test.mjs +71 -0
- package/bin/pool/agent.mjs +15 -1
- package/bin/pool/router.mjs +43 -5
- package/bin/runner.mjs +18 -1
- package/commands/deploy-pr.md +12 -0
- package/commands/forgedock-init.md +1 -0
- package/commands/orchestrate/phase-4-execution.md +1 -0
- package/commands/orchestrate.md +1 -0
- package/commands/review-pr.md +17 -13
- package/docs/CONFIG.md +73 -2
- package/docs/DECISIONS.md +32 -0
- package/docs/ISSUE-WATCH.md +95 -0
- package/docs/NORTHSTAR-TEMPLATE.md +21 -0
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/TELEMETRY.md +7 -0
- package/package.json +1 -2
- package/pool/README.md +1 -1
- package/pool/image/Dockerfile +2 -1
- package/pool/image/cs-relay +31 -0
- package/pool/image/entrypoint.sh +10 -0
- package/pool/install/install-schedd.sh +22 -0
- package/pool/install/unit-hardening.sh +6 -0
- package/scripts/postdeploy-rollup-link.sh +40 -0
- package/scripts/postdeploy-rollup-link.test.sh +46 -0
- package/scripts/refresh-bot-token.sh +122 -0
- package/scripts/review-delta.sh +14 -4
- package/scripts/review-delta.test.sh +21 -0
- package/scripts/review-finalize.sh +69 -25
- package/scripts/review-finalize.test.sh +32 -0
- package/scripts/review-pr-arguments.test.sh +10 -0
- package/scripts/review-pr-changed-verification.test.sh +54 -0
- package/scripts/review-pr-delta-wiring.test.sh +1 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* #4766: north-star decision engine. Settles a lane's decision-only `needs-human` escalation from the target repo's own
|
|
5
|
+
* policy file (forge.yaml `decisions.policy`, default NORTHSTAR.md, then CLAUDE.md, AGENTS.md) and the anchored decisions
|
|
6
|
+
* register (#4695); external actions (the authority matrix, `decisions.human_only`) stay with a human.
|
|
7
|
+
*
|
|
8
|
+
* Deterministic first: `loadPolicy` and `splitEscalation` are pure facts (terminal code, block type, matrix hints). The only
|
|
9
|
+
* judgment is one injected `judge()` call per decision part. A one-way, low-confidence or clause-less answer escalates with the
|
|
10
|
+
* analysis attached, never a guess. Shadow mode (the default) posts `FORGE:DECISION (shadow)` and keeps the label; live mode
|
|
11
|
+
* records the decision, clears needs-human and requeues. A veto (`forge:decision-veto` label, or a reply within
|
|
12
|
+
* `veto_hours`) reverts it; a reversal rate above `reversal_max` forces shadow. Never throws: an error is an `error` row.
|
|
13
|
+
* The issue-watch stage (#4765) calls `settle` once per escalation (key: the escalation comment id).
|
|
14
|
+
*/
|
|
15
|
+
import { parseForgeYaml } from "../forge-utils.mjs";
|
|
16
|
+
import { foldDecisions, recordDecisions as recordToRegister } from "./decisions.mjs";
|
|
17
|
+
|
|
18
|
+
export const VETO_LABEL = "forge:decision-veto";
|
|
19
|
+
export const NEEDS_HUMAN_LABEL = "needs-human";
|
|
20
|
+
export const POLICY_FALLBACKS = ["NORTHSTAR.md", "CLAUDE.md", "AGENTS.md"];
|
|
21
|
+
export const VETO_HOURS_DEFAULT = 24;
|
|
22
|
+
export const REVERSAL_MAX_DEFAULT = 0.2;
|
|
23
|
+
export const REVERSAL_MIN_SAMPLE = 5;
|
|
24
|
+
const PARTS_MAX = 5;
|
|
25
|
+
const POLICY_TEXT_MAX = 12000;
|
|
26
|
+
|
|
27
|
+
/** The authority matrix: typed classes with built-in match hints (a forge.yaml `hint_<class>` list replaces them). */
|
|
28
|
+
export const DEFAULT_CLASSES = {
|
|
29
|
+
spend: /\b(spend|spending|paid|purchase|billing|invoice|budget increase|buy)\b/i,
|
|
30
|
+
credentials: /\b(credentials?|secrets?|api[ _-]?keys?|passwords?|tokens?|oauth)\b/i,
|
|
31
|
+
"external-publish": /\b(publish|npm publish|public announcement|tweet|press release)\b/i,
|
|
32
|
+
"customer-send": /\b(customer[- ]facing|customer sends?|newsletter)\b|\b(send|e?mail|notify|message)\b[^.\n]{0,40}\bcustomers?\b/i,
|
|
33
|
+
"prod-write": /\b(production (write|database|db|data change)|prod write|as root|\broot\b|sudo)\b/i,
|
|
34
|
+
promotion: /\b(promot(e|ion)|staging\s*(→|->|to)\s*main|release to (production|main))\b/i,
|
|
35
|
+
legal: /\b(legal|licen[cs]e change|gdpr|terms of service|contract signature)\b/i,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const csv = (v) => String(v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
39
|
+
const hintRe = (words) => new RegExp(`\\b(${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`, "i");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The decisions policy of a repo: `{policyPath, policyText, mode, matrix:[{class, re}], vetoHours, reversalMax, warnings}`.
|
|
43
|
+
* forge.yaml keys (one nesting level, comma lists): `decisions:` `policy`, `mode` (shadow|live, default shadow),
|
|
44
|
+
* `human_only` (class names, default all of DEFAULT_CLASSES), `hint_<class>` (match words), `veto_hours`, `reversal_max`.
|
|
45
|
+
* `io.readFile(path)` returns the text or throws.
|
|
46
|
+
*/
|
|
47
|
+
export function loadPolicy({ io, cwd = "." } = {}) {
|
|
48
|
+
const read = (p) => { try { return String(io.readFile(`${cwd}/${p}`)); } catch { return null; } };
|
|
49
|
+
const warnings = [];
|
|
50
|
+
const raw = read("forge.yaml");
|
|
51
|
+
let d = {};
|
|
52
|
+
if (raw != null) { try { d = parseForgeYaml(raw)?.decisions; } catch { d = {}; } }
|
|
53
|
+
d = d && typeof d === "object" ? d : {};
|
|
54
|
+
let policyPath = null, policyText = "";
|
|
55
|
+
for (const p of [String(d.policy ?? "").trim(), ...POLICY_FALLBACKS].filter(Boolean)) {
|
|
56
|
+
const t = read(p);
|
|
57
|
+
if (t != null) { policyPath = p; policyText = t.slice(0, POLICY_TEXT_MAX); break; }
|
|
58
|
+
if (p === String(d.policy ?? "").trim()) warnings.push(`decisions.policy ${p} not readable`);
|
|
59
|
+
}
|
|
60
|
+
const names = csv(d.human_only).length ? csv(d.human_only) : Object.keys(DEFAULT_CLASSES);
|
|
61
|
+
const matrix = [];
|
|
62
|
+
for (const name of names) {
|
|
63
|
+
const own = csv(d[`hint_${name}`]);
|
|
64
|
+
if (own.length) matrix.push({ class: name, re: hintRe(own) });
|
|
65
|
+
else if (DEFAULT_CLASSES[name]) matrix.push({ class: name, re: DEFAULT_CLASSES[name] });
|
|
66
|
+
else warnings.push(`human_only class ${name} has no hint_${name} and is not built in: dropped`);
|
|
67
|
+
}
|
|
68
|
+
const hours = Number(d.veto_hours), rev = Number(d.reversal_max);
|
|
69
|
+
return {
|
|
70
|
+
policyPath, policyText, matrix, warnings,
|
|
71
|
+
mode: d.mode === "live" ? "live" : "shadow",
|
|
72
|
+
vetoHours: Number.isFinite(hours) && hours >= 0 ? hours : VETO_HOURS_DEFAULT,
|
|
73
|
+
reversalMax: Number.isFinite(rev) && rev >= 0 && rev <= 1 ? rev : REVERSAL_MAX_DEFAULT,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const EVIDENCE_RE = /\b(missing|needs?|requires?|no)\b[^.\n]{0,60}\bread[- ]only\b[^.\n]{0,40}\b(prod|production)\b|\bread[- ]only (prod|production) data\b/i;
|
|
78
|
+
const POSTDEPLOY_RE = /\b(all (remaining )?(items|checks|criteria) (are|is) post[- ]deploy|post[- ]deploy[- ]only)\b/i;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Splits an escalation into decision and external parts from facts. `{terminalCode, blockType, text}`, `matrix` from
|
|
82
|
+
* loadPolicy, `retryable` a set of codes recovery owns. Returns `{kind, decisions:[text], external:[{class, text}]}` with
|
|
83
|
+
* kind `recovery | evidence | post-deploy | external | decision | mixed`.
|
|
84
|
+
*/
|
|
85
|
+
export function splitEscalation({ terminalCode = null, blockType = null, text = "" } = {}, matrix = [], { retryable = [] } = {}) {
|
|
86
|
+
const body = String(text ?? "");
|
|
87
|
+
const out = (kind, decisions = [], external = []) => ({ kind, decisions, external });
|
|
88
|
+
if (terminalCode && new Set(retryable).has(terminalCode)) return out("recovery");
|
|
89
|
+
if (terminalCode === "conformance.unimplemented" || EVIDENCE_RE.test(body)) return out("evidence");
|
|
90
|
+
if (POSTDEPLOY_RE.test(body)) return out("post-deploy");
|
|
91
|
+
const klass = (t) => matrix.find((m) => m.re.test(t))?.class ?? null;
|
|
92
|
+
if (blockType === "credentials" && matrix.some((m) => m.class === "credentials")) return out("external", [], [{ class: "credentials", text: body.slice(0, 200) }]);
|
|
93
|
+
const lines = body.split("\n").map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])\s+/, "").trim()).filter(Boolean);
|
|
94
|
+
const bulleted = body.split("\n").some((l) => /^\s*(?:[-*]|\d+[.)])\s+/.test(l));
|
|
95
|
+
const parts = bulleted ? lines : [body.trim()].filter(Boolean);
|
|
96
|
+
const decisions = [], external = [];
|
|
97
|
+
for (const p of parts) { const c = klass(p); if (c) external.push({ class: c, text: p.slice(0, 200) }); else decisions.push(p.slice(0, 400)); }
|
|
98
|
+
return out(external.length && decisions.length ? "mixed" : external.length ? "external" : "decision", decisions.slice(0, PARTS_MAX), external);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A judge answer that may be applied: a clause, reversible, not low confidence. Otherwise the reason it must escalate. */
|
|
102
|
+
export function admissible(a) {
|
|
103
|
+
if (!a || typeof a !== "object") return { ok: false, why: "no answer" };
|
|
104
|
+
if (!String(a.choice ?? "").trim()) return { ok: false, why: "no choice" };
|
|
105
|
+
if (!String(a.clause ?? "").trim()) return { ok: false, why: "no applicable policy clause or precedent" };
|
|
106
|
+
if (a.reversibility !== "reversible") return { ok: false, why: `${a.reversibility ?? "unknown"} reversibility (only reversible decisions are settled)` };
|
|
107
|
+
if (!["high", "medium"].includes(a.confidence)) return { ok: false, why: `${a.confidence ?? "no"} confidence` };
|
|
108
|
+
return { ok: true, why: "" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether a settled decision was vetoed: the `forge:decision-veto` label, or a comment after `decidedAt` (ms) within
|
|
113
|
+
* `vetoHours` whose first word is `veto`. `comments` is `[{body, createdAt}]`.
|
|
114
|
+
*/
|
|
115
|
+
export function isVetoed({ labels = [], comments = [], decidedAt = 0, vetoHours = VETO_HOURS_DEFAULT, now = Date.now() } = {}) {
|
|
116
|
+
if ((labels ?? []).includes(VETO_LABEL)) return true;
|
|
117
|
+
const end = decidedAt + vetoHours * 3600_000;
|
|
118
|
+
return (comments ?? []).some((c) => {
|
|
119
|
+
const t = Date.parse(c?.createdAt);
|
|
120
|
+
return Number.isFinite(t) && t > decidedAt && t <= Math.min(end, now) && /^\s*\/?veto\b/i.test(String(c?.body ?? ""));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Mode in force: a reversal rate above the cap (with a minimum sample) turns live back to shadow. */
|
|
125
|
+
export function effectiveMode(policy, { decided = 0, reversed = 0 } = {}) {
|
|
126
|
+
if (policy.mode !== "live") return { mode: "shadow", gated: false };
|
|
127
|
+
const rate = decided >= REVERSAL_MIN_SAMPLE ? reversed / decided : 0;
|
|
128
|
+
return rate > policy.reversalMax ? { mode: "shadow", gated: true, rate } : { mode: "live", gated: false, rate };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Counts for the watcher status and telemetry from settle rows. */
|
|
132
|
+
export function tally(rows) {
|
|
133
|
+
const t = { decided: 0, evidence: 0, folded: 0, external_kept: 0, escalated: 0, vetoed: 0, shadow: 0 };
|
|
134
|
+
for (const r of rows ?? []) {
|
|
135
|
+
if (r?.action === "decided") t.decided++;
|
|
136
|
+
else if (r?.action === "shadow") t.shadow++;
|
|
137
|
+
else if (r?.action === "escalated") t.escalated++;
|
|
138
|
+
else if (r?.action === "vetoed") t.vetoed++;
|
|
139
|
+
else if (r?.action === "evidence-needed") t.evidence++;
|
|
140
|
+
else if (r?.action === "post-deploy-only") t.folded++;
|
|
141
|
+
else if (r?.action === "external" || r?.kept) t.external_kept++;
|
|
142
|
+
}
|
|
143
|
+
return t;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const oneLine = (v, n = 300) => String(v ?? "").replace(/\s+/g, " ").trim().slice(0, n);
|
|
147
|
+
const render = (a, policy) => `**What**: ${oneLine(a.choice)}\n**Clause**: ${oneLine(a.clause)} (${policy.policyPath ?? "no policy file"})\n**Rejected**: ${oneLine(a.rejected) || "-"}\n**Reversibility**: ${a.reversibility ?? "-"}\n**Confidence**: ${a.confidence ?? "-"}`;
|
|
148
|
+
|
|
149
|
+
/** Classification only (`--dry-run`): what `settle` would do, no judge call and no write. */
|
|
150
|
+
export function classify(item, policy, opts = {}) {
|
|
151
|
+
const s = splitEscalation(item, policy.matrix, opts);
|
|
152
|
+
return { issue: item.issue, escalationId: item.escalationId ?? null, kind: s.kind, decisions: s.decisions.length, external: s.external.map((e) => e.class) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Settles one escalation. `item`: `{issue, repo, escalationId, text, terminalCode, blockType, options, anchors, comments}`;
|
|
157
|
+
* `deps`: `{judge, post(marker, body, issue), setLabels({issue, add, remove}), requeue(issue), records, stats, emit}`.
|
|
158
|
+
* Idempotent on `escalationId` (a prior engine comment carries `Escalation: <id>`). Returns one row, never throws.
|
|
159
|
+
*/
|
|
160
|
+
export async function settle(item, policy, deps = {}, opts = {}) {
|
|
161
|
+
const row = { issue: item?.issue ?? null, escalationId: item?.escalationId ?? null, kind: null, action: "skipped" };
|
|
162
|
+
try {
|
|
163
|
+
if (item.escalationId && (item.comments ?? []).some((c) => /FORGE:DECISION/.test(String(c?.body ?? "")) && String(c.body).includes(`Escalation: ${item.escalationId}`))) return { ...row, reason: "already handled" };
|
|
164
|
+
const s = splitEscalation(item, policy.matrix, opts);
|
|
165
|
+
row.kind = s.kind;
|
|
166
|
+
if (s.kind === "recovery") return { ...row, reason: "recovery owns this code" };
|
|
167
|
+
if (s.kind === "external") return { ...row, action: "external", kept: true, class: s.external[0]?.class, reason: `skip: external (${s.external[0]?.class})` };
|
|
168
|
+
if (s.kind === "evidence") return { ...row, action: "evidence-needed" };
|
|
169
|
+
if (s.kind === "post-deploy") return { ...row, action: "post-deploy-only" };
|
|
170
|
+
const key = `Escalation: ${item.escalationId ?? "-"}`;
|
|
171
|
+
const precedent = foldDecisions(deps.records ?? []).filter((d) => d.status !== "superseded");
|
|
172
|
+
const answers = [];
|
|
173
|
+
for (const part of s.decisions) {
|
|
174
|
+
const a = await deps.judge({ issue: item.issue, decision: part, options: item.options ?? [], policy: policy.policyText, policyPath: policy.policyPath, precedent });
|
|
175
|
+
const v = admissible(a);
|
|
176
|
+
if (!v.ok) {
|
|
177
|
+
await deps.post("FORGE:DECISION (escalated)", `## Escalated, not decided\n${key}\n**Part**: ${oneLine(part)}\n**Why**: ${v.why}\n${a?.choice ? `**Analysis**: leaning ${oneLine(a.choice)}\n` : ""}needs-human is kept.`, item.issue);
|
|
178
|
+
deps.emit?.({ event: "DECISION_ENGINE", issue: item.issue, decided: 0, escalated: 1, vetoed: 0, mode: policy.mode });
|
|
179
|
+
return { ...row, action: "escalated", reason: v.why };
|
|
180
|
+
}
|
|
181
|
+
answers.push(a);
|
|
182
|
+
}
|
|
183
|
+
const { mode, gated } = effectiveMode(policy, deps.stats ?? {});
|
|
184
|
+
const body = answers.map((a, i) => `### Decision ${i + 1}\n${render(a, policy)}`).join("\n\n");
|
|
185
|
+
if (mode === "shadow") {
|
|
186
|
+
await deps.post("FORGE:DECISION (shadow)", `## Decision (shadow${gated ? ", reversal rate above cap" : ""})\n${key}\n${body}\n\nWould clear needs-human and requeue; the label is kept.`, item.issue);
|
|
187
|
+
deps.emit?.({ event: "DECISION_ENGINE", issue: item.issue, decided: 0, escalated: 0, vetoed: 0, mode: "shadow" });
|
|
188
|
+
return { ...row, action: "shadow", gated };
|
|
189
|
+
}
|
|
190
|
+
await deps.post("FORGE:DECISION", `## Decision\n${key}\n${body}${s.external.length ? `\n\nExternal part stays needs-human: ${s.external.map((e) => e.class).join(", ")}.` : ""}`, item.issue);
|
|
191
|
+
const anchors = item.anchors ?? [];
|
|
192
|
+
const registered = anchors.length ? await (deps.recordDecisions ?? recordToRegister)(answers.map((a) => ({ choice: a.choice, why: a.clause, anchors: a.anchors?.length ? a.anchors : anchors })), { issue: item.issue, repo: item.repo, post: deps.post }) : [];
|
|
193
|
+
if (s.external.length) { // mixed: the decision part is settled, the external part keeps the label
|
|
194
|
+
deps.emit?.({ event: "DECISION_ENGINE", issue: item.issue, decided: answers.length, escalated: 0, vetoed: 0, mode: "live" });
|
|
195
|
+
return { ...row, action: "decided", kept: true, choice: answers[0].choice, registered: registered.length };
|
|
196
|
+
}
|
|
197
|
+
await deps.setLabels({ issue: item.issue, remove: [NEEDS_HUMAN_LABEL] });
|
|
198
|
+
await deps.requeue(item.issue);
|
|
199
|
+
deps.emit?.({ event: "DECISION_ENGINE", issue: item.issue, decided: answers.length, escalated: 0, vetoed: 0, mode: "live" });
|
|
200
|
+
return { ...row, action: "decided", choice: answers[0].choice, registered: registered.length };
|
|
201
|
+
} catch (e) {
|
|
202
|
+
return { ...row, action: "error", reason: oneLine(e?.message) };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Reverts a vetoed live decision: needs-human back, the requeue undone, the veto label cleared. One row, never throws. */
|
|
207
|
+
export async function revertVeto(item, deps = {}) {
|
|
208
|
+
try {
|
|
209
|
+
await deps.setLabels({ issue: item.issue, add: [NEEDS_HUMAN_LABEL], remove: [VETO_LABEL] });
|
|
210
|
+
await deps.unrequeue?.(item.issue);
|
|
211
|
+
await deps.post("FORGE:DECISION (vetoed)", `## Decision vetoed\nEscalation: ${item.escalationId ?? "-"}\nThe owner vetoed the decision: needs-human restored and the requeue reverted.`, item.issue);
|
|
212
|
+
deps.emit?.({ event: "DECISION_ENGINE", issue: item.issue, decided: 0, escalated: 0, vetoed: 1, reversed: 1 });
|
|
213
|
+
return { issue: item.issue, action: "vetoed" };
|
|
214
|
+
} catch (e) {
|
|
215
|
+
return { issue: item?.issue ?? null, action: "error", reason: oneLine(e?.message) };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { loadPolicy, splitEscalation, settle, admissible, isVetoed, effectiveMode, revertVeto, classify, tally, VETO_LABEL } from "./decision-engine.mjs";
|
|
6
|
+
import { mapRecord } from "../engine/telemetry-seam.mjs";
|
|
7
|
+
import { formatOutcomeLists } from "./orchestrate.mjs";
|
|
8
|
+
|
|
9
|
+
const files = (m) => ({ readFile: (p) => { const k = p.replace(/^\.\//, ""); if (k in m) return m[k]; throw new Error("ENOENT"); } });
|
|
10
|
+
const policy = () => loadPolicy({ io: files({ "NORTHSTAR.md": "# star\n1. cheap first", "forge.yaml": "decisions:\n mode: live\n" }), cwd: "." });
|
|
11
|
+
const good = { choice: "use option A", clause: "NORTHSTAR 2: cut waste", rejected: "option B", reversibility: "reversible", confidence: "high" };
|
|
12
|
+
|
|
13
|
+
// Fixtures modelled on the six AlterLab escalations (#37240, #37119, #36969, #36694, #37264, #37276).
|
|
14
|
+
const FIX = {
|
|
15
|
+
37240: { text: "- Which retry strategy for the sync job?\n- Rotate the production API key (credentials)", terminalCode: "builder.blocked", want: "mixed" },
|
|
16
|
+
37119: { text: "- Choose the pagination style\n- Send the announcement email to customers", terminalCode: "builder.blocked", want: "mixed" },
|
|
17
|
+
36969: { text: "- Pick the cache TTL\n- Which naming for the new table", terminalCode: "builder.blocked", want: "decision" },
|
|
18
|
+
36694: { text: "- Keep the legacy endpoint or drop it?\n- Promote staging to main after the change", terminalCode: "builder.blocked", want: "mixed" },
|
|
19
|
+
37264: { text: "The check needs read-only production data we do not have.", terminalCode: "conformance.unimplemented", want: "evidence" },
|
|
20
|
+
37276: { text: "All items are post-deploy: nothing to build before release.", terminalCode: "conformance.deferred", want: "post-deploy" },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
test("#4766 loadPolicy: defaults, fallback order, matrix schema", () => {
|
|
24
|
+
const p = loadPolicy({ io: files({ "AGENTS.md": "a" }) });
|
|
25
|
+
assert.equal(p.policyPath, "AGENTS.md");
|
|
26
|
+
assert.equal(p.mode, "shadow");
|
|
27
|
+
assert.equal(p.vetoHours, 24);
|
|
28
|
+
assert.deepEqual(p.matrix.map((m) => m.class), ["spend", "credentials", "external-publish", "customer-send", "prod-write", "promotion", "legal"]);
|
|
29
|
+
const q = loadPolicy({ io: files({ "forge.yaml": "decisions:\n policy: STAR.md\n human_only: spend, gdpr, bogus\n hint_gdpr: gdpr, dpa\n veto_hours: 6\n reversal_max: 0.5\n", "STAR.md": "s", "NORTHSTAR.md": "n" }) });
|
|
30
|
+
assert.equal(q.policyPath, "STAR.md");
|
|
31
|
+
assert.deepEqual(q.matrix.map((m) => m.class), ["spend", "gdpr"]);
|
|
32
|
+
assert.ok(q.warnings.some((w) => /bogus/.test(w)));
|
|
33
|
+
assert.equal(q.vetoHours, 6);
|
|
34
|
+
assert.equal(q.reversalMax, 0.5);
|
|
35
|
+
assert.equal(loadPolicy({ io: files({}) }).policyPath, null);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("#4766 splitEscalation: the six AlterLab fixtures", () => {
|
|
39
|
+
const { matrix } = policy();
|
|
40
|
+
for (const [n, f] of Object.entries(FIX)) assert.equal(splitEscalation(f, matrix).kind, f.want, `#${n}`);
|
|
41
|
+
const s = splitEscalation(FIX[37240], matrix);
|
|
42
|
+
assert.equal(s.decisions.length, 1);
|
|
43
|
+
assert.equal(s.external[0].class, "credentials");
|
|
44
|
+
assert.equal(splitEscalation({ text: "x", terminalCode: "ci.red" }, matrix, { retryable: ["ci.red"] }).kind, "recovery");
|
|
45
|
+
assert.equal(splitEscalation({ text: "need the token", blockType: "credentials" }, matrix).kind, "external");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("#4766 admissible: one-way, low confidence and no clause escalate", () => {
|
|
49
|
+
assert.equal(admissible(good).ok, true);
|
|
50
|
+
assert.equal(admissible({ ...good, reversibility: "one-way" }).ok, false);
|
|
51
|
+
assert.equal(admissible({ ...good, confidence: "low" }).ok, false);
|
|
52
|
+
assert.equal(admissible({ ...good, clause: "" }).ok, false);
|
|
53
|
+
assert.equal(admissible(null).ok, false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function harness(over = {}) {
|
|
57
|
+
const posts = [], labels = [], requeued = [], events = [], registered = [];
|
|
58
|
+
return {
|
|
59
|
+
posts, labels, requeued, events, registered,
|
|
60
|
+
deps: {
|
|
61
|
+
judge: async () => good,
|
|
62
|
+
post: async (m, b, i) => { posts.push({ m, b, i }); },
|
|
63
|
+
setLabels: async (x) => { labels.push(x); },
|
|
64
|
+
requeue: async (i) => { requeued.push(i); },
|
|
65
|
+
recordDecisions: async (list) => { registered.push(...list); return list; },
|
|
66
|
+
emit: (r) => events.push(r), ...over,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const item = (n, extra = {}) => ({ issue: n, repo: "o/r", escalationId: `c${n}`, anchors: ["bin/x.mjs"], ...FIX[n], ...extra });
|
|
71
|
+
|
|
72
|
+
test("#4766 shadow posts FORGE:DECISION (shadow), keeps the label, no requeue", async () => {
|
|
73
|
+
const h = harness();
|
|
74
|
+
const p = { ...policy(), mode: "shadow" };
|
|
75
|
+
const r = await settle(item(36969), p, h.deps);
|
|
76
|
+
assert.equal(r.action, "shadow");
|
|
77
|
+
assert.equal(h.posts[0].m, "FORGE:DECISION (shadow)");
|
|
78
|
+
assert.deepEqual([h.labels, h.requeued, h.registered], [[], [], []]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("#4766 live clears needs-human, requeues, writes the register", async () => {
|
|
82
|
+
const h = harness();
|
|
83
|
+
const r = await settle(item(36969), policy(), h.deps);
|
|
84
|
+
assert.equal(r.action, "decided");
|
|
85
|
+
assert.deepEqual(h.labels, [{ issue: 36969, remove: ["needs-human"] }]);
|
|
86
|
+
assert.deepEqual(h.requeued, [36969]);
|
|
87
|
+
assert.equal(h.registered.length, 2);
|
|
88
|
+
assert.equal(h.events[0].decided, 2);
|
|
89
|
+
assert.match(h.posts[0].b, /Escalation: c36969/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("#4766 mixed keeps needs-human; external-only untouched; evidence and post-deploy only classified", async () => {
|
|
93
|
+
const h = harness();
|
|
94
|
+
const m = await settle(item(37240), policy(), h.deps);
|
|
95
|
+
assert.equal(m.action, "decided");
|
|
96
|
+
assert.equal(m.kept, true);
|
|
97
|
+
assert.deepEqual([h.labels, h.requeued], [[], []]);
|
|
98
|
+
const e = await settle({ issue: 1, text: "rotate the credentials", escalationId: "x" }, policy(), h.deps);
|
|
99
|
+
assert.equal(e.action, "external");
|
|
100
|
+
assert.equal(e.reason, "skip: external (credentials)");
|
|
101
|
+
assert.equal((await settle(item(37264), policy(), h.deps)).action, "evidence-needed");
|
|
102
|
+
assert.equal((await settle(item(37276), policy(), h.deps)).action, "post-deploy-only");
|
|
103
|
+
assert.deepEqual(classify(item(37240), policy()), { issue: 37240, escalationId: "c37240", kind: "mixed", decisions: 1, external: ["credentials"] });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("#4766 an inadmissible answer escalates with analysis and never changes labels", async () => {
|
|
107
|
+
const h = harness({ judge: async () => ({ ...good, reversibility: "one-way" }) });
|
|
108
|
+
const r = await settle(item(36969), policy(), h.deps);
|
|
109
|
+
assert.equal(r.action, "escalated");
|
|
110
|
+
assert.match(h.posts[0].b, /one-way/);
|
|
111
|
+
assert.deepEqual([h.labels, h.requeued], [[], []]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("#4766 idempotent on the escalation comment id; errors become rows", async () => {
|
|
115
|
+
const h = harness();
|
|
116
|
+
const seen = [{ body: "<!-- FORGE:DECISION -->\nEscalation: c36969" }];
|
|
117
|
+
assert.equal((await settle(item(36969, { comments: seen }), policy(), h.deps)).action, "skipped");
|
|
118
|
+
assert.equal(h.posts.length, 0);
|
|
119
|
+
const bad = harness({ post: async () => { throw new Error("boom"); } });
|
|
120
|
+
assert.equal((await settle(item(36969), policy(), bad.deps)).action, "error");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("#4766 veto: label or reply within the window; revertVeto restores needs-human", async () => {
|
|
124
|
+
const t0 = Date.parse("2026-10-30T00:00:00Z");
|
|
125
|
+
assert.equal(isVetoed({ labels: [VETO_LABEL] }), true);
|
|
126
|
+
const c = (h, body) => ({ body, createdAt: new Date(t0 + h * 3600_000).toISOString() });
|
|
127
|
+
const base = { decidedAt: t0, vetoHours: 24, now: t0 + 48 * 3600_000 };
|
|
128
|
+
assert.equal(isVetoed({ ...base, comments: [c(2, "veto: wrong")] }), true);
|
|
129
|
+
assert.equal(isVetoed({ ...base, comments: [c(30, "veto")] }), false);
|
|
130
|
+
assert.equal(isVetoed({ ...base, comments: [c(2, "looks good")] }), false);
|
|
131
|
+
const h = harness({ unrequeue: async (i) => h.requeued.push(`un${i}`) });
|
|
132
|
+
const r = await revertVeto({ issue: 5, escalationId: "c5" }, h.deps);
|
|
133
|
+
assert.equal(r.action, "vetoed");
|
|
134
|
+
assert.deepEqual(h.labels, [{ issue: 5, add: ["needs-human"], remove: [VETO_LABEL] }]);
|
|
135
|
+
assert.deepEqual(h.requeued, ["un5"]);
|
|
136
|
+
assert.equal(h.events[0].reversed, 1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("#4766 a reversal rate above the cap forces shadow", async () => {
|
|
140
|
+
const p = policy();
|
|
141
|
+
assert.equal(effectiveMode(p, { decided: 10, reversed: 1 }).mode, "live");
|
|
142
|
+
assert.equal(effectiveMode(p, { decided: 10, reversed: 5 }).mode, "shadow");
|
|
143
|
+
assert.equal(effectiveMode(p, { decided: 2, reversed: 2 }).mode, "live");
|
|
144
|
+
const h = harness();
|
|
145
|
+
const r = await settle(item(36969), p, { ...h.deps, stats: { decided: 10, reversed: 5 } });
|
|
146
|
+
assert.equal(r.action, "shadow");
|
|
147
|
+
assert.equal(r.gated, true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("#4766 tally, telemetry mapping and the batch report 'decided' section", () => {
|
|
151
|
+
assert.deepEqual(tally([{ action: "decided" }, { action: "shadow" }, { action: "escalated" }, { action: "external" }, { action: "evidence-needed" }, { action: "post-deploy-only" }]),
|
|
152
|
+
{ decided: 1, evidence: 1, folded: 1, external_kept: 1, escalated: 1, vetoed: 0, shadow: 1 });
|
|
153
|
+
const m = mapRecord({ event: "DECISION_ENGINE", issue: 1, decided: 2, escalated: 0, vetoed: 1, reversed: 1, mode: "live" }, 0);
|
|
154
|
+
assert.equal(m.event, "decision_engine");
|
|
155
|
+
assert.deepEqual(m.payload, { decided: 2, escalated: 0, vetoed: 1, reversed: 1, mode: "live" });
|
|
156
|
+
const lines = formatOutcomeLists([{ issue: 7, outcome: "merged", decided: { choice: "use option A" } }]);
|
|
157
|
+
assert.match(lines.join("\n"), /decided .*\n- #7: use option A/);
|
|
158
|
+
assert.deepEqual(formatOutcomeLists([{ issue: 7, outcome: "merged" }]), []);
|
|
159
|
+
});
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -8,8 +8,8 @@ import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
|
|
|
8
8
|
import { promises as fsp } from "node:fs";
|
|
9
9
|
import { dirname, join, resolve, sep } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { runCliBackendAsync, resolveClaudeCliBinary, getClaudeCliCapabilities, buildLeanSessionFlags, buildReadOnlyToolFlags } from "../runner.mjs";
|
|
12
|
-
import { makeIo } from "../engine-cli.mjs";
|
|
11
|
+
import { runCliBackendAsync, resolveClaudeCliBinary, getClaudeCliCapabilities, buildLeanSessionFlags, buildReadOnlyToolFlags, sanitizeCliModel } from "../runner.mjs";
|
|
12
|
+
import { makeIo, readSubagentModel } from "../engine-cli.mjs";
|
|
13
13
|
import { makeProjector } from "../engine/projector.mjs";
|
|
14
14
|
import { makeLedger, countLiveLanes, ledgerRoot } from "./ledger.mjs";
|
|
15
15
|
import { makeDepCache } from "./depcache.mjs";
|
|
@@ -18,7 +18,7 @@ import { gatherContextFacts } from "./context.mjs";
|
|
|
18
18
|
import { makePitfallStore } from "./pitfalls.mjs";
|
|
19
19
|
import { enumeratingTests, missingEntries } from "./enumerating.mjs";
|
|
20
20
|
import { resolveBaseConflicts } from "./conflictres.mjs";
|
|
21
|
-
import { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts, generatorProblems, journalOrderProblems } from "./sequence.mjs";
|
|
21
|
+
import { seqNumber, sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts, generatorProblems, journalOrderProblems } from "./sequence.mjs";
|
|
22
22
|
import { duplicateExports } from "./postmerge.mjs";
|
|
23
23
|
import { englishCopies } from "./i18n.mjs";
|
|
24
24
|
import { scopedChecks } from "./checks.mjs";
|
|
@@ -76,21 +76,39 @@ export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
|
|
|
76
76
|
|
|
77
77
|
/** #4456: `--settings` registering the re-read nudge (bin/hooks/reread-nudge.mjs) as a PostToolUse hook on Read and Bash. */
|
|
78
78
|
export const REREAD_HOOK = fileURLToPath(new URL("../hooks/reread-nudge.mjs", import.meta.url));
|
|
79
|
-
export const
|
|
79
|
+
export const rereadHooks = (node = process.execPath, hook = REREAD_HOOK) => {
|
|
80
80
|
const hooks = [{ type: "command", command: `${JSON.stringify(node)} ${JSON.stringify(hook)}` }];
|
|
81
81
|
// #4580: the same script denies a full-file Read of an unchanged path already read twice (PreToolUse)
|
|
82
|
-
return
|
|
82
|
+
return { PostToolUse: [{ matcher: "Read|Bash", hooks }], PreToolUse: [{ matcher: "Read", hooks }] };
|
|
83
83
|
};
|
|
84
|
+
export const rereadNudgeFlags = (node = process.execPath, hook = REREAD_HOOK) => ["--settings", JSON.stringify({ hooks: rereadHooks(node, hook) })];
|
|
84
85
|
|
|
85
|
-
|
|
86
|
+
/**
|
|
87
|
+
* #4843: ForgeDock owns the subagent model of the sessions it launches. An operator settings.json `env` such as
|
|
88
|
+
* CLAUDE_CODE_SUBAGENT_MODEL=sonnet + _FORCE=1 would otherwise override every subagent, the #4391 Haiku explorer
|
|
89
|
+
* included, and a child env var cannot undo it (settings env wins); a `--settings` flag does. FORCE=0 lets a
|
|
90
|
+
* ForgeDock-defined agent's own `model` win. CLAUDE_CODE_SUBAGENT_MODEL (the default for agents without one) is
|
|
91
|
+
* set only from forge.yaml `agents.subagent_model`; unset leaves the operator's own default alone (#4849).
|
|
92
|
+
*/
|
|
93
|
+
export const ownedModelEnv = (subagentModel = null) => {
|
|
94
|
+
const m = sanitizeCliModel(subagentModel);
|
|
95
|
+
return { ...(m ? { CLAUDE_CODE_SUBAGENT_MODEL: m } : {}), CLAUDE_CODE_SUBAGENT_MODEL_FORCE: "0" };
|
|
96
|
+
};
|
|
97
|
+
/** The one `--settings` of a lean session: the owned model env, plus the re-read hooks for builder sessions (#4456). */
|
|
98
|
+
export const leanSettingsFlags = ({ subagentModel = null, reread = false } = {}) =>
|
|
99
|
+
["--settings", JSON.stringify({ env: ownedModelEnv(subagentModel), ...(reread ? { hooks: rereadHooks() } : {}) })];
|
|
100
|
+
|
|
101
|
+
export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable, subagentModel = null } = {}) {
|
|
86
102
|
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, rereadNudge = false, maxTurns = null, agents = null, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null }) => {
|
|
87
103
|
const bin = resolveClaudeCliBinary(cwd) || "claude";
|
|
88
104
|
const caps = getClaudeCliCapabilities(cwd);
|
|
89
105
|
// #4391: custom subagents (the explorer) for the builder session; null leaves argv unchanged
|
|
90
|
-
const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : [])
|
|
91
|
-
|
|
106
|
+
const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : [])];
|
|
107
|
+
// #4843 owned models, in extraArgs so FORGEDOCK_FULL_SESSION=1 (which drops sessionFlags) keeps them (#4848 BUG-1);
|
|
108
|
+
// #4456 re-read hooks: builder sessions only, and like the other session flags not in a full session
|
|
109
|
+
const settings = leanSettingsFlags({ subagentModel, reread: rereadNudge && !readOnly && !noTools && process.env.FORGEDOCK_FULL_SESSION !== "1" });
|
|
92
110
|
const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
|
|
93
|
-
args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
|
|
111
|
+
args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: [...(resume ? ["--resume", resume] : []), ...settings],
|
|
94
112
|
logger: { log() {} },
|
|
95
113
|
idleMs, maxMs, stream: idleMs > 0, control, onSpawn, busy, busyMs, onIdleExtend, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}), wrap: probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
|
|
96
114
|
// #4395: a resumed session's first turn carries the prior history, so only a fresh session reports a baseline.
|
|
@@ -275,12 +293,12 @@ export function makeGhCache({ root = null, tmp = tmpdir() } = {}) {
|
|
|
275
293
|
};
|
|
276
294
|
}
|
|
277
295
|
|
|
278
|
-
export function makeLeanIo({ repo }) {
|
|
296
|
+
export function makeLeanIo({ repo, subagentModel = readSubagentModel() }) {
|
|
279
297
|
const base = makeIo();
|
|
280
298
|
const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000, ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}) })).stdout;
|
|
281
299
|
return {
|
|
282
300
|
ghCache: (root) => makeGhCache({ root }),
|
|
283
|
-
gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), heavy: makeHeavyIo({ host: makeHostProbe() }), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
|
|
301
|
+
gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), heavy: makeHeavyIo({ host: makeHostProbe() }), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner({ subagentModel }), waitCiGreen: base.waitCiGreen,
|
|
284
302
|
/** Shared dependency cache (#4220): the repo's own package manager, argv only, bounded by the cache's timeout. */
|
|
285
303
|
deps: makeDepCache({ exec: async (cmd, args, { cwd, env, timeoutMs } = {}) => String((await pexec(cmd, args, { cwd, env: { ...process.env, ...(env ?? {}) }, timeout: timeoutMs ?? 0, maxBuffer: 100 * 1024 * 1024 })).stdout) }),
|
|
286
304
|
/** Names of the checks the base branch requires: classic protection plus rulesets (#4158). [] when neither has any. */
|
|
@@ -389,8 +407,16 @@ export function makeLeanIo({ repo }) {
|
|
|
389
407
|
sequenceSnapshot: ({ cwd, base }) => snapshotLane({ git: (a) => run("git")(a, { cwd }), base }),
|
|
390
408
|
sequenceVerify: ({ cwd, base, before, moves }) => verifyRenumber({ git: (a) => run("git")(a, { cwd }), base, before, moves }),
|
|
391
409
|
/** #4386: the deterministic renumber, sequence-only base-sync resolution, and the host sequence lease at the merge gate. */
|
|
392
|
-
sequenceRenumber: ({ cwd, base, laneRef, regenerate, reserve }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate, reserve }),
|
|
393
|
-
sequenceResolve: ({ cwd, files }) => resolveSequenceConflicts({ git: (a) => run("git")(a, { cwd }), files }),
|
|
410
|
+
sequenceRenumber: ({ cwd, base, laneRef, regenerate, reserve, openNumbers }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate, reserve, openNumbers }),
|
|
411
|
+
sequenceResolve: ({ cwd, files, partial }) => resolveSequenceConflicts({ git: (a) => run("git")(a, { cwd }), files, partial }),
|
|
412
|
+
/** #4732: numbers the other open PRs on `lane` already carry in `dir` (gh only, no fetch); [] on any failure. */
|
|
413
|
+
async sequenceOpenNumbers({ cwd, lane, dir }) {
|
|
414
|
+
try {
|
|
415
|
+
const me = String(await run("git")(["rev-parse", "--abbrev-ref", "HEAD"], { cwd })).trim();
|
|
416
|
+
const prs = JSON.parse(await run("gh")(["pr", "list", "--state", "open", "--base", lane, "--limit", "100", "--json", "headRefName,files"], { cwd }));
|
|
417
|
+
return prs.filter((p) => p?.headRefName !== me).flatMap((p) => (p?.files ?? []).map((f) => String(f?.path ?? ""))).filter((f) => dirname(f) === dir).map((f) => Number(seqNumber(f))).filter(Number.isFinite);
|
|
418
|
+
} catch { return []; }
|
|
419
|
+
},
|
|
394
420
|
/** #4588: deterministic base-merge resolution of union / JSON-map / generated files (bin/lean/conflictres.mjs). */
|
|
395
421
|
conflictResolve: ({ cwd, files, cfg }) => resolveBaseConflicts({ git: (a) => run("git")(a, { cwd }), cwd, files, cfg }),
|
|
396
422
|
sequenceLease: makeSequenceLeaseIo(),
|
package/bin/lean/execution.mjs
CHANGED
|
@@ -34,9 +34,14 @@
|
|
|
34
34
|
* orchestrate runs a remote-placed lane locally when `max_remote` or the execution budget is exhausted.
|
|
35
35
|
*
|
|
36
36
|
* Host capabilities (#4542): a lane that needs something only the dispatching host has (the SOPS/age key, scoped ops
|
|
37
|
-
* credentials, read-only prod monitor SSH) is placed local in `remote` and `hybrid` mode
|
|
38
|
-
*
|
|
37
|
+
* credentials, read-only prod monitor SSH) is placed local in `remote` and `hybrid` mode unless the provider relays it
|
|
38
|
+
* (`detectNeeds`, `placeLane` `needs`/`relays`). #4778: a remote provider relays the capabilities of forge.yaml
|
|
39
|
+
* `execution.relay_commands` (allowlisted read-only wrappers the host runs for the sandbox, relay.mjs). forge.yaml `execution.host_capabilities` declares which the host has; a
|
|
39
40
|
* remote lane that blocks on one of them ends `env.capability_remote` and is recovered locally (`hostCapabilityBlock`).
|
|
41
|
+
* #4785: needs are read from the body, FORGE:DECISION comments and the frozen contract's validate commands
|
|
42
|
+
* (`placementText`); a remote lane whose frozen validate commands name a declared one ends before building (`contractHostNeeds`).
|
|
43
|
+
* #4776: the body is read without its quoted material (comments, code, quotes, evidence sections) and the frozen
|
|
44
|
+
* `repro_test` command counts like a validate command, so a quoted evidence query no longer pins a lane to the host.
|
|
40
45
|
*/
|
|
41
46
|
import { execFile } from "node:child_process";
|
|
42
47
|
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
@@ -53,6 +58,8 @@ import { httpTransport, isActionsEndpoint, remoteProvider } from "./sandbox-remo
|
|
|
53
58
|
import { ADMISSION_DEFAULTS } from "./admission.mjs";
|
|
54
59
|
import { classifyStep, heavyConfig, memAvailableMb } from "./heavy.mjs";
|
|
55
60
|
import { detectPins } from "./toolchain.mjs";
|
|
61
|
+
import { parseContractComment, noComments } from "./render.mjs";
|
|
62
|
+
import { parseRelayCommands } from "./relay.mjs";
|
|
56
63
|
|
|
57
64
|
export const EXECUTION_MODES = Object.freeze(["local", "remote", "hybrid"]);
|
|
58
65
|
export const SANDBOX_PROVIDERS = Object.freeze(["local", "container", "remote", "pool"]);
|
|
@@ -108,9 +115,10 @@ export function parseExecution(sec = {}) {
|
|
|
108
115
|
const patRaw = Array.isArray(s.host_capability_patterns) ? s.host_capability_patterns : str(s.host_capability_patterns) ? str(s.host_capability_patterns).split(",") : [];
|
|
109
116
|
const hostCapabilityPatterns = patRaw.map((x) => { const t = str(x), i = t.indexOf(":"); return { name: t.slice(0, i).trim().toLowerCase(), pattern: t.slice(i + 1).trim() }; })
|
|
110
117
|
.filter((x) => CAPABILITY_RE.test(x.name) && x.pattern && safeRegExp(x.pattern));
|
|
118
|
+
const relay = Object.fromEntries(Object.entries(parseRelayCommands(s.relay_commands)).filter(([c]) => CAPABILITY_RE.test(c))); // #4778
|
|
111
119
|
const uw = Number(str(s.unreachable_window_min));
|
|
112
120
|
const unreachableWindowMin = str(s.unreachable_window_min) !== "" && Number.isFinite(uw) && uw >= 0 ? uw : null; // unset: providerLanes defaults to 5
|
|
113
|
-
return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}) };
|
|
121
|
+
return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}), ...(Object.keys(relay).length ? { relay } : {}) };
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
const round1 = (x) => Math.round(x * 10) / 10;
|
|
@@ -211,6 +219,66 @@ export function hostCapabilityBlock(summary, caps = null) {
|
|
|
211
219
|
return named.length && named.every((n) => have.has(n)) ? named : [];
|
|
212
220
|
}
|
|
213
221
|
|
|
222
|
+
const EVIDENCE_HEADING = /^(#{1,6})\s*(?:evidence|logs?|data|observations?|measurements?|quer(?:y|ies)|samples?|findings?)\b/i;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* An issue body without its quoted material (#4776), pure: HTML comments (FORGE:STATE too), fenced code blocks, evidence-like
|
|
226
|
+
* sections (heading to the next heading of the same or higher level), blockquote lines and inline code spans. What is left is
|
|
227
|
+
* prose the author wrote as an instruction, not evidence they pasted.
|
|
228
|
+
*/
|
|
229
|
+
function proseOf(body) {
|
|
230
|
+
const kept = [];
|
|
231
|
+
let fence = null, skip = 0;
|
|
232
|
+
for (const line of noComments(body).split("\n")) {
|
|
233
|
+
const f = /^\s*(`{3,}|~{3,})/.exec(line);
|
|
234
|
+
if (fence) { if (f && f[1][0] === fence[0] && f[1].length >= fence.length) fence = null; continue; }
|
|
235
|
+
if (f) { fence = f[1]; continue; }
|
|
236
|
+
const h = /^(#{1,6})\s/.exec(line);
|
|
237
|
+
if (h && skip && h[1].length <= skip) skip = 0;
|
|
238
|
+
if (!skip) { const e = EVIDENCE_HEADING.exec(line); if (e) skip = e[1].length; }
|
|
239
|
+
if (skip || /^\s*>/.test(line)) continue;
|
|
240
|
+
kept.push(line.replace(/`[^`\n]*`/g, ""));
|
|
241
|
+
}
|
|
242
|
+
return kept.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The validate and repro_test commands of a frozen contract (#4776). */
|
|
246
|
+
function contractCommands(contract) {
|
|
247
|
+
const cmds = (Array.isArray(contract?.validate) ? contract.validate : []).map((x) => String(x?.command ?? "")).filter(Boolean);
|
|
248
|
+
const r = contract?.repro_test;
|
|
249
|
+
if (r && typeof r === "object" && typeof r.command === "string" && r.command) cmds.push(r.command);
|
|
250
|
+
return cmds;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The text host-capability placement reads (#4785, #4776), pure: the issue body without quoted material (`proseOf`), every
|
|
255
|
+
* `<!-- FORGE:DECISION` comment (prose or JSON, unstripped) and the validate and repro_test commands of the last parseable
|
|
256
|
+
* `<!-- FORGE:CONTRACT -->` comment. `comments` are gh comment objects (`{body}`) or strings; anything else is ignored.
|
|
257
|
+
*/
|
|
258
|
+
export function placementText({ body = "", comments = [] } = {}) {
|
|
259
|
+
const bodies = (Array.isArray(comments) ? comments : []).map((c) => String((typeof c === "string" ? c : c?.body) ?? "").trimStart());
|
|
260
|
+
let validate = [];
|
|
261
|
+
for (const b of bodies) {
|
|
262
|
+
if (!b.startsWith("<!-- FORGE:CONTRACT -->")) continue;
|
|
263
|
+
const c = parseContractComment(b)?.contract;
|
|
264
|
+
if (c && typeof c === "object") validate = contractCommands(c);
|
|
265
|
+
}
|
|
266
|
+
return [proseOf(body), ...bodies.filter((b) => b.startsWith("<!-- FORGE:DECISION")), ...validate].join("\n");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Declared host capabilities a contract's validate and repro_test commands name (#4785, #4776): `{caps, ids}`, both empty when none. */
|
|
270
|
+
export function contractHostNeeds(contract, caps = null) {
|
|
271
|
+
const have = new Set(caps?.hostCapabilities ?? []);
|
|
272
|
+
const ids = [], found = new Set();
|
|
273
|
+
const rows = (Array.isArray(contract?.validate) ? contract.validate : []).map((v) => [String(v?.id ?? ""), v?.command]);
|
|
274
|
+
if (typeof contract?.repro_test?.command === "string") rows.push(["repro_test", contract.repro_test.command]);
|
|
275
|
+
for (const [id, command] of rows) {
|
|
276
|
+
const named = detectNeeds(command, caps).filter((n) => have.has(n));
|
|
277
|
+
if (named.length) { ids.push(id); for (const n of named) found.add(n); }
|
|
278
|
+
}
|
|
279
|
+
return { caps: [...found].sort(), ids };
|
|
280
|
+
}
|
|
281
|
+
|
|
214
282
|
const DOCS_LABELS = new Set(["docs", "documentation", "type:docs"]);
|
|
215
283
|
|
|
216
284
|
/**
|
|
@@ -429,7 +497,7 @@ export function providerByName(name, opts) {
|
|
|
429
497
|
if (name === "remote") {
|
|
430
498
|
const exec = opts?.exec ?? {};
|
|
431
499
|
const env = opts?.env ?? process.env;
|
|
432
|
-
return remoteProvider({ batch: opts.batch, repo: opts.repo, settings: exec, env, transport: remoteTransportFor(exec, { transport: opts?.transport, env }), ...(opts?.now ? { now: opts.now } : {}) });
|
|
500
|
+
return remoteProvider({ batch: opts.batch, repo: opts.repo, settings: { ...exec, ...(typeof opts?.prefetch === "function" ? { prefetch: opts.prefetch } : {}) }, env, transport: remoteTransportFor(exec, { transport: opts?.transport, env }), ...(opts?.now ? { now: opts.now } : {}) });
|
|
433
501
|
}
|
|
434
502
|
if (name === "pool") {
|
|
435
503
|
// #4546: `remote` with the endpoint and token defaulted to the local router, and the repo's forge.yaml sent along.
|
|
@@ -443,7 +511,8 @@ export function providerByName(name, opts) {
|
|
|
443
511
|
const tokenEnv = exec.tokenEnv ?? (router?.token ? "FORGEDOCK_POOL_TOKEN" : undefined);
|
|
444
512
|
// #4685: an empty env/keychain lookup of a configured token_env falls back to the router's token file.
|
|
445
513
|
const credentials = (n) => (n === "FORGEDOCK_POOL_TOKEN" && !exec.tokenEnv ? router?.token ?? null : resolveCredential(n, { env }) ?? router?.token ?? null);
|
|
446
|
-
const settings = { ...exec, ...(exec.endpoint ? {} : router ? { endpoint: router.endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(forgeYaml ? { forgeYaml } : {})
|
|
514
|
+
const settings = { ...exec, ...(exec.endpoint ? {} : router ? { endpoint: router.endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(forgeYaml ? { forgeYaml } : {}),
|
|
515
|
+
...(typeof opts?.prefetch === "function" ? { prefetch: opts.prefetch } : {}) }; // #4778: dispatch-time relay pre-fetch
|
|
447
516
|
return remoteProvider({ batch: opts.batch, repo: opts.repo, settings, name: "pool", env, credentials, transport: remoteTransportFor(settings, { transport: opts?.transport, env, credentials }), ...(opts?.now ? { now: opts.now } : {}) });
|
|
448
517
|
}
|
|
449
518
|
const e = new Error(`unknown execution.provider ${JSON.stringify(name)} (known: ${SANDBOX_PROVIDERS.join(", ")})`);
|