forgedock 1.32.0 → 1.33.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 (69) hide show
  1. package/bin/engine/outcome-codes.mjs +7 -3
  2. package/bin/engine/phases.mjs +29 -8
  3. package/bin/engine-cli.mjs +84 -24
  4. package/bin/forgedock.mjs +21 -1
  5. package/bin/lean/basesync.mjs +98 -0
  6. package/bin/lean/basesync.test.mjs +151 -0
  7. package/bin/lean/batch.mjs +9 -3
  8. package/bin/lean/batch.test.mjs +134 -3
  9. package/bin/lean/budget.mjs +7 -1
  10. package/bin/lean/budget.test.mjs +9 -1
  11. package/bin/lean/conformance.mjs +20 -7
  12. package/bin/lean/conformance.test.mjs +29 -2
  13. package/bin/lean/depcache.mjs +23 -3
  14. package/bin/lean/depcache.test.mjs +42 -1
  15. package/bin/lean/effects.mjs +29 -3
  16. package/bin/lean/execution.mjs +106 -14
  17. package/bin/lean/execution.test.mjs +101 -1
  18. package/bin/lean/hostkill.mjs +8 -0
  19. package/bin/lean/lean.mjs +65 -24
  20. package/bin/lean/lean.test.mjs +221 -10
  21. package/bin/lean/lightlane.mjs +38 -13
  22. package/bin/lean/lightlane.test.mjs +35 -1
  23. package/bin/lean/orchestrate.mjs +93 -26
  24. package/bin/lean/prompts.mjs +17 -7
  25. package/bin/lean/recovery.test.mjs +26 -0
  26. package/bin/lean/retry.mjs +15 -1
  27. package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
  28. package/bin/lean/sandbox-remote.mjs +40 -7
  29. package/bin/lean/schedd.mjs +50 -14
  30. package/bin/lean/schedd.test.mjs +133 -21
  31. package/bin/lean/seqlease.mjs +49 -2
  32. package/bin/lean/seqlease.test.mjs +14 -0
  33. package/bin/lean/sequence.mjs +54 -6
  34. package/bin/lean/sequence.test.mjs +83 -1
  35. package/bin/pool/agent.mjs +435 -0
  36. package/bin/pool/cli.mjs +110 -0
  37. package/bin/pool/docker.mjs +56 -0
  38. package/bin/pool/http.mjs +55 -0
  39. package/bin/pool/router.mjs +299 -0
  40. package/bin/pool/store.mjs +25 -0
  41. package/bin/pool/version.mjs +44 -0
  42. package/bin/runner.mjs +1 -0
  43. package/bin/stats/aggregate.mjs +6 -0
  44. package/bin/stats/index.mjs +14 -2
  45. package/bin/stats/local-source.mjs +1 -1
  46. package/bin/stats/pool.mjs +51 -0
  47. package/bin/stats/pool.test.mjs +39 -0
  48. package/bin/stats/query.mjs +2 -2
  49. package/bin/stats/stats.test.mjs +15 -1
  50. package/commands/orchestrate.md +3 -3
  51. package/docs/CONFIG.md +23 -9
  52. package/docs/OUTCOME-CODES.md +5 -3
  53. package/docs/STATS.md +7 -0
  54. package/package.json +2 -1
  55. package/pool/README.md +13 -0
  56. package/pool/image/Dockerfile +46 -0
  57. package/pool/image/claude-shim +8 -0
  58. package/pool/image/cs-cred +12 -0
  59. package/pool/image/entrypoint.sh +61 -0
  60. package/pool/image/gh-shim +6 -0
  61. package/pool/install/install-agent-linux.sh +53 -0
  62. package/pool/install/install-agent.ps1 +225 -0
  63. package/pool/install/install-agent.sh +101 -0
  64. package/pool/install/install-router.sh +67 -0
  65. package/pool/install/keepalive.ps1 +36 -0
  66. package/pool/install/uninstall-agent.ps1 +20 -0
  67. package/scripts/hotspots.sh +33 -21
  68. package/scripts/hotspots.test.sh +22 -3
  69. package/scripts/refresh-bot-token.test.sh +56 -0
