forgedock 1.36.0 → 1.37.0

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 (112) hide show
  1. package/bin/bot-token-status.mjs +26 -0
  2. package/bin/engine/events.mjs +10 -1
  3. package/bin/engine/events.test.mjs +15 -1
  4. package/bin/engine/outcome-codes.mjs +39 -1
  5. package/bin/engine/phases.mjs +65 -5
  6. package/bin/engine/telemetry-seam.mjs +1 -1
  7. package/bin/engine/telemetry-seam.test.mjs +4 -0
  8. package/bin/engine-cli.mjs +268 -37
  9. package/bin/engine.mjs +3 -1
  10. package/bin/forgedock.mjs +108 -5
  11. package/bin/labels.json +50 -0
  12. package/bin/lean/batch.mjs +10 -4
  13. package/bin/lean/batch.test.mjs +75 -1
  14. package/bin/lean/conformance.e2e.test.mjs +1 -1
  15. package/bin/lean/conformance.mjs +125 -11
  16. package/bin/lean/conformance.test.mjs +122 -1
  17. package/bin/lean/decisions.mjs +181 -0
  18. package/bin/lean/decisions.test.mjs +71 -0
  19. package/bin/lean/effects.mjs +29 -14
  20. package/bin/lean/execution.mjs +60 -16
  21. package/bin/lean/execution.test.mjs +59 -2
  22. package/bin/lean/gatewatch.mjs +31 -0
  23. package/bin/lean/gatewatch.test.mjs +59 -0
  24. package/bin/lean/guardgate.mjs +170 -0
  25. package/bin/lean/guardgate.test.mjs +153 -0
  26. package/bin/lean/guards.mjs +243 -0
  27. package/bin/lean/guards.test.mjs +177 -0
  28. package/bin/lean/heavy.mjs +54 -8
  29. package/bin/lean/heavy.test.mjs +74 -1
  30. package/bin/lean/hostkill.mjs +1 -1
  31. package/bin/lean/integration.mjs +276 -0
  32. package/bin/lean/integration.test.mjs +189 -0
  33. package/bin/lean/lean.mjs +134 -28
  34. package/bin/lean/lean.test.mjs +239 -22
  35. package/bin/lean/lifecycle.mjs +129 -0
  36. package/bin/lean/lifecycle.test.mjs +118 -0
  37. package/bin/lean/light.mjs +1 -1
  38. package/bin/lean/milestonesync.mjs +130 -0
  39. package/bin/lean/milestonesync.test.mjs +133 -0
  40. package/bin/lean/orchestrate.mjs +83 -15
  41. package/bin/lean/parked-sweep.mjs +45 -0
  42. package/bin/lean/parked-sweep.test.mjs +71 -0
  43. package/bin/lean/postdeploy-check.mjs +38 -6
  44. package/bin/lean/postdeploy-check.test.mjs +62 -2
  45. package/bin/lean/postdeploy-monitor.mjs +12 -2
  46. package/bin/lean/postdeploy-monitor.test.mjs +27 -1
  47. package/bin/lean/prefilter.mjs +83 -23
  48. package/bin/lean/prefilter.test.mjs +73 -1
  49. package/bin/lean/probe.mjs +2 -2
  50. package/bin/lean/probe.test.mjs +1 -1
  51. package/bin/lean/prompts.mjs +15 -9
  52. package/bin/lean/resume.test.mjs +1 -1
  53. package/bin/lean/retry.mjs +28 -0
  54. package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
  55. package/bin/lean/sandbox-remote.mjs +4 -4
  56. package/bin/lean/schedd.mjs +53 -9
  57. package/bin/lean/schedd.test.mjs +77 -5
  58. package/bin/lean/sequence.mjs +24 -0
  59. package/bin/lean/sequence.test.mjs +31 -1
  60. package/bin/lean/signoff.mjs +66 -0
  61. package/bin/lean/signoff.test.mjs +102 -0
  62. package/bin/lean/train.mjs +177 -0
  63. package/bin/lean/train.test.mjs +113 -0
  64. package/bin/pool/cli.mjs +2 -0
  65. package/bin/pool/health.mjs +30 -0
  66. package/bin/review-context.mjs +298 -0
  67. package/bin/runner.mjs +26 -3
  68. package/bin/stats/compounding.mjs +157 -0
  69. package/bin/stats/compounding.test.mjs +74 -0
  70. package/bin/stats/index.mjs +21 -2
  71. package/commands/bootstrap.md +10 -8
  72. package/commands/deploy-info.md +3 -0
  73. package/commands/issue.md +2 -0
  74. package/commands/milestone.md +43 -54
  75. package/commands/orchestrate/phase-4-execution.md +2 -0
  76. package/commands/orchestrate.md +8 -1
  77. package/commands/pipeline-status.md +20 -0
  78. package/commands/review-pr.md +1 -0
  79. package/commands/work-on/build/implement.md +1 -1
  80. package/commands/work-on/build.md +4 -2
  81. package/commands/work-on/investigate.md +3 -1
  82. package/commands/work-on/remediate.md +1 -1
  83. package/docs/CONFIG.md +71 -2
  84. package/docs/EVENTS.md +1 -4
  85. package/docs/OUTCOME-CODES.md +9 -1
  86. package/docs/STATS.md +20 -0
  87. package/docs/site/start-a-new-project.md +1 -1
  88. package/docs/spec/label-state-machine.md +6 -0
  89. package/package.json +1 -1
  90. package/pool/install/install-agent.sh +4 -2
  91. package/pool/install/install-router.sh +4 -2
  92. package/pool/install/install-schedd.sh +28 -0
  93. package/pool/install/unit-hardening.sh +34 -0
  94. package/scripts/acceptance-red-green.sh +19 -2
  95. package/scripts/acceptance-red-green.test.sh +2 -0
  96. package/scripts/bootstrap-create.mjs +3 -2
  97. package/scripts/bootstrap-validate.mjs +31 -4
  98. package/scripts/file-deferred-followups.sh +285 -77
  99. package/scripts/file-deferred-followups.test.sh +111 -4
  100. package/scripts/hotspots.sh +23 -2
  101. package/scripts/hotspots.test.sh +9 -0
  102. package/scripts/plan-validate-live.mjs +111 -0
  103. package/scripts/readiness.mjs +141 -0
  104. package/scripts/review-prepare.sh +17 -1
  105. package/scripts/review-prepare.test.sh +17 -0
  106. package/scripts/settle-scope.mjs +74 -0
  107. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  108. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  109. package/scripts/wait-ci-green.sh +14 -3
  110. package/scripts/wait-ci-green.test.sh +3 -1
  111. package/templates/bootstrap/needs-human-issue.md +4 -1
  112. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/bin/forgedock.mjs CHANGED
