forgedock 1.48.1 → 1.49.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.
@@ -76,6 +76,10 @@ await d.closed;
76
76
  return child;
77
77
  };
78
78
  h.client = { scheddDir: h.sched, spawnDaemon: null };
79
+ // A forge.yaml with host admission off, so a test that needs N live lanes does not depend on this host's free memory/cores
80
+ // (liveLaneCap derives the cap from them). Run the clients from h.work via h.inWork to use it.
81
+ writeFileSync(join(work, "forge.yaml"), "engine:\n admission_enabled: false\n");
82
+ h.inWork = async (fn) => { const prev = process.cwd(); process.chdir(work); try { return await fn(); } finally { process.chdir(prev); } };
79
83
  h.launched = () => readFileSync(h.launches, "utf8").trim().split("\n").filter(Boolean).map(Number).sort();
80
84
  h.lanePids = () => listBatches(h.root).flatMap((id) => Object.values(openBatch(h.root, id).store.load()?.running ?? {}).map((r) => r.pid).filter(Boolean));
81
85
  h.stubPids = () => (existsSync(h.lanePidFile) ? readFileSync(h.lanePidFile, "utf8").trim().split("\n").filter(Boolean).map(Number) : []);
@@ -119,7 +123,7 @@ describe("schedd host scheduler (#4192)", () => {
119
123
  const d = await h.daemon();
120
124
  const submit = (issues) => orchestrateFromCli([...issues, "--parallel", "2", "--repo", "o/r", "--no-prefilter"], h.client);
121
125
  // One capture for the three concurrent clients (console.log is process-wide).
122
- const all = capture(() => Promise.all([submit(["1", "2"]), submit(["4"]), submit(["5"])]));
126
+ const all = capture(() => h.inWork(() => Promise.all([submit(["1", "2"]), submit(["4"]), submit(["5"])])));
123
127
  await until(() => h.launched().length === 4 && h.lanePids().length === 4);
124
128
  const reg = JSON.parse(readFileSync(join(h.sched, "registry.json"), "utf8"));
125
129
  assert.equal(Object.keys(reg.slots).length, 4);
@@ -197,7 +201,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
197
201
  try {
198
202
  await h.daemon();
199
203
  const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: a[2] === "9" ? "CLOSED" : "OPEN" }) : "o/r\n") } };
200
- const { res } = await withRoot(h, () => capture(async () => {
204
+ const { res } = await withRoot(h, () => h.inWork(() => capture(async () => {
201
205
  const first = orchestrateFromCli(["1", "--parallel", "3", "--repo", "o/r", "--no-prefilter"], h.client);
202
206
  await until(() => h.launched().length === 1 && h.lanePids().length === 1);
203
207
  const [id] = listBatches(h.root);
@@ -217,7 +221,7 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
217
221
  const again = await orchestrateFromCli(["--resume", id], client);
218
222
  assert.deepEqual(again.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"], [4, "merged"], [6, "merged"]]);
219
223
  return done;
220
- }));
224
+ })));
221
225
  assert.equal(res.rows.length, 3);
222
226
  assert.deepEqual(h.launched(), [1, 2, 4, 6], "each issue launched once");
223
227
  } finally { await h.cleanup(); }
@@ -644,6 +648,19 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
644
648
  assert.equal(env.COMPUTESHARE_TOKEN, "daemon-tok");
645
649
  });
646
650
 