@@ -46,10 +46,12 @@ export const OUTCOME_CODES = Object.freeze({
46
46
  INFRA_DISK_QUOTA: "infra.disk_quota",
47
47
  INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
48
48
  CLI_TIMEOUT: "cli.timeout",
49
+ CLI_HOST_KILLED: "cli.host_killed", // forge#4518: the builder CLI was SIGTERM/SIGKILLed from outside (host, OOM guard, operator); deferred, never counted by the breaker
49
50
  CLI_SPAWN_FAILED: "cli.spawn_failed", // forge#4129: deterministic spawn failure (E2BIG, ENOENT, EACCES, ...)
50
51
  ENV_SETUP_FAILED: "env.setup_failed", // forge#4163: forge.yaml env.setup failed before the builder; an environment error, not a build failure
51
52
  ENV_TOOL_MISSING: "env.tool_missing", // forge#4323: a validate command could not run because a host tool or module is missing; an environment error, not a conformance failure
52
53
  ENV_REPRO_UNRUNNABLE: "env.repro_unrunnable", // forge#4334: the repro harness could not run the repro test on head; an environment error, not a conformance failure
54
+ ENV_CAPABILITY_REMOTE: "env.capability_remote", // forge#4542: a remote sandbox lane blocked only on a host-only capability (SOPS key, ops credentials, prod monitor SSH) the dispatching host has; recovered locally
53
55
  SANDBOX_START_FAILED: "sandbox.start_failed", // forge#4254: a lane sandbox could not start; needs-human, not relaunched
54
56
  SANDBOX_CAPACITY: "sandbox.capacity", // forge#4254: the sandbox provider had no capacity; deferred with backoff
55
57
  LEASE_HELD: "lease.held",
@@ -175,6 +177,7 @@ export function codeForTerminalReason(reason) {
175
177
  case "dependency-open": return OUTCOME_CODES.DEPENDENCY_OPEN;
176
178
  case "dependency-unreadable": return OUTCOME_CODES.DEPENDENCY_UNREADABLE;
177
179
  case "dependency-blocked": return OUTCOME_CODES.DEPENDENCY_BLOCKED;
180
+ case "budget-exceeded": return OUTCOME_CODES.BUDGET_EXCEEDED; // forge#4505: the non-human turn-overrun end
178
181
  default: return UNCLASSIFIED;
179
182
  }
180
183
  }
@@ -183,20 +186,21 @@ export function codeForTerminalReason(reason) {
183
186
  * Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
184
187
  * (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
185
188
  */
186
- export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.BUDGET_EXCEEDED]);
189
+ export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE, OUTCOME_CODES.ENV_CAPABILITY_REMOTE, OUTCOME_CODES.BUDGET_EXCEEDED]);
187
190
  /** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
188
191
  export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
189
192
 
190
193
  /**
191
194
  * `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
192
- * Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `budget.exceeded`, `contract.invalid` after a mid-run issue
193
- * edit (`issueEdited`), and `ci-pending` on a conflicting PR (`conflicting`). Human: `builder.blocked`. Anything else
195
+ * Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `env.capability_remote`, `budget.exceeded`, `contract.invalid` after a mid-run issue
196
+ * edit (`issueEdited`), `ci-pending` on a conflicting PR (`conflicting`), and the non-human `budget-exceeded` turn-overrun end (forge#4505). Human: `builder.blocked`. Anything else
194
197
  * (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
195
198
  */
196
199
  export function classifyNeedsHuman(row) {
197
200
  if (!row || typeof row !== "object") return "terminal";
198
201
  const outcome = row.outcome ?? row.terminalReason;
199
202
  if (outcome === "ci-pending") return row.conflicting === true ? "recoverable" : "terminal";
203
+ if (outcome === "budget-exceeded") return "recoverable";
200
204
  if (outcome !== "needs-human") return "terminal";
201
205
  if (HUMAN_CODES.includes(row.code)) return "human";
202
206
  if (row.code === OUTCOME_CODES.CONTRACT_INVALID) return row.issueEdited === true ? "recoverable" : "terminal";
@@ -308,20 +308,41 @@ 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;
311
+ // A declaration line: optional list marker, optional bold, "Depends on" or "Blocked by" (any case), then one linear run of blanks, colons
312
+ // and bold markers (no chained quantifiers: #4517), then a list of `#N` (#4121, #4506). The list is `#N` items joined by `,` `&` `and` `or`
313
+ // (each item may carry a parenthetical note, whose own `#N` are prose); any other text ends it, so a trailing
314
+ // "see #99", "Fixes #12", a code span or a sentence never adds an edge (release review #4515).
315
+ const DEPENDS_ON_RE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?(?:depends on|blocked by)[ \t:*_]*(#\d+\b[^\n]*)/gim;
316
+ const DEP_ITEM_RE = /^(?<![\w/])#(\d+)\b(?:[ \t]*\([^()\n]*\))?/;
317
+ const DEP_JOIN_RE = /^[ \t]*(?:,[ \t]*(?:(?:and|or)\b[ \t]*)?|&[ \t]*|(?:and|or)\b[ \t]*)/i;
318
+
319
+ /** The `#N` list at the start of a declaration's tail: items and joiners only, stopping at the first other text. */
320
+ function dependsOnList(tail) {
321
+ const out = [];
322
+ let rest = tail;
323
+ for (;;) {
324
+ const item = DEP_ITEM_RE.exec(rest);
325
+ if (!item) break;
326
+ out.push(Number(item[1]));
327
+ rest = rest.slice(item[0].length);
328
+ const join = DEP_JOIN_RE.exec(rest);
329
+ if (!join || !/^#\d/.test(rest.slice(join[0].length))) break;
330
+ rest = rest.slice(join[0].length);
331
+ }
332
+ return out;
333
+ }
314
334
 
315
335
  /**
316
336
  * 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.
337
+ * `- Depends on: #X and #Y`, `Depends on #X (why) and #Y (why)`, `Blocked by #X`. Only declaration lines count, and on
338
+ * them only the leading `#N` list; a mid-line or trailing mention is prose. Shared by the engine's admission
339
+ * (`openDependency`) and `forgedock orchestrate` so both agree on what blocks what.
319
340
  * @returns {number[]} unique, positive, never `issue` itself
320
341
  */
321
342
  export function parseDependsOn(body, issue = null) {
322
343
  const out = new Set();
323
344
  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]));
345
+ for (const n of dependsOnList(m[1])) out.add(n);
325
346
  }