@@ -86,6 +86,8 @@ import {
86
86
  CYAN,
87
87
  } from "./tui.mjs";
88
88
  import { buildMinimalForgeYaml } from "./init-detect.mjs";
89
+ import { checkContext, refreshContextFlow } from "./review-context.mjs";
90
+ import { botTokenStatus } from "./bot-token-status.mjs";
89
91
  import {
90
92
  parseNameStatusDiff,
91
93
  classifyCommandChanges,
@@ -234,7 +236,7 @@ const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
234
236
  // --minimal is init-only. Subcommands with their own flag parsing (run,
235
237
  // run-issue, demo) receive restArgs — everything after the command token.
236
238
  const rawArgs = process.argv.slice(2);
237
- const FLAGS = new Set(["--fast", "--manual", "--verbose", "--minimal", "--extras"]);
239
+ const FLAGS = new Set(["--fast", "--manual", "--verbose", "--minimal", "--extras", "--refresh-context"]);
238
240
  const flags = rawArgs.filter((a) => FLAGS.has(a));
239
241
  // --global is stripped from positionals/restArgs for backward compatibility
240
242
  // with existing invocations, but is otherwise a no-op — see the install-mode
@@ -1042,6 +1044,13 @@ function ensureToolStateIgnored(c) {
1042
1044
 
1043
1045
  async function initFlow(c) {
1044
1046
  const outputPath = join(c.cwd, "forge.yaml");
1047
+ if (flags.includes("--refresh-context")) {
1048
+ return refreshContextFlow({
1049
+ cwd: c.cwd,
1050
+ out: c.stdout,
1051
+ deps: { interactive: process.stdin.isTTY === true, confirm, backup: backupExisting, write: atomicWriteFile },
1052
+ });
1053
+ }
1045
1054
  const hasExisting = existsSync(outputPath);
1046
1055
  if (hasExisting && process.stdin.isTTY !== true) {
1047
1056
  const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
@@ -2704,6 +2713,15 @@ async function doctor(fix = false) {
2704
2713
  );
2705
2714
  }
2706
2715
 
2716
+ try {
2717
+ const ctxCheck = checkContext({ configPath: forgeYamlPath, root: process.cwd(), repo: forgeOwner && forgeRepo ? `${forgeOwner}/${forgeRepo}` : "" });
2718
+ if (ctxCheck.stale) {
2719
+ warn("review context", `REVIEW_CONTEXT_STALE: ${ctxCheck.reasons.map((r) => r.detail).join("; ")}. Run: npx forgedock init --refresh-context`);
2720
+ } else if (ctxCheck.advisory) {
2721
+ warn("review context", `REVIEW_CONTEXT_STALE: generated ${ctxCheck.generated_at}, ${ctxCheck.merged_since} PRs merged since (limit ${ctxCheck.max_merged_prs}). Run: npx forgedock init --refresh-context`);
2722
+ }
2723
+ } catch { /* advisory check: never fail doctor */ }
2724
+
2707
2725
  const defaultMatch = content.match(/^\s+default:\s+"([^"]*)"/m);
2708
2726
  const stagingMatch = content.match(/^\s+staging:\s+"([^"]*)"/m);
2709
2727
  if (
@@ -3243,14 +3261,20 @@ async function doctor(fix = false) {
3243
3261
  }
3244
3262
 
3245
3263
  // ── Check 12: GitHub App / bot token status ─────────────────────────────────
3246
- // Informational only — never a hard failure. Installing the ForgeDock GitHub
3264
+ // Informational without a refresh stamp (#4662: with one, a stale or expiring token fails). Installing the ForgeDock GitHub
3247
3265
  // App registers it against the account/org but does not, by itself, mint a
3248
3266
  // bot token: that requires the app's private key, which only the app owner
3249
3267
  // holds. Pipeline gh calls always use whatever `gh auth` context is active
3250
3268
  // locally (personal token unless the operator manually configured a bot
3251
3269
  // token) — this check exists so that state is surfaced instead of silent.
3252
3270
  // See docs/CONFIG.md "GitHub App Install" and forge#1890.
3253
- {
3271
+ const botTok = botTokenStatus(); // #4662: lifetime from the refresh stamp; no stamp means no refresher is installed
3272
+ if (botTok.configured) {
3273
+ const min = (ms) => Math.round(ms / 60000);
3274
+ const detail = botTok.remainingMs != null ? `${min(botTok.remainingMs)} min left, refreshed ${min(botTok.ageMs)} min ago (${botTok.refreshedAt})` : botTok.reason;
3275
+ if (botTok.ok) pass("GitHub App / bot token", detail);
3276
+ else fail(`GitHub App / bot token: ${botTok.reason} (${detail})`, "run scripts/refresh-bot-token.sh and install the timer: docs/CONFIG.md \"Bot token refresh\"");
3277
+ } else {
3254
3278
  warn(
3255
3279
  "GitHub App / bot token",
3256
3280
  "pipeline commands use your active `gh auth` (personal token, unless you've manually configured a bot token). Installing the GitHub App alone does not create one — see docs/CONFIG.md \"GitHub App Install\".",
@@ -3277,6 +3301,19 @@ async function doctor(fix = false) {
3277
3301
  }
3278
3302
  }
3279
3303
 
3304
+ // ── Check 14: Router/schedd restarts (#4673) ───────────────────────────────
3305
+ // A restart in the last hour usually means an OOM killer or SIGTERM took the coordinator and dropped in-flight lanes. Warn only.
3306
+ {
3307
+ try {
3308
+ const { restartCheck } = await import("./pool/health.mjs");
3309
+ for (const unit of ["computeshare-router", "forgedock-schedd"]) {
3310
+ const r = restartCheck({ unit });
3311
+ if (r.status === "warn") warn(`Restarts: ${unit}`, r.detail);
3312
+ else if (r.status === "pass") pass(`Restarts: ${unit}`, r.detail);
3313
+ }
3314
+ } catch { /* no systemd here: nothing to report */ }
3315
+ }
3316
+
3280
3317
  // ── Summary ────────────────────────────────────────────────────────────────
3281
3318
  console.log("");
3282
3319
  if (fix && fixesApplied > 0) {
@@ -3314,12 +3351,16 @@ function help() {
3314
3351
  ["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
3315
3352
  ["npx forgedock bootstrap <idea|docs> [--dry-run] [--spike <path>] | --plan <plan.json> [--dry-run]", "Headless /bootstrap: idea to orchestrate-ready backlog (--dry-run previews milestones, titles, DAG and estimated agent cost without GitHub writes); --plan validates and creates (or previews) a plan, and re-running resumes from GitHub"],
3316
3353
  ["npx forgedock run-issue <issue> [--lane <lane>] [--lane-override] [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline; the lane comes from the issue: milestone → milestone/<slug>, else staging; --lane is an assertion, never the default branch) (--retry reopens an engine-error run, or a needs-human run once the label is cleared; --wait-lease waits for a held lease; exit 75 = deferred, lease held)"],
3354
+ ["npx forgedock plan validate --live [--repo owner/repo] [--json]", "Re-run the bootstrap dependency-order rules on live GitHub state: each cross-milestone Depends-on edge is ok, held or invalid (exit 1 on invalid)"],
3317
3355
  ["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering; each issue on its own lane, cross-lane dependencies reported"],
3318
- ["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N...", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, requeue a terminal issue (and the dependents skipped behind it) after an operator decision (refused while it is labelled needs-human or closed; a finished batch is reopened), or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume)"],
3356
+ ["npx forgedock orchestrate --resume <id> [N...] | --set-parallel <id> K | --add <id> N... | --requeue <id> N... [--reason \"...\"] | --cancel <id> | --hold <id> N... | --release <id> N... | --show-env <id> | --set-env <id> KEY=VAL | --unset-env <id> KEY", "Reattach to a batch hosted by the schedd daemon (a finished one replays its report, an orphaned one is resumed with live lanes re-adopted), change its concurrency live, add issues to a running (or held) batch under its K and DAG, requeue a terminal issue (and the dependents skipped behind it) after an operator decision (refused while it is labelled needs-human or closed; a finished batch is reopened), or stop one batch without stopping schedd (running lanes torn down with their work saved, the rest held for --resume), or hold/release single issues of a batch (a held issue is not admitted until released or named by --resume), or print (secrets redacted), set or unset a batch's pinned lane env (applies to newly launched lanes)"],
3319
3357
  ["npx forgedock triage <issue...> [--lane L] [--dry-run]", "Already-fixed pre-filter: close issues a merged PR or green CI already resolved; print the rest"],
3320
3358
  ["npx forgedock probe [issue...] [--dry-run]", "Run declared acceptance probes for closed issues whose fix is on the default branch; a fail reopens the issue"],
3321
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
+ ["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
+ ["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"],
3322
3362
  ["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
3363
+ ["npx forgedock decisions list [--stale] [--refresh]", "List anchored decisions (FORGE:DECISION); --stale shows those with an anchor missing on the base"],
3323
3364
  ["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)"],
3324
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"],
3325
3366
  ["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"],
@@ -3350,6 +3391,7 @@ function help() {
3350
3391
  ["--manual", "Plain text prompts instead of the review screen (init)"],
3351
3392
  ["--verbose", "Show detection sources for every field (init)"],
3352
3393
  ["--minimal", "Generate a minimal forge.yaml with required sections only (init)"],
3394
+ ["--refresh-context", "Regenerate review.context/tech_stack from manifests and show a diff for approval (init)"],
3353
3395
  ["--version, -v", "Print the installed version and check for updates"],
3354
3396
  ];
3355
3397
 
@@ -3903,7 +3945,7 @@ async function watch() {
3903
3945
  const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3904
3946
  const KNOWN_COMMANDS = new Set([
3905
3947
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3906
- "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "sched", "schedd", "pool", "check", "help", "--help", "-h",
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",
3907
3949
  "version", "--version", "-v",
3908
3950
  ]);
3909
3951
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -4060,6 +4102,12 @@ switch (command) {
4060
4102
  case "run":
4061
4103
  await run();
4062
4104
  break;
4105
+ case "plan": {
4106
+ // #4659: bootstrap-validate's dependency rules on live GitHub state
4107
+ const { runLive } = await import("../scripts/plan-validate-live.mjs");
4108
+ exitCode = await runLive(restArgs);
4109
+ break;
4110
+ }
4063
4111
  case "bootstrap": {
4064
4112
  // #4177: headless /bootstrap; --plan runs scripts/bootstrap-create.mjs directly (no LLM), a re-run resumes.
4065
4113
  const { runBootstrap } = await import("./bootstrap.mjs");
@@ -4136,6 +4184,17 @@ switch (command) {
4136
4184
  }
4137
4185
  break;
4138
4186
  }
4187
+ case "decisions": {
4188
+ // #4695: anchored decisions of the local index
4189
+ const { decisionsFromCli } = await import("./engine-cli.mjs");
4190
+ try {
4191
+ await decisionsFromCli(restArgs);
4192
+ } catch (err) {
4193
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4194
+ exitCode = 1;
4195
+ }
4196
+ break;
4197
+ }
4139
4198
  case "check": {
4140
4199
  // #4364: the builder's in-session run of the engine's validate and pre-PR checks; local only
4141
4200
  const { checkFromCli } = await import("./engine-cli.mjs");
@@ -4199,6 +4258,50 @@ switch (command) {
4199
4258
  }
4200
4259
  break;
4201
4260
  }
4261
+ case "milestone-sync": {
4262
+ // On-demand milestone branch sync (#4663): PR + auto-merge under protected branches, one debounced sync PR.
4263
+ const { milestoneSyncFromCli } = await import("./engine-cli.mjs");
4264
+ try {
4265
+ exitCode = await milestoneSyncFromCli(restArgs);
4266
+ } catch (err) {
4267
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4268
+ exitCode = 1;
4269
+ }
4270
+ break;
4271
+ }
4272
+ case "milestone": {
4273
+ // Native unattended milestone train (#4658): `forgedock milestone train <slug...>`.
4274
+ const { trainFromCli } = await import("./engine-cli.mjs");
4275
+ try {
4276
+ if (restArgs[0] !== "train") throw new Error("usage: forgedock milestone train <slug...> [--parallel-milestones N] | --resume <id> | --insert <id> <slug> | --show <id>");
4277
+ const result = await trainFromCli(restArgs.slice(1));
4278
+ if (result && !result.ok) exitCode = 1;
4279
+ } catch (err) {
4280
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4281
+ exitCode = 1;
4282
+ }
4283
+ break;
4284
+ }
4285
+ case "guards": {
4286
+ // Fix guards (#4692): `forgedock guards list [--path p]` | `guards backfill --since <date>`.
4287
+ const { guardsFromCli } = await import("./engine-cli.mjs");
4288
+ try { exitCode = await guardsFromCli(restArgs); } catch (err) {
4289
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4290
+ exitCode = 1;
4291
+ }
4292
+ break;
4293
+ }
4294
+ case "integration": {
4295
+ // Milestone integration runs (#4667): `forgedock integration run` for cron or a scheduled workflow.
4296
+ const { integrationFromCli } = await import("./engine-cli.mjs");
4297
+ try {
4298
+ exitCode = await integrationFromCli(restArgs);
4299
+ } catch (err) {
4300
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4301
+ exitCode = 1;
4302
+ }
4303
+ break;
4304
+ }
4202
4305
  case "probe": {
4203
4306
  const { probeFromCli } = await import("./engine-cli.mjs");
4204
4307
  try {
package/bin/labels.json CHANGED
@@ -69,11 +69,31 @@
69
69
  "color": "E4E669",
70
70
  "description": "Pipeline stalled — requires human intervention. Managed by ForgeDock."
71
71
  },
72
+ {
73
+ "name": "needs-human:external",
74
+ "color": "E4E669",
75
+ "description": "Waiting on an external prerequisite (credential, account, sign-off). Comment names the owner and unblock condition. Managed by ForgeDock."
76
+ },
77
+ {
78
+ "name": "parked",
79
+ "color": "FBCA04",
80
+ "description": "Mechanical park on a terminal code; per-code labels are parked:<code>. Managed by ForgeDock."
81
+ },
82
+ {
83
+ "name": "remediation-queue",
84
+ "color": "FBCA04",
85
+ "description": "Parked issue awaiting /work-on:remediate. Managed by ForgeDock."
86
+ },
72
87
  {
73
88
  "name": "workflow:engine-error",
74
89
  "color": "B60205",
75
90
  "description": "Pipeline stalled — engine/tool failure (not a genuine human-judgment block). Managed by ForgeDock."
76
91
  },
92
+ {
93
+ "name": "awaiting-due",
94
+ "color": "C5DEF5",
95
+ "description": "Post-deploy follow-up parked until its due date; re-queued by the engine. Managed by ForgeDock."
96
+ },
77
97
  {
78
98
  "name": "awaiting-promotion",
79
99
  "color": "C5DEF5",
@@ -183,5 +203,35 @@
183
203
  "name": "performance",
184
204
  "color": "F9D0C4",
185
205
  "description": "Performance optimization or regression. Managed by ForgeDock."
206
+ },
207
+ {
208
+ "name": "integration-red",
209
+ "color": "D93F0B",
210
+ "description": "Scheduled full-suite integration run on a milestone branch is red. Managed by ForgeDock."
211
+ },
212
+ {
213
+ "name": "followup:engineering",
214
+ "color": "BFD4F2",
215
+ "description": "Deferred buildable engineering work. Managed by ForgeDock."
216
+ },
217
+ {
218
+ "name": "followup:post-deploy",
219
+ "color": "BFD4F2",
220
+ "description": "Deferred post-deploy checks. Managed by ForgeDock."
221
+ },
222
+ {
223
+ "name": "followup:external",
224
+ "color": "BFD4F2",
225
+ "description": "Deferred items blocked on something outside the repo (credentials, third parties). Managed by ForgeDock."
226
+ },
227
+ {
228
+ "name": "followup:ops",
229
+ "color": "BFD4F2",
230
+ "description": "Deferred ops work (drills, runbooks). Managed by ForgeDock."
231
+ },
232
+ {
233
+ "name": "external-prerequisite",
234
+ "color": "E99695",
235
+ "description": "Follow-up that waits on an external prerequisite. Managed by ForgeDock."
186
236
  }
187
237
  ]
@@ -99,7 +99,7 @@ export function openBatch(root, id) {
99
99
  if (!ID_RE.test(String(id ?? ""))) throw new Error(`invalid batch id ${JSON.stringify(id)}`);
100
100
  const dir = join(root, id);
101
101
  const f = (name) => join(dir, name);
102
- return {
102
+ const self = {
103
103
  id, dir,
104
104
  exists: () => existsSync(f("plan.json")),
105
105
  readPlan: () => readJson(f("plan.json")),
@@ -122,16 +122,19 @@ export function openBatch(root, id) {
122
122
  * `order`, in the file or earlier in `list`: skipped `already in batch`. @returns {{added:number[], skipped:Array<{issue, reason}>}}
123
123
  */
124
124
  addIssues: (list) => {
125
- const have = new Set([...(readJson(f("state.json"))?.order ?? []), ...readAdded(f("add"))]);
126
- const added = [], skipped = [];
125
+ const st = readJson(f("state.json")) ?? {};
126
+ const have = new Set([...(st.order ?? []), ...readAdded(f("add"))]);
127
+ const added = [], skipped = [], requeued = [];
127
128
  for (const x of Array.isArray(list) ? list : []) {
128
129
  const n = typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN;
129
130
  if (!Number.isSafeInteger(n) || n < 1) { skipped.push({ issue: x, reason: "not an integer" }); continue; }
131
+ // #4687: an issue skipped in the batch (a dependency ended) is re-queued, not refused; any other row is "already in batch"
132
+ if (have.has(n) && st.results?.[n]?.outcome === "skipped" && !requeued.includes(n) && self.requeue([n], { reason: "--add", by: "operator" }).requeued.length) { requeued.push(n); continue; }
130
133
  if (have.has(n)) { skipped.push({ issue: n, reason: "already in batch" }); continue; }
131
134
  have.add(n); added.push(n);
132
135
  }
133
136
  if (added.length) writeAtomic(f("add"), JSON.stringify([...new Set([...readAdded(f("add")), ...added])].sort((a, b) => a - b)) + "\n");
134
- return { added, skipped };
137
+ return { added, skipped, requeued };
135
138
  },
136
139
  /**
137
140
  * `--requeue` (#4550): record that each issue returns to pending. Refused: not a positive integer, not in the batch's
@@ -164,6 +167,8 @@ export function openBatch(root, id) {
164
167
  unrunRequeues: () => { const done = new Set(readJson(f("state.json"))?.requeueApplied ?? []); return readRequeueFile(f("requeue")).filter((e) => !done.has(requeueKey(e))); },
165
168
  /** Issues held by `--hold` (#4592): the `hold` file, sorted unique positive integers; [] when missing or invalid. */
166
169
  readHeld: () => readAdded(f("hold")),
170
+ /** #4656: gates waiting on issues (`gates` file: [{ name, waitsOn: [n...], since? }], written by the ship/settle pre-flight); [] when missing or invalid. */
171
+ readGates: () => { const v = readJson(f("gates")); return Array.isArray(v) ? v.filter((g) => g && typeof g.name === "string" && Array.isArray(g.waitsOn)) : []; },
167
172
  /** Merge the positive integers of `list` into the `hold` file. @returns {number[]} the issues newly held */
168
173
  holdIssues: (list) => {
169
174
  const have = new Set(readAdded(f("hold")));
@@ -197,6 +202,7 @@ export function openBatch(root, id) {
197
202
  resultFile: (n) => f(join("lanes", `${n}.json`)),
198
203
  logFile: (n) => f(join("lanes", `${n}.log`)),
199
204
  };
205
+ return self;
200
206
  }
201
207
 
202
208
  /** True when `pid` is alive and (where /proc exists) its command line names `marker`, so a reused pid is not adopted. */
@@ -89,6 +89,21 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
89
89
  assert.deepEqual(deferPolicy({ code: "host.busy" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
90
90
  });
91
91
 
92
+ it("#4685 a breaker tripped by transport errors closes itself after the cooldown; any other stays tripped", async () => {
93
+ const run = async (detail) => {
94
+ let t = 0; const updates = []; const saves = [];
95
+ const rows = await orchestrate({ issues: [1, 2, 3, 4], parallel: 1, readBody: async () => "", now: () => t, sleep: async (ms) => { t += ms; }, maxDeferrals: 3, breaker: { errors: 3, window_min: 30 },
96
+ onUpdate: (r) => updates.push(r), store: { load: () => null, save: (s) => saves.push(s) },
97
+ runOne: async (n) => { t += 1000; return n <= 3 ? { terminalReason: "engine-error", detail } : { terminalReason: "merged", pr: 5 }; } });
98
+ return { rows, updates, saves };
99
+ };
100
+ const tr = await run("sandbox launch: fetch failed");
101
+ const trip = tr.saves.map((s) => s.breaker).find(Boolean);
102
+ assert.equal(trip?.transport, true);
103
+ const other = await run("boom");
104
+ assert.equal(other.saves.map((s) => s.breaker).find(Boolean)?.transport, undefined);
105
+ });
106
+
92
107
  it("gh.unavailable deferrals (forge#4157) are requeued, never relaunched as engine-error, and never trip the breaker", async () => {
93
108
  const { now, sleep } = fakeClock();
94
109
  const updates = [];
@@ -105,6 +120,31 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
105
120
  assert.deepEqual(deferPolicy({ code: "gh.unavailable", retryInSeconds: 60 }, { now: 0 }), { delayMs: 60000 });
106
121
  });
107
122
 
123
+ it("gh.auth deferrals (forge#4662) are requeued, never relaunched as engine-error, and never trip the breaker", async () => {
124
+ const { now, sleep } = fakeClock();
125
+ const updates = [];
126
+ let calls = 0;
127
+ const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 3, breaker: { errors: 3, window_min: 30 },
128
+ onUpdate: (r) => updates.push(r),
129
+ runOne: async () => (++calls <= 3
130
+ ? { terminalReason: "deferred", code: "gh.auth", retryInSeconds: 120, detail: "gh auth failed (HTTP 401)" }
131
+ : { terminalReason: "merged", pr: 5 }) });
132
+ assert.equal(calls, 4);
133
+ assert.equal(rows[0].outcome, "merged");
134
+ assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
135
+ assert.equal(updates.filter((u) => /requeued in 120s: gh.auth/.test(u.detail ?? "")).length, 3);
136
+ });
137
+
138
+ it("#4690 env.lane_limit (needs-human) is never counted by the breaker and the batch keeps admitting", async () => {
139
+ const { now, sleep } = fakeClock();
140
+ const updates = [], ran = [];
141
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "", now, sleep, breaker: { errors: 1, window_min: 30 }, onUpdate: (r) => updates.push(r),
142
+ runOne: async (n) => { ran.push(n); return n === 1 ? { terminalReason: "needs-human", code: "env.lane_limit", detail: "TasksMax" } : { terminalReason: "merged", pr: n }; } });
143
+ assert.deepEqual(ran, [1, 2, 3]);
144
+ assert.ok(!updates.some((u) => u.outcome === "breaker" || u.outcome === "engine-error"));
145
+ assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "merged", "merged"]);
146
+ });
147
+
108
148
  it("a usage limit holds every admission until its reset; a deferred issue's dependents wait instead of being skipped", async () => {
109
149
  const { now, sleep, c } = fakeClock();
110
150
  const t0 = c.t, starts = {};
@@ -309,6 +349,26 @@ describe("#4318: the batch dispatcher's signal relabel and the needs-human relab
309
349
  const b = hook({ state: "OPEN", body: "" }, { comment: true });
310
350
  assert.equal(await b.run(), true);
311
351
  });
352
+ it("#4736: a mechanical park creates its parked:<code> label before the edit", async () => {
353
+ const calls = [];
354
+ const gh = async (a) => { calls.push(a.slice(0, 3).join(" ")); return a[1] === "view" ? JSON.stringify({ state: "OPEN", body: "" }) : ""; };
355
+ assert.equal(await makeRelabelNeedsHuman({ gh, repo: "o/r", now: () => 1000 })({ issue: 7, evidence: "e", parked: { code: "ci.red", pr: 3 } }), true);
356
+ assert.deepEqual(calls.slice(0, 3), ["issue view 7", "label create parked:ci.red", "issue edit 7"]);
357
+ });
358
+ });
359
+
360
+ it("#4736: a parked relabel that reports failed is not reported as queued for remediation", async () => {
361
+ const leanIo = { gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })), git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : "") };
362
+ const out = [], l = console.log;
363
+ console.log = (...x) => out.push(x.join(" "));
364
+ try {
365
+ await orchestrateFromCli(["1", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("pf-"), batchRoot: tmpDir("pf-batch-"), allowTmpfs: true,
366
+ emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, leanIo, sched: null, decide: () => {},
367
+ orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } },
368
+ relabelNeedsHuman: async (x) => (x.parked ? "failed" : true),
369
+ leanModule: { runCore: async () => ({ terminalReason: "needs-human", code: "ci.red", detail: "x", pr: 5 }) } });
370
+ } finally { console.log = l; }
371
+ assert.doesNotMatch(out.join("\n"), /remediation queue/);
312
372
  });
313
373
  });
314
374
 
@@ -428,6 +488,19 @@ describe("#4408: issues added to a running batch (forgedock orchestrate --add)",
428
488
  assert.deepEqual(plain.map((r) => r.issue), [1]);
429
489
  });
