forgedock 1.24.5 → 1.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lean/lean.mjs CHANGED
@@ -12,19 +12,48 @@ import { codeForTerminalReason, OUTCOME_CODES } from "../engine/outcome-codes.mj
12
12
  import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAULT_SESSION_LIMIT_WAIT_MS, parseLeaseHolder, sanitizeLeaseHost } from "../engine.mjs";
13
13
  import { openDependency } from "../engine/phases.mjs";
14
14
  import { checkAdmission, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
15
+ import { resolveLane, isDefaultLane } from "./lane.mjs";
15
16
  import { retrying, PUSH_TRANSIENT_RE, PUSH_PERMANENT_RE, GH_TRANSIENT_RE, MERGE_TRANSIENT_RE, MERGE_CONFLICT_RE } from "./retry.mjs";
16
17
  import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, capComment, sanitizeMd } from "./render.mjs";
17
- import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, PLAN_FILE, IMPLEMENTATION_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
18
+ import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, PLAN_FILE, IMPLEMENTATION_FILE, builderPrompt, resumeBuilderPrompt, reviewerPrompt, REVIEW_DIFF_MAX_BYTES, parseVerdict, blockCorrectionPrompt, HUMAN_BLOCK_REASONS } from "./prompts.mjs";
18
19
 
19
20
  export const DEFAULT_REVIEWER_MODEL = "claude-sonnet-5-5";
20
21
  export const REGISTER_MARKER = "FORGE:WORK-ON"; // alias marker carried by the FORGE:TRAJECTORY comment
21
22
  const num = (v) => (Number.isFinite(v) ? v : null);
23
+ export const MAX_SUBISSUES = 8;
24
+
25
+ /**
26
+ * Typed builder block (#4124), from result.json `block`. Returns {kind:"dependency", issues} | {kind:"split", subissues}
27
+ * | {kind:"human", why} | {kind:"untyped", problem}. Only `human` with an allowed reason may end as needs-human.
28
+ */
29
+ export function classifyBlock(result, issue) {
30
+ const b = result?.block;
31
+ if (!b || typeof b !== "object" || Array.isArray(b)) return { kind: "untyped", problem: "it has no typed \"block\" object" };
32
+ if (b.kind === "dependency") {
33
+ const issues = [...new Set((Array.isArray(b.issues) ? b.issues : []).map((x) => Number(String(x).replace(/^#/, ""))))].filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue));
34
+ return issues.length ? { kind: "dependency", issues } : { kind: "untyped", problem: "the dependency block names no other issue number" };
35
+ }
36
+ if (b.kind === "split-required") {
37
+ const subs = (Array.isArray(b.subissues) ? b.subissues : []).filter((x) => x && typeof x.title === "string" && x.title.trim() && typeof x.body === "string");
38
+ if (!subs.length) return { kind: "untyped", problem: "the split-required block lists no sub-issues with a title and body" };
39
+ if (subs.length > MAX_SUBISSUES) return { kind: "untyped", problem: `the split-required block lists ${subs.length} sub-issues (at most ${MAX_SUBISSUES})` };
40
+ return { kind: "split", subissues: subs.map((x, i) => ({ title: x.title.trim(), body: x.body,
41
+ dependsOn: [...new Set((Array.isArray(x.depends_on) ? x.depends_on : []).filter((j) => Number.isInteger(j) && j >= 0 && j < i))] })) };
42
+ }
43
+ if (b.kind === "human") {
44
+ return HUMAN_BLOCK_REASONS.includes(b.why) ? { kind: "human", why: b.why }
45
+ : { kind: "untyped", problem: `"${String(b.why ?? "").slice(0, 80)}" is not a human-only reason (${HUMAN_BLOCK_REASONS.join(", ")})` };
46
+ }
47
+ return { kind: "untyped", problem: `block kind "${String(b.kind ?? "").slice(0, 40)}" is unknown` };
48
+ }
22
49
 
23
50
  /**
24
51
  * @param {object} o
25
52
  * @param {number} o.issue
26
53
  * @param {string} o.repo "owner/repo"
27
- * @param {string} o.lane base branch (staging|main)
54
+ * @param {string} [o.lane] requested lane: an assertion checked against the lane resolved from the issue (#4125)
55
+ * @param {boolean} [o.laneOverride] accept a requested lane that differs from the resolved one (never the default branch)
56
+ * @param {object} [o.branches] forge.yaml branches {default, staging, featurePattern} (lane.mjs readBranches)
28
57
  * @param {string} o.cwd main checkout (worktrees are added from here)
29
58
  * @param {string} o.dir run-log dir
30
59
  * @param {object} o.io injected effects: gh, git, runner, waitCiGreen, transitionLabel, fs, projector?, now?, sleep?
@@ -33,11 +62,11 @@ const num = (v) => (Number.isFinite(v) ? v : null);
33
62
  * @param {number} [o.ciMaxMinutes] remote-CI wait budget (default 360); exhaustion is NOT needs-human
34
63
  */
35
64
  export async function runCore(o) {
36
- const { issue, repo, lane, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
65
+ const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL,
37
66
  ciMaxMinutes = 360, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
38
67
  const now = io.now ?? (() => Date.now());
39
68
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
40
- const base = `origin/${lane}`;
69
+ let lane = null, base = null, defaults = []; // set by lane resolution, before the lease
41
70
  const branch = `feat/issue-${issue}-lean`;
42
71
  const wt = join(o.worktreeRoot ?? join(cwd, ".claude", "worktrees"), `lean-${issue}`);
43
72
  const t0 = now();
@@ -101,7 +130,7 @@ export async function runCore(o) {
101
130
  const code = codeOverride ?? codeForTerminalReason(reason);
102
131
  ev({ event: "RUN_TERMINAL", reason, code });
103
132
  await emit({ event: "RUN_TERMINAL", reason, code });
104
- if (["needs-human", "engine-error"].includes(reason)) {
133
+ if (["needs-human", "engine-error", "dependency-open", "dependency-blocked", "dependency-unreadable", "decomposed"].includes(reason)) {
105
134
  const wf = ["investigating", "building", "in-review"].map((x) => `workflow:${x}`).join(",");
106
135
  try { await ghc(["issue", "edit", String(issue), "-R", repo, "--remove-label", wf]); } catch { /* best effort */ }
107
136
  }
@@ -285,11 +314,75 @@ export async function runCore(o) {
285
314
  }
286
315
  const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
287
316
 
317
+ /** Live-checks a builder block (#4124). Dependency issues are re-read, so a closed or unknown number is not a block. */
318
+ async function resolveBlock(r) {
319
+ const b = classifyBlock(r, issue);
320
+ if (b.kind !== "dependency") return b;
321
+ const open = [], blockers = [];
322
+ for (const d of b.issues) {
323
+ let v;
324
+ try { v = JSON.parse(String(await ghc(["issue", "view", String(d), "-R", repo, "--json", "state,labels"]))); } catch (e) {
325
+ return { kind: "unreadable", detail: `dependency #${d} named by the builder could not be read: ${String(e?.message ?? e).slice(0, 200)}` };
326
+ }
327
+ if (String(v?.state).toUpperCase() !== "OPEN") continue;
328
+ open.push(d);
329
+ if ((v.labels ?? []).some((l) => (l?.name ?? l) === "needs-human")) blockers.push(d);
330
+ }
331
+ if (!open.length) return { kind: "untyped", problem: `its dependencies ${b.issues.map((n) => `#${n}`).join(", ")} are not open` };
332
+ return { kind: "dependency", issues: open, blockers };
333
+ }
334
+ /** Ends a run whose builder stayed blocked: only a typed human reason is needs-human. */
335
+ async function endBlocked(b, summary) {
336
+ const s = summary ?? "(no summary)";
337
+ const refs = (ns) => ns.map((n) => `#${n}`).join(", ");
338
+ if (b.kind === "human") return await needsHuman(`Builder is blocked (${b.why}): ${s}`, OUTCOME_CODES.BUILDER_BLOCKED);
339
+ if (b.kind === "unreadable") return await finish("dependency-unreadable", b.detail);
340
+ if (b.kind === "dependency") {
341
+ const blocked = b.blockers.length > 0;
342
+ const detail = blocked
343
+ ? `blocked by ${refs(b.blockers)} (needs-human); depends on ${refs(b.issues)}. ${s}`
344
+ : `depends on ${refs(b.issues)} which is still open; requeue behind it. ${s}`;
345
+ T.decisions.push(`Builder block: dependency on ${refs(b.issues)}${blocked ? ` (needs-human: ${refs(b.blockers)})` : ""}`);
346
+ try { await post("FORGE:DEPENDENCY", `## ${blocked ? "Blocked by" : "Waiting on"} ${refs(blocked ? b.blockers : b.issues)}\n${detail}\n\n_${usageLine()}_`); } catch { /* best effort */ }
347
+ return blocked
348
+ ? await finish("dependency-blocked", detail, { code: OUTCOME_CODES.DEPENDENCY_BLOCKED, dependencies: b.issues, blockers: b.blockers }, OUTCOME_CODES.DEPENDENCY_BLOCKED)
349
+ : await finish("dependency-open", detail, { code: OUTCOME_CODES.DEPENDENCY_OPEN, dependencies: b.issues }, OUTCOME_CODES.DEPENDENCY_OPEN);
350
+ }
351
+ if (b.kind === "split") {
352
+ const filed = [];
353
+ try {
354
+ for (const sub of b.subissues) {
355
+ const lines = [`**Parent**: #${issue}`, ...sub.dependsOn.map((j) => `**Depends on**: #${filed[j]}`)];
356
+ const out = String(await ghw(["issue", "create", "-R", repo, "--title", sub.title, "--body", `${sub.body.trim()}\n\n${lines.join("\n")}`]));
357
+ const n = Number(/\/issues\/(\d+)/.exec(out)?.[1]);
358
+ if (!Number.isSafeInteger(n)) throw new Error(`cannot parse the sub-issue number from: ${out.slice(0, 200)}`);
359
+ filed.push(n);
360
+ }
361
+ } catch (e) { return await finish("engine-error", `filing sub-issues failed after ${refs(filed) || "none"}: ${e?.message ?? e}`, { subissues: filed }); }
362
+ T.decisions.push(`Builder block: split into ${refs(filed)}`);
363
+ try { await post("FORGE:DECOMPOSED", `## Split into sub-issues\n${filed.map((n, i) => `- #${n} ${b.subissues[i].title}`).join("\n")}\n\n${s}`); } catch { /* best effort */ }
364
+ await label("decomposed");
365
+ return await finish("decomposed", `split into ${refs(filed)}`, { code: OUTCOME_CODES.DECOMPOSED, subissues: filed });
366
+ }
367
+ return await needsHuman(`Builder is blocked without a valid typed reason after one correction (${b.problem}): ${s}`, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED);
368
+ }
369
+
288
370
  // ---- host admission (#4105): before the lease, the worktree or any GitHub call ----
289
371
  if (io.host && o.admission?.enabled !== false) {
290
372
  const a = await checkAdmission({ cfg: o.admission, host: io.host });
291
373
  if (!a.ok) return { terminalReason: "deferred", code: OUTCOME_CODES.HOST_BUSY, detail: a.detail, retryInSeconds: ADMISSION_RETRY_SECONDS };
292
374
  }
375
+ // ---- lane (#4125): worked out from the issue before any lease, comment or worktree; fail closed ----
376
+ {
377
+ const r = await resolveLane({ issue, repo, io, cwd, branches, requested: requestedLane, override: laneOverride });
378
+ if (!r.ok) return { terminalReason: "lane-refused", code: r.code, detail: r.detail };
379
+ lane = r.lane; base = `origin/${lane}`; defaults = r.defaults;
380
+ if (r.overridden) T.decisions.push(`Lane override: ${lane} instead of the resolved ${r.expected}`);
381
+ }
382
+ /** Last guard before a PR is created or merged: the default branch is never a lane. */
383
+ const laneGuard = (target, what) => {
384
+ if (isDefaultLane(target, defaults)) throw Object.assign(new Error(`refusing to ${what} into the default branch ${target}`), { laneCode: OUTCOME_CODES.LANE_DEFAULT_BRANCH });
385
+ };
293
386
  // ---- admission: Depends-on refusal, then lease ----
294
387
  {
295
388
  let dep = null;
@@ -335,10 +428,15 @@ export async function runCore(o) {
335
428
  try { await io.git(["fetch", "origin", lane], { cwd }); } catch { /* offline: use the local ref */ }
336
429
  let resumed = null;
337
430
  try {
338
- const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid"])) || "[]");
431
+ const prs = JSON.parse(String(await ghc(["pr", "list", "-R", repo, "--head", branch, "--state", "open", "--json", "number,headRefOid,baseRefName"])) || "[]");
339
432
  const remote = String(await io.git(["ls-remote", "--heads", "origin", branch], { cwd })).trim();
340
- if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null };
433
+ if (prs.length || remote) resumed = { pr: prs[0]?.number ?? null, base: prs[0]?.baseRefName ?? null };
341
434
  } catch { /* no resume info: start fresh */ }
435
+ // An open PR on another base (a hand-retargeted PR, or one opened before lane resolution) is never merged (#4125).
436
+ if (resumed?.base && resumed.base !== lane) {
437
+ return await needsHuman(`Open PR #${resumed.pr} targets \`${resumed.base}\`, but #${issue}'s lane is \`${lane}\`; retarget or close it before rerunning.`,
438
+ isDefaultLane(resumed.base, defaults) ? OUTCOME_CODES.LANE_DEFAULT_BRANCH : OUTCOME_CODES.LANE_MISMATCH);
439
+ }
342
440
  try { await io.git(["worktree", "remove", "--force", wt], { cwd }); } catch { /* none */ }
343
441
  try { await io.git(["worktree", "prune"], { cwd }); } catch { /* best effort */ }
344
442
  // Concurrent lanes in one repo race on .git/config.lock / index.lock during `worktree add`; retry with jitter.
@@ -400,6 +498,13 @@ export async function runCore(o) {
400
498
  await label("building");
401
499
  await builder(++attemptN, builderPrompt(issue) + (fromCheckpoint ? CONTINUE_NOTE.replace("BASE", base) : ""), null, true);
402
500
  result = await readResult();
501
+ let block = result?.status === "blocked" ? await resolveBlock(result) : null;
502
+ if (block?.kind === "untyped" && sessionId) { // #4124: one corrective resume before any untyped block can reach needs-human
503
+ T.decisions.push(`Builder block rejected (${block.problem}); resumed once`);
504
+ await builder(++attemptN, blockCorrectionPrompt(block.problem), sessionId);
505
+ result = await readResult();
506
+ block = result?.status === "blocked" ? await resolveBlock(result) : null;
507
+ }
403
508
  const files0 = await changedFiles();
404
509
  const evidence = (Array.isArray(result?.evidence) ? result.evidence : []).filter((x) => typeof x === "string" && x.trim());
405
510
  const closing = ["invalid", "already-fixed"].includes(result?.status);
@@ -413,7 +518,7 @@ export async function runCore(o) {
413
518
  await label("invalid");
414
519
  return await finish("invalid", result.summary ?? result.status);
415
520
  }
416
- if (result?.status === "blocked") return await needsHuman(`Builder is blocked: ${result.summary ?? "(no summary)"}`, OUTCOME_CODES.BUILDER_BLOCKED);
521
+ if (block) return await endBlocked(block, result.summary);
417
522
  if (!files0.length) return await needsHuman(`Builder made no commit${result ? ` (status "${result.status}")` : " and wrote no result.json"}.\n\n${result?.summary ?? ""}`, OUTCOME_CODES.BUILDER_NO_COMMIT);
418
523
  await flushStages(result, ["plan", "implementation"]);
419
524
  T.plan = "posted"; T.implement = [`${files0.length} file(s)`, branch];
@@ -428,9 +533,11 @@ export async function runCore(o) {
428
533
  const doReview = async () => {
429
534
  for (let t = 0; t < 2; t++) {
430
535
  const diff = String(await git(["diff", `${base}...HEAD`]));
536
+ // #4129: an over-budget diff is bounded in the prompt; the stat keeps every changed file visible.
537
+ const stat = Buffer.byteLength(diff, "utf-8") > REVIEW_DIFF_MAX_BYTES ? String(await git(["diff", "--stat=200", `${base}...HEAD`]).catch(() => "")) : "";
431
538
  await renew();
432
539
  const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
433
- prompt: reviewerPrompt({ issue: iss, diff }) });
540
+ prompt: reviewerPrompt({ issue: iss, diff, stat, base }) });
434
541
  await renew();
435
542
  await attempt("review", ++reviewN, rr);
436
543
  const v = parseVerdict(rr?.text);
@@ -456,6 +563,7 @@ export async function runCore(o) {
456
563
  // ---- 5. push + PR ----
457
564
  const files = await changedFiles();
458
565
  await gitPush(["push", "-u", "origin", branch]);
566
+ laneGuard(lane, "open a PR");
459
567
  const prOut = resumed?.pr ? `/pull/${resumed.pr}` : String(await ghw(["pr", "create", "-R", repo, "--base", lane, "--head", branch,
460
568
  "--title", `${iss.title} (#${issue})`, "--body", renderPrBody({ issue, investigation: await readStage(STAGES[0]), plan: await readStage(STAGES[1]), implementation: await readStage(STAGES[2]), result, gitFiles: files })])).trim();
461
569
  const pr = Number(/\/pull\/(\d+)/.exec(prOut)?.[1] ?? /(\d+)\s*$/.exec(prOut)?.[1]);
@@ -583,6 +691,7 @@ export async function runCore(o) {
583
691
  }
584
692
  if (syncs > 0 && (await prConflicting()) === "conflict") continue; // the base moved again while waiting
585
693
  T.merge = [policyText, `merged at ${localHead.slice(0, 12)}`];
694
+ laneGuard(resumed?.base ?? lane, "merge");
586
695
  try {
587
696
  await ghc(["pr", "merge", String(pr), "-R", repo, "--merge", "--match-head-commit", localHead]);
588
697
  break;
@@ -608,6 +717,10 @@ export async function runCore(o) {
608
717
  return finish("deferred", `usage limit hit; resets at ${when}`, { code: OUTCOME_CODES.RATE_LIMITED, resetAt: when, resetAtEpochMs: Number.isFinite(e.resetAtEpochMs) ? e.resetAtEpochMs : null }, OUTCOME_CODES.RATE_LIMITED);
609
718
  }
610
719
  if (e?.deferCode) return finish("deferred", e.deferDetail ?? String(e.message), { code: e.deferCode }, e.deferCode);
720
+ if (e?.laneCode) return await needsHuman(e.message, e.laneCode);
721
+ if (e?.code === "CLI_SPAWN_FAILED" || e?.spawnDeterministic === true) { // #4129: certain to fail again, so not engine-error (orchestrate would relaunch it)
722
+ return await needsHuman(`The claude CLI could not be started (${e.spawnErrno ?? "spawn error"}); a rerun fails the same way: ${String(e.message).slice(0, 400)}`, OUTCOME_CODES.CLI_SPAWN_FAILED);
723
+ }
611
724
  if (e?.pushRejected) return await needsHuman(`git push was rejected (no retry): ${String(e?.stderr ?? e?.message ?? e).slice(0, 400)}`);
612
725
  return finish("engine-error", e?.message ?? String(e));
613
726
  }