326
347
  return [...out].filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue));
327
348
  }
@@ -329,8 +350,8 @@ export function parseDependsOn(body, issue = null) {
329
350
  /**
330
351
  * forge#3932: does the issue declare a `Depends on` / `Blocked by` dependency that is still OPEN?
331
352
  * Honoured only when the issue BODY's author is trusted (`isTrustedComment`: OWNER/MEMBER/COLLABORATOR,
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 }`
353
+ * Bot, or FORGE_TRAIL_TRUSTED_LOGINS) and only for a declaration line's leading `#N` list as `parseDependsOn` reads it (mid-line
354
+ * and trailing prose is ignored). A self-reference is ignored. Returns `null` (nothing blocking), `{ open: [N, ...] }`, or `{ unreadable: true, detail }`
334
355
  * when the issue or a declared dependency cannot be read (fail closed: the caller refuses the run).
335
356
  * An empty read of the issue itself means "no body to trust" and yields `null`.
336
357
  */
@@ -18,7 +18,7 @@ import { parseForgeYaml } from "./forge-utils.mjs";
18
18
  import { makeEmitTelemetry } from "./engine/telemetry-seam.mjs";
19
19
  import { readBranches, resolveLane, isDefaultLane } from "./lean/lane.mjs";
20
20
  import { parseVerify } from "./lean/verify.mjs";
21
- import { parseExecution } from "./lean/execution.mjs";
21
+ import { parseExecution, detectNeeds } from "./lean/execution.mjs";
22
22
  import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lean/budget.mjs";
23
23
  import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
24
24
  import { parseLightConfig, LIGHT_DEFAULTS } from "./lean/light.mjs";
@@ -326,6 +326,11 @@ export function readVerify(cwd = process.cwd()) {
326
326
  return sec && typeof sec === "object" ? parseVerify(sec) : undefined;
327
327
  } catch { return undefined; }
328
328
  }
