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
|
@@ -81,6 +81,7 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
81
81
|
REVIEW_NO_VERDICT: "review.no_verdict",
|
|
82
82
|
// Core work-on conformance gate (bin/lean/conformance.mjs).
|
|
83
83
|
CONTRACT_INVALID: "contract.invalid",
|
|
84
|
+
CONTRACT_INVALID_COMMAND: "contract.invalid_command",
|
|
84
85
|
REVIEW_INCOMPLETE: "review.incomplete",
|
|
85
86
|
REVIEW_INCONSISTENT: "review.inconsistent",
|
|
86
87
|
CONFORMANCE_OMISSION: "conformance.omission",
|
|
@@ -194,14 +195,14 @@ export function codeForTerminalReason(reason) {
|
|
|
194
195
|
* Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
|
|
195
196
|
* (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
|
|
196
197
|
*/
|
|
197
|
-
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED]);
|
|
198
|
+
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED]);
|
|
198
199
|
/** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
|
|
199
200
|
export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
|
|
200
201
|
|
|
201
202
|
/**
|
|
202
203
|
* `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
|
|
203
204
|
* Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.capability_remote`, `budget.exceeded`, `contract.invalid` after a mid-run issue
|
|
204
|
-
* edit (`issueEdited`), `ci-pending` on a conflicting PR (`conflicting`), and the non-human `budget-exceeded` turn-overrun end (forge#4505). Human: `builder.blocked`. Anything else
|
|
205
|
+
* edit (`issueEdited`) or when every problem was an unmatched quote (`quoteMismatch`), `ci-pending` on a conflicting PR (`conflicting`), and the non-human `budget-exceeded` turn-overrun end (forge#4505). Human: `builder.blocked`. Anything else
|
|
205
206
|
* (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
|
|
206
207
|
*/
|
|
207
208
|
export function classifyNeedsHuman(row, { parked = false } = {}) {
|
|
@@ -221,10 +222,18 @@ function classifyNeedsHumanBase(row) {
|
|
|
221
222
|
if (outcome === "budget-exceeded") return "recoverable";
|
|
222
223
|
if (outcome !== "needs-human") return "terminal";
|
|
223
224
|
if (HUMAN_CODES.includes(row.code)) return "human";
|
|
224
|
-
if (row.code === OUTCOME_CODES.CONTRACT_INVALID) return row.issueEdited === true ? "recoverable" : "terminal";
|
|
225
|
+
if (row.code === OUTCOME_CODES.CONTRACT_INVALID) return row.issueEdited === true || row.quoteMismatch === true ? "recoverable" : "terminal";
|
|
225
226
|
return RECOVERABLE_CODES.includes(row.code) ? "recoverable" : "terminal";
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
/** forge#4725: mechanical/environment codes recover on their own budget (`recovery.mechanicalMaxAttempts`), apart from conformance failures. */
|
|
230
|
+
export const MECHANICAL_CODES = Object.freeze([OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.CONTRACT_INVALID_COMMAND, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.ENV_CAPABILITY_REMOTE]);
|
|
231
|
+
|
|
232
|
+
/** `mechanical` | `conformance`: the recovery budget class of a lane result row (a conflicting `ci-pending` is a base conflict). */
|
|
233
|
+
export function recoveryClassOf(row) {
|
|
234
|
+
return MECHANICAL_CODES.includes(recoveryCodeOf(row)) ? "mechanical" : "conformance";
|
|
235
|
+
}
|
|
236
|
+
|
|
228
237
|
/** The code a recovery lane works on: a conflicting `ci-pending` is recovered as a base conflict. */
|
|
229
238
|
export function recoveryCodeOf(row) {
|
|
230
239
|
return (row?.outcome ?? row?.terminalReason) === "ci-pending" ? OUTCOME_CODES.BASE_CONFLICT : normalizeCode(row?.code);
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* RUN_TERMINAL -> `run_terminal` (reason, code, wall_ms; lean adds milestone, conformance_mode, shadow_would, the pr and
|
|
9
9
|
* builder_tier/class/model/escalated 0|1, forge#4394)
|
|
10
10
|
* SANDBOX_USAGE -> `sandbox_usage` (provider, sandbox_id, minutes, cost_usd, batch; one per settled remote lane, forge#4255)
|
|
11
|
+
* DECISION_ENGINE -> `decision_engine` (decided, escalated, vetoed, reversed, mode, needs_human_before/after; forge#4766)
|
|
11
12
|
* STRUCTURE_DELTA -> `structure_delta` (edges_added/removed, import_files_added/removed, boundary_changed/loosened 0|1; forge#4159)
|
|
12
13
|
* lean's merge-time record (forge#4165) adds milestone, the pr and string lists: deps_added and
|
|
13
14
|
* new_edges / removed_edges as "from:to" comma-joined, top_growth "file+N", decisions "K1:lint",
|
|
@@ -79,6 +80,10 @@ export function mapRecord(rec, wallMs) {
|
|
|
79
80
|
import_files_added: rec.import_files_added, import_files_removed: rec.import_files_removed, boundary_changed: bit(rec.boundary_changed), boundary_loosened: bit(rec.boundary_loosened),
|
|
80
81
|
...structureLists(rec) }) };
|
|
81
82
|
}
|
|
83
|
+
if (rec.event === "DECISION_ENGINE") { // #4766
|
|
84
|
+
return { event: "decision_engine", phase: null, payload: compact({ decided: rec.decided, escalated: rec.escalated, vetoed: rec.vetoed, reversed: rec.reversed, mode: rec.mode,
|
|
85
|
+
needs_human_before: rec.needs_human_before, needs_human_after: rec.needs_human_after }) };
|
|
86
|
+
}
|
|
82
87
|
if (rec.event === "CI_PENDING_WAIT") {
|
|
83
88
|
return { event: "ci_pending_wait", phase: rec.phase ?? "review", payload: compact({ pr: rec.pr, minutes_waited: rec.minutes_waited, result: rec.result, polls: rec.polls, rate_limit_sleeps: rec.rate_limit_sleeps }) };
|
|
84
89
|
}
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -8,10 +8,13 @@ import { execFile, spawn } from "node:child_process";
|
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { homedir, hostname } from "node:os";
|
|
11
|
+
import { homedir, hostname, cpus } from "node:os";
|
|
12
|
+
import { localLaneCap } from "./lean/admission.mjs";
|
|
13
|
+
import { memAvailableMb } from "./lean/heavy.mjs";
|
|
12
14
|
import { join, resolve as resolvePath } from "node:path";
|
|
13
15
|
import { runIssue, DEFAULT_MAX_ATTEMPTS, sanitizeLeaseHost } from "./engine.mjs";
|
|
14
16
|
import { makeProjector } from "./engine/projector.mjs";
|
|
17
|
+
import { isTrustedActor, parseDependsOn } from "./engine/phases.mjs";
|
|
15
18
|
import { readLog, readLogFile, deriveState, runLogPath, migrateRunLogs } from "./engine/runlog.mjs";
|
|
16
19
|
import { assertOpenCodeNativeRuntime } from "./runner.mjs";
|
|
17
20
|
import { parseForgeYaml } from "./forge-utils.mjs";
|
|
@@ -20,7 +23,8 @@ import { makeEventEmitter, probeSweepEvents, experimentEvent } from "./engine/ev
|
|
|
20
23
|
import { readExperiment } from "./experiment.mjs";
|
|
21
24
|
import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
|
|
22
25
|
import { parseVerify } from "./lean/verify.mjs";
|
|
23
|
-
import { parseExecution, detectNeeds } from "./lean/execution.mjs";
|
|
26
|
+
import { parseExecution, detectNeeds, placementText } from "./lean/execution.mjs";
|
|
27
|
+
import { relayPrefetchCommands } from "./lean/relay.mjs";
|
|
24
28
|
import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lean/budget.mjs";
|
|
25
29
|
import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
|
|
26
30
|
import { parseLightConfig, LIGHT_DEFAULTS } from "./lean/light.mjs";
|
|
@@ -284,6 +288,13 @@ export async function readPostDeployMinAge(cwd = process.cwd()) {
|
|
|
284
288
|
export function readMilestoneCloseOn(cwd = process.cwd()) {
|
|
285
289
|
try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.milestones?.close_on ?? "").trim().toLowerCase() === "staging" ? "staging" : "default"; } catch { return "default"; }
|
|
286
290
|
}
|
|
291
|
+
/** forge.yaml `agents.subagent_model` (#4843): the default model of subagents in sessions ForgeDock launches; null when unset or invalid. */
|
|
292
|
+
export function readSubagentModel(cwd = process.cwd()) {
|
|
293
|
+
try {
|
|
294
|
+
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.agents?.subagent_model ?? "").trim();
|
|
295
|
+
return /^[A-Za-z0-9._:[\]-]+$/.test(v) ? v : null;
|
|
296
|
+
} catch { return null; }
|
|
297
|
+
}
|
|
287
298
|
/** forge.yaml `lean.explore` (on|off) and `lean.explore_model` (#4391): the builder's explorer subagent; undefined when neither is set or valid. */
|
|
288
299
|
export function readExplore(cwd = process.cwd()) {
|
|
289
300
|
try {
|
|
@@ -308,14 +319,24 @@ function readLeanSetting(key, cwd = process.cwd()) {
|
|
|
308
319
|
* forge.yaml `engine.admission` (#4105). The parser flattens one nested level into `engine`, so the keys are read as
|
|
309
320
|
* `engine.enabled|min_free_mem_mb|min_free_tmp_mb|max_load_per_core`, or flat `admission_*` equivalents.
|
|
310
321
|
*/
|
|
322
|
+
/** #4777: `sched.cap` as a function: min(global_parallel, the host-derived local lane cap), re-probed at every admission attempt. */
|
|
323
|
+
export function liveLaneCap(globalParallel, { cfg = readAdmission(), host = { cores: () => cpus().length, memAvailableMb } } = {}) {
|
|
324
|
+
return () => {
|
|
325
|
+
let derived = null;
|
|
326
|
+
try { derived = localLaneCap({ cfg, cores: host.cores(), memAvailableMb: host.memAvailableMb() }); } catch { derived = null; }
|
|
327
|
+
return derived === null ? globalParallel : globalParallel === null ? derived : Math.min(globalParallel, derived);
|
|
328
|
+
};
|
|
329
|
+
}
|
|
311
330
|
export function readAdmission(cwd = process.cwd()) {
|
|
312
331
|
try {
|
|
313
332
|
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
|
|
314
333
|
const num = (v) => { const n = Number(v); return v !== undefined && v !== "" && Number.isFinite(n) && n >= 0 ? n : undefined; };
|
|
334
|
+
const pos = (v) => { const n = num(v); return n > 0 ? n : undefined; };
|
|
315
335
|
const en = String(e.admission_enabled ?? e.enabled ?? "").trim().toLowerCase();
|
|
316
336
|
return { ...(en === "false" ? { enabled: false } : {}),
|
|
317
337
|
min_free_mem_mb: num(e.min_free_mem_mb ?? e.admission_min_free_mem_mb), min_free_tmp_mb: num(e.min_free_tmp_mb ?? e.admission_min_free_tmp_mb),
|
|
318
|
-
max_load_per_core: num(e.max_load_per_core ?? e.admission_max_load_per_core)
|
|
338
|
+
max_load_per_core: num(e.max_load_per_core ?? e.admission_max_load_per_core),
|
|
339
|
+
lane_cpu_reservation: pos(e.lane_cpu_reservation ?? e.admission_lane_cpu_reservation), lane_mem_mb: pos(e.lane_mem_mb ?? e.admission_lane_mem_mb) };
|
|
319
340
|
} catch { return {}; }
|
|
320
341
|
}
|
|
321
342
|
/**
|
|
@@ -382,6 +403,11 @@ export function readHostCaps(cwd = process.cwd()) {
|
|
|
382
403
|
const e = readExecution(cwd);
|
|
383
404
|
return e?.hostCapabilities?.length ? { hostCapabilities: e.hostCapabilities, ...(e.hostCapabilityPatterns ? { hostCapabilityPatterns: e.hostCapabilityPatterns } : {}) } : undefined;
|
|
384
405
|
}
|
|
406
|
+
/** #4778: the capabilities the dispatching host relays to this sandbox lane (`CS_RELAY_CAPS`, set by the remote provider); `{}` when none. */
|
|
407
|
+
export function readRelayCaps(env = process.env) {
|
|
408
|
+
const caps = String(env.CS_RELAY_CAPS ?? "").split(",").map((x) => x.trim().toLowerCase()).filter((x) => /^[a-z][a-z0-9_-]*$/.test(x));
|
|
409
|
+
return caps.length ? { relayCaps: [...new Set(caps)] } : {};
|
|
410
|
+
}
|
|
385
411
|
/**
|
|
386
412
|
* forge.yaml `execution:` section (#4253, block style: `execution:` then ` mode: remote`, ` max_remote: 4`,
|
|
387
413
|
* ` provider: container`, ` image`, ` secrets` comma-separated env names; remote provider #4254: ` endpoint`,
|
|
@@ -913,7 +939,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
913
939
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
914
940
|
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
915
941
|
emitTelemetry: deps.emitTelemetry ?? composeEmit(makeEmitTelemetry({ repo: leanRepo }), makeEventEmitter({ repo: leanRepo, config: readEvents() }).emit),
|
|
916
|
-
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}) });
|
|
942
|
+
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}), ...readRelayCaps(), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}) });
|
|
917
943
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
918
944
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
919
945
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -1141,6 +1167,7 @@ async function envBatch({ argv, root, print = (l) => console.log(l) }) {
|
|
|
1141
1167
|
const i = argv.indexOf(op), id = argv[i + 1], arg = argv[i + 2];
|
|
1142
1168
|
if (!id || id.startsWith("--") || (op !== "--show-env" && !arg)) throw new Error(ORCH_USAGE);
|
|
1143
1169
|
const batchMod = await import("./lean/batch.mjs"), { redactEnv } = await import("./lean/schedd.mjs");
|
|
1170
|
+
if (op === "--set-env" && batchMod.SECRET_ENV.test(arg.split("=")[0])) throw new Error(`--set-env: ${arg.split("=")[0]} looks like a secret and is never stored in batch state; set it in the daemon's environment (or keychain) instead`);
|
|
1144
1171
|
const b = batchMod.openBatch(root, id);
|
|
1145
1172
|
const plan = b.readPlan();
|
|
1146
1173
|
if (!plan) throw new Error(`no batch ${id} in ${root}`);
|
|
@@ -1188,6 +1215,27 @@ const parallelOf = (argv) => {
|
|
|
1188
1215
|
return parallel;
|
|
1189
1216
|
};
|
|
1190
1217
|
|
|
1218
|
+
/**
|
|
1219
|
+
* #4773: the `Blocked by` / `Depends on` edges the added issues imply: declaration lines of a trusted body whose target is still open
|
|
1220
|
+
* (a closed or unreadable target is a no-op, the scheduler skips it too). @returns {{from:number,to:number}[]}
|
|
1221
|
+
*/
|
|
1222
|
+
async function addedEdges({ added, records, io, repo }) {
|
|
1223
|
+
const edges = [], seen = new Map();
|
|
1224
|
+
for (const n of added) {
|
|
1225
|
+
const rec = records.get(n);
|
|
1226
|
+
if (!rec || !bodyTrusted(rec)) continue;
|
|
1227
|
+
for (const d of parseDependsOn(rec.body, n)) {
|
|
1228
|
+
if (!seen.has(d)) {
|
|
1229
|
+
let st = null;
|
|
1230
|
+
try { st = JSON.parse(await io.gh(["issue", "view", String(d), ...(repo ? ["-R", repo] : []), "--json", "state"]))?.state ?? null; } catch { st = null; }
|
|
1231
|
+
seen.set(d, st);
|
|
1232
|
+
}
|
|
1233
|
+
if (seen.get(d) === "OPEN") edges.push({ from: n, to: d });
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
return edges;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1191
1239
|
/**
|
|
1192
1240
|
* #4408: add `tokens` to batch `id`. Not a plain integer: skipped; not OPEN on the batch's repo (`gh issue view --json
|
|
1193
1241
|
* state`): skipped `closed` (`unreadable` when the read fails); the rest go to the batch's `add` file, through the daemon
|
|
@@ -1198,15 +1246,16 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
|
|
|
1198
1246
|
const b = batchMod.openBatch(root, id);
|
|
1199
1247
|
if (!b.exists()) throw new Error(`no batch ${id} in ${root}`);
|
|
1200
1248
|
const repo = b.readPlan()?.repo ?? null;
|
|
1201
|
-
const skipped = [], ints = [], state = new Map();
|
|
1249
|
+
const skipped = [], ints = [], state = new Map(), records = new Map();
|
|
1202
1250
|
for (const t of tokens) {
|
|
1203
1251
|
if (/^#?\d+$/.test(String(t))) ints.push(parseInt(String(t).replace("#", ""), 10));
|
|
1204
1252
|
else skipped.push({ issue: t, reason: "not an integer" });
|
|
1205
1253
|
}
|
|
1206
1254
|
for (const n of new Set(ints)) {
|
|
1207
1255
|
let st = null;
|
|
1208
|
-
|
|
1209
|
-
|
|
1256
|
+
let v = null;
|
|
1257
|
+
try { v = JSON.parse(await io.gh(["issue", "view", String(n), ...(repo ? ["-R", repo] : []), "--json", "state,body,author,authorAssociation"])); st = v?.state ?? null; } catch { st = null; }
|
|
1258
|
+
state.set(n, st); records.set(n, v);
|
|
1210
1259
|
}
|
|
1211
1260
|
const open = [];
|
|
1212
1261
|
for (const n of ints) {
|
|
@@ -1220,7 +1269,9 @@ async function addToBatch({ id, tokens, root, io, viaDaemon = null, print = (l)
|
|
|
1220
1269
|
if (res.reopened === undefined && res.requeued?.length && b.readReport()) b.clearReport(); // #4687: no daemon: the next --resume reruns it
|
|
1221
1270
|
}
|
|
1222
1271
|
const out = { ok: true, batch: id, added: res.added ?? [], requeued: res.requeued ?? [], skipped: [...(res.skipped ?? []), ...skipped] };
|
|
1272
|
+
out.edges = await addedEdges({ added: out.added, records, io, repo }); // #4773
|
|
1223
1273
|
print(`batch ${id}: added ${out.added.map((n) => `#${n}`).join(" ") || (out.requeued.length ? "" : "(none)")}${out.requeued.length ? `${out.added.length ? " " : ""}${out.requeued.map((n) => `requeued #${n}`).join(" ")}` : ""}${out.skipped.length ? `; skipped ${out.skipped.map((x) => `${/^\d+$/.test(String(x.issue)) ? "#" : ""}${x.issue} (${x.reason})`).join(", ")}` : ""}`);
|
|
1274
|
+
if (out.edges.length) print(`batch ${id}: edges ${out.edges.map((e) => `#${e.from} blocked by #${e.to}`).join(", ")}`);
|
|
1224
1275
|
return out;
|
|
1225
1276
|
}
|
|
1226
1277
|
|
|
@@ -1469,6 +1520,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1469
1520
|
let batch, plan = null;
|
|
1470
1521
|
if (resumeId) {
|
|
1471
1522
|
batch = batchMod.openBatch(root, resumeId);
|
|
1523
|
+
batch.scrubPlan(); // #4787: a pre-fix plan.json keeps no secret; the lanes re-read it from the environment
|
|
1472
1524
|
plan = batch.readPlan();
|
|
1473
1525
|
if (!plan) throw new Error(`no batch ${resumeId} in ${root}`);
|
|
1474
1526
|
argv = plan.argv;
|
|
@@ -1548,7 +1600,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1548
1600
|
// Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes, or
|
|
1549
1601
|
// sandboxes when forge.yaml `execution` places them remote (#4253).
|
|
1550
1602
|
const labelsOf = new Map(); // issue -> label names, filled by readBody (one gh call per issue)
|
|
1603
|
+
const trustedOf = new Map(); // #4773: issue -> its body author is trusted (a `Blocked by` line is an edge only then), filled by readBody
|
|
1551
1604
|
const needsOf = new Map(); // issue -> host-only capabilities its body names (#4542), filled by readBody
|
|
1605
|
+
const prefetchOf = new Map(); // issue -> the frozen contract's relayable read-only commands (#4778), filled by readBody
|
|
1552
1606
|
let execForNeeds = null;
|
|
1553
1607
|
let lanes, remote = null, execBudget = null, execConf = null, hybrid = false;
|
|
1554
1608
|
const execMode = () => (deps.execution !== undefined ? deps.execution : readExecution(cwd))?.mode ?? "local";
|
|
@@ -1556,7 +1610,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1556
1610
|
const { runCore } = deps.leanModule;
|
|
1557
1611
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1558
1612
|
lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), ...(ctx.recover ? { recover: ctx.recover } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
1559
|
-
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}), ...(readFollowupsDefaultMilestone(cwd) ? { followupsDefaultMilestone: readFollowupsDefaultMilestone(cwd) } : {}) }) };
|
|
1613
|
+
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...readRelayCaps(), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}), ...(readFollowupsDefaultMilestone(cwd) ? { followupsDefaultMilestone: readFollowupsDefaultMilestone(cwd) } : {}) }) };
|
|
1560
1614
|
} else {
|
|
1561
1615
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1562
1616
|
// #4690: lanes learn K (the default MemoryMax is derived from it); one line says so when no scope caps them.
|
|
@@ -1589,7 +1643,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1589
1643
|
// #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
|
|
1590
1644
|
let gitDir = null;
|
|
1591
1645
|
try { gitDir = resolvePath(cwd, String(await leanIo.git(["rev-parse", "--git-common-dir"], { cwd })).trim()); } catch { gitDir = null; }
|
|
1592
|
-
const provider = deps.sandboxProvider ?? execMod.providerByName(exec.provider, { batch, repo, cwd, image: exec.image, gitDir, script: laneScript, bin: deps.laneBin, exec, env });
|
|
1646
|
+
const provider = deps.sandboxProvider ?? execMod.providerByName(exec.provider, { batch, repo, cwd, image: exec.image, gitDir, script: laneScript, bin: deps.laneBin, exec, env, prefetch: (n) => prefetchOf.get(n) ?? [] });
|
|
1593
1647
|
const { makeLedger } = await import("./lean/ledger.mjs");
|
|
1594
1648
|
const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
|
|
1595
1649
|
const bud = exec.budget ?? {};
|
|
@@ -1763,9 +1817,11 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1763
1817
|
rows = await orchestrate({
|
|
1764
1818
|
...(estimate ? { estimate } : {}),
|
|
1765
1819
|
beforeAdmit,
|
|
1820
|
+
...(orch.blockedLabel ? { getBlocked: async () => JSON.parse(await leanIo.gh(["issue", "list", "-R", repo, "--label", orch.blockedLabel, "--state", "open", "--limit", "200", "--json", "number"])).map((x) => x.number) } : {}), // #4773
|
|
1821
|
+
trusted: (n) => trustedOf.get(n) === true,
|
|
1766
1822
|
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, getGates: batch.readGates, ...lanes,
|
|
1767
1823
|
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
|
|
1768
|
-
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null, exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1824
|
+
...(registry ? { sched: { registry, batch: batch.id, repo, cap: liveLaneCap(orch.globalParallel ?? null), exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1769
1825
|
...(remote ? { remote } : {}),
|
|
1770
1826
|
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
1771
1827
|
...(recoveryOn ? { recovery: orch.recovery } : {}),
|
|
@@ -1780,6 +1836,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1780
1836
|
dispatchCheck: deps.dispatchCheck !== undefined ? deps.dispatchCheck : makeDispatchCheck({ gh: (a) => leanIo.gh(a), repo }),
|
|
1781
1837
|
// #4387: an out-of-set dependency labelled needs-human is not pulled into the batch
|
|
1782
1838
|
issueLabels: labelNames,
|
|
1839
|
+
// #4725: a closed (completed) dependency is satisfied even when it still carries a stale needs-human label
|
|
1840
|
+
issueState: async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "state,stateReason"])); return v.state === "CLOSED" && String(v.stateReason ?? "COMPLETED").toUpperCase() !== "COMPLETED" ? "CLOSED_NOT_PLANNED" : v.state; },
|
|
1783
1841
|
// #4318: a dispatcher signal relabels the queued recoveries before it exits (the daemon resumes its batches instead)
|
|
1784
1842
|
...(daemon ? {} : { onExit: (fn) => { exitRelabel = fn; } }),
|
|
1785
1843
|
// #4454: the daemon's shutdown stop; orchestrate throws batch.stopped, so no report is written and a resume adopts the lanes
|
|
@@ -1803,8 +1861,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1803
1861
|
sink.line(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
|
|
1804
1862
|
},
|
|
1805
1863
|
readBody: maint || registry || hybrid
|
|
1806
|
-
? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels"])); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); needsOf.set(n, detectNeeds(v.
|
|
1807
|
-
: async (n) => { const
|
|
1864
|
+
? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels,comments,author,authorAssociation"])); trustedOf.set(n, bodyTrusted(v)); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }
|
|
1865
|
+
: async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,comments,author,authorAssociation"])); trustedOf.set(n, bodyTrusted(v)); needsOf.set(n, detectNeeds(placementText(v), execForNeeds)); if (execForNeeds?.relay) prefetchOf.set(n, relayPrefetchCommands(v, execForNeeds.relay)); return v.body; }, // #4785: decisions and the frozen contract's validate commands too
|
|
1808
1866
|
onUpdate: (r) => sink.row(r),
|
|
1809
1867
|
});
|
|
1810
1868
|
} finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
@@ -1911,10 +1969,16 @@ export function makeRelabelNeedsHuman({ gh, repo, now = () => Date.now() }) {
|
|
|
1911
1969
|
{
|
|
1912
1970
|
const external = { owner: "", unblock: "" }; // the default non-parked relabel is a human-owned wait
|
|
1913
1971
|
const { NEEDS_HUMAN_EXTERNAL_LABEL } = await import("./engine/outcome-codes.mjs");
|
|
1972
|
+
// #4725: a recorded premise that already holds is not a blocker; an identical owner ask links to its entry instead of a new one.
|
|
1973
|
+
const prem = await import("./lean/needs-human-premise.mjs");
|
|
1974
|
+
const predicate = prem.parsePredicate(evidence);
|
|
1975
|
+
if (predicate && await prem.evalPredicate(predicate, { repo, io: { gh, pkgVersion: prem.registryPkgVersion }, now: now() }) === true) return "skipped";
|
|
1976
|
+
const reg = await prem.registerAsk({ repo, io: { gh }, issue, ask: String(evidence ?? "") });
|
|
1977
|
+
const extra = [prem.premiseAnnotation(predicate), reg.existing ? `Same owner ask as #${reg.existing} (one ask, linked): answering it unblocks this issue too.` : ""].filter(Boolean).join("\n");
|
|
1914
1978
|
try { await gh(["issue", "edit", String(issue), "-R", repo, "--add-label", `needs-human,${NEEDS_HUMAN_EXTERNAL_LABEL}`]); } catch { return "failed"; }
|
|
1915
1979
|
try {
|
|
1916
1980
|
const { capComment } = await import("./lean/render.mjs");
|
|
1917
|
-
await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:NEEDS_HUMAN external=true -->\n## Needs human (external)\nOwner: ${external.owner || "unassigned (name an owner)"}\nUnblock condition: ${external.unblock || "unstated (name the condition that unblocks this issue)"}\n${evidence}`)]);
|
|
1981
|
+
await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:NEEDS_HUMAN external=true -->\n## Needs human (external)\nOwner: ${external.owner || "unassigned (name an owner)"}\nUnblock condition: ${external.unblock || "unstated (name the condition that unblocks this issue)"}\n${extra ? `${extra}\n` : ""}${evidence}${!reg.existing && reg.entry ? `\n\n${reg.entry}` : ""}`)]);
|
|
1918
1982
|
} catch { /* best effort */ }
|
|
1919
1983
|
return true;
|
|
1920
1984
|
}
|
|
@@ -1937,6 +2001,7 @@ async function scheddDue() {
|
|
|
1937
2001
|
const leanIo = (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1938
2002
|
await sweepDue({ repo, cwd: process.cwd(), io: leanIo, onResult: probeEventSink(repo) });
|
|
1939
2003
|
await sweepParkedFollowups({ repo, io: leanIo }); // #4689: re-queue parked post-deploy follow-ups on the same pass
|
|
2004
|
+
try { const prem = await import("./lean/needs-human-premise.mjs"); await prem.sweepPremises({ repo, io: { gh: (a) => leanIo.gh(a), pkgVersion: prem.registryPkgVersion } }); } catch { /* best effort (#4725) */ } // clear needs-human whose recorded premise now holds
|
|
1940
2005
|
}
|
|
1941
2006
|
|
|
1942
2007
|
/** #4689: re-evaluate the parked post-deploy follow-ups of the repo (promotion, due date, check now defined); never throws. */
|
|
@@ -1958,10 +2023,17 @@ export async function scheddFromCli(argv, deps = {}) {
|
|
|
1958
2023
|
let sock;
|
|
1959
2024
|
try { sock = await schedd.connectDaemon({ dir, spawnDaemon: null }); } catch { console.log("schedd: not running"); return { running: false }; }
|
|
1960
2025
|
const st = await schedd.request(sock, { op: "status" });
|
|
1961
|
-
console.log(`schedd: pid ${st.pid}, ${st.batches.length} batch(es)${st.batches.length ? ": " + st.batches.map((b) => b.id).join(", ") : ""}`);
|
|
2026
|
+
console.log(`schedd: pid ${st.pid}, ${st.batches.length} batch(es)${st.batches.length ? ": " + st.batches.map((b) => b.id).join(", ") : ""}${Number.isFinite(st.lastTick) ? `, last tick ${Math.round((Date.now() - st.lastTick) / 1000)} s ago` : ""}`);
|
|
1962
2027
|
return { running: true, ...st };
|
|
1963
2028
|
}
|
|
1964
|
-
if (argv
|
|
2029
|
+
if (argv[0] === "watchdog") { // #4780: run by the schedd watchdog timer; signals a live daemon whose heartbeat went stale
|
|
2030
|
+
const health = await import("./lean/schedd-health.mjs");
|
|
2031
|
+
const i = argv.indexOf("--stall-sec"), sec = i >= 0 ? Number(argv[i + 1]) : NaN;
|
|
2032
|
+
if (i >= 0 && !(sec > 0)) throw new Error("usage: forgedock schedd watchdog [--stall-sec N]");
|
|
2033
|
+
return (deps.watchdog ?? health.watchdog)({ dir, ...(sec > 0 ? { stallMs: sec * 1000 } : {}) });
|
|
2034
|
+
}
|
|
2035
|
+
if (argv.length) throw new Error("usage: forgedock schedd [status | watchdog [--stall-sec N]]");
|
|
2036
|
+
(deps.renice ?? (await import("./lean/schedd-health.mjs")).bestEffortRenice)(); // #4780: lanes must not starve the scheduler
|
|
1965
2037
|
process.title = "forgedock-schedd"; // distinctive name for OOM-killer avoid lists and ps (#4673)
|
|
1966
2038
|
const d = await schedd.startDaemon({ dir, runBatch: (a) => runBatch({ ...a, daemon: true, deps: deps.batchDeps ?? {} }), ...(deps.due !== undefined ? { due: deps.due } : { due: scheddDue }) });
|
|
1967
2039
|
if (!d.running) return d;
|
|
@@ -2120,11 +2192,13 @@ export async function checkFromCli(argv, deps = {}) {
|
|
|
2120
2192
|
export async function schedFromCli(argv, deps = {}) {
|
|
2121
2193
|
const schedMod = await import("./lean/sched.mjs");
|
|
2122
2194
|
const reg = deps.sched ?? schedMod.openSched(schedMod.schedDir());
|
|
2195
|
+
const health = await import("./lean/schedd-health.mjs");
|
|
2123
2196
|
const usage = "usage: forgedock sched show | forgedock sched cap <N>|--clear | forgedock sched weight <owner/repo> <N>|--clear";
|
|
2124
2197
|
const [sub, arg, arg2] = argv;
|
|
2125
2198
|
if (!sub || sub === "show") {
|
|
2126
2199
|
const st = reg.list();
|
|
2127
2200
|
const who = (e) => `#${e.issue} ${e.repo ?? "-"} batch ${e.batch ?? "-"} P${e.priority}`;
|
|
2201
|
+
console.log(deps.tick ?? health.tickText(health.stallCheck({ dir: schedMod.schedDir() })));
|
|
2128
2202
|
console.log(`host cap: ${st.cap ?? "none set here (each batch's orchestration.global_parallel applies)"}; running ${st.slots.length}, queued ${st.queue.length}`);
|
|
2129
2203
|
const ws = Object.entries(st.weights ?? {});
|
|
2130
2204
|
console.log(`repo weights: ${ws.length ? ws.map(([r, n]) => `${r}=${n}`).join(", ") : "equal (none set)"}`);
|
|
@@ -2152,14 +2226,19 @@ export async function schedFromCli(argv, deps = {}) {
|
|
|
2152
2226
|
throw new Error(usage);
|
|
2153
2227
|
}
|
|
2154
2228
|
|
|
2229
|
+
/** #4773: is the author of a `gh issue view --json author,authorAssociation` record trusted (the engine admission's `isTrustedActor`)? */
|
|
2230
|
+
const bodyTrusted = (v) => isTrustedActor({ association: v?.authorAssociation, type: v?.author?.is_bot ? "Bot" : "User", login: v?.author?.login });
|
|
2231
|
+
|
|
2155
2232
|
/**
|
|
2156
2233
|
* forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off),
|
|
2157
2234
|
* `orchestration.max_deferrals` (default 3) and `orchestration.global_parallel` (#4191: host-wide lane cap; unset = none). The parser flattens one nested level, so the breaker keys arrive as
|
|
2158
2235
|
* `orchestration.errors|window_min` (or flat `breaker_errors|breaker_window_min`).
|
|
2159
2236
|
* `orchestration.recovery.max_attempts` (#4267; arrives as `max_attempts`, or flat `recovery_max_attempts`): recovery lanes per
|
|
2160
|
-
* issue for a mechanical needs-human, default 1, 0 turns recovery off.
|
|
2237
|
+
* issue for a mechanical needs-human, default 1, 0 turns recovery off. `orchestration.recovery.mechanical_max_attempts` (#4725; or
|
|
2238
|
+
* `mechanical_max_attempts`): the separate budget of env.*, base.conflict and conflicting-PR rows, default 2, never below `max_attempts`.
|
|
2161
2239
|
* `orchestration.base_sync.enabled` (#4525; default true, false opts out) and `base_sync.union_globs` (comma list of globs of
|
|
2162
2240
|
* append-only files whose two-sided line additions a parked PR's base sync keeps both of).
|
|
2241
|
+
* `orchestration.blocked_label` (#4773; default `blocked`, empty turns it off): an open issue carrying it is admitted but not dispatched.
|
|
2163
2242
|
*/
|
|
2164
2243
|
export function readOrchestration(cwd = process.cwd()) {
|
|
2165
2244
|
let o = {};
|
|
@@ -2168,8 +2247,8 @@ export function readOrchestration(cwd = process.cwd()) {
|
|
|
2168
2247
|
const errors = int(o.breaker_errors ?? o.errors, 3), windowMin = int(o.breaker_window_min ?? o.window_min, 30);
|
|
2169
2248
|
const gp = int(o.global_parallel, null);
|
|
2170
2249
|
const recoveryMax = int(o.recovery_max_attempts ?? o.max_attempts, 1);
|
|
2171
|
-
return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), globalParallel: gp >= 1 ? gp : null,
|
|
2172
|
-
recovery: { maxAttempts: recoveryMax },
|
|
2250
|
+
return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), blockedLabel: String(o.blocked_label ?? "blocked").trim(), globalParallel: gp >= 1 ? gp : null,
|
|
2251
|
+
recovery: { maxAttempts: recoveryMax, mechanicalMaxAttempts: Math.max(int(o.recovery_mechanical_max_attempts ?? o.mechanical_max_attempts, 2), recoveryMax) }, // #4725: env/base/sequence codes have their own budget
|
|
2173
2252
|
// #4525: parked-PR base sync. The parser flattens one level, so `base_sync.enabled|union_globs` arrive as `enabled|union_globs` (or flat `base_sync_*`).
|
|
2174
2253
|
baseSync: { enabled: String(o.base_sync_enabled ?? o.enabled ?? "true").trim().toLowerCase() !== "false",
|
|
2175
2254
|
unionGlobs: String(o.base_sync_union_globs ?? o.union_globs ?? "").replace(/^\[|\]$/g, "").split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean) } };
|
package/bin/forgedock.mjs
CHANGED
|
@@ -3359,10 +3359,11 @@ function help() {
|
|
|
3359
3359
|
["npx forgedock check [--base REF] [--json]", "Run the lane's frozen validate commands and change-scoped pre-PR checks locally in this worktree, as the engine will (no GitHub, no Actions)"],
|
|
3360
3360
|
["npx forgedock milestone train <slug...> [--parallel-milestones N] | --resume <id> | --insert <id> <slug> [--before s|--after s] | --show <id>", "Unattended multi-milestone train: one durable state file (order, active set, lifecycle), typed STOP reasons, resumable; never edits forge.yaml"],
|
|
3361
3361
|
["npx forgedock guards list [--path p] [--json] | backfill --since <date> [--dry-run]", "Fix guards: list the FORGE:GUARD records (test files + protected hunks of merged fixes) covering a path, or build them for already-merged lean lanes"],
|
|
3362
|
+
["npx forgedock issue-watch run [--name <w>] [--repo owner/repo] [--once] [--dry-run] | status [--name <w>]", "Named, scoped, capped watches (forge.yaml issue_watch.watches) that claim matching open issues into lanes or one long-lived orchestrate batch, or (mode: release) pool them and cut themed releases; see docs/ISSUE-WATCH.md"],
|
|
3362
3363
|
["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
|
|
3363
3364
|
["npx forgedock decisions list [--stale] [--refresh]", "List anchored decisions (FORGE:DECISION); --stale shows those with an anchor missing on the base"],
|
|
3364
3365
|
["npx forgedock sched show | cap <N>|--clear | weight <owner/repo> <N>|--clear", "Show the host lane registry, set the host-wide lane cap shared by orchestrate batches, or set a repo's share of slots within a priority (default equal)"],
|
|
3365
|
-
["npx forgedock schedd [status]", "Host scheduler daemon hosting every orchestrate batch (auto-spawned by orchestrate; run in the foreground for a systemd-user unit); status shows its pid and
|
|
3366
|
+
["npx forgedock schedd [status | watchdog]", "Host scheduler daemon hosting every orchestrate batch (auto-spawned by orchestrate; run in the foreground for a systemd-user unit); status shows its pid, batches and last tick; watchdog restarts a stalled daemon"],
|
|
3366
3367
|
["npx forgedock pool router [install] | enroll [--cpus N --mem GB] | status | upgrade [agent] | remove <agent>", "Built-in remote compute pool: run the router (foreground, or install it as a user service), print the one-time install command for a host, list agents, upgrade or remove them"],
|
|
3367
3368
|
["npx forgedock gc [--apply] [--older-than 6h] [--docker] [--worktrees] [--branches] [--tmp] [--processes] [--sandboxes]", "Report (dry-run default) and reclaim ForgeDock-owned leftovers: stale lane ledgers, forgedock-labelled containers/volumes, lean-*/agent-* worktrees, abandoned lane branches, ~/.forge/tmp scratch, orphaned lane processes, orphaned lane sandboxes; unowned resources and unpushed work are only reported"],
|
|
3368
3369
|
["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
|
|
@@ -3945,7 +3946,7 @@ async function watch() {
|
|
|
3945
3946
|
const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
|
|
3946
3947
|
const KNOWN_COMMANDS = new Set([
|
|
3947
3948
|
"install", "init", "enable", "disable", "status", "uninstall", "update",
|
|
3948
|
-
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "help", "--help", "-h",
|
|
3949
|
+
"run", "bootstrap", "backend-check", "run-issue", "orchestrate", "milestone", "milestone-sync", "guards", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "issue-watch", "labels", "config", "stats", "limits", "decisions", "sched", "schedd", "pool", "check", "help", "--help", "-h",
|
|
3949
3950
|
"version", "--version", "-v",
|
|
3950
3951
|
]);
|
|
3951
3952
|
if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
|
|
@@ -4206,6 +4207,16 @@ switch (command) {
|
|
|
4206
4207
|
}
|
|
4207
4208
|
break;
|
|
4208
4209
|
}
|
|
4210
|
+
case "issue-watch": {
|
|
4211
|
+
const { issueWatchFromCli } = await import("./lean/issuewatch.mjs");
|
|
4212
|
+
try {
|
|
4213
|
+
await issueWatchFromCli(restArgs);
|
|
4214
|
+
} catch (err) {
|
|
4215
|
+
process.stderr.write(`${RED}${err.message}${RESET}\n`);
|
|
4216
|
+
exitCode = 1;
|
|
4217
|
+
}
|
|
4218
|
+
break;
|
|
4219
|
+
}
|
|
4209
4220
|
case "limits": {
|
|
4210
4221
|
const { limitsFromCli } = await import("./engine-cli.mjs");
|
|
4211
4222
|
try {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4769: default-off rollouts must not ship dormant. Pure helpers: detect new off-by-default keys in a diff of a repo-declared
|
|
4
|
+
// rollout registry, require an activation row for them, check mode-naming acceptance criteria, and report dormant flags.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { parseForgeYaml } from "../forge-utils.mjs";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_DORMANT_DAYS = 14;
|
|
10
|
+
const OFF = String.raw`(?:false|False|FALSE|0|off|OFF|null|None|""|''|"(?:false|off|0)"|'(?:false|off|0)')`;
|
|
11
|
+
const ENTRY_RE = new RegExp(String.raw`^["']?([A-Za-z_][\w.-]*)["']?\s*[:=]\s*${OFF}\s*,?;?\s*(?:#.*|//.*)?$`);
|
|
12
|
+
const MODE_RE = /\b(shadow|cohort|treated)\b/i;
|
|
13
|
+
const flagRe = (flag) => new RegExp(`(^|[^\\w])${String(flag).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\w])`);
|
|
14
|
+
|
|
15
|
+
/** forge.yaml `rollouts: files: "a.json,b.py"` (comma-separated paths of the registry or env flag list) and `dormant_days`; null when absent. */
|
|
16
|
+
export function readRollouts(cwd, read = (p) => readFileSync(p, "utf8")) {
|
|
17
|
+
let sec;
|
|
18
|
+
try { sec = parseForgeYaml(read(join(cwd, "forge.yaml")))?.rollouts; } catch { return null; }
|
|
19
|
+
const files = String(sec?.files ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
20
|
+
if (!files.length) return null;
|
|
21
|
+
const days = Number(sec?.dormant_days);
|
|
22
|
+
return { files, days: Number.isFinite(days) && days > 0 ? days : DEFAULT_DORMANT_DAYS };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const inRegistry = (cfg, path) => (cfg?.files ?? []).some((f) => path === f || path.startsWith(f.endsWith("/") ? f : `${f}/`));
|
|
26
|
+
|
|
27
|
+
/** The keys a unified diff adds to a registry file with a default of off/false/0 (a key that is only changed, not new, is excluded). */
|
|
28
|
+
export function detectNewOffKeys(cfg, diffText) {
|
|
29
|
+
if (!cfg?.files?.length) return [];
|
|
30
|
+
const added = new Set(), seen = new Set();
|
|
31
|
+
let path = null;
|
|
32
|
+
for (const line of String(diffText ?? "").split("\n")) {
|
|
33
|
+
const m = /^\+\+\+ (?:b\/)?(.+)$/.exec(line);
|
|
34
|
+
if (m) { path = m[1].trim(); continue; }
|
|
35
|
+
if (!path || !inRegistry(cfg, path) || !/^[+-][^+-]|^[+-]$/.test(line)) continue;
|
|
36
|
+
const e = ENTRY_RE.exec(line.slice(1).trim());
|
|
37
|
+
if (line[0] === "-") { const k = /^["']?([A-Za-z_][\w.-]*)["']?\s*[:=]/.exec(line.slice(1).trim()); if (k) seen.add(k[1]); }
|
|
38
|
+
else if (e) added.add(e[1]);
|
|
39
|
+
}
|
|
40
|
+
return [...added].filter((k) => !seen.has(k)).sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const matches = (pattern, flag) => (pattern.endsWith("*") ? flag.startsWith(pattern.slice(0, -1)) : pattern === flag);
|
|
44
|
+
/** Whether a requirement is an activation row for `flag`: a defer `owner:promote:<flag>` bucket, or a deliver row whose why/evidence names it. */
|
|
45
|
+
export function coversFlag(r, flag) {
|
|
46
|
+
const b = String(r?.bucket ?? "");
|
|
47
|
+
if (r?.disposition === "defer" && b.startsWith("owner:promote:")) return matches(b.slice("owner:promote:".length), flag);
|
|
48
|
+
return r?.disposition === "deliver" && flagRe(flag).test(`${r?.why ?? ""} ${r?.evidence ?? ""}`);
|
|
49
|
+
}
|
|
50
|
+
const uncovered = (contract, keys) => keys.filter((k) => !(contract?.requirements ?? []).some((r) => coversFlag(r, k)));
|
|
51
|
+
|
|
52
|
+
/** Conformance failures: every new off key needs an activation row; a requirement naming shadow/cohort/treated fails while its flag ships off uncovered. */
|
|
53
|
+
export function activationFailures(contract, keys) {
|
|
54
|
+
const miss = uncovered(contract, keys ?? []);
|
|
55
|
+
if (!miss.length) return [];
|
|
56
|
+
const out = [{ kind: "activation_missing", detail: `default-off ${miss.join(", ")} added with no activation row: add a requirement with bucket owner:promote:<flag> (first cohort, guard metrics, shadow evidence) or a deliver row naming the flag` }];
|
|
57
|
+
for (const r of contract?.requirements ?? []) {
|
|
58
|
+
if (MODE_RE.test(String(r?.quote ?? "")) && r.disposition === "deliver") out.push({ kind: "mode_unreachable", detail: `${r.id} names ${MODE_RE.exec(r.quote)[1].toLowerCase()} mode but ${miss.join(", ")} ships off and no activation row covers it` });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Dormancy report: introduced `[{flag, parent, since}]` (since: ms or ISO) that `flagState(flag)` (injected, read-only) says are still
|
|
65
|
+
* "off" after `days`, minus flags with a recorded keep-off decision (`decisions`: `[{flag, decision:"keep-off"}]`, flag may end in `*`).
|
|
66
|
+
*/
|
|
67
|
+
export async function dormancyReport({ introduced = [], flagState, decisions = [], now = Date.now(), days = DEFAULT_DORMANT_DAYS }) {
|
|
68
|
+
const kept = decisions.filter((d) => d?.decision === "keep-off").map((d) => String(d.flag));
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const i of introduced) {
|
|
71
|
+
const age = Math.floor((now - new Date(i.since).getTime()) / 86400e3);
|
|
72
|
+
if (!(age >= days) || kept.some((k) => matches(k, i.flag))) continue;
|
|
73
|
+
if (String(await flagState(i.flag)).toLowerCase() === "off") out.push({ flag: i.flag, parent: i.parent ?? null, days: age });
|
|
74
|
+
}
|
|
75
|
+
return out.sort((a, b) => b.days - a.days);
|
|
76
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { readRollouts, detectNewOffKeys, activationFailures, dormancyReport, coversFlag } from "./activation.mjs";
|
|
6
|
+
import { precheck } from "./conformance.mjs";
|
|
7
|
+
|
|
8
|
+
const cfg = { files: ["config/rollouts.json"], days: 14 };
|
|
9
|
+
const DIFF = [
|
|
10
|
+
"diff --git a/config/rollouts.json b/config/rollouts.json", "--- a/config/rollouts.json", "+++ b/config/rollouts.json", "@@ -1,3 +1,5 @@",
|
|
11
|
+
'+ "clearance_exit_routing": false,', '+ "already_on": true,', '- "old_flag": true', '+ "old_flag": false,',
|
|
12
|
+
"diff --git a/src/other.json b/src/other.json", "--- a/src/other.json", "+++ b/src/other.json", '+ "elsewhere": false,',
|
|
13
|
+
].join("\n");
|
|
14
|
+
const Q_SHADOW = "Hit > 0 and unbound < 5% in shadow within a week";
|
|
15
|
+
const contract = (reqs) => ({ requirements: reqs, deliverables: [], preserve: [], validate: [] });
|
|
16
|
+
const pre = (c, newOffKeys) => precheck({ contract: c, result: { requirements: c.requirements.map((r) => ({ id: r.id, status: "implemented" })) }, changedFiles: [], newOffKeys });
|
|
17
|
+
|
|
18
|
+
describe("activation rows (#4769)", () => {
|
|
19
|
+
it("reads the forge.yaml rollouts registry", () => {
|
|
20
|
+
assert.deepEqual(readRollouts("/x", () => 'rollouts:\n files: "a.json, b.py"\n dormant_days: 30\n'), { files: ["a.json", "b.py"], days: 30 });
|
|
21
|
+
assert.equal(readRollouts("/x", () => "engine:\n path: core\n"), null);
|
|
22
|
+
assert.equal(readRollouts("/x", () => { throw new Error("none"); }), null);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("a registry diff yields only new off keys in declared files", () => {
|
|
26
|
+
assert.deepEqual(detectNewOffKeys(cfg, DIFF), ["clearance_exit_routing"]);
|
|
27
|
+
assert.deepEqual(detectNewOffKeys(null, DIFF), []);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("a contract without an activation row fails; an owner:promote row or a named deliver row passes", () => {
|
|
31
|
+
const base = { id: "R1", quote: "Ship the clearance routing change", disposition: "deliver" };
|
|
32
|
+
const keys = detectNewOffKeys(cfg, DIFF);
|
|
33
|
+
const bad = pre(contract([base]), keys);
|
|
34
|
+
assert.equal(bad.ok, false); assert.ok(bad.failures.some((f) => f.kind === "activation_missing"));
|
|
35
|
+
const owner = { id: "R2", quote: "Turn it on after shadow", disposition: "defer", bucket: "owner:promote:clearance_exit_routing", why: "w" };
|
|
36
|
+
assert.equal(pre(contract([base, owner]), keys).ok, true);
|
|
37
|
+
assert.equal(pre(contract([base, { ...owner, bucket: "owner:promote:clearance_*" }]), keys).ok, true);
|
|
38
|
+
assert.equal(pre(contract([{ ...base, why: "also flips clearance_exit_routing to the first cohort" }]), keys).ok, true);
|
|
39
|
+
assert.equal(coversFlag({ disposition: "defer", bucket: "owner:promote:other" }, "clearance_exit_routing"), false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("no new off key leaves precheck unchanged", () => {
|
|
43
|
+
assert.equal(pre(contract([{ id: "R1", quote: Q_SHADOW, disposition: "deliver" }]), []).ok, true);
|
|
44
|
+
assert.equal(pre(contract([{ id: "R1", quote: Q_SHADOW, disposition: "deliver" }]), null).ok, true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("an 'in shadow' AC fails when the change ships off with no activation row, and passes with one", () => {
|
|
48
|
+
const ac = { id: "R1", quote: Q_SHADOW, disposition: "deliver" };
|
|
49
|
+
const fails = activationFailures(contract([ac]), ["clearance_exit_routing"]);
|
|
50
|
+
assert.ok(fails.some((f) => f.kind === "mode_unreachable" && /R1 names shadow/.test(f.detail)));
|
|
51
|
+
const row = { id: "R2", quote: "Promote it", disposition: "defer", bucket: "owner:promote:clearance_exit_routing", why: "w" };
|
|
52
|
+
assert.deepEqual(activationFailures(contract([ac, row]), ["clearance_exit_routing"]), []);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("dormancy report (#4769)", () => {
|
|
57
|
+
const now = Date.parse("2026-10-29T00:00:00Z");
|
|
58
|
+
const introduced = [
|
|
59
|
+
{ flag: "EXTRACTION_V2_CANARY_PCT", parent: 21002, since: "2026-06-17" },
|
|
60
|
+
{ flag: "clearance_exit_routing", parent: 36817, since: "2026-10-10" },
|
|
61
|
+
{ flag: "recent_flag", parent: 1, since: "2026-10-25" },
|
|
62
|
+
{ flag: "now_on", parent: 2, since: "2026-01-01" },
|
|
63
|
+
{ flag: "egress_country_exploration", parent: 36623, since: "2026-09-01" },
|
|
64
|
+
];
|
|
65
|
+
const flagState = async (f) => (f === "now_on" ? "on" : "off");
|
|
66
|
+
it("lists still-off flags past N days with days dormant and parent", async () => {
|
|
67
|
+
const r = await dormancyReport({ introduced, flagState, now });
|
|
68
|
+
assert.deepEqual(r.map((x) => [x.flag, x.parent, x.days]), [["EXTRACTION_V2_CANARY_PCT", 21002, 134], ["egress_country_exploration", 36623, 58], ["clearance_exit_routing", 36817, 19]]);
|
|
69
|
+
assert.deepEqual((await dormancyReport({ introduced, flagState, now, days: 60 })).map((x) => x.flag), ["EXTRACTION_V2_CANARY_PCT"]);
|
|
70
|
+
});
|
|
71
|
+
it("a keep-off decision (exact or wildcard) silences the entry", async () => {
|
|
72
|
+
const r = await dormancyReport({ introduced, flagState, now, decisions: [{ flag: "EXTRACTION_V2_*", decision: "keep-off" }, { flag: "egress_country_exploration", decision: "promote" }] });
|
|
73
|
+
assert.deepEqual(r.map((x) => x.flag), ["egress_country_exploration", "clearance_exit_routing"]);
|
|
74
|
+
});
|
|
75
|
+
});
|