forgedock 1.32.1 → 1.34.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 (77) hide show
  1. package/bin/engine/outcome-codes.mjs +7 -3
  2. package/bin/engine/phases.mjs +3 -3
  3. package/bin/engine-cli.mjs +56 -11
  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.test.mjs +103 -2
  8. package/bin/lean/budget.mjs +7 -1
  9. package/bin/lean/budget.test.mjs +9 -1
  10. package/bin/lean/conformance.mjs +46 -10
  11. package/bin/lean/conformance.test.mjs +50 -2
  12. package/bin/lean/depcache.mjs +23 -3
  13. package/bin/lean/depcache.test.mjs +42 -1
  14. package/bin/lean/effects.mjs +1 -1
  15. package/bin/lean/execution.mjs +102 -12
  16. package/bin/lean/execution.test.mjs +101 -1
  17. package/bin/lean/hostkill.mjs +8 -0
  18. package/bin/lean/lean.mjs +49 -21
  19. package/bin/lean/lean.test.mjs +133 -7
  20. package/bin/lean/lightlane.mjs +38 -13
  21. package/bin/lean/lightlane.test.mjs +35 -1
  22. package/bin/lean/orchestrate.mjs +63 -17
  23. package/bin/lean/prompts.mjs +19 -9
  24. package/bin/lean/recovery.test.mjs +26 -0
  25. package/bin/lean/sandbox-provider.contract.test.mjs +74 -1
  26. package/bin/lean/sandbox-remote.mjs +40 -7
  27. package/bin/lean/seqlease.mjs +49 -2
  28. package/bin/lean/seqlease.test.mjs +14 -0
  29. package/bin/lean/sequence.mjs +54 -6
  30. package/bin/lean/sequence.test.mjs +83 -1
  31. package/bin/pool/agent.mjs +435 -0
  32. package/bin/pool/cli.mjs +110 -0
  33. package/bin/pool/docker.mjs +56 -0
  34. package/bin/pool/http.mjs +55 -0
  35. package/bin/pool/router.mjs +299 -0
  36. package/bin/pool/store.mjs +25 -0
  37. package/bin/pool/version.mjs +44 -0
  38. package/bin/review-staging.mjs +14 -2
  39. package/bin/runner.mjs +1 -0
  40. package/bin/stats/aggregate.mjs +6 -0
  41. package/bin/stats/efficiency.mjs +103 -17
  42. package/bin/stats/efficiency.test.mjs +103 -4
  43. package/bin/stats/index.mjs +40 -6
  44. package/bin/stats/local-source.mjs +1 -1
  45. package/bin/stats/pool.mjs +51 -0
  46. package/bin/stats/pool.test.mjs +39 -0
  47. package/bin/stats/query.mjs +2 -2
  48. package/bin/stats/stats.test.mjs +15 -1
  49. package/commands/orchestrate.md +3 -3
  50. package/commands/review-pr-agents/protocols.md +1 -1
  51. package/commands/review-pr-staging.md +1 -1
  52. package/docs/CONFIG.md +23 -9
  53. package/docs/OUTCOME-CODES.md +5 -3
  54. package/docs/STATS.md +36 -1
  55. package/package.json +2 -1
  56. package/pool/README.md +13 -0
  57. package/pool/image/Dockerfile +46 -0
  58. package/pool/image/claude-shim +8 -0
  59. package/pool/image/cs-cred +12 -0
  60. package/pool/image/entrypoint.sh +61 -0
  61. package/pool/image/gh-shim +6 -0
  62. package/pool/install/install-agent-linux.sh +53 -0
  63. package/pool/install/install-agent.ps1 +225 -0
  64. package/pool/install/install-agent.sh +101 -0
  65. package/pool/install/install-router.sh +67 -0
  66. package/pool/install/keepalive.ps1 +36 -0
  67. package/pool/install/uninstall-agent.ps1 +20 -0
  68. package/scripts/hotspots.sh +33 -21
  69. package/scripts/hotspots.test.sh +22 -3
  70. package/scripts/review-finalize.sh +17 -1
  71. package/scripts/review-finalize.test.sh +41 -0
  72. package/scripts/review-pr-domain-map.test.sh +2 -2
  73. package/scripts/review-pr-staging-scope.test.sh +4 -2
  74. package/scripts/review-prepare.sh +62 -27
  75. package/scripts/review-prepare.test.sh +53 -0
  76. package/scripts/review-staging-pre.sh +29 -9
  77. package/scripts/review-staging-pre.test.sh +18 -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,11 +308,11 @@ 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 a list of `#N` (#4121, #4506). The list is `#N` items joined by `,` `&` `and` `or`
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
313
  // (each item may carry a parenthetical note, whose own `#N` are prose); any other text ends it, so a trailing
314
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]*:?(?:\*\*|__)?[ \t]*:?[ \t]*(#\d+\b[^\n]*)/gim;
315
+ const DEPENDS_ON_RE = /^[ \t]*(?:[-*+][ \t]+)?(?:\*\*|__)?(?:depends on|blocked by)[ \t:*_]*(#\d+\b[^\n]*)/gim;
316
316
  const DEP_ITEM_RE = /^(?<![\w/])#(\d+)\b(?:[ \t]*\([^()\n]*\))?/;
317
317
  const DEP_JOIN_RE = /^[ \t]*(?:,[ \t]*(?:(?:and|or)\b[ \t]*)?|&[ \t]*|(?:and|or)\b[ \t]*)/i;
318
318
 
@@ -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
@@ -1327,13 +1332,15 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1327
1332
  // Lanes: in-process `runCore` when a lean module is injected (tests), otherwise detached `run-issue` processes, or
1328
1333
  // sandboxes when forge.yaml `execution` places them remote (#4253).
1329
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;
1330
1337
  let lanes, remote = null, execBudget = null, execConf = null, hybrid = false;
1331
1338
  const execMode = () => (deps.execution !== undefined ? deps.execution : readExecution(cwd))?.mode ?? "local";
1332
1339
  if (deps.leanModule) {
1333
1340
  const { runCore } = deps.leanModule;
1334
1341
  const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1335
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,
1336
- 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) } : {}) }) };
1337
1344
  } else {
1338
1345
  const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
1339
1346
  // #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
@@ -1348,6 +1355,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1348
1355
  }, adopt: l.adopt });