329
+ /** #4542: forge.yaml `execution.host_capabilities` (+ patterns) for a lane's remote-block mapping; undefined when none are declared. */
330
+ export function readHostCaps(cwd = process.cwd()) {
331
+ const e = readExecution(cwd);
332
+ return e?.hostCapabilities?.length ? { hostCapabilities: e.hostCapabilities, ...(e.hostCapabilityPatterns ? { hostCapabilityPatterns: e.hostCapabilityPatterns } : {}) } : undefined;
333
+ }
329
334
  /**
330
335
  * forge.yaml `execution:` section (#4253, block style: `execution:` then ` mode: remote`, ` max_remote: 4`,
331
336
  * ` provider: container`, ` image`, ` secrets` comma-separated env names; remote provider #4254: ` endpoint`,
@@ -849,7 +854,7 @@ export async function runFromCli(argv, deps = {}) {
849
854
  lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
850
855
  onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
851
856
  emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
852
- admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
857
+ admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}) , ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}) });
853
858
  } catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
854
859
  finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
855
860
  if (interrupted) return lres; // the signal handler reported and exits
@@ -1199,19 +1204,34 @@ async function orchestrateClient(argv, deps = {}) {
1199
1204
  }
1200
1205
  msg = { op: "submit", argv: sub, cwd: process.cwd(), env: schedd.batchEnv(process.env) };
1201
1206
  }
1202
- const sock = await connect();
1207
+ let sock = await connect(), req = msg, reattached = false, losses = 0;
1203
1208
  let id = resumeId;
1204
- try {
1205
- const done = await schedd.request(sock, msg, (m) => {
1206
- if (m.type === "batch") id = m.id;
1207
- else if (m.type === "row") console.log(formatRowLine(m.row));
1208
- else if (m.type === "line") console.log(m.text);
1209
- else if (m.type === "done" && m.replay && m.report) console.log(m.report);
1210
- });
1211
- return done.result;
1212
- } catch (e) {
1213
- if (id && /closed the connection/.test(e?.message ?? "")) throw new Error(`${e.message}; the batch keeps its state: forgedock orchestrate --resume ${id}`);
1214
- throw e;
1209
+ const onMsg = (m) => {
1210
+ if (m.type === "batch") id = m.id;
1211
+ else if (m.type === "row") { if (!(reattached && m.replay)) console.log(formatRowLine(m.row)); }
1212
+ else if (m.type === "line") console.log(m.text);
1213
+ else if (m.type === "done" && m.replay && m.report) console.log(m.report);
1214
+ };
1215
+ // #4503: when the daemon dies mid-batch, reconnect (spawning one unless `spawnDaemon: null`) and re-attach, within `reconnectMs`.
1216
+ const reconnectMs = deps.reconnectMs ?? 60_000, sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1217
+ for (;;) {
1218
+ try {
1219
+ return (await schedd.request(sock, req, onMsg)).result;
1220
+ } catch (e) {
1221
+ if (!id || !(/closed the connection/.test(e?.message ?? "") || ["ECONNRESET", "EPIPE"].includes(e?.code))) throw e;
1222
+ const giveUp = () => new Error(`${e.message}; the batch keeps its state: forgedock orchestrate --resume ${id}`);
1223
+ if (++losses > 10) throw giveUp();
1224
+ console.log(`schedd connection lost; reconnecting to batch ${id}`);
1225
+ const end = Date.now() + reconnectMs;
1226
+ for (sock = null; !sock;) {
1227
+ try { sock = await connect(); } catch {
1228
+ if (Date.now() >= end) throw giveUp();
1229
+ await sleep(250);
1230
+ }
1231
+ }
1232
+ req = { op: "attach", id };
1233
+ reattached = true;
1234
+ }
1215
1235
  }
1216
1236
  }
1217
1237
 
@@ -1312,13 +1332,15 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1312
1332
  // Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes, or
1313
1333
  // sandboxes when forge.yaml `execution` places them remote (#4253).
1314
1334
  const labelsOf = new Map(); // issue -> label names, filled by readBody (one gh call per issue)
1335
+ const needsOf = new Map(); // issue -> host-only capabilities its body names (#4542), filled by readBody
1336
+ let execForNeeds = null;
1315
1337
  let lanes, remote = null, execBudget = null, execConf = null, hybrid = false;
1316
1338
  const execMode = () => (deps.execution !== undefined ? deps.execution : readExecution(cwd))?.mode ?? "local";
1317
1339
  if (deps.leanModule) {
1318
1340
  const { runCore } = deps.leanModule;
1319
1341
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1320
1342
  lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), ...(ctx.recover ? { recover: ctx.recover } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
1321
- admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
1343
+ admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}) }) };
1322
1344
  } else {
1323
1345
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1324
1346
  // #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
@@ -1333,6 +1355,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1333
1355
  }, adopt: l.adopt });
1334
1356
  const execMod = await import("./lean/execution.mjs");
1335
1357
  const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
1358
+ execForNeeds = exec ?? null;
1336
1359
  hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
1337
1360
  if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
1338
1361
  // #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
@@ -1345,12 +1368,12 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1345
1368
  const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
1346
1369
  ...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
1347
1370
  lanes = withPlacement(remoteLanes);
1348
- if (bud.onBudget === "local" || hybrid) {
1371
+ {
1349
1372
  // #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
1350
1373
  const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
1351
1374
  lanes = withPlacement({
1352
1375
  runOne: (n, ln, ctx = {}) => (ctx.remote ? remoteLanes : local).runOne(n, ln, ctx),
1353
- adopt: (n, rec) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec),
1376
+ adopt: (n, rec, ctx) => (rec?.remote || rec?.handle ? remoteLanes : local).adopt(n, rec, ctx),
1354
1377
  });
1355
1378
  }
1356
1379
  // #4255: hard execution budget per batch and per host UTC day; every settled sandbox is recorded and reported.
@@ -1358,15 +1381,26 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1358
1381
  execBudget = deps.executionBudget ?? execMod.openExecutionBudget({ budget: bud, file: execMod.executionBudgetFile(env), ...(deps.now ? { now: deps.now } : {}) });
1359
1382
  const emitSbx = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1360
1383
  // #4256: hybrid places each lane from facts (pure placeLane); validation weight is a repo fact, read once per batch.
1361
- const place = hybrid ? await (async () => {
1384
+ const relays = Array.isArray(provider.relays) ? provider.relays : [];
1385
+ const place = !hybrid ? (n) => execMod.placeLane({ exec, needs: needsOf.get(n) ?? [], relays }) : await (async () => {
1362
1386
  const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
1363
1387
  let weight = deps.validationWeight ?? null;
1364
1388
  if (!weight) { try { weight = await execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] }); } catch { weight = { heavy: false, signals: [] }; } }
1365
1389
  const headroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
1366
- return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom() });
1367
- })() : null;
1390
+ return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom(), needs: needsOf.get(n) ?? [], relays });
1391
+ })();
1392
+ // #4540: provider free slots, refreshed in the background (admission is sync); unknown (null) never blocks.
1393
+ let capSample = null, onlineSample = null, capAt = 0, capLast = -Infinity; // #4547: online = agents alive, null unknown
1394
+ const capacity = () => {
1395
+ const t = deps.now ? deps.now() : Date.now();
1396
+ if (typeof provider.capacity === "function" && t - capLast >= (deps.pollMs ?? 5000)) {
1397
+ capLast = t;
1398
+ Promise.resolve().then(() => provider.capacity()).then((c) => { capSample = c && Number.isInteger(c.free) ? c.free : null; onlineSample = c && Number.isInteger(c.online) ? c.online : null; capAt++; }, () => { capSample = null; onlineSample = null; capAt++; });
1399
+ }
1400
+ return capSample === null && onlineSample === null ? null : { free: capSample, online: onlineSample, at: capAt };
1401
+ };
1368
1402
  remote = {
1369
- max: exec.maxRemote, isRemote: () => true, ...(place ? { place, fallbackLocal: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
1403
+ capacity, max: exec.maxRemote, isRemote: () => true, place, ...(hybrid ? { fallbackLocal: true } : {}), ...(exec.fallbackLocal ? { offlineFallback: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
1370
1404
  admit: (u) => execBudget.admit(u),
1371
1405
  onUsage: (n, sb, rec) => {
1372
1406
  const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
@@ -1507,6 +1541,17 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1507
1541
  ...(daemon ? {} : { onExit: (fn) => { exitRelabel = fn; } }),
1508
1542
  // #4454: the daemon's shutdown stop; orchestrate throws batch.stopped, so no report is written and a resume adopts the lanes
1509
1543
  ...(signal ? { signal } : {}),
1544
+ // #4525: a parked needs-human PR is merged with its moved base: clean or union-resolved merges are pushed, other conflicts reported
1545
+ ...(deps.syncParked !== undefined ? (deps.syncParked ? { syncParked: deps.syncParked } : {}) : orch.baseSync?.enabled !== true ? {} : { syncParked: async ({ pr, last }) => {
1546
+ try {
1547
+ const v = JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "state,headRefName,baseRefName,isCrossRepository"]));
1548
+ if (v.state !== "OPEN" || v.isCrossRepository) return { status: "unchanged" };
1549
+ const { execFile } = await import("node:child_process"), { promisify } = await import("node:util");
1550
+ const run = promisify(execFile);
1551
+ return await (await import("./lean/basesync.mjs")).syncParked({ git: async (a, o = {}) => String((await run("git", a, { cwd: o.cwd ?? cwd, maxBuffer: 1 << 24 })).stdout), base: v.baseRefName, branch: v.headRefName,
1552
+ unionGlobs: orch.baseSync?.unionGlobs ?? [], last });
1553
+ } catch (e) { return { status: "error", detail: String(e?.message ?? e).slice(0, 160) }; }
1554
+ } }),
1510
1555
  conflicting: deps.conflicting ?? (async (_n, pr) => {
1511
1556
  try { return JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "mergeable"]))?.mergeable === "CONFLICTING"; } catch { return false; }
1512
1557
  }),
@@ -1515,8 +1560,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1515
1560
  sink.line(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
1516
1561
  },
1517
1562
  readBody: maint || registry || hybrid
1518
- ? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels"])); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); return v.body; }
1519
- : async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
1563
+ ? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels"])); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); needsOf.set(n, detectNeeds(v.body, execForNeeds)); return v.body; }
1564
+ : async (n) => { const b = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body; needsOf.set(n, detectNeeds(b, execForNeeds)); return b; },
1520
1565
  onUpdate: (r) => sink.row(r),
1521
1566
  });
1522
1567
  } finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
@@ -1645,6 +1690,16 @@ export async function scheddFromCli(argv, deps = {}) {
1645
1690
  return { running: false };
1646
1691
  }
1647
1692
 
1693
+ /**
1694
+ * `forgedock pool router [install] | enroll [--cpus N --mem GB] | status | upgrade [agent] | remove <agent>` (#4544)
1695
+ * The built-in remote compute pool (`bin/pool/`). `pool router` runs in the foreground (for a systemd-user unit:
1696
+ * `ExecStart=forgedock pool router`); `router install` installs that unit. `enroll` prints the one-time install command.
1697
+ */
1698
+ export async function poolFromCli(argv, deps = {}) {
1699
+ const { runPoolCli } = await import("./pool/cli.mjs");
1700
+ return (deps.runPoolCli ?? runPoolCli)(argv, deps.poolDeps ?? {});
1701
+ }
1702
+
1648
1703
  /**
1649
1704
  * `forgedock limits show` · `forgedock limits clear <id>|--all` (#4190)
1650
1705
  * Usage-limit holds are kept per account (backend identity) in `~/.forge/limits.json` (or `FORGEDOCK_LIMITS_FILE`) and
@@ -1787,6 +1842,8 @@ export async function schedFromCli(argv, deps = {}) {
1787
1842
  * `orchestration.errors|window_min` (or flat `breaker_errors|breaker_window_min`).
1788
1843
  * `orchestration.recovery.max_attempts` (#4267; arrives as `max_attempts`, or flat `recovery_max_attempts`): recovery lanes per
1789
1844
  * issue for a mechanical needs-human, default 1, 0 turns recovery off.
1845
+ * `orchestration.base_sync.enabled` (#4525; default true, false opts out) and `base_sync.union_globs` (comma list of globs of
1846
+ * append-only files whose two-sided line additions a parked PR's base sync keeps both of).
1790
1847
  */