430
490
 
491
+ it("#4687 --add re-queues an issue that is skipped in the batch; any other ended issue stays 'already in batch'", () => {
492
+ const root = tmpDir("batch-add-skipped-");
493
+ const b = openBatch(root, "b1");
494
+ b.writePlan({ argv: ["1"], repo: "o/r" });
495
+ b.store.save({ order: [1, 2, 3], results: { 1: { outcome: "needs-human" }, 2: { outcome: "skipped", detail: "dependency #1 ended needs-human" }, 3: { outcome: "merged" } } });
496
+ const r = b.addIssues([2, 3, 1]);
497
+ assert.deepEqual(r.requeued, [2]);
498
+ assert.deepEqual(r.added, []);
499
+ assert.deepEqual(r.skipped.map((x) => [x.issue, x.reason]), [[3, "already in batch"], [1, "already in batch"]]);
500
+ assert.deepEqual(b.readRequeued().map((e) => e.issue), [2]);
501
+ assert.deepEqual(b.readAdded(), []);
502
+ });
503
+
431
504
  it("the add file: integers only, no issue twice, cumulative; --add through the CLI skips closed and unreadable issues", async () => {
432
505
  const root = tmpDir("batch-add-");
433
506
  const b = openBatch(root, "b1");
@@ -659,7 +732,7 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
659
732
 
660
733
  it("makeDispatchCheck: merged head-branch PR, closed issue, open issue, unreadable answers fail open", async () => {
661
734
  const mk = (issue, prs, events = "") => makeDispatchCheck({ repo: "o/r", gh: async (a) => {
662
- if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state"]); if (issue instanceof Error) throw issue; return issue; }
735
+ if (a[0] === "issue") { assert.deepEqual(a, ["issue", "view", "9", "-R", "o/r", "--json", "state,stateReason"]); if (issue instanceof Error) throw issue; return issue; }
663
736
  if (a[0] === "api") { assert.equal(a[1], "repos/o/r/issues/9/events"); if (events instanceof Error) throw events; return events; }
664
737
  assert.deepEqual(a.slice(0, 6), ["pr", "list", "-R", "o/r", "--head", "feat/issue-9-lean"]);
665
738
  if (prs instanceof Error) throw prs;
@@ -673,6 +746,7 @@ describe("#4481: a closed issue or a merged lane PR is skipped at dispatch", ()
673
746
  assert.equal(await mk('{"state":"OPEN"}', '[{"number":12}]')(9), null, "no mergedAt: the lane runs");
674
747
  assert.deepEqual(await mk('{"state":"CLOSED"}', merged, new Error("not read"))(9), { outcome: "merged", pr: 12, detail: "PR #12 already merged" }, "closed: no reopen lookup");
675
748
  assert.deepEqual(await mk('{"state":"CLOSED"}', "[]")(9), { outcome: "invalid", detail: "issue closed" });
749
+ assert.deepEqual(await mk('{"state":"CLOSED","stateReason":"NOT_PLANNED"}', "[]")(9), { outcome: "invalid", detail: "issue closed (not planned)", final: true }, "#4687: not planned is final");
676
750
  assert.equal(await mk('{"state":"OPEN"}', "[]")(9), null);
677
751
  assert.equal(await mk('{"body":"","labels":[]}', '{"body":""}')(9), null, "a stub without state or a list: run");
678
752
  assert.equal(await mk(new Error("x"), new Error("y"))(9), null);
@@ -56,7 +56,7 @@ const contract = (reqs) => ({ requirements: reqs, deliverables: reqs.filter((r)
56
56
  preserve: [{ id: "P1", what: `${Q.AC3} (src/billing.js price())` }, { id: "P2", what: `${Q.AC2} (test/routes.test.js)` }], validate: [{ id: "V1", command: V1 }] });
57
57
  const FULL = () => contract([req("R1"), req("R2"), req("R3"), req("R4"), req("R5")]);
58
58
  const OMIT = () => contract([req("R1"), req("R2"), req("R4"), req("R5")]);
59
- const DEFER = () => contract([req("R1"), req("R2"), req("R3", "defer", { why: "export.js ships on the data-export module's own release train" }), req("R4"), req("R5")]);
59
+ const DEFER = () => contract([req("R1"), req("R2"), req("R3", "defer", { bucket: "new-issue", why: "export.js ships on the data-export module's own release train" }), req("R4"), req("R5")]);
60
60
 
61
61
  // ---- world ----
62
62
  function makeWorld(dir, script) {