1349
1356
  const execMod = await import("./lean/execution.mjs");
1350
1357
  const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
1358
+ execForNeeds = exec ?? null;
1351
1359
  hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
1352
1360
  if (exec && (execMod.placement(exec) === "remote" || hybrid)) {
1353
1361
  // #4253: lanes run in sandboxes, ledger-tracked per lane, admitted against max_remote instead of the host cap.
@@ -1360,7 +1368,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1360
1368
  const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0,
1361
1369
  ...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}) });
1362
1370
  lanes = withPlacement(remoteLanes);
1363
- if (bud.onBudget === "local" || hybrid) {
1371
+ {
1364
1372
  // #4255: once the budget refuses a remote lane, the rest run as detached host lanes. #4256: hybrid mixes both.
1365
1373
  const local = batchMod.detachedLanes({ batch, cwd, env, script: laneScript, bin: deps.laneBin, pollMs: deps.pollMs, laneArgs });
1366
1374
  lanes = withPlacement({
@@ -1373,15 +1381,26 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1373
1381
  execBudget = deps.executionBudget ?? execMod.openExecutionBudget({ budget: bud, file: execMod.executionBudgetFile(env), ...(deps.now ? { now: deps.now } : {}) });
1374
1382
  const emitSbx = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
1375
1383
  // #4256: hybrid places each lane from facts (pure placeLane); validation weight is a repo fact, read once per batch.
1376
- 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 () => {
1377
1386
  const verifyMode = (deps.verify !== undefined ? deps.verify : readVerify(cwd))?.mode ?? "local";
1378
1387
  let weight = deps.validationWeight ?? null;
1379
1388
  if (!weight) { try { weight = await execMod.validationWeight({ cwd, precheckCommands: readPrecheckCommands(cwd) ?? [] }); } catch { weight = { heavy: false, signals: [] }; } }
1380
1389
  const headroom = deps.hostHeadroom ?? (() => execMod.hostHeadroom({ cfg: readAdmission(cwd), heavyCfg: readHeavy(cwd) }));
1381
- return (n) => execMod.placeLane({ exec, verifyMode, labels: labelsOf.get(n) ?? [], weight, headroom: headroom() });
1382
- })() : 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
+ };
1383
1402
  remote = {
1384
- 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,
1385
1404
  admit: (u) => execBudget.admit(u),
1386
1405
  onUsage: (n, sb, rec) => {
1387
1406
  const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
@@ -1522,6 +1541,17 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1522
1541
  ...(daemon ? {} : { onExit: (fn) => { exitRelabel = fn; } }),
1523
1542
  // #4454: the daemon's shutdown stop; orchestrate throws batch.stopped, so no report is written and a resume adopts the lanes
1524
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
+ } }),
1525
1555
  conflicting: deps.conflicting ?? (async (_n, pr) => {
1526
1556
  try { return JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "mergeable"]))?.mergeable === "CONFLICTING"; } catch { return false; }
