forgedock 1.24.4 → 1.25.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.
@@ -44,13 +44,21 @@ export const OUTCOME_CODES = Object.freeze({
44
44
  INFRA_DISK_QUOTA: "infra.disk_quota",
45
45
  INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
46
46
  CLI_TIMEOUT: "cli.timeout",
47
+ CLI_SPAWN_FAILED: "cli.spawn_failed", // forge#4129: deterministic spawn failure (E2BIG, ENOENT, EACCES, ...)
47
48
  LEASE_HELD: "lease.held",
48
49
  DEPENDENCY_OPEN: "dependency.open",
49
50
  DEPENDENCY_UNREADABLE: "dependency.unreadable",
51
+ DEPENDENCY_BLOCKED: "dependency.blocked",
52
+ // Lane resolution refusals (#4125): before any lease, comment or worktree; terminal reason `lane-refused`.
53
+ LANE_MISMATCH: "lane.mismatch",
54
+ LANE_DEFAULT_BRANCH: "lane.default_branch",
55
+ LANE_BRANCH_MISSING: "lane.branch_missing",
56
+ LANE_UNRESOLVED: "lane.unresolved",
50
57
  INVALID: "invalid",
51
58
  DECOMPOSED: "decomposed",
52
59
  // Lean core work-on (bin/lean) needs-human causes.
53
60
  BUILDER_BLOCKED: "builder.blocked",
61
+ BUILDER_BLOCK_UNTYPED: "builder.block_untyped",
54
62
  BUILDER_NO_EVIDENCE: "builder.no_evidence",
55
63
  BUILDER_NO_COMMIT: "builder.no_commit",
56
64
  REVIEW_FAILED: "review.failed",
@@ -106,6 +114,8 @@ export function codeForRunnerError(err) {
106
114
  return OUTCOME_CODES.INFRA_CLI_SPAWN_TRANSIENT;
107
115
  case "CLI_TIMEOUT":
108
116
  return OUTCOME_CODES.CLI_TIMEOUT;
117
+ case "CLI_SPAWN_FAILED":
118
+ return OUTCOME_CODES.CLI_SPAWN_FAILED;
109
119
  case "NO_API_KEY":
110
120
  case "NO_SDK":
111
121
  case "CLI_BACKEND_FAILED":
@@ -136,6 +146,7 @@ export function codeForTerminalReason(reason) {
136
146
  case "deferred": return OUTCOME_CODES.LEASE_HELD;
137
147
  case "dependency-open": return OUTCOME_CODES.DEPENDENCY_OPEN;
138
148
  case "dependency-unreadable": return OUTCOME_CODES.DEPENDENCY_UNREADABLE;
149
+ case "dependency-blocked": return OUTCOME_CODES.DEPENDENCY_BLOCKED;
139
150
  default: return UNCLASSIFIED;
140
151
  }
141
152
  }
@@ -308,12 +308,29 @@ async function readIssueRecord(n, io) {
308
308
  } catch { return null; }
309
309
  }
310
310
 
311
+ // A declaration line: optional list marker, optional bold, "Depends on" or "Blocked by" (any case), optional colon
312
+ // inside or after the bold, then one or more `#N` separated by commas, "and", "&" or spaces (#4121).
313
+ const DEPENDS_ON_RE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?(?:depends on|blocked by)[ \t]*:?(?:\*\*|__)?[ \t]*:?[ \t]*(#\d+\b(?:[ \t]*(?:,|&|\band\b)?[ \t]*#\d+\b)*)/gim;
314
+
315
+ /**
316
+ * Issue numbers an issue body declares it waits on: `**Depends on**: #X`, `**Depends on** #X, #Y`, `Depends on #X`,
317
+ * `- Depends on: #X and #Y`, `Blocked by #X`. Only declaration lines count; a mid-line mention is prose. Shared by the
318
+ * engine's admission (`openDependency`) and `forgedock orchestrate` so both agree on what blocks what.
319
+ * @returns {number[]} unique, positive, never `issue` itself
320
+ */
321
+ export function parseDependsOn(body, issue = null) {
322
+ const out = new Set();
323
+ for (const m of String(body ?? "").replace(/\r\n/g, "\n").matchAll(DEPENDS_ON_RE)) {
324
+ for (const r of m[1].matchAll(/#(\d+)/g)) out.add(Number(r[1]));
325
+ }
326
+ return [...out].filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue));
327
+ }
328
+
311
329
  /**
312
- * forge#3932: does the issue declare a `**Depends on**: #N` dependency that is still OPEN?
330
+ * forge#3932: does the issue declare a `Depends on` / `Blocked by` dependency that is still OPEN?
313
331
  * Honoured only when the issue BODY's author is trusted (`isTrustedComment`: OWNER/MEMBER/COLLABORATOR,
314
- * Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for the exact line decompose.md emits
315
- * (`**Depends on**: #N` at line start; loose "depends on/after" prose is ignored). A self-reference is
316
- * ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
332
+ * Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for a declaration line `parseDependsOn` accepts (mid-line
333
+ * prose is ignored). A self-reference is ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
317
334
  * when the issue or a declared dependency cannot be read (fail closed: the caller refuses the run).
318
335
  * An empty read of the issue itself means "no body to trust" and yields `null`.
319
336
  */
@@ -324,8 +341,7 @@ export async function openDependency(issue, io) {
324
341
  }
325
342
  if (rec === null) return { unreadable: true, detail: `issue ${issue} could not be parsed for its **Depends on** line` };
326
343
  if (rec.empty || typeof rec.body !== "string" || !isTrustedComment(rec)) return null;
327
- const deps = [...new Set([...rec.body.replace(/\r\n/g, "\n").matchAll(/^\*\*Depends on\*\*:[ \t]*#(\d+)\b/gm)].map((m) => Number(m[1])))]
328
- .filter((n) => n > 0 && n !== Number(issue));
344
+ const deps = parseDependsOn(rec.body, issue);
329
345
  const open = [];
330
346
  for (const dep of deps) {
331
347
  let d;
@@ -73,7 +73,7 @@ export function scopeEvents(events, path = "full") {
73
73
  export function deriveState(allEvents, { path = "full" } = {}) {
74
74
  const events = scopeEvents(allEvents, path);
75
75
  /** @type {import("./phases.mjs").RunState} */
76
- const s = { v: 0, run: null, issue: null, lane: "staging", committed: [],
76
+ const s = { v: 0, run: null, issue: null, lane: null, committed: [],
77
77
  phase: null, branch: null, pr: null, terminal: false,
78
78
  terminalReason: null, lease: null, lastRateLimit: null,
79
79
  remediationKind: null, outcomeCode: null };
@@ -16,6 +16,8 @@ import { readLog, deriveState } from "./engine/runlog.mjs";
16
16
  import { assertOpenCodeNativeRuntime } from "./runner.mjs";
17
17
  import { parseForgeYaml } from "./forge-utils.mjs";
18
18
  import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
19
+ import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
20
+ import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
19
21
 
20
22
  /**
21
23
  * Workflow labels that mark an issue as "in the pipeline" (not yet terminal).
@@ -264,6 +266,19 @@ async function resolveDefaultRepo(io) {
264
266
  }
265
267
  }
266
268
 
269
+ /**
270
+ * #4125: the default (production) branch is never a lane, on any path or entry point. Checks forge.yaml
271
+ * `branches.default` and GitHub's default branch (best effort here; the core path re-checks it fail-closed).
272
+ */
273
+ export async function assertLaneNotDefault(io, lane, repo = null, cwd = process.cwd()) {
274
+ if (!lane) return;
275
+ let gh = null;
276
+ try { gh = String(await io.gh(["repo", "view", ...(repo ? [repo] : []), "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"])).trim() || null; } catch { gh = null; }
277
+ if (isDefaultLane(lane, [readBranches(cwd).default, gh].filter(Boolean))) {
278
+ throw new Error(`lane.default_branch: --lane ${lane} is the default branch. Lanes are staging or milestone/<slug>; promotion to the default branch is a human-merged release PR.`);
279
+ }
280
+ }
281
+
267
282
  /**
268
283
  * Guards against cross-repo state confusion (forge#1593): `--repo` used to be
269
284
  * threaded into the `gh issue list` enumeration call but never into the
@@ -418,7 +433,11 @@ export function lastLocalRun(dir) {
418
433
  * `runIssue` engine driver.
419
434
  *
420
435
  * Flags:
421
- * --lane <lane> Required — e.g. `main` or `staging`.
436
+ * --lane <lane> Core path: optional assertion. The lane is worked out from the issue (milestone →
437
+ * forge.yaml branches.feature_pattern, else branches.staging); a different --lane is refused
438
+ * (`lane-refused`, lane.mismatch) unless --lane-override. Full path: required.
439
+ * The default branch is never accepted (#4125).
440
+ * --lane-override Core path: accept a --lane that differs from the resolved lane (recorded in the trajectory).
422
441
  * --repo <owner/repo> Optional — must match the cwd-resolved repo (forge#1593).
423
442
  * --backend <cli|api|auto> Optional (forge#2028). Forwarded to every phase's
424
443
  * `runCommand()` call. Omit to keep runner.mjs's own
@@ -436,6 +455,8 @@ export function lastLocalRun(dir) {
436
455
  * expires or its same-host pid is dead, then continue.
437
456
  * Without it, a held lease exits `deferred` (code 75).
438
457
  * --path <core|full|lean|auto> Engine path; overrides forge.yaml engine.path. Default core (lean work-on); lean/auto are aliases; full is the legacy multi-phase pipeline.
458
+ * --result-file <path> Core path (#4121): write the run result as JSON to <path> (atomically, also when the run
459
+ * throws). `forgedock orchestrate` lanes report through it.
439
460
  * --retry Optional (forge#3511). Reopen a run that ended `engine-error`
440
461
  * (e.g. a session-limit hit) and resume from its last committed
441
462
  * phase. A run that ended `phase-complete` (multi-phase issue, phase merged,
@@ -444,15 +465,16 @@ export function lastLocalRun(dir) {
444
465
  */
445
466
  export async function runFromCli(argv, deps = {}) {
446
467
  const issue = parseInt(argv[0], 10);
447
- if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> --lane <lane> [--retry] [--wait-lease]");
468
+ if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--retry] [--wait-lease]");
448
469
  assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
449
470
  const lane = flag(argv, "--lane");
450
- if (!lane) throw new Error("--lane is required: e.g. --lane main or --lane staging. No default to prevent accidental production targeting.");
471
+ if (argv.includes("--lane") && (!lane || lane.startsWith("--"))) throw new Error("--lane requires a value: staging or milestone/<slug>");
472
+ const laneOverride = argv.includes("--lane-override");
451
473
  const repo = flag(argv, "--repo");
452
474
  const backend = flag(argv, "--backend");
453
475
  const model = flag(argv, "--model");
454
476
  // Boolean flag: only counts at a flag position, never as another flag's value.
455
- const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path"].includes(argv[i - 1]));
477
+ const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file"].includes(argv[i - 1]));
456
478
  // forge#3574: --wait-lease blocks (bounded by the lease TTL) until a live
457
479
  // holder's lease expires or becomes reclaimable. Same flag-position guard.
458
480
  const waitLease = argv.some((a, i) => a === "--wait-lease" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
@@ -463,8 +485,11 @@ export async function runFromCli(argv, deps = {}) {
463
485
  if (enginePath !== "full" && (retry || waitLease)) {
464
486
  throw new Error("--retry and --wait-lease are not supported with the core path (a rerun resumes from the open PR or remote branch automatically); use --path full for them");
465
487
  }
488
+ // The full path takes the lane as given (its spec runs classify-lane), so it must be explicit; no default.
489
+ if (enginePath === "full" && !lane) throw new Error("--lane is required with --path full: staging or milestone/<slug>. No default to prevent accidental production targeting.");
466
490
  const io = deps.io ?? makeIo();
467
491
  await assertRepoMatchesCwd(io, repo);
492
+ await assertLaneNotDefault(io, lane, repo);
468
493
  const runIssueFn = deps.runIssue ?? runIssue;
469
494
  const agentId = `cli_${sanitizeLeaseHost(hostname())}_${process.pid}`;
470
495
  // Injectable for tests (forge#2175) — defaults to the real ~/.forge/runs dir.
@@ -496,10 +521,18 @@ export async function runFromCli(argv, deps = {}) {
496
521
  if (!leanRepo) throw new Error("core path needs the repo: pass --repo or run inside a checkout with a GitHub remote");
497
522
  const { runCore } = deps.leanModule ?? (await import("./lean/lean.mjs"));
498
523
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo: leanRepo });
499
- const lres = await runCore({ issue, repo: leanRepo, lane, cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
524
+ // #4121: a batch lane reports its result through --result-file (written atomically, also for a thrown run).
525
+ const resultFile = flag(argv, "--result-file");
526
+ const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
527
+ let lres;
528
+ try {
529
+ lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
500
530
  emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
501
531
  admission: readAdmission(), ...readCoreLimits(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
532
+ } catch (e) { report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
533
+ report(lres);
502
534
  if (lres.code === "host.busy") { console.log(`issue #${issue} → deferred (${lres.detail})`); return lres; }
535
+ if (lres.terminalReason === "lane-refused") { console.log(`issue #${issue} → lane-refused ${lres.code} (${lres.detail})`); return lres; }
503
536
  console.log(`issue #${issue} → ${lres.terminalReason}`);
504
537
  if (!["merged", "invalid"].includes(lres.terminalReason)) console.log(lres.detail ?? "");
505
538
  return lres;
@@ -565,7 +598,8 @@ export async function runFromCli(argv, deps = {}) {
565
598
  *
566
599
  * Flags:
567
600
  * --dry-run Print the stalled list and exit 0 without dispatching anything.
568
- * --lane Lane to pass to run-issue (required — no default to prevent accidental production targeting).
601
+ * --lane Optional lane assertion passed to run-issue. Omit it to let each issue's lane be worked out from
602
+ * its milestone (#4125); the default branch is refused.
569
603
  * --repo GitHub repo (owner/repo). Must match the repo `gh` resolves by default
570
604
  * (the cwd git remote) — cross-repo dispatch is refused (forge#1593),
571
605
  * since state reads/writes are cwd-scoped and would otherwise silently
@@ -584,11 +618,12 @@ export async function runFromCli(argv, deps = {}) {
584
618
  export async function resumeStalledFromCli(argv, deps = {}) {
585
619
  const dryRun = argv.includes("--dry-run");
586
620
  const lane = flag(argv, "--lane");
587
- if (!lane) throw new Error("--lane is required for resume-stalled: e.g. --lane main or --lane staging.");
621
+ if (argv.includes("--lane") && (!lane || lane.startsWith("--"))) throw new Error("--lane requires a value: staging or milestone/<slug>");
588
622
  const repo = flag(argv, "--repo");
589
623
 
590
624
  const io = deps.io ?? makeIo();
591
625
  await assertRepoMatchesCwd(io, repo);
626
+ await assertLaneNotDefault(io, lane, repo);
592
627
  const dispatch = deps.dispatch ?? runFromCli;
593
628
  const projector = makeProjector(io);
594
629
  const now = Date.now();
@@ -641,7 +676,7 @@ export async function resumeStalledFromCli(argv, deps = {}) {
641
676
  for (const issue of stalled) {
642
677
  console.log(`resume-stalled: dispatching #${issue} …`);
643
678
  try {
644
- await dispatch([String(issue), "--lane", lane, ...(repo ? ["--repo", repo] : [])]);
679
+ await dispatch([String(issue), ...(lane ? ["--lane", lane] : []), ...(repo ? ["--repo", repo] : [])]);
645
680
  dispatched.push(issue);
646
681
  } catch (err) {
647
682
  const message = err?.message ?? String(err);
@@ -663,37 +698,166 @@ export async function resumeStalledFromCli(argv, deps = {}) {
663
698
  function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; }
664
699
 
665
700
  /**
666
- * `forgedock orchestrate <issue...> [--parallel K] [--lane L] [--repo owner/repo]`
667
- * Runs core work-on per issue with K concurrency (default 3), honouring `**Depends on**: #X` among the given issues.
668
- * Prints a compact summary table. Lane defaults to staging.
701
+ * `forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] [--no-prefilter]`
702
+ * `forgedock orchestrate --resume <batch-id>` · `forgedock orchestrate --set-parallel <batch-id> <K>`
703
+ * Runs core work-on per issue with K concurrency (default 3), honouring `Depends on` / `Blocked by` among the given issues.
704
+ * A `dependency-open` issue is requeued behind its dependency; a `decomposed` one adds its sub-issues (#4124).
705
+ * The already-fixed pre-filter (#4123) runs before each lane unless `--no-prefilter`.
706
+ * Each issue gets its own lane, worked out from its milestone (#4125); `--lane` is an assertion checked per issue (a
707
+ * mismatch ends `lane-refused`). Dependency edges between lanes are printed before any lane starts. Prints a summary table.
708
+ * Durable (#4121): the batch is kept in `~/.forge/batches/<id>/` and each lane is a detached `run-issue` process, so
709
+ * `--resume <id>` continues after the dispatcher dies, re-adopting live lanes. `deferred` lanes are requeued by code,
710
+ * forge.yaml `orchestration.breaker` ({errors, window_min}, default 3 in 30) stops admissions on an engine-error
711
+ * burst, and `--set-parallel <id> K` changes K of a running batch.
669
712
  */
670
713
  export async function orchestrateFromCli(argv, deps = {}) {
671
- const valued = ["--parallel", "--lane", "--repo", "--model"];
714
+ const batchMod = await import("./lean/batch.mjs");
715
+ const root = deps.batchRoot ?? batchMod.batchRoot();
716
+ if (argv.includes("--set-parallel")) {
717
+ const i = argv.indexOf("--set-parallel");
718
+ const b = batchMod.openBatch(root, argv[i + 1]);
719
+ if (!b.exists()) throw new Error(`no batch ${argv[i + 1]} in ${root}`);
720
+ const k = Number(argv[i + 2]);
721
+ b.setParallel(k);
722
+ console.log(`batch ${b.id}: parallel set to ${k}`);
723
+ return { ok: true, batch: b.id, parallel: k };
724
+ }
725
+ const resumeId = flag(argv, "--resume");
726
+ let batch, plan = null;
727
+ if (resumeId) {
728
+ batch = batchMod.openBatch(root, resumeId);
729
+ plan = batch.readPlan();
730
+ if (!plan) throw new Error(`no batch ${resumeId} in ${root}`);
731
+ argv = plan.argv;
732
+ }
733
+ const valued = ["--parallel", "--lane", "--repo", "--model", "--prefilter-model"];
672
734
  const issues = [];
673
735
  for (let i = 0; i < argv.length; i++) {
674
736
  if (valued.includes(argv[i])) { i++; continue; }
675
737
  if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
676
738
  }
677
- if (!issues.length) throw new Error("usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--repo owner/repo]");
739
+ if (!issues.length) throw new Error("usage: forgedock orchestrate <issue...> [--parallel K] [--lane L] [--lane-override] [--repo owner/repo] | --resume <id> | --set-parallel <id> <K>");
678
740
  const parallel = flag(argv, "--parallel") == null ? 3 : parseInt(flag(argv, "--parallel"), 10);
679
741
  if (!Number.isInteger(parallel) || parallel < 1) throw new Error("--parallel must be a positive integer");
680
- const lane = flag(argv, "--lane") ?? "staging";
742
+ const lane = flag(argv, "--lane");
743
+ const laneOverride = argv.includes("--lane-override");
681
744
  const model = flag(argv, "--model");
682
745
  const io = deps.io ?? makeIo();
683
- const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
746
+ const repo = flag(argv, "--repo") ?? plan?.repo ?? (await resolveDefaultRepo(io));
684
747
  if (!repo) throw new Error("orchestrate needs the repo: pass --repo or run inside a checkout with a GitHub remote");
748
+ await assertLaneNotDefault(io, lane, repo);
749
+ const cwd = plan?.cwd ?? process.cwd();
750
+ if (!deps.allowTmpfs && process.env.FORGEDOCK_BATCH_ALLOW_TMPFS !== "1") batchMod.assertDiskPath(root);
751
+ if (!batch) {
752
+ const base = deps.batchId ?? batchMod.newBatchId();
753
+ batch = batchMod.openBatch(root, base);
754
+ for (let i = 2; batch.exists(); i++) batch = batchMod.openBatch(root, `${base}.${i}`); // never resume by accident
755
+ plan = { v: 1, id: batch.id, argv: [...argv], repo, cwd, created: new Date().toISOString() };
756
+ batch.writePlan(plan);
757
+ batch.setParallel(parallel);
758
+ } else {
759
+ // A resume is the operator's decision to continue: the breaker starts closed again.
760
+ const st = batch.store.load();
761
+ if (st) batch.store.save({ ...st, breaker: null, errorTimes: [] });
762
+ }
763
+ console.log(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
685
764
  const dir = deps.dir ?? runDir();
686
- const { runCore } = deps.leanModule ?? (await import("./lean/lean.mjs"));
687
765
  const { orchestrate, formatTable } = await import("./lean/orchestrate.mjs");
688
766
  const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
689
- const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
767
+ const { makePrefilter, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
768
+ const prefilter = argv.includes("--no-prefilter") ? null
769
+ : makePrefilter({ repo, cwd, io: leanIo, ...prefilterModel(argv) });
770
+ const branches = readBranches(cwd);
771
+ const fetched = new Set();
772
+ const laneOf = async (n) => {
773
+ const r = await resolveLane({ issue: n, repo, io: leanIo, cwd, branches, requested: lane, override: laneOverride });
774
+ if (r.ok && prefilter && !fetched.has(r.lane)) { fetched.add(r.lane); try { await leanIo.git(["fetch", "origin", r.lane], { cwd }); } catch { /* offline: use the local ref */ } }
775
+ return r;
776
+ };
777
+ // Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes.
778
+ let lanes;
779
+ if (deps.leanModule) {
780
+ const { runCore } = deps.leanModule;
781
+ const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
782
+ lanes = { adopt: null, runOne: (n, ln) => runCore({ issue: n, repo, lane: ln, laneOverride, branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry,
783
+ admission: readAdmission(cwd), ...readCoreLimits(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
784
+ } else {
785
+ lanes = batchMod.detachedLanes({ batch, cwd, script: deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url)), bin: deps.laneBin, pollMs: deps.pollMs,
786
+ laneArgs: (n, ln) => ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : [])] });
787
+ }
788
+ const orch = deps.orchestration ?? readOrchestration(cwd);
789
+ const crossLane = [];
690
790
  const rows = await orchestrate({
691
- issues, parallel,
791
+ issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, ...lanes,
792
+ breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}),
793
+ onCrossLane: (e) => {
794
+ crossLane.push(e);
795
+ console.log(`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`);
796
+ },
692
797
  readBody: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
693
- runOne: (n) => runCore({ issue: n, repo, lane, cwd: process.cwd(), dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry,
694
- admission: readAdmission(), ...readCoreLimits(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) }),
695
- onUpdate: (r) => console.log(`#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`),
798
+ onUpdate: (r) => console.log(r.issue == null ? `${r.outcome}: ${r.detail}` : `#${r.issue} → ${r.outcome}${r.detail && r.outcome !== "merged" ? ` (${r.detail})` : ""}`),
696
799
  });
697
800
  console.log("\n" + formatTable(rows));
698
- return { rows, ok: rows.every((r) => ["merged", "invalid"].includes(r.outcome)) };
801
+ const pfLine = formatPrefilterSummary(rows);
802
+ if (pfLine) console.log(pfLine);
803
+ if (crossLane.length) console.log(`cross-lane edges: ${crossLane.map((e) => `#${e.issue} (${e.lane}) → #${e.dependency} (${e.dependencyLane})`).join(", ")}`);
804
+ if (rows.some((r) => r.outcome === "held")) console.log(`held issues stay pending: forgedock orchestrate --resume ${batch.id}`);
805
+ return { rows, crossLane, batch: batch.id, ok: rows.every((r) => ["merged", "invalid", "decomposed"].includes(r.outcome)) };
806
+ }
807
+
808
+ /**
809
+ * forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off) and
810
+ * `orchestration.max_deferrals` (default 3). The parser flattens one nested level, so the breaker keys arrive as
811
+ * `orchestration.errors|window_min` (or flat `breaker_errors|breaker_window_min`).
812
+ */
813
+ export function readOrchestration(cwd = process.cwd()) {
814
+ let o = {};
815
+ try { o = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.orchestration ?? {}; } catch { o = {}; }
816
+ const int = (v, d) => { const n = Number(v); return v !== undefined && v !== "" && Number.isInteger(n) && n >= 0 ? n : d; };
817
+ const errors = int(o.breaker_errors ?? o.errors, 3), windowMin = int(o.breaker_window_min ?? o.window_min, 30);
818
+ return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3) };
819
+ }
820
+
821
+ /** `--prefilter-model M`, else forge.yaml `engine.lean_prefilter_model`, else the pre-filter default. */
822
+ function prefilterModel(argv) {
823
+ const m = flag(argv, "--prefilter-model") ?? readLeanSetting("prefilter_model");
824
+ return m ? { model: m } : {};
825
+ }
826
+
827
+ /**
828
+ * `forgedock triage <issue...> [--lane L] [--repo owner/repo] [--dry-run] [--prefilter-model M]` (#4123)
829
+ * Each issue is checked against its own lane (#4125); `--lane` is an assertion, a refused lane is reported and dropped.
830
+ * Runs only the already-fixed pre-filter: closes issues it proves fixed (with evidence) and prints the issues that
831
+ * still need a lane as the last line (`lanes: #1 #2`). `--dry-run` reports candidates without a model call or close.
832
+ */
833
+ export async function triageFromCli(argv, deps = {}) {
834
+ const valued = ["--lane", "--repo", "--prefilter-model"];
835
+ const issues = [];
836
+ for (let i = 0; i < argv.length; i++) {
837
+ if (valued.includes(argv[i])) { i++; continue; }
838
+ if (/^#?\d+$/.test(argv[i])) issues.push(parseInt(argv[i].replace("#", ""), 10));
839
+ }
840
+ if (!issues.length) throw new Error("usage: forgedock triage <issue...> [--lane L] [--repo owner/repo] [--dry-run]");
841
+ const lane = flag(argv, "--lane");
842
+ const io = deps.io ?? makeIo();
843
+ const repo = flag(argv, "--repo") ?? (await resolveDefaultRepo(io));
844
+ if (!repo) throw new Error("triage needs the repo: pass --repo or run inside a checkout with a GitHub remote");
845
+ const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
846
+ const { prefilterIssue, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
847
+ const branches = readBranches();
848
+ const fetched = new Set();
849
+ const rows = [];
850
+ for (const n of [...new Set(issues)]) {
851
+ // #4125: each issue's own lane; `--lane` is an assertion. A refused lane never reaches the pre-filter or a lane.
852
+ const ln = await resolveLane({ issue: n, repo, io: leanIo, cwd: process.cwd(), branches, requested: lane, override: argv.includes("--lane-override") });
853
+ if (!ln.ok) { console.log(`#${n} → lane-refused (${ln.code}: ${ln.detail})`); rows.push({ issue: n, outcome: "lane-refused", cost_usd: 0, prefilter: null }); continue; }
854
+ if (!fetched.has(ln.lane)) { fetched.add(ln.lane); try { await leanIo.git(["fetch", "origin", ln.lane], { cwd: process.cwd() }); } catch { /* offline: use the local ref */ } }
855
+ const pf = await prefilterIssue({ issue: n, repo, lane: ln.lane, cwd: process.cwd(), io: leanIo, dryRun: argv.includes("--dry-run"), ...prefilterModel(argv) });
856
+ console.log(`#${n} → ${pf.closed ? "closed" : pf.candidate ? "candidate" : "lane"} (${pf.detail})`);
857
+ rows.push({ issue: n, outcome: pf.closed ? "invalid" : "pending", cost_usd: pf.cost_usd, prefilter: pf });
858
+ }
859
+ const lanes = rows.filter((r) => r.prefilter && !r.prefilter.closed).map((r) => r.issue);
860
+ console.log(formatPrefilterSummary(rows));
861
+ console.log(`lanes: ${lanes.map((n) => `#${n}`).join(" ") || "(none)"}`);
862
+ return { rows, lanes };
699
863
  }
package/bin/engine.mjs CHANGED
@@ -381,7 +381,7 @@ export const ENGINE_VERSION = (() => {
381
381
  })();
382
382
 
383
383
  export async function runIssue(opts) {
384
- const { issue, dir, agentId, lane = "staging", io, runner,
384
+ const { issue, dir, agentId, lane, io, runner,
385
385
  now = () => Date.now(), maxAttempts = DEFAULT_MAX_ATTEMPTS,
386
386
  commandsDir = fileURLToPath(new URL("../commands", import.meta.url)),
387
387
  // Optional execution-backend override for every phase's `runner()`
@@ -436,6 +436,8 @@ export async function runIssue(opts) {
436
436
  // any state I/O or timer creation, unchanged from the inline block this
437
437
  // replaces).
438
438
  validateRunIssueOptions({ backend, leaseTtlMs, leaseRenewIntervalMs, maxSessionLimitPauses, sessionLimitDefaultWaitMs });
439
+ // #4125: no silent lane default; the caller (run-issue --lane) names it, and the CLI refuses the default branch.
440
+ if (typeof lane !== "string" || !lane.trim()) throw new Error("runIssue: lane is required (staging or milestone/<slug>); there is no default");
439
441
 
440
442
  const projector = makeProjector(io);
441
443
 
package/bin/forgedock.mjs CHANGED
@@ -3207,8 +3207,10 @@ function help() {
3207
3207
  ["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
3208
3208
  ["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
3209
3209
  ["npx forgedock run <cmd> [args]", "Run a command headlessly (local claude CLI or Anthropic API)"],
3210
- ["npx forgedock run-issue <issue> --lane <lane> [--path core|full] [--retry] [--wait-lease]", "Run the lean core work-on on one issue (default; --path full = legacy multi-phase pipeline) (--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)"],
3211
- ["npx forgedock orchestrate <issue...> [--parallel K] [--lane L]", "Run core work-on on several issues with bounded concurrency and Depends-on ordering"],
3210
+ ["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)"],
3211
+ ["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"],
3212
+ ["npx forgedock orchestrate --resume <id> | --set-parallel <id> K", "Continue a batch after the dispatcher died (live lanes re-adopted), or change its concurrency live"],
3213
+ ["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"],
3212
3214
  ["npx forgedock gc", "Prune stale per-run scratch dirs under ~/.forge/tmp/runs (older than 6h, owner dead)"],
3213
3215
  ["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
3214
3216
  ["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
@@ -3783,10 +3785,10 @@ async function watch() {
3783
3785
  // The journey-routed commands render their own branded marks (hero/compact);
3784
3786
  // splash() would double-brand them. Engine-surface commands, help, and
3785
3787
  // unknown-command output keep the logo.
3786
- const SPLASH_COMMANDS = new Set(["run", "run-issue", "orchestrate", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3788
+ const SPLASH_COMMANDS = new Set(["run", "run-issue", "orchestrate", "triage", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3787
3789
  const KNOWN_COMMANDS = new Set([
3788
3790
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3789
- "run", "backend-check", "run-issue", "orchestrate", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "help", "--help", "-h",
3791
+ "run", "backend-check", "run-issue", "orchestrate", "triage", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "help", "--help", "-h",
3790
3792
  "version", "--version", "-v",
3791
3793
  ]);
3792
3794
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -3974,7 +3976,7 @@ switch (command) {
3974
3976
  // an uncaught throw, which already set exitCode = 1 below. Now that
3975
3977
  // runIssue() resolves cleanly with a terminalReason instead of throwing,
3976
3978
  // preserve that same non-zero exit code for this reason too.
3977
- if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable" || result.terminalReason === "dependency-open" || result.terminalReason === "dependency-unreadable")) exitCode = 1;
3979
+ if (result && (result.terminalReason === "needs-human" || result.terminalReason === "engine-error" || result.terminalReason === "not-retryable" || result.terminalReason === "dependency-open" || result.terminalReason === "dependency-unreadable" || result.terminalReason === "dependency-blocked" || result.terminalReason === "lane-refused")) exitCode = 1;
3978
3980
  // forge#3574: a lease-held deferral did NOT run the issue; exit 75
3979
3981
  // (EX_TEMPFAIL) so orchestrators neither count it as done nor as a failure.
3980
3982
  if (result && result.terminalReason === "deferred") exitCode = 75;
@@ -4002,6 +4004,16 @@ switch (command) {
4002
4004
  }
4003
4005
  break;
4004
4006
  }
4007
+ case "triage": {
4008
+ const { triageFromCli } = await import("./engine-cli.mjs");
4009
+ try {
4010
+ await triageFromCli(restArgs);
4011
+ } catch (err) {
4012
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4013
+ exitCode = 1;
4014
+ }
4015
+ break;
4016
+ }
4005
4017
  case "resume-stalled": {
4006
4018
  const { resumeStalledFromCli } = await import("./engine-cli.mjs");
4007
4019
  try {
@@ -0,0 +1,147 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Durable batch state and detached lanes for `forgedock orchestrate` (#4121).
5
+ *
6
+ * A batch lives in `~/.forge/batches/<id>/` (or `FORGEDOCK_BATCH_DIR`): `plan.json` (the command), `state.json` (the
7
+ * scheduler state, rewritten after every transition), `log.jsonl` (one line per lane update), `parallel` (live K) and
8
+ * `lanes/<issue>.{json,log}` (each lane's result and output). Every write is tmp + fsync + rename, and the root must be
9
+ * on disk, never tmpfs. A lane is its own session (`setsid` through `detached`), so killing the dispatcher leaves it
10
+ * running; a resumed dispatcher re-attaches to it by pid, checking `/proc/<pid>/cmdline` against its result path.
11
+ */
12
+ import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
13
+ import { spawn } from "node:child_process";
14
+ import { homedir } from "node:os";
15
+ import { dirname, join, resolve, sep } from "node:path";
16
+
17
+ export const RAM_FS = new Set(["tmpfs", "ramfs"]);
18
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
19
+
20
+ export function batchRoot(env = process.env) {
21
+ return env.FORGEDOCK_BATCH_DIR || join(homedir(), ".forge", "batches");
22
+ }
23
+
24
+ /** `[{mount, type}]` from /proc/self/mounts; empty when unreadable (non-Linux). */
25
+ export function readMounts(file = "/proc/self/mounts") {
26
+ try {
27
+ return readFileSync(file, "utf8").split("\n").filter(Boolean).map((l) => {
28
+ const [, mount, type] = l.split(" ");
29
+ return { mount: mount.replace(/\\(\d{3})/g, (_, o) => String.fromCharCode(parseInt(o, 8))), type };
30
+ });
31
+ } catch { return []; }
32
+ }
33
+
34
+ /** Filesystem type of the mount that holds `path` (longest mount-point prefix); null when unknown. */
35
+ export function fsTypeOf(path, mounts = readMounts()) {
36
+ const p = resolve(path);
37
+ let best = null;
38
+ for (const m of mounts) {
39
+ const under = m.mount === "/" || p === m.mount || p.startsWith(m.mount.endsWith(sep) ? m.mount : m.mount + sep);
40
+ if (under && (!best || m.mount.length > best.mount.length)) best = m;
41
+ }
42
+ return best?.type ?? null;
43
+ }
44
+
45
+ /** Throws when `path` is on a RAM filesystem: a full tmpfs truncated an earlier state file to 0 bytes. */
46
+ export function assertDiskPath(path, mounts = readMounts()) {
47
+ const type = fsTypeOf(path, mounts);
48
+ if (RAM_FS.has(type)) throw new Error(`batch state must be on disk, but ${path} is on ${type}; set FORGEDOCK_BATCH_DIR to a disk path`);
49
+ }
50
+
51
+ /** Write `data` to `file` atomically: a reader sees the old or the new content, never a truncated file. */
52
+ export function writeAtomic(file, data) {
53
+ mkdirSync(dirname(file), { recursive: true });
54
+ const tmp = `${file}.tmp-${process.pid}`;
55
+ const fd = openSync(tmp, "w");
56
+ try { writeSync(fd, data); fsyncSync(fd); } finally { closeSync(fd); }
57
+ renameSync(tmp, file);
58
+ }
59
+
60
+ const readJson = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
61
+
62
+ export function newBatchId(now = Date.now(), pid = process.pid) {
63
+ return `b${new Date(now).toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15)}-${pid}`;
64
+ }
65
+
66
+ /** Handle on one batch directory. */
67
+ export function openBatch(root, id) {
68
+ if (!ID_RE.test(String(id ?? ""))) throw new Error(`invalid batch id ${JSON.stringify(id)}`);
69
+ const dir = join(root, id);
70
+ const f = (name) => join(dir, name);
71
+ return {
72
+ id, dir,
73
+ exists: () => existsSync(f("plan.json")),
74
+ readPlan: () => readJson(f("plan.json")),
75
+ writePlan: (plan) => writeAtomic(f("plan.json"), JSON.stringify(plan, null, 2) + "\n"),
76
+ store: {
77
+ load: () => readJson(f("state.json")),
78
+ save: (state) => writeAtomic(f("state.json"), JSON.stringify(state) + "\n"),
79
+ log: (row) => { try { appendFileSync(f("log.jsonl"), JSON.stringify({ at: new Date().toISOString(), ...row }) + "\n"); } catch { /* the log is advisory */ } },
80
+ },
81
+ /** Live K: the `parallel` file, read on every scheduler pass; null when missing or invalid. */
82
+ readParallel: () => { try { const k = parseInt(readFileSync(f("parallel"), "utf8"), 10); return Number.isInteger(k) && k >= 1 ? k : null; } catch { return null; } },
83
+ setParallel: (k) => {
84
+ if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
85
+ writeAtomic(f("parallel"), `${k}\n`);
86
+ },
87
+ resultFile: (n) => f(join("lanes", `${n}.json`)),
88
+ logFile: (n) => f(join("lanes", `${n}.log`)),
89
+ };
90
+ }
91
+
92
+ /** True when `pid` is alive and (where /proc exists) its command line names `marker`, so a reused pid is not adopted. */
93
+ export function isLaneAlive(pid, marker, { kill = process.kill.bind(process), procDir = "/proc" } = {}) {
94
+ if (!Number.isInteger(pid) || pid <= 0) return false;
95
+ try { kill(pid, 0); } catch (e) { if (e?.code !== "EPERM") return false; }
96
+ if (!existsSync(procDir)) return true;
97
+ let cmd;
98
+ try { cmd = readFileSync(join(procDir, String(pid), "cmdline"), "utf8"); } catch { return false; }
99
+ return !marker || cmd.split("\0").includes(marker);
100
+ }
101
+
102
+ /**
103
+ * Start one lane in its own session: `<bin> <script> run-issue <issue> ...args --result-file <lanes/N.json>`, output to
104
+ * `lanes/N.log`. A stale result from an earlier attempt is removed first. @returns {{pid:number}}
105
+ */
106
+ export function launchLane({ batch, issue, args = [], bin = process.execPath, script, cwd = process.cwd(), env = process.env, spawnFn = spawn }) {
107
+ const rf = batch.resultFile(issue);
108
+ mkdirSync(dirname(rf), { recursive: true });
109
+ rmSync(rf, { force: true });
110
+ const out = openSync(batch.logFile(issue), "a");
111
+ try {
112
+ const child = spawnFn(bin, [script, "run-issue", String(issue), ...args, "--result-file", rf], { cwd, env, detached: true, stdio: ["ignore", out, out] });
113
+ child.on?.("error", () => { /* surfaces as a dead pid without a result */ });
114
+ child.unref?.();
115
+ return { pid: child.pid };
116
+ } finally { closeSync(out); }
117
+ }
118
+
119
+ /** Poll a lane until its result file appears; a lane that died without one is an engine-error. */
120
+ export async function waitLane({ batch, issue, pid, alive = isLaneAlive, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), pollMs = 5000 }) {
121
+ const rf = batch.resultFile(issue);
122
+ for (;;) {
123
+ const r = readJson(rf);
124
+ if (r) return r;
125
+ if (!alive(pid, rf)) {
126
+ const last = readJson(rf); // it may have written the result just before exiting
127
+ return last ?? { terminalReason: "engine-error", detail: `lane pid ${pid} exited without a result; see ${batch.logFile(issue)}` };
128
+ }
129
+ await sleep(pollMs);
130
+ }
131
+ }
132
+
133
+ /** Lane seams for `orchestrate()`: `runOne` launches a detached lane, `adopt` re-attaches to a live one (else null). */
134
+ export function detachedLanes({ batch, laneArgs, script, cwd, env, bin, alive = isLaneAlive, pollMs, spawnFn }) {
135
+ return {
136
+ runOne: async (n, lane, ctx = {}) => {
137
+ const { pid } = launchLane({ batch, issue: n, args: laneArgs(n, lane), script, cwd, env, bin, spawnFn });
138
+ ctx.onStart?.({ pid });
139
+ return waitLane({ batch, issue: n, pid, alive, pollMs });
140
+ },
141
+ adopt: async (n, rec) => {
142
+ const rf = batch.resultFile(n);
143
+ if (!existsSync(rf) && !alive(rec.pid, rf)) return null;
144
+ return waitLane({ batch, issue: n, pid: rec.pid, alive, pollMs });
145
+ },
146
+ };
147
+ }