1791
1848
  export function readOrchestration(cwd = process.cwd()) {
1792
1849
  let o = {};
@@ -1796,7 +1853,10 @@ export function readOrchestration(cwd = process.cwd()) {
1796
1853
  const gp = int(o.global_parallel, null);
1797
1854
  const recoveryMax = int(o.recovery_max_attempts ?? o.max_attempts, 1);
1798
1855
  return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), globalParallel: gp >= 1 ? gp : null,
1799
- recovery: { maxAttempts: recoveryMax } };
1856
+ recovery: { maxAttempts: recoveryMax },
1857
+ // #4525: parked-PR base sync. The parser flattens one level, so `base_sync.enabled|union_globs` arrive as `enabled|union_globs` (or flat `base_sync_*`).
1858
+ baseSync: { enabled: String(o.base_sync_enabled ?? o.enabled ?? "true").trim().toLowerCase() !== "false",
1859
+ unionGlobs: String(o.base_sync_union_globs ?? o.union_globs ?? "").replace(/^\[|\]$/g, "").split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean) } };
1800
1860
  }
1801
1861
 
1802
1862
  /**
package/bin/forgedock.mjs CHANGED
@@ -1878,6 +1878,12 @@ function selfUpdateGlobalInstall(version) {
1878
1878
  }
1879
1879
  }
1880
1880
 
1881
+ /** #4545: after an update, push the new code + lane image to pool agents (no-op without a pool router config). */
1882
+ async function upgradePoolAgents() {
1883
+ const { upgradeAgentsAfterUpdate } = await import("./pool/version.mjs");
1884
+ await upgradeAgentsAfterUpdate({ warn: (m) => console.log(` ${YELLOW}${m}${RESET}`) });
1885
+ }
1886
+
1881
1887
  async function update() {
1882
1888
  console.log("");
1883
1889
  console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
@@ -1994,6 +2000,7 @@ async function update() {
1994
2000
  printGitCloneChangelogSummary(before, after);
1995
2001
  }
1996
2002
  await relinkAndHint();
2003
+ await upgradePoolAgents();
1997
2004
  } catch (err) {
1998
2005
  console.log(
1999
2006
  ` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
@@ -2081,6 +2088,7 @@ async function update() {
2081
2088
  }
2082
2089
 
2083
2090
  await relinkAndHint();
2091
+ await upgradePoolAgents();
2084
2092
  }
2085
2093
  console.log("");
2086
2094
  }
@@ -3296,6 +3304,7 @@ function help() {
3296
3304
  ["npx forgedock limits show | clear <id>|--all", "Show or clear per-account usage-limit holds shared by orchestrate batches"],
3297
3305
  ["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)"],
3298
3306
  ["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"],
3307
+ ["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"],
3299
3308
  ["npx forgedock gc [--apply] [--older-than 6h] [--docker] [--worktrees] [--branches] [--tmp] [--processes] [--sandboxes]", "Report (dry-run default) and reclaim ForgeDock-owned leftovers: stale lane ledgers, forgedock-labelled containers/volumes, lean-*/agent-* worktrees, abandoned lane branches, ~/.forge/tmp scratch, orphaned lane processes, orphaned lane sandboxes; unowned resources and unpushed work are only reported"],
3300
3309
  ["npx forgedock resume-stalled [--dry-run]", "Fleet stall recovery — re-dispatch expired-lease issues"],
3301
3310
  ["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
@@ -3876,7 +3885,7 @@ async function watch() {
3876
3885
  const SPLASH_COMMANDS = new Set(["run", "bootstrap", "run-issue", "orchestrate", "triage", "probe", "gc", "resume-stalled", "demo", "doctor", "watch", "help", "--help", "-h"]);
3877
3886
  const KNOWN_COMMANDS = new Set([
3878
3887
  "install", "init", "enable", "disable", "status", "uninstall", "update",
3879
- "run", "bootstrap", "backend-check", "run-issue", "orchestrate", "triage", "probe", "review-staging", "gc", "resume-stalled", "demo", "doctor", "watch", "labels", "config", "stats", "limits", "sched", "schedd", "check", "help", "--help", "-h",
3888
+ "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",
3880
3889
  "version", "--version", "-v",
3881
3890
  ]);
3882
3891
  if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
@@ -4141,6 +4150,17 @@ switch (command) {
4141
4150
  }
4142
4151
  break;
4143
4152
  }
4153
+ case "pool": {
4154
+ // #4544: the built-in remote compute pool (router, enrollment, status, upgrade, remove)
4155
+ const { poolFromCli } = await import("./engine-cli.mjs");
4156
+ try {
4157
+ await poolFromCli(restArgs);
4158
+ } catch (err) {
4159
+ process.stderr.write(`${RED}${err.message}${RESET}\n`);
4160
+ exitCode = String(err.message).startsWith("usage:") ? 2 : 1;
4161
+ }
4162
+ break;
4163
+ }
4144
4164
  case "sched": {
4145
4165
  const { schedFromCli } = await import("./engine-cli.mjs");
4146
4166
  try {
@@ -0,0 +1,98 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Base sync of a parked PR (#4525): a lane that ended needs-human leaves its PR open and nothing owns the branch, so it
5
+ * rots as the base moves. `syncParked` merges the base into the PR branch in a throwaway worktree. A clean merge, or one
6
+ * whose only conflicts are hunks where both sides purely added lines in a file matching `base_sync.union_globs`
7
+ * (`unionResolve`: keep both), is pushed as a plain merge commit (never forced). Any other conflict is aborted and its
8
+ * paths returned, the branch untouched. Deterministic (git only, no LLM); sequence/journal files are never resolved here
9
+ * (they belong to sequence.mjs). Never throws.
10
+ */
11
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { basename, join } from "node:path";
14
+ import { seqNumber } from "./sequence.mjs";
15
+
16
+ const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
17
+
18
+ /** Glob (`*` within a segment, `**` across, `?`) to a RegExp; a pattern with no `/` matches the file name anywhere. */
19
+ const globRe = (g) => {
20
+ const body = String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\//g, "\u0000").replace(/\*\*/g, "\u0001").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, "(?:.*/)?").replace(/\u0001/g, ".*");
21
+ return new RegExp(`^${String(g).includes("/") ? "" : "(?:.*/)?"}${body}$`);
22
+ };
23
+ export const matchesGlobs = (path, globs = []) => globs.some((g) => String(g).trim() && globRe(String(g).trim()).test(String(path)));
24
+
25
+ /** Never resolved here: a migration journal and numbered sequence files are sequence.mjs's. */
26
+ export const isSequenceFile = (path) => basename(String(path)) === "_journal.json" || seqNumber(path) !== null;
27
+
28
+ /**
29
+ * Resolve a diff3-style conflicted text (`<<<<<<<` ours, `|||||||` base, `=======` theirs, `>>>>>>>`): a hunk resolves
30
+ * only when its base section is empty (both sides only added lines), keeping ours then theirs. Returns the resolved
31
+ * text, or null when the text has no conflict, no base section, or a hunk where either side changed or removed base lines.
32
+ */
33
+ export function unionResolve(text) {
34
+ const src = String(text ?? "").split("\n");
35
+ const out = [];
36
+ let i = 0, hunks = 0;
37
+ while (i < src.length) {
38
+ if (!src[i].startsWith("<<<<<<<")) { out.push(src[i++]); continue; }
39
+ const ours = [], base = [], theirs = [];
40
+ let part = ours, hasBase = false, closed = false;
41
+ for (i++; i < src.length; i++) {
42
+ const l = src[i];
43
+ if (l.startsWith("|||||||")) { part = base; hasBase = true; } else if (l.startsWith("=======") && part !== theirs) part = theirs;
44
+ else if (l.startsWith(">>>>>>>") && part === theirs) { closed = true; i++; break; } else part.push(l);
45
+ }
46
+ if (!closed || !hasBase || base.length) return null;
47
+ out.push(...ours, ...theirs); hunks++;
48
+ }
49
+ return hunks ? out.join("\n") : null;
50
+ }
51
+
52
+ /**
53
+ * @param {{git:(args:string[], o?:{cwd?:string})=>Promise<string>, base:string, branch:string, unionGlobs?:string[], last?:?string, tmp?:string}} p
54
+ * `git` runs in the repo checkout and throws on failure; `last` is the base sha already handled for this PR.
55
+ * @returns {Promise<{status:"unchanged"|"up-to-date"|"merged"|"conflict"|"error", base?:string, paths?:string[], detail?:string}>}
56
+ */
57
+ export async function syncParked({ git, base, branch, unionGlobs = [], last = null, tmp = tmpdir() }) {
58
+ let wt = null, baseSha;
59
+ try {
60
+ await git(["fetch", "origin", base, branch]);
61
+ baseSha = String(await git(["rev-parse", `origin/${base}`])).trim();
62
+ if (last && last === baseSha) return { status: "unchanged", base: baseSha };
63
+ try { await git(["merge-base", "--is-ancestor", `origin/${base}`, `origin/${branch}`]); return { status: "up-to-date", base: baseSha }; } catch { /* behind */ }
64
+ wt = mkdtempSync(join(tmp, "basesync-"));
65
+ await git(["worktree", "add", "--detach", wt, `origin/${branch}`]);
66
+ const g = (a) => git(a, { cwd: wt });
67
+ const ident = String(await g(["config", "user.email"]).catch(() => "")).trim() ? [] : ["-c", "user.name=ForgeDock", "-c", "user.email=forgedock@users.noreply.github.com"];
68
+ let clean = true;
69
+ try { await g([...ident, "merge", "--no-edit", `origin/${base}`]); } catch { clean = false; }
70
+ if (!clean) {
71
+ const conflicted = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=U"]).catch(() => ""));
72
+ const stages = new Map();
73
+ for (const l of lines(await g(["-c", "core.quotepath=off", "ls-files", "-u"]).catch(() => ""))) {
74
+ const m = /^\d+ \S+ (\d)\t(.+)$/.exec(l); if (m) stages.set(m[2], (stages.get(m[2]) ?? 0) + (1 << Number(m[1])));
75
+ }
76
+ const fixes = [], refused = [];
77
+ for (const f of conflicted) {
78
+ let resolved = null;
79
+ if (stages.get(f) === 14 && !isSequenceFile(f) && matchesGlobs(f, unionGlobs)) { // stages 1,2,3 present: both sides edited an existing file
80
+ try { await g(["checkout", "--conflict=diff3", "--", f]); resolved = unionResolve(readFileSync(join(wt, f), "utf8")); } catch { resolved = null; }
81
+ }
82
+ if (resolved === null) refused.push(f); else fixes.push([f, resolved]);
83
+ }
84
+ if (!conflicted.length || refused.length) {
85
+ await g(["merge", "--abort"]).catch(() => {});
86
+ return { status: "conflict", base: baseSha, paths: refused.length ? refused : ["(unknown files)"] };
87
+ }
88
+ for (const [f, text] of fixes) { writeFileSync(join(wt, f), text); await g(["add", "--", f]); }
89
+ await g([...ident, "commit", "--no-edit"]);
90
+ }
91
+ await git(["push", "origin", `${(await g(["rev-parse", "HEAD"])).trim()}:refs/heads/${branch}`]); // plain push: a rejected race is an error, never forced
92
+ return { status: "merged", base: baseSha };
93
+ } catch (e) {
94
+ return { status: "error", ...(baseSha ? { base: baseSha } : {}), detail: String(e?.message ?? e).split("\n")[0].slice(0, 200) };
95
+ } finally {
96
+ if (wt) { await git(["worktree", "remove", "--force", wt]).catch(() => {}); try { rmSync(wt, { recursive: true, force: true }); } catch { /* best effort */ } }
97
+ }
98
+ }