1527
1557
  }),
@@ -1530,8 +1560,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
1530
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`);
1531
1561
  },
1532
1562
  readBody: maint || registry || hybrid
1533
- ? 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; }
1534
- : 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; },
1535
1565
  onUpdate: (r) => sink.row(r),
1536
1566
  });
1537
1567
  } finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
@@ -1660,6 +1690,16 @@ export async function scheddFromCli(argv, deps = {}) {
1660
1690
  return { running: false };
1661
1691
  }
1662
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
+
1663
1703
  /**
1664
1704
  * `forgedock limits show` · `forgedock limits clear <id>|--all` (#4190)
1665
1705
  * Usage-limit holds are kept per account (backend identity) in `~/.forge/limits.json` (or `FORGEDOCK_LIMITS_FILE`) and
@@ -1802,6 +1842,8 @@ export async function schedFromCli(argv, deps = {}) {
1802
1842
  * `orchestration.errors|window_min` (or flat `breaker_errors|breaker_window_min`).
1803
1843
  * `orchestration.recovery.max_attempts` (#4267; arrives as `max_attempts`, or flat `recovery_max_attempts`): recovery lanes per
1804
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).
1805
1847
  */
1806
1848
  export function readOrchestration(cwd = process.cwd()) {
1807
1849
  let o = {};
@@ -1811,7 +1853,10 @@ export function readOrchestration(cwd = process.cwd()) {
1811
1853
  const gp = int(o.global_parallel, null);
1812
1854
  const recoveryMax = int(o.recovery_max_attempts ?? o.max_attempts, 1);
1813
1855
  return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), globalParallel: gp >= 1 ? gp : null,
1814
- 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) } };
1815
1860
  }
1816
1861
 
1817
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
+ }
@@ -0,0 +1,151 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // #4525: parked-PR base sync. Unit tests for the union resolver, and a real temp git repo (bare origin + clone).
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { execFileSync } from "node:child_process";
7
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { tmpDir } from "../tests/_tmp.mjs";
10
+ import { readOrchestration } from "../engine-cli.mjs";
11
+ import { orchestrate } from "./orchestrate.mjs";
12
+ import { unionResolve, matchesGlobs, isSequenceFile, syncParked } from "./basesync.mjs";
13
+
14
+ const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
15
+ const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
16
+ const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
17
+
18
+ const hunk = (ours, base, theirs) => `top\n<<<<<<< HEAD\n${ours}||||||| base\n${base}=======\n${theirs}>>>>>>> origin/main\nbottom\n`;
19
+
20
+ describe("unionResolve (#4525)", () => {
21
+ it("keeps both sides when both only added lines", () => {
22
+ assert.equal(unionResolve(hunk("a\n", "", "b\n")), "top\na\nb\nbottom\n");
23
+ });
24
+ it("refuses a hunk that modified or deleted base lines", () => {
25
+ assert.equal(unionResolve(hunk("a\n", "x\n", "b\n")), null);
26
+ assert.equal(unionResolve(hunk("", "x\n", "b\n")), null);
27
+ });
28
+ it("refuses text with no base section or no conflict", () => {
29
+ assert.equal(unionResolve("top\n<<<<<<< HEAD\na\n=======\nb\n>>>>>>> x\n"), null);
30
+ assert.equal(unionResolve("plain\n"), null);
31
+ });
32
+ it("refuses a file when any one hunk is not a pure addition", () => {
33
+ assert.equal(unionResolve(`${hunk("a\n", "", "b\n")}${hunk("c\n", "y\n", "d\n")}`), null);
34
+ });
35
+ it("matches globs and excludes sequence files", () => {
36
+ assert.ok(matchesGlobs("src/registry/index.ts", ["src/registry/*.ts"]));
37
+ assert.ok(matchesGlobs("a/b/CHANGELOG.md", ["CHANGELOG.md"]));
38
+ assert.ok(matchesGlobs("a/b/c.txt", ["a/**"]));
39
+ assert.ok(!matchesGlobs("a/b/c.txt", ["a/*.txt"]));
40
+ assert.ok(!matchesGlobs("x", []));
41
+ assert.ok(isSequenceFile("db/drizzle/meta/_journal.json"));
42
+ assert.ok(isSequenceFile("db/drizzle/0007_usage.sql"));
43
+ assert.ok(!isSequenceFile("src/registry.ts"));
44
+ });
45
+ });
46
+
47
+ /** origin (bare) + clone `d` on main with registry.txt, other.txt; branch `lane` from main. Returns helpers. */
48
+ function fixture() {
49
+ const root = tmpDir("basesync-");
50
+ sh(root, "init", "-q", "--bare", "-b", "main", "origin.git");
51
+ const d = join(root, "work");
52
+ sh(root, "clone", "-q", "origin.git", "work");
53
+ sh(d, "checkout", "-qb", "main");
54
+ put(d, { "reg/registry.txt": "a\nb\n", "other.txt": "one\ntwo\n", "db/_journal.json": "{}\n" });
55
+ commit(d, "init"); sh(d, "push", "-q", "origin", "main");
56
+ sh(d, "checkout", "-qb", "lane");
57
+ const onLane = (files) => { sh(d, "checkout", "-q", "lane"); put(d, files); commit(d, "lane"); sh(d, "push", "-q", "origin", "lane"); };
58
+ const onMain = (files) => { sh(d, "checkout", "-q", "main"); put(d, files); commit(d, "base"); sh(d, "push", "-q", "origin", "main"); };
59
+ const git = async (a, o = {}) => sh(o.cwd ?? d, ...a);
60
+ const tip = (b) => sh(root, "--git-dir", "origin.git", "rev-parse", b).trim();
61
+ const show = (b, f) => sh(root, "--git-dir", "origin.git", "show", `${b}:${f}`);
62
+ return { d, onLane, onMain, git, tip, show, tmp: root };
63
+ }
64
+
65
+ describe("syncParked (#4525)", () => {
66
+ it("pushes a clean merge of the base into the branch", async () => {
67
+ const f = fixture();
68
+ f.onLane({ "lane.txt": "x\n" });
69
+ f.onMain({ "other.txt": "one\ntwo\nthree\n" });
70
+ const r = await syncParked({ git: f.git, base: "main", branch: "lane", tmp: f.tmp });
71
+ assert.equal(r.status, "merged");
72
+ assert.equal(f.show("lane", "other.txt"), "one\ntwo\nthree\n");
73
+ assert.equal(f.show("lane", "lane.txt"), "x\n");
74
+ });
75
+ it("is up-to-date when the base is already merged, and unchanged for a handled base sha", async () => {
76
+ const f = fixture();
77
+ f.onLane({ "lane.txt": "x\n" });
78
+ const r = await syncParked({ git: f.git, base: "main", branch: "lane", tmp: f.tmp });
79
+ assert.equal(r.status, "up-to-date");
80
+ f.onMain({ "m.txt": "m\n" });
81
+ const r2 = await syncParked({ git: f.git, base: "main", branch: "lane", tmp: f.tmp, last: f.tip("main") });
82
+ assert.equal(r2.status, "unchanged");
83
+ });
84
+ it("union-resolves a two-sided list addition in a matching file and pushes", async () => {
85
+ const f = fixture();
86
+ f.onLane({ "reg/registry.txt": "a\nb\nlane\n" });
87
+ f.onMain({ "reg/registry.txt": "a\nb\nbase\n" });
88
+ const r = await syncParked({ git: f.git, base: "main", branch: "lane", unionGlobs: ["reg/*.txt"], tmp: f.tmp });
89
+ assert.equal(r.status, "merged");
90
+ assert.equal(f.show("lane", "reg/registry.txt"), "a\nb\nlane\nbase\n");
91
+ });
92
+ it("reports other conflicts and leaves the branch alone", async () => {
93
+ const f = fixture();
94
+ f.onLane({ "other.txt": "one\nLANE\n" });
95
+ f.onMain({ "other.txt": "one\nBASE\n" });
96
+ const before = f.tip("lane");
97
+ const r = await syncParked({ git: f.git, base: "main", branch: "lane", unionGlobs: ["reg/*.txt"], tmp: f.tmp });
98
+ assert.equal(r.status, "conflict");
99
+ assert.deepEqual(r.paths, ["other.txt"]);
100
+ assert.equal(f.tip("lane"), before);
101
+ });
102
+ it("does not union-resolve a file outside the globs, or a _journal.json even when matched", async () => {
103
+ const f = fixture();
104
+ f.onLane({ "reg/registry.txt": "a\nb\nlane\n", "db/_journal.json": "{\"l\":1}\n" });
105
+ f.onMain({ "reg/registry.txt": "a\nb\nbase\n", "db/_journal.json": "{\"b\":1}\n" });
106
+ const r = await syncParked({ git: f.git, base: "main", branch: "lane", unionGlobs: ["**/*.json"], tmp: f.tmp });
107
+ assert.equal(r.status, "conflict");
108
+ assert.deepEqual(r.paths.sort(), ["db/_journal.json", "reg/registry.txt"]);
109
+ assert.equal(readFileSync(join(f.d, "other.txt"), "utf8").length > 0, true);
110
+ });
111
+ it("returns an error (never throws) when git fails", async () => {
112
+ const r = await syncParked({ git: async () => { throw new Error("boom\nmore"); }, base: "main", branch: "lane" });
113
+ assert.deepEqual(r, { status: "error", detail: "boom" });
114
+ });
115
+ });
116
+
117
+ describe("orchestrate parked-PR sync seam (#4525)", () => {
118
+ it("syncs a parked needs-human PR while the batch runs; comments conflicting paths once per base sha", async () => {
119
+ const calls = [];
120
+ let release; const gate = new Promise((r) => { release = r; });
121
+ const decisions = [];
122
+ const seam = async (a) => { calls.push(a); if (calls.length >= 3) release(); return a.last ? { status: "unchanged", base: a.last } : { status: "conflict", base: "b".repeat(40), paths: ["x.ts", "y.ts"] }; };
123
+ const rows = await orchestrate({ issues: [1, 2], parallel: 2, readBody: async () => "", pollMs: 5, syncEveryMs: 10, syncParked: seam,
124
+ runOne: async (n) => { if (n === 1) return { terminalReason: "needs-human", pr: 101, detail: "stuck" }; await gate; return { terminalReason: "merged", pr: 102 }; },
125
+ decide: (d) => { decisions.push(d); } });
126
+ assert.equal(rows.find((r) => r.issue === 1).outcome, "needs-human");
127
+ assert.ok(calls.length >= 3 && calls.every((c) => c.issue === 1 && c.pr === 101));
128
+ assert.equal(calls[0].last, null);
129
+ assert.equal(calls[1].last, "b".repeat(40));
130
+ const mine = decisions.filter((d) => d.issue === 1 && /conflicts with its base/.test(d.what));
131
+ assert.equal(mine.length, 1);
132
+ assert.match(mine[0].what, /x\.ts, y\.ts/);
133
+ });
134
+ it("without the seam nothing is synced and rows are unchanged", async () => {
135
+ const decisions = [];
136
+ const rows = await orchestrate({ issues: [1, 2], parallel: 2, readBody: async () => "", syncEveryMs: 1, decide: (d) => { decisions.push(d); },
137
+ runOne: async (n) => (n === 1 ? { terminalReason: "needs-human", pr: 101, detail: "stuck" } : { terminalReason: "merged", pr: 102 }) });
138
+ assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "merged"]);
139
+ assert.equal(decisions.length, 0);
140
+ });
141
+ });
142
+
143
+ describe("readOrchestration base_sync (#4525)", () => {
144
+ const read = (yaml) => { const d = tmpDir("basesync-cfg-"); writeFileSync(join(d, "forge.yaml"), yaml); return readOrchestration(d).baseSync; };
145
+ it("defaults on with no globs; parses enabled and union_globs; false opts out", () => {
146
+ assert.deepEqual(read("project:\n name: x\n"), { enabled: true, unionGlobs: [] });
147
+ assert.deepEqual(read("orchestration:\n base_sync:\n enabled: true\n union_globs: \"reg/*.ts, CHANGELOG.md\"\n"), { enabled: true, unionGlobs: ["reg/*.ts", "CHANGELOG.md"] });
148
+ assert.equal(read("orchestration:\n base_sync:\n enabled: false\n").enabled, false);
149
+ assert.equal(read("orchestration:\n base_sync_enabled: false\n").enabled, false);
150
+ });
151
+ });