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.
Files changed (81) hide show
  1. package/bin/engine/outcome-codes.mjs +12 -3
  2. package/bin/engine/telemetry-seam.mjs +5 -0
  3. package/bin/engine-cli.mjs +97 -18
  4. package/bin/forgedock.mjs +13 -2
  5. package/bin/lean/activation.mjs +76 -0
  6. package/bin/lean/activation.test.mjs +75 -0
  7. package/bin/lean/admission.mjs +17 -1
  8. package/bin/lean/batch.mjs +51 -21
  9. package/bin/lean/batch.test.mjs +95 -5
  10. package/bin/lean/conformance.mjs +53 -4
  11. package/bin/lean/conformance.test.mjs +24 -1
  12. package/bin/lean/decision-engine.mjs +217 -0
  13. package/bin/lean/decision-engine.test.mjs +159 -0
  14. package/bin/lean/effects.mjs +39 -13
  15. package/bin/lean/execution.mjs +74 -5
  16. package/bin/lean/execution.test.mjs +64 -1
  17. package/bin/lean/explore.test.mjs +1 -1
  18. package/bin/lean/guards.mjs +3 -2
  19. package/bin/lean/guards.test.mjs +24 -3
  20. package/bin/lean/heavy.mjs +15 -2
  21. package/bin/lean/heavy.test.mjs +31 -2
  22. package/bin/lean/issuewatch-release.mjs +245 -0
  23. package/bin/lean/issuewatch-release.test.mjs +244 -0
  24. package/bin/lean/issuewatch.mjs +386 -0
  25. package/bin/lean/issuewatch.test.mjs +280 -0
  26. package/bin/lean/lane.mjs +4 -3
  27. package/bin/lean/lane.test.mjs +12 -0
  28. package/bin/lean/lean.mjs +103 -16
  29. package/bin/lean/lean.test.mjs +127 -5
  30. package/bin/lean/needs-human-premise.mjs +177 -0
  31. package/bin/lean/needs-human-premise.test.mjs +136 -0
  32. package/bin/lean/orchestrate-blocked.test.mjs +71 -0
  33. package/bin/lean/orchestrate.mjs +93 -21
  34. package/bin/lean/postdeploy-check.mjs +88 -2
  35. package/bin/lean/postdeploy-check.test.mjs +78 -0
  36. package/bin/lean/prodwatch-sources.mjs +165 -0
  37. package/bin/lean/prodwatch-sources.test.mjs +108 -0
  38. package/bin/lean/prompts.mjs +9 -3
  39. package/bin/lean/recovery.test.mjs +14 -0
  40. package/bin/lean/relay.mjs +182 -0
  41. package/bin/lean/relay.test.mjs +214 -0
  42. package/bin/lean/sandbox-provider.contract.test.mjs +13 -1
  43. package/bin/lean/sandbox-remote.mjs +53 -9
  44. package/bin/lean/sched.test.mjs +30 -0
  45. package/bin/lean/schedd-health.mjs +69 -0
  46. package/bin/lean/schedd-health.test.mjs +51 -0
  47. package/bin/lean/schedd.mjs +29 -12
  48. package/bin/lean/schedd.test.mjs +40 -4
  49. package/bin/lean/sequence.mjs +57 -24
  50. package/bin/lean/sequence.test.mjs +71 -0
  51. package/bin/pool/agent.mjs +15 -1
  52. package/bin/pool/router.mjs +43 -5
  53. package/bin/runner.mjs +18 -1
  54. package/commands/deploy-pr.md +12 -0
  55. package/commands/forgedock-init.md +1 -0
  56. package/commands/orchestrate/phase-4-execution.md +1 -0
  57. package/commands/orchestrate.md +1 -0
  58. package/commands/review-pr.md +17 -13
  59. package/docs/CONFIG.md +73 -2
  60. package/docs/DECISIONS.md +32 -0
  61. package/docs/ISSUE-WATCH.md +95 -0
  62. package/docs/NORTHSTAR-TEMPLATE.md +21 -0
  63. package/docs/OUTCOME-CODES.md +2 -1
  64. package/docs/TELEMETRY.md +7 -0
  65. package/package.json +1 -2
  66. package/pool/README.md +1 -1
  67. package/pool/image/Dockerfile +2 -1
  68. package/pool/image/cs-relay +31 -0
  69. package/pool/image/entrypoint.sh +10 -0
  70. package/pool/install/install-schedd.sh +22 -0
  71. package/pool/install/unit-hardening.sh +6 -0
  72. package/scripts/postdeploy-rollup-link.sh +40 -0
  73. package/scripts/postdeploy-rollup-link.test.sh +46 -0
  74. package/scripts/refresh-bot-token.sh +122 -0
  75. package/scripts/review-delta.sh +14 -4
  76. package/scripts/review-delta.test.sh +21 -0
  77. package/scripts/review-finalize.sh +69 -25
  78. package/scripts/review-finalize.test.sh +32 -0
  79. package/scripts/review-pr-arguments.test.sh +10 -0
  80. package/scripts/review-pr-changed-verification.test.sh +54 -0
  81. 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
  }
@@ -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
- try { st = JSON.parse(await io.gh(["issue", "view", String(n), ...(repo ? ["-R", repo] : []), "--json", "state"]))?.state ?? null; } catch { st = null; }
1209
- state.set(n, st);
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.body, execForNeeds)); return v.body; }
1807
- : async (n) => { const b = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body; needsOf.set(n, detectNeeds(b, execForNeeds)); return b; },
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.length) throw new Error("usage: forgedock schedd [status]");
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 batches"],
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
+ });