651
+ it("5131: a daemon secret wins over an empty pin, a non-empty pin wins (REG-1 #5142), batchEnv pins no empty value", () => {
652
+ assert.equal(mergeEnv({ COMPUTESHARE_TOKEN: "daemon-tok" }, { COMPUTESHARE_TOKEN: "" }).COMPUTESHARE_TOKEN, "daemon-tok");
653
+ assert.equal(mergeEnv({ COMPUTESHARE_TOKEN: "daemon-tok" }, { COMPUTESHARE_TOKEN: "client-tok" }).COMPUTESHARE_TOKEN, "client-tok", "a non-empty passthrough pin wins (release review #5142 REG-1)");
654
+ assert.equal(mergeEnv({ GH_TOKEN: "daemon-acct" }, { GH_TOKEN: "pinned-acct" }).GH_TOKEN, "pinned-acct", "an env_passthrough GH_TOKEN keeps the batch's account");
655
+ assert.equal(mergeEnv({ PATH: "/d" }, { COMPUTESHARE_TOKEN: "client-tok" }).COMPUTESHARE_TOKEN, "client-tok");
656
+ assert.equal(mergeEnv({ COMPUTESHARE_TOKEN: "" }, { COMPUTESHARE_TOKEN: "client-tok" }).COMPUTESHARE_TOKEN, "client-tok");
657
+ assert.equal(mergeEnv({}, { COMPUTESHARE_TOKEN: "" }).COMPUTESHARE_TOKEN, undefined, "an empty pin never overrides");
658
+ assert.equal(mergeEnv({ CLAUDE_CONFIG_DIR: "/d" }, { CLAUDE_CONFIG_DIR: "/c" }).CLAUDE_CONFIG_DIR, "/c", "non-secret pins keep winning");
659
+ const kept = batchEnv({ PATH: "/", COMPUTESHARE_TOKEN: "", GH_HOST: "" }, { passthrough: ["COMPUTESHARE_TOKEN"], exists: () => true });
660
+ assert.deepEqual(Object.keys(kept), ["PATH"]);
661
+ assert.deepEqual(batchEnv({ COMPUTESHARE_TOKEN: "client-tok" }, { passthrough: ["COMPUTESHARE_TOKEN"] }), { COMPUTESHARE_TOKEN: "client-tok" });
662
+ });
663
+
647
664
  it("#4734: the pin keeps non-secret provider selectors, drops secrets and session ids, and droppedEnv names what was left out", () => {
648
665
  const client = { PATH: "/", GH_TOKEN: "fresh", CLAUDE_CODE_USE_BEDROCK: "1", ANTHROPIC_BASE_URL: "http://proxy", ANTHROPIC_MODEL: "m", ANTHROPIC_API_KEY: "k",
649
666
  FORGEDOCK_POOL_TOKEN: "p", CLAUDE_CONFIG_DIR: "/acct", CLAUDE_CODE_SESSION_ID: "s", CLAUDE_PID: "1" };
package/docs/CONFIG.md CHANGED
@@ -1030,7 +1030,8 @@ execution:
1030
1030
  | `allow_actions` | boolean | No | `true` accepts a GitHub Actions endpoint. Default `false`. |
1031
1031
  | `budget_minutes`, `budget_usd` | number | No | The `execution.budget` per batch: once the batch's sandbox minutes / estimated USD (settled lanes plus the running ones' elapsed time) reach the cap, no new remote lane is admitted. Missing, non-numeric or negative means no cap. |
1032
1032
  | `day_budget_minutes`, `day_budget_usd` | number | No | The same cap per host UTC day, summed over every batch on the host from the host-day store (`execution-budget.json` in `~/.forge/sched/`, or `FORGEDOCK_EXECUTION_BUDGET_FILE`). A corrupt or unreadable store refuses remote lanes while a day cap is set; remove the file to reset it. |
1033
- | `fallback_local` | boolean | No | `true`: a remote-placed lane runs on the host while the provider reports no agent online (`GET {endpoint}/status -> {"online": N}`, the built-in router answers it with `online`, `total` and per-agent `ok`/`paused`), so a batch completes locally with every pool agent offline or asleep (forge#4547). Default `false`: such lanes defer (`sandbox.capacity`). A missing or failing `/status` is unknown and never triggers it. |
1033
+ | `fallback_local` | boolean | No | `true`: a remote-placed lane runs on the host while the provider reports no agent online (`GET {endpoint}/status -> {"online": N}`, the built-in router answers it with `online`, `total` and per-agent `ok`/`paused`), so a batch completes locally with every pool agent offline or asleep (forge#4547). Default `false`: such lanes defer (`sandbox.capacity`). A missing or failing `/status` is unknown and never triggers it. It also covers a sustained `sandbox.capacity`: an issue the pool refused `capacity_fallback_after` times (default 3) for capacity runs on the host when the host has headroom (the host-busy guard; a busy host or a failing probe keeps it waiting remote) (forge#5112). |
1034
+ | `capacity_fallback_after` | integer | No | With `fallback_local: true`: how many `sandbox.capacity` deferrals of one issue make it run on the host, subject to the host-busy guard (forge#5112). Default: 3. A missing, non-integer or non-positive value uses the default. |
1034
1035
  | `account_rotator` | string | No | `tokenbaton`: the `claude` on PATH is the tokenbaton shim, which runs each call on the logged-in account with the most room (`CLAUDE_CONFIG_DIR=~/.claude-<n>`) (forge#5094). forgedock reads `~/.tokenbaton/current` (the account number) and `~/.tokenbaton/live.<n>` (`<5h used %> <5h reset s> <weekly used %> <weekly reset s> <updated s>`), re-read on every check and never written. An account has headroom when its 5-hour use is under 95% and its weekly use under 100% (a window whose reset has passed counts as 0). While any account has headroom no host-wide usage hold is recorded and admissions continue; they stop only when every account is exhausted and resume when the first one resets. A lane whose call hits a usage limit retries that same call (same session) on the next account at once, at most once per account, before it pauses or defers. `forgedock limits show` lists each account's 5-hour use. Any other value, or unreadable files: off (per-account holds as before). |
1035
1036
  | `warm_sandboxes` | integer | No | Warm sandboxes per repo the pool agent keeps ready (checkout at the base ref, dependency cache primed). When `N > 0` the launch spec carries `warm: N` and the lane first calls `POST {endpoint}/sandboxes/claim`; a ready warm sandbox takes the lane (refreshed to the lane's base ref with an incremental fetch before work starts), and when none is ready, the claim fails or the endpoint lacks the route the lane starts cold. Default `0` (off): no claim call and the launch spec is unchanged (forge#4912). |
1036
1037
  | `unreachable_window_min` | number | No | Remote providers: how long a lane keeps polling (with backoff, up to 30s) after the provider's transport fails (connection refused/reset, `fetch failed`, timeout, 502/503/504) before it ends `deferred` with `sandbox.unreachable` (forge#4672). Inside the window the lane never ends and the sandbox is never torn down; after it the sandbox is kept and adopted on the next attempt or `--resume`; the engine-error breaker does not count it. `adopt` applies the same window. Default: 5 |
@@ -1271,6 +1272,7 @@ review:
1271
1272
  | `domains.*` | string | No | Domain-specific notes keyed by review agent name (`billing`, `auth`, `database`, `frontend`, `api`, `infra`, `security`) |
1272
1273
  | `max_filed_findings` | integer >= 0 | No | Per-review cap on ISSUE dispositions (`scripts/cap-findings.sh`, applied by `review-pr` §6B.5 and `review-pr-staging` §7B (via `review-finalize.sh classify`) after classification). Default `2`; `1` when the reviewed PR is itself a fix for a `review-finding` issue, where every filed HIGH/CRITICAL consumes that budget. ISSUE findings are ranked by severity, then confidence, then safety-domain agent (security, auth, billing, concurrency, database), then input order; an unparseable severity or confidence ranks as MEDIUM/LIKELY. The overflow becomes `NOTE capped`: listed in the PR body and counted as `notes_capped` in `FORGE:NOTE_DISPOSITION`, never filed and never dropped. **HIGH/CRITICAL findings are never capped** and still block; `ISSUE fix-efficacy-*` findings (the linked issue is not fixed, or the PR re-introduces its defect) are never capped either and count against the review-finding budget like HIGH. `0` is valid (cap every MEDIUM/LOW finding); a non-numeric or negative value falls back to the default with a warning. An explicit value overrides both defaults. |
1273
1274
  | `key_paths` | map(string→list of strings) | No | Domain-to-file mapping used by `/work-on` investigation and `/review-pr` agents to quickly locate relevant files. Keys are domain names matching issue labels; values are lists of file path patterns (glob-style, relative to repo root). When present, agents use this table instead of inferring files from labels and issue body. |
1275
+ | `enforce_domains` | string or list | No | Lean lanes (#5099). Review domains whose touch makes the conformance gate enforce for the whole lane (sticky, like a `risk_paths` hit): any of `frontend`, `auth`, `infra`, `database`, `api`, as a comma-separated string or a block list. Unknown names are dropped. Default empty: lanes stay in shadow (ADR `devdocs/decisions/4398-risk-path-enforce.md`: enforce a domain only once its shadow false-block rate is near 0). Independently of this key, the lean reviewer always gets a `<domain_checks>` block naming the domains the diff touches, using `scripts/review-roster.sh` path triggers (for example `web/`, `app/`, `*.tsx`, `next.config.*` → frontend, with the dynamic-API-in-static/ISR-tree check). It reports domain findings as `regressions` rows, which block even in shadow. The run log gets `REVIEW_DOMAINS {domains, enforced}`. |
1274
1276
 
1275
1277
  **Commands that use this section**: `review-pr`, `review-pr-agents` (all domain agents), `work-on` (Phase 1B investigation)
1276
1278
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.48.1",
3
+ "version": "1.49.0",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { fileURLToPath } from "node:url";
12
- import { parseDependsOn } from "../bin/engine/phases.mjs";
12
+ import { parseDependsOn, isTrustedComment } from "../bin/engine/phases.mjs";
13
+ import { parseState } from "../bin/engine/state.mjs";
13
14
 
14
15
  /** Milestones in train order. Input: [{ title, number, due_on }]. */
15
16
  export function orderMilestones(milestones) {
@@ -60,7 +61,9 @@ export async function readLive({ gh, repo }) {
60
61
  * Parallel-start gate: dependencies of `issues` (not themselves in the batch) that are not merged into `baseOf(issue)`.
61
62
  * A dep is merged when it is CLOSED by a PR that is merged with that base, or whose merge commit is an ancestor of
62
63
  * that base (`isAncestor(sha, base)`, e.g. it arrived through a milestone sync). With no linked PR (GitHub links only
63
- * default-branch merges, #5086) the merged `feat/issue-<dep>-lean` PR is looked up instead. Unreadable issues are skipped (fail open:
64
+ * default-branch merges, #5086) the PR is looked up instead, in order: the trusted FORGE:STATE `mergedTo` record whose PR reads merged
65
+ * with the recorded commit (#5120), the merged `feat/issue-<dep>-lean` PR, then merged PRs on the base whose title/body closes the
66
+ * dependency (#5120). Each lookup that fails finds nothing. Unreadable issues are skipped (fail open:
64
67
  * the engine's own openDependency still guards each lane); an unreadable dep counts as unmet.
65
68
  * `sha`/`kind` (#5031): `kind` is "commit" when the dependency merged but its commit is not on the base yet (`sha`), else "other".
66
69
  * @returns {Promise<Array<{ issue: number, dep: number, base: string, why: string, sha: string|null, kind: "commit"|"other" }>>}
@@ -95,12 +98,26 @@ export async function unmetDependencies({ issues, baseOf, gh, repo, isAncestor }
95
98
  await check(parse(await gh(["pr", "view", String(pr.number), ...r, "--json", "baseRefName,mergedAt,mergeCommit"])));
96
99
  if (merged) break;
97
100
  }
98
- // #5086: GitHub links closing PRs only for default-branch merges, so a milestone-lane dependency has no references.
99
- // Fall back to the lane PR by head-branch convention; a failed or non-array lookup finds nothing (still refused).
101
+ // #5086/#5120: GitHub links closing PRs only for default-branch merges, so a milestone-lane dependency has no references.
102
+ // Fall back to the engine's record, the lane PR by head-branch convention, then PR bodies; a failed or non-array lookup finds nothing.
103
+ const list = async (args) => { try { const j = parse(await gh(["pr", "list", ...r, ...args])); return Array.isArray(j) ? j : []; } catch { return []; } };
100
104
  if (!merged && !missing) {
101
- let prs = [];
102
- try { prs = parse(await gh(["pr", "list", ...r, "--head", `feat/issue-${dep}-lean`, "--state", "merged", "--json", "number,baseRefName,mergedAt,mergeCommit", "--limit", "5"])); } catch { prs = []; }
103
- for (const p of Array.isArray(prs) ? prs : []) { await check(p); if (merged) break; }
105
+ try {
106
+ const rec = parse(await gh(["api", `repos/${repo ?? "{owner}/{repo}"}/issues/${dep}`]));
107
+ const m = isTrustedComment(rec) && typeof rec.body === "string" ? parseState(rec.body)?.mergedTo : null;
108
+ if (m && m.lane === base && Number.isInteger(m.pr) && typeof m.sha === "string" && /^[0-9a-f]{7,64}$/i.test(m.sha)) {
109
+ const p = parse(await gh(["pr", "view", String(m.pr), ...r, "--json", "baseRefName,mergedAt,mergeCommit"]));
110
+ if (p?.mergeCommit?.oid === m.sha) await check(p);
111
+ }
112
+ } catch { /* no usable record */ }
113
+ }
114
+ if (!merged && !missing) {
115
+ for (const p of await list(["--head", `feat/issue-${dep}-lean`, "--state", "merged", "--json", "number,baseRefName,mergedAt,mergeCommit", "--limit", "5"])) { await check(p); if (merged) break; }
116
+ }
117
+ if (!merged && !missing) {
118
+ const closes = new RegExp(`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\b:?\\s+(?:[\\w.-]+/[\\w.-]+)?#${dep}(?!\\d)`, "i");
119
+ const prs = await list(["--state", "merged", "--base", base, "--search", `${dep} in:body`, "--json", "number,baseRefName,mergedAt,mergeCommit,title,body", "--limit", "20"]);
120
+ for (const p of prs.filter((x) => closes.test(`${x?.title ?? ""}\n${x?.body ?? ""}`))) { await check(p); if (merged) break; }
104
121
  }
105
122
  if (!merged) sha = missing;
106
123
  if (!merged) why = missing ? `merge commit ${missing.slice(0, 10)} is not on ${base}` : `closed but not merged into ${base}`;