forgedock 1.35.0 → 1.37.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 (129) hide show
  1. package/README.md +78 -324
  2. package/bin/bot-token-status.mjs +26 -0
  3. package/bin/engine/events.mjs +161 -0
  4. package/bin/engine/events.test.mjs +142 -0
  5. package/bin/engine/outcome-codes.mjs +39 -1
  6. package/bin/engine/phases.mjs +65 -5
  7. package/bin/engine/telemetry-seam.mjs +5 -4
  8. package/bin/engine/telemetry-seam.test.mjs +4 -0
  9. package/bin/engine-cli.mjs +372 -46
  10. package/bin/engine.mjs +3 -1
  11. package/bin/experiment.mjs +55 -0
  12. package/bin/forgedock.mjs +108 -5
  13. package/bin/hooks/reread-nudge.mjs +15 -4
  14. package/bin/labels.json +50 -0
  15. package/bin/lean/batch.mjs +52 -6
  16. package/bin/lean/batch.test.mjs +176 -2
  17. package/bin/lean/conformance.e2e.test.mjs +1 -1
  18. package/bin/lean/conformance.mjs +136 -11
  19. package/bin/lean/conformance.test.mjs +148 -1
  20. package/bin/lean/decisions.mjs +181 -0
  21. package/bin/lean/decisions.test.mjs +71 -0
  22. package/bin/lean/effects.mjs +32 -14
  23. package/bin/lean/execution.mjs +62 -16
  24. package/bin/lean/execution.test.mjs +59 -2
  25. package/bin/lean/gatewatch.mjs +31 -0
  26. package/bin/lean/gatewatch.test.mjs +59 -0
  27. package/bin/lean/guardgate.mjs +170 -0
  28. package/bin/lean/guardgate.test.mjs +153 -0
  29. package/bin/lean/guards.mjs +243 -0
  30. package/bin/lean/guards.test.mjs +177 -0
  31. package/bin/lean/heavy.mjs +54 -8
  32. package/bin/lean/heavy.test.mjs +74 -1
  33. package/bin/lean/hostkill.mjs +1 -1
  34. package/bin/lean/i18n.mjs +65 -0
  35. package/bin/lean/i18n.test.mjs +47 -0
  36. package/bin/lean/integration.mjs +276 -0
  37. package/bin/lean/integration.test.mjs +189 -0
  38. package/bin/lean/lane.mjs +5 -2
  39. package/bin/lean/lean.mjs +169 -30
  40. package/bin/lean/lean.test.mjs +360 -24
  41. package/bin/lean/lifecycle.mjs +129 -0
  42. package/bin/lean/lifecycle.test.mjs +118 -0
  43. package/bin/lean/light.mjs +1 -1
  44. package/bin/lean/milestonesync.mjs +130 -0
  45. package/bin/lean/milestonesync.test.mjs +133 -0
  46. package/bin/lean/orchestrate.mjs +132 -20
  47. package/bin/lean/parked-sweep.mjs +45 -0
  48. package/bin/lean/parked-sweep.test.mjs +71 -0
  49. package/bin/lean/postdeploy-check.mjs +181 -0
  50. package/bin/lean/postdeploy-check.test.mjs +206 -0
  51. package/bin/lean/postdeploy-monitor.mjs +97 -0
  52. package/bin/lean/postdeploy-monitor.test.mjs +147 -0
  53. package/bin/lean/precheck.mjs +6 -4
  54. package/bin/lean/precheck.test.mjs +17 -2
  55. package/bin/lean/prefilter.mjs +106 -21
  56. package/bin/lean/prefilter.test.mjs +86 -6
  57. package/bin/lean/probe.mjs +4 -3
  58. package/bin/lean/probe.test.mjs +1 -1
  59. package/bin/lean/prompts.mjs +23 -9
  60. package/bin/lean/resume.test.mjs +1 -1
  61. package/bin/lean/retry.mjs +28 -0
  62. package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
  63. package/bin/lean/sandbox-remote.mjs +11 -4
  64. package/bin/lean/schedd.mjs +77 -13
  65. package/bin/lean/schedd.test.mjs +131 -5
  66. package/bin/lean/sequence.mjs +24 -0
  67. package/bin/lean/sequence.test.mjs +31 -1
  68. package/bin/lean/signoff.mjs +66 -0
  69. package/bin/lean/signoff.test.mjs +102 -0
  70. package/bin/lean/train.mjs +177 -0
  71. package/bin/lean/train.test.mjs +113 -0
  72. package/bin/pool/cli.mjs +2 -0
  73. package/bin/pool/health.mjs +30 -0
  74. package/bin/review-context.mjs +298 -0
  75. package/bin/runner.mjs +26 -3
  76. package/bin/stats/aggregate.mjs +1 -1
  77. package/bin/stats/compounding.mjs +157 -0
  78. package/bin/stats/compounding.test.mjs +74 -0
  79. package/bin/stats/experiment.mjs +44 -0
  80. package/bin/stats/github-source.mjs +2 -1
  81. package/bin/stats/index.mjs +62 -3
  82. package/bin/stats/release.mjs +75 -0
  83. package/bin/stats/release.test.mjs +68 -0
  84. package/commands/bootstrap.md +10 -8
  85. package/commands/deploy-info.md +3 -0
  86. package/commands/issue.md +2 -0
  87. package/commands/milestone.md +43 -54
  88. package/commands/orchestrate/phase-4-execution.md +2 -0
  89. package/commands/orchestrate.md +15 -3
  90. package/commands/pipeline-status.md +20 -0
  91. package/commands/review-pr.md +1 -0
  92. package/commands/work-on/build/implement.md +1 -1
  93. package/commands/work-on/build.md +4 -2
  94. package/commands/work-on/investigate.md +3 -1
  95. package/commands/work-on/remediate.md +1 -1
  96. package/docs/CONFIG.md +120 -2
  97. package/docs/EVENTS.md +65 -0
  98. package/docs/OUTCOME-CODES.md +9 -1
  99. package/docs/STATS.md +51 -0
  100. package/docs/TELEMETRY.md +3 -1
  101. package/docs/site/install.md +89 -0
  102. package/docs/site/start-a-new-project.md +1 -1
  103. package/docs/spec/label-state-machine.md +6 -0
  104. package/package.json +1 -1
  105. package/pool/install/install-agent.sh +4 -2
  106. package/pool/install/install-router.sh +4 -2
  107. package/pool/install/install-schedd.sh +28 -0
  108. package/pool/install/unit-hardening.sh +34 -0
  109. package/scripts/acceptance-red-green.sh +19 -2
  110. package/scripts/acceptance-red-green.test.sh +2 -0
  111. package/scripts/bench-lightlane.mjs +2 -1
  112. package/scripts/bootstrap-create.mjs +3 -2
  113. package/scripts/bootstrap-validate.mjs +31 -4
  114. package/scripts/eval-gate-scorecard.mjs +7 -0
  115. package/scripts/file-deferred-followups.sh +286 -77
  116. package/scripts/file-deferred-followups.test.sh +117 -2
  117. package/scripts/hotspots.sh +23 -2
  118. package/scripts/hotspots.test.sh +9 -0
  119. package/scripts/plan-validate-live.mjs +111 -0
  120. package/scripts/readiness.mjs +141 -0
  121. package/scripts/review-prepare.sh +17 -1
  122. package/scripts/review-prepare.test.sh +17 -0
  123. package/scripts/settle-scope.mjs +74 -0
  124. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  125. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  126. package/scripts/wait-ci-green.sh +14 -3
  127. package/scripts/wait-ci-green.test.sh +3 -1
  128. package/templates/bootstrap/needs-human-issue.md +4 -1
  129. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -43,3 +43,31 @@ export async function ghCacheRetry(call, cache) {
43
43
  return await call(cache?.env?.());
44
44
  }
45
45
  }
46
+
47
+ /** #4662: an expired or revoked token (HTTP 401). 403 is a missing scope or a rate limit, which a refresh does not fix. */
48
+ export const GH_AUTH_RE = /HTTP 401|\b401\b[^\n]{0,40}(?:unauthorized|bad credentials)|bad credentials|requires authentication|authentication required|gh auth login|try authenticating/i;
49
+ export const GH_AUTH_RETRY_SECONDS = 120;
50
+
51
+ /** Runs scripts/refresh-bot-token.sh (or $FORGE_REFRESH_TOKEN_SCRIPT); resolves on exit 0, rejects otherwise. */
52
+ export async function runRefreshScript(env = process.env) {
53
+ const { execFile } = await import("node:child_process");
54
+ const script = env.FORGE_REFRESH_TOKEN_SCRIPT || new URL("../../scripts/refresh-bot-token.sh", import.meta.url).pathname;
55
+ return new Promise((res, rej) => execFile("bash", [script], { env, timeout: 60000 }, (err, _out, stderr) => (err ? rej(Object.assign(err, { stderr })) : res())));
56
+ }
57
+
58
+ /**
59
+ * #4662: one refresh-and-retry for an auth failure. `refresh` runs once per `state` (shared across calls, so a dead token
60
+ * does not spawn a refresh per gh call); when the call still fails with an auth error the error carries
61
+ * `deferCode: "gh.auth"` so the lane ends `deferred` (requeued in `retryInSeconds`), never `engine-error`.
62
+ */
63
+ export async function refreshAuthOnce(call, { refresh, state = { refreshed: false } }) {
64
+ try { return await call(); } catch (e) {
65
+ if (!GH_AUTH_RE.test(errText(e))) throw e;
66
+ if (!state.refreshed) {
67
+ state.refreshed = true;
68
+ try { await refresh(); state.ok = true; } catch { state.ok = false; }
69
+ if (state.ok) { try { const r = await call(); state.refreshed = false; return r; } catch (e2) { if (!GH_AUTH_RE.test(errText(e2))) throw e2; e = e2; } }
70
+ }
71
+ throw Object.assign(e, { deferCode: "gh.auth", deferDetail: `gh auth failed (HTTP 401) after a token refresh: ${errText(e).trim().split("\n")[0].slice(0, 200)}`, retryInSeconds: GH_AUTH_RETRY_SECONDS });
72
+ }
73
+ }
@@ -186,9 +186,7 @@ describe("remote adapter (#4254)", () => {
186
186
  const flaky = { ...fakeTransport(), status: async () => { throw new Error("ECONNRESET"); } };
187
187
  const q = remoteProvider({ batch, repo: "o/r", transport: flaky, env: {} });
188
188
  const h = await q.start({ issue: 6, args: [] });
189
- const seen = [];
190
- for (let i = 0; i < 5; i++) seen.push(await q.status(h));
191
- assert.deepEqual(seen, ["running", "running", "running", "running", "gone"]);
189
+ await assert.rejects(() => q.status(h), /ECONNRESET/); // #4672: a transport error propagates; providerLanes retries it for the unreachable window
192
190
  });
193
191
 
194
192
  it("httpTransport: bearer token, JSON protocol, 404 = gone/destroyed, 429 retryable; the token never in errors", async () => {
@@ -303,6 +301,13 @@ describe("pool provider (#4546)", () => {
303
301
  assert.equal(Buffer.from(spec.env.CS_FORGE_YAML_B64, "base64").toString(), "verify:\n mode: ci\n");
304
302
  });
305
303
 
304
+ it("#4685 a configured token_env that resolves empty falls back to the router token", async () => {
305
+ const { cfg } = setupPool();
306
+ const batch = openBatch(tmpDir("pool-b3-"), "bt");
307
+ const c = await launchOnce(() => providerByName("pool", { batch, repo: "o/r", cwd: tmpDir("pool-cwd3-"), exec: parseExecution({ mode: "remote", provider: "pool", token_env: "NO_SUCH_TOKEN_4685" }), env: {}, poolConfigPath: cfg }));
308
+ assert.equal(c.init.headers.authorization, "Bearer pool-token-789");
309
+ });
310
+
306
311
  it("an explicit endpoint wins; no forge.yaml adds no env; a missing router config is misconfigured", async () => {
307
312
  const { dir } = setupPool();
308
313
  const batch = openBatch(tmpDir("pool-b2-"), "bt");
@@ -328,3 +333,31 @@ describe("remote capacity (GET /status, #4540)", () => {
328
333
  assert.equal(await p({ capacity: async () => { throw new Error("x"); } }).capacity(), null);
329
334
  });
330
335
  });
336
+
337
+ describe("remote stop at the checkpoint (#4612)", () => {
338
+ it("httpTransport.stop: POST .../stop, 2xx true, 404/405 false, other statuses throw", async () => {
339
+ const calls = [];
340
+ const mk = (status) => httpTransport({ endpoint: "https://pool.example/v1", fetchFn: async (url, init) => { calls.push([init.method, url]); return { status, ok: status < 400, text: async () => "", headers: { get: () => null } }; } });
341
+ assert.equal(await mk(202).stop("a b"), true);
342
+ assert.deepEqual(calls[0], ["POST", "https://pool.example/v1/sandboxes/a%20b/stop"]);
343
+ assert.equal(await mk(404).stop("x"), false);
344
+ assert.equal(await mk(405).stop("x"), false);
345
+ await assert.rejects(mk(500).stop("x"), /stop: HTTP 500/);
346
+ });
347
+
348
+ it("provider.stop and providerLanes.stopLane signal the transport, never destroy; no transport stop = false", async () => {
349
+ const root = tmpDir("remote-stop-");
350
+ const batch = openBatch(root, "bt");
351
+ const { provider, transport } = remoteFixture(batch);
352
+ const stopped = [];
353
+ const lanes = providerLanes({ provider, laneArgs: () => [], ledgerFor: () => null });
354
+ assert.equal(await lanes.stopLane(1, { handle: { id: "sbx" } }), false, "the transport has no stop");
355
+ transport.stop = async (id) => { stopped.push(id); return true; };
356
+ assert.equal(await lanes.stopLane(1, { handle: { id: "sbx" } }), true);
357
+ assert.equal(await lanes.stopLane(1, {}), false, "no handle");
358
+ transport.stop = async () => { throw new Error("boom"); };
359
+ assert.equal(await provider.stop({ id: "sbx" }), false);
360
+ assert.deepEqual(stopped, ["sbx"]);
361
+ assert.deepEqual(transport.destroyed, []);
362
+ });
363
+ });
@@ -23,7 +23,6 @@ import { scopedEnv } from "./execution.mjs";
23
23
  import { resolveCredential } from "./credentials.mjs";
24
24
 
25
25
  const REDACTED = "[redacted]";
26
- const STATUS_ERRORS = 5;
27
26
  /** Replace every value of `secrets` (4+ chars, longest first) in `text`. */
28
27
  export function redact(text, secrets = []) {
29
28
  let s = String(text ?? "");
@@ -44,6 +43,7 @@ export function isActionsEndpoint(endpoint) {
44
43
  * GET /sandboxes/:id/logs?cursor=N -> {text, cursor}, GET /sandboxes/:id/result -> lane result (404 = none yet),
45
44
  * GET /sandboxes/:id/usage -> {minutes, agent?} (agent: the pool agent that ran it, #4549), DELETE /sandboxes/:id (404 = gone). `Authorization: Bearer <token>`.
46
45
  * 429/503 on launch is a retryable capacity error (`Retry-After` seconds). Error text never carries the token.
46
+ * Optional `POST /sandboxes/:id/stop` (#4612) asks the pool to SIGTERM the lane so its teardown checkpoints and it reports `deferred run.interrupted`; 2xx = sent, 404/405 = unsupported or gone (never a DELETE fallback).
47
47
  * Optional `GET /status -> {free: N}` is the pool's free slots (#4540); anything else (404, error, malformed) is unknown (null).
48
48
  */
49
49
  export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fetch }) {
@@ -57,6 +57,8 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
57
57
  };
58
58
  const fail = (what, r) => {
59
59
  const e = new Error(`sandbox ${what}: HTTP ${r.status}`);
60
+ e.status = r.status;
61
+ if ([502, 503, 504].includes(r.status)) e.transient = true; // #4672: a router restart or gateway blip, retried by the lane poll
60
62
  if (r.status === 429 || r.status === 503) { e.retryable = true; const n = Number(r.retryAfter); if (Number.isFinite(n) && n > 0) e.retryInSeconds = n; }
61
63
  return e;
62
64
  };
@@ -73,6 +75,7 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
73
75
  async logs(id, cursor = 0) { const r = await call("GET", `${p(id)}/logs?cursor=${encodeURIComponent(cursor)}`); return r.ok ? { text: String(r.data?.text ?? ""), cursor: r.data?.cursor ?? cursor } : { text: "", cursor }; },
74
76
  async result(id) { const r = await call("GET", `${p(id)}/result`); return r.ok && r.data && typeof r.data === "object" ? r.data : null; },
75
77
  async usage(id) { const r = await call("GET", `${p(id)}/usage`); return r.ok && Number.isFinite(Number(r.data?.minutes)) ? { minutes: Number(r.data.minutes), ...(typeof r.data.agent === "string" && r.data.agent ? { agent: r.data.agent } : {}) } : null; }, // agent: #4549, optional
78
+ async stop(id) { const r = await call("POST", `${p(id)}/stop`); if (r.ok) return true; if (r.status === 404 || r.status === 405) return false; throw fail("stop", r); },
76
79
  async destroy(id) { const r = await call("DELETE", p(id)); if (r.ok || r.status === 404) return true; throw fail("destroy", r); },
77
80
  async capacity() {
78
81
  // `free` (#4540) and agent liveness `online`/`total` (#4547) are each optional; none valid is unknown.
@@ -111,7 +114,6 @@ export function gitIdentityEnv(identity, laneEnv = {}) {
111
114
  export function remoteProvider({ batch, repo, settings = {}, name = "remote", transport, env = process.env, credentials = (name) => resolveCredential(name, { env }), now = () => Date.now(), gitIdentity = hostGitIdentity }) {
112
115
  if (!transport) throw new Error("remote provider needs a transport");
113
116
  const idFor = (issue) => `fdrsbx-${safeSegment(batch.id)}-${issue}`;
114
- const errs = new Map(); // sandbox id -> consecutive status errors
115
117
  const known = new Set(); // secret values seen this process, for redaction
116
118
  const remember = (v) => { if (typeof v === "string" && v.length >= 4) known.add(v); };
117
119
  for (const k of settings.secrets ?? []) remember(credentials(k));
@@ -154,8 +156,8 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
154
156
  },
155
157
  async status(h) {
156
158
  let s;
157
- // A transient transport error is not an exit; STATUS_ERRORS in a row is (the lane then ends without a result).
158
- try { s = await transport.status(h.id); errs.delete(h.id); } catch { const k = (errs.get(h.id) ?? 0) + 1; errs.set(h.id, k); return k >= STATUS_ERRORS ? "gone" : "running"; }
159
+ // #4672: a transport error is not an exit; it propagates and providerLanes retries it for execution.unreachable_window_min.
160
+ s = await transport.status(h.id);
159
161
  await pullLogs(h);
160
162
  return s === "running" ? "running" : s === "gone" ? "gone" : "exited";
161
163
  },
@@ -167,6 +169,11 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
167
169
  async capacity() {
168
170
  try { return (await transport.capacity?.()) ?? null; } catch { return null; }
169
171
  },
172
+ /** #4612: a graceful stop at the lane's checkpoint; false when unsupported or failed. Never destroys the sandbox. */
173
+ async stop(h) {
174
+ if (typeof transport.stop !== "function") return false;
175
+ try { return (await transport.stop(h.id)) === true; } catch { return false; }
176
+ },
170
177
  async teardown(h) {
171
178
  await minutesOf(h);
172
179
  try { return (await transport.destroy(h.id)) !== false; } catch { return false; }
@@ -11,9 +11,10 @@
11
11
  * {op:"attach", id, resume?} → (`resume` also clears a pending cancel, #4592) log.jsonl rows replayed, then live rows/lines and done; a finished batch replays report.json
12
12
  * ({type:"done", replay:true}) unless it has unrun added issues; a batch nobody hosts is resumed here
13
13
  * {op:"set-parallel", id, k} → {type:"ok", parallel}
14
+ * {op:"requeue", id, issues, reason, by} → {type:"ok", requeued, refused, hosted, reopened} (#4550): see batch.requeue; a finished batch is hosted again
14
15
  * {op:"add", id, issues} → {type:"ok", added, skipped, hosted} (#4408): merged into the batch's `add` file, which a
15
16
  * hosted runner reads on its next pass and an unhosted batch on its next resume
16
- * {op:"cancel", id} → {type:"ok", cancelled:true, hosted} (#4376): writes the batch's `cancel` file; that batch
17
+ * {op:"cancel", id, stopRemote?} → {type:"ok", cancelled:true, hosted} (#4376): writes the batch's `cancel` file; that batch
17
18
  * admits nothing more, its running local lanes get SIGTERM (their teardown saves the work)
18
19
  * and the rest end held; no other batch and not the daemon. A finished, unhosted batch:
19
20
  * {type:"ok", cancelled:false, finished:true}, nothing written
@@ -34,6 +35,7 @@
34
35
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
35
36
  import { createConnection, createServer } from "node:net";
36
37
  import { spawn } from "node:child_process";
38
+ import { tmpdir } from "node:os";
37
39
  import { join } from "node:path";
38
40
  import { schedDir, pidAlive } from "./sched.mjs";
39
41
  import { batchRoot, listBatches, openBatch } from "./batch.mjs";
@@ -41,22 +43,63 @@ import { batchRoot, listBatches, openBatch } from "./batch.mjs";
41
43
  export const IDLE_MS = 10 * 60 * 1000;
42
44
  export const STOP_MS = 10 * 1000;
43
45
  export const LOCK_CHECK_MS = 5 * 1000;
46
+ export const DUE_MS = 15 * 60 * 1000; // #4627: how often the due callback (windowed post-deploy monitoring) runs while the daemon is up
44
47
 
45
48
  export function scheddPaths(dir = schedDir()) {
46
49
  return { dir, sock: join(dir, "schedd.sock"), lock: join(dir, "schedd.lock"), log: join(dir, "schedd.log") };
47
50
  }
48
51
 
49
- // Env keys a batch keeps for its lanes (account and backend selectors, tool paths); persisted in plan.json.
50
- const ENV_KEEP = /^(PATH|HOME|USER|SHELL|LANG|LC_ALL|TMPDIR|FORGEDOCK_.*|CLAUDE_.*|ANTHROPIC_.*|GH_.*|GITHUB_.*)$/;
52
+ // Env keys a batch keeps for its lanes (account selectors, tool paths); persisted in plan.json (#4691). Session-scoped
53
+ // `CLAUDE_CODE_*` / `CLAUDE_PID` and `ANTHROPIC_*` are not kept, except the non-secret provider/endpoint/model selectors
54
+ // (`CLAUDE_CODE_USE_*`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL`, ...; #4734); more names come from `orchestration.env_passthrough`.
55
+ const ENV_KEEP = /^(PATH|HOME|USER|SHELL|LANG|LC_.*|TMPDIR|CLAUDE_CONFIG_DIR|CLAUDE_CODE_USE_.*|ANTHROPIC_(BASE_URL|MODEL|SMALL_FAST_MODEL|DEFAULT_.*_MODEL)|FORGEDOCK_.*|GH_.*|GITHUB_.*)$/;
56
+ // What the 1.36.0 pin carried (plus FORGEDOCK_*): a key of these families not pinned now is named at submit (#4734).
57
+ const LEGACY_PIN = /^(CLAUDE_.*|ANTHROPIC_.*|GH_.*|GITHUB_.*|FORGEDOCK_.*)$/;
58
+ const SESSION_SCOPED = /^(CLAUDE_PID|CLAUDE_CODE_(SESSION|MESSAGING|ENTRYPOINT|EXECPATH|SSE).*)$/;
59
+ // A secret never rides the allowlist: it resolves at lane start (env, keychain) unless the operator names it as a passthrough.
60
+ export const SECRET_ENV = /(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|API_?KEY|PRIVATE_?KEY)/i;
61
+ const SESSION_DIR = /(^|\/)(arg0|claude[^/]*)(\/|$)/;
51
62
 
52
- /** The allowlisted part of `env`. */
53
- export function batchEnv(env = process.env) {
54
- return Object.fromEntries(Object.entries(env).filter(([k, v]) => ENV_KEEP.test(k) && typeof v === "string"));
63
+ /** A forge.yaml glob (`*` within a segment, `**` across segments, `?`) as an anchored RegExp. */
64
+ export function globToRegExp(glob) {
65
+ const re = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*").replace(/\?/g, "[^/]");
66
+ return new RegExp(`^${re}$`);
55
67
  }
56
68
 
57
- /** `base` without its allowlisted keys, plus `kept`: an account selector the client did not set never leaks in from the daemon. */
69
+ /** `path` without non-existent dirs, per-session temp dirs (an `arg0`/`claude*` dir under a temp root) and `exclude` glob matches. */
70
+ export function sanitizePath(path, { exclude = [], exists = existsSync, tmpRoots = ["/tmp", "/var/tmp", tmpdir(), process.env.TMPDIR] } = {}) {
71
+ const globs = exclude.map(globToRegExp);
72
+ const roots = tmpRoots.filter(Boolean).map((r) => r.replace(/\/+$/, ""));
73
+ const keep = (d) => d && exists(d) && !globs.some((g) => g.test(d.replace(/\/+$/, "")))
74
+ && !(roots.some((r) => d === r || d.startsWith(`${r}/`)) && SESSION_DIR.test(d));
75
+ const seen = new Set();
76
+ return String(path).split(":").filter((d) => keep(d) && !seen.has(d) && seen.add(d)).join(":");
77
+ }
78
+
79
+ /**
80
+ * The allowlisted part of `env`, with PATH sanitised. `passthrough` names extra keys to keep (`orchestration.env_passthrough`,
81
+ * the configured `execution.token_env`); `pathExclude` is `orchestration.path_exclude`. Secret-looking names are dropped unless named.
82
+ */
83
+ export function batchEnv(env = process.env, { passthrough = [], pathExclude = [], exists = existsSync, tmpRoots } = {}) {
84
+ const extra = new Set(passthrough);
85
+ const out = Object.fromEntries(Object.entries(env).filter(([k, v]) => typeof v === "string" && (extra.has(k) || (ENV_KEEP.test(k) && !SECRET_ENV.test(k)))));
86
+ if (typeof out.PATH === "string") out.PATH = sanitizePath(out.PATH, { exclude: pathExclude, exists, ...(tmpRoots ? { tmpRoots } : {}) });
87
+ return out;
88
+ }
89
+
90
+ /** Sorted names (never values) of `env` keys the pre-allowlist pin carried that `kept` does not: the lane gets the daemon's value or resolves it at lane start (#4734). */
91
+ export function droppedEnv(env = process.env, kept = {}) {
92
+ return Object.keys(env).filter((k) => typeof env[k] === "string" && LEGACY_PIN.test(k) && !SESSION_SCOPED.test(k) && !(k in kept)).sort();
93
+ }
94
+
95
+ /** `env` with secret-looking values redacted (`--show-env`). */
96
+ export function redactEnv(env) {
97
+ return Object.fromEntries(Object.entries(env ?? {}).map(([k, v]) => [k, SECRET_ENV.test(k) ? "<redacted>" : v]));
98
+ }
99
+
100
+ /** `base` without its allowlisted non-secret keys (secret-named ones, e.g. GH_TOKEN, stay from the daemon: never pinned), plus `kept`: an account selector the client did not set never leaks in from the daemon. */
58
101
  export function mergeEnv(base, kept) {
59
- const out = Object.fromEntries(Object.entries(base).filter(([k]) => !ENV_KEEP.test(k)));
102
+ const out = Object.fromEntries(Object.entries(base).filter(([k]) => k in kept || !ENV_KEEP.test(k) || SECRET_ENV.test(k)));
60
103
  return { ...out, ...kept };
61
104
  }
62
105
 
@@ -83,7 +126,7 @@ function takeLock(lock, pid, alive) {
83
126
  */
84
127
  export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(process.env.FORGEDOCK_SCHEDD_IDLE_MS) || IDLE_MS,
85
128
  root = batchRoot(), log = (m) => console.log(m), pid = process.pid, alive = pidAlive, resumeOnStart = true, stopMs = STOP_MS,
86
- lockCheckMs = LOCK_CHECK_MS } = {}) {
129
+ lockCheckMs = LOCK_CHECK_MS, due = null, dueMs = Number(process.env.FORGEDOCK_SCHEDD_DUE_MS) || DUE_MS } = {}) {
87
130
  const p = scheddPaths(dir);
88
131
  mkdirSync(dir, { recursive: true });
89
132
  const lock = takeLock(p.lock, pid, alive);
@@ -139,8 +182,20 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
139
182
  if (req.op === "add") {
140
183
  const b = openBatch(root, req.id);
141
184
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
142
- const { added, skipped } = b.addIssues(req.issues ?? []);
143
- return send({ type: "ok", added, skipped, hosted: hosted.has(req.id) }, true);
185
+ const { added, skipped, requeued = [] } = b.addIssues(req.issues ?? []);
186
+ // #4687: a re-queued skipped issue reopens a finished, unhosted batch like --requeue does
187
+ const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
188
+ if (reopen) await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} });
189
+ return send({ type: "ok", added, skipped, requeued, hosted: hosted.has(req.id), reopened: reopen }, true);
190
+ }
191
+ if (req.op === "requeue") {
192
+ // #4550: the runner applies the entries on its next pass; a finished, unhosted batch is reopened (hosted as a resume).
193
+ const b = openBatch(root, req.id);
194
+ if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
195
+ const { requeued, refused } = b.requeue(req.issues ?? [], { reason: req.reason, by: req.by });
196
+ const reopen = requeued.length > 0 && !hosted.has(req.id) && !starting.has(req.id);
197
+ if (reopen) await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} });
198
+ return send({ type: "ok", requeued, refused, hosted: hosted.has(req.id), reopened: reopen }, true);
144
199
  }
145
200
  if (req.op === "hold" || req.op === "release") {
146
201
  const b = openBatch(root, req.id);
@@ -152,7 +207,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
152
207
  const b = openBatch(root, req.id);
153
208
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
154
209
  if (!hosted.has(req.id) && b.readReport()) return send({ type: "ok", cancelled: false, finished: true }, true);
155
- b.requestCancel({ reason: "operator" });
210
+ b.requestCancel({ reason: "operator", stopRemote: req.stopRemote === true });
156
211
  return send({ type: "ok", cancelled: true, hosted: hosted.has(req.id) }, true);
157
212
  }
158
213
  const sub = (m) => send(m, m.type === "done" || m.type === "error");
@@ -173,7 +228,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
173
228
  const b = openBatch(root, req.id);
174
229
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
175
230
  const rep = b.readReport();
176
- if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length) {
231
+ if (rep && !(rep.rows ?? []).some((r) => r.outcome === "held") && !b.unrunAdds().length && !b.unrunRequeues().length) {
177
232
  return send({ type: "done", replay: true, result: { rows: rep.rows ?? [], crossLane: rep.crossLane ?? [], batch: b.id, ok: !!rep.ok }, report: rep.report ?? null }, true);
178
233
  }
179
234
  const ent = await host({ argv: ["--resume", req.id], cwd: process.cwd(), env: {} }, sub);
@@ -212,6 +267,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
212
267
  if (closing) return closing;
213
268
  clearInterval(idleTimer);
214
269
  clearInterval(lockTimer);
270
+ if (dueTimer) clearInterval(dueTimer);
215
271
  for (const c of conns) c.destroy();
216
272
  const stopping = [...runs.keys()];
217
273
  for (const ac of runs.values()) ac.abort();
@@ -241,6 +297,14 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
241
297
  else if (!existsSync(p.sock)) { log(`schedd: lost socket ${p.sock}, exiting`); close(); }
242
298
  }, lockCheckMs);
243
299
  lockTimer.unref?.();
300
+ // #4627: windowed post-deploy checks. One run at a time; a failure is logged and never stops the daemon.
301
+ let dueRunning = false;
302
+ const dueTimer = due ? setInterval(async () => {
303
+ if (closing || dueRunning) return;
304
+ dueRunning = true;
305
+ try { await due(); } catch (e) { log(`schedd: due run failed: ${e?.message ?? e}`); } finally { dueRunning = false; }
306
+ }, dueMs) : null;
307
+ dueTimer?.unref?.();
244
308
 
245
309
  // Daemon restart: resume every daemon-hosted batch that never wrote its report; live lanes are re-adopted.
246
310
  if (resumeOnStart) {
@@ -3,12 +3,12 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { spawn } from "node:child_process";
6
- import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { homedir, tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { tmpDir } from "../tests/_tmp.mjs";
10
10
  import { openBatch, isLaneAlive, listBatches } from "./batch.mjs";
11
- import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv, exitOnSignals } from "./schedd.mjs";
11
+ import { startDaemon, scheddPaths, connectDaemon, request, mergeEnv, batchEnv, droppedEnv, sanitizePath, redactEnv, exitOnSignals } from "./schedd.mjs";
12
12
  import { schedDir } from "./sched.mjs";
13
13
  import { formatTable, orchestrate } from "./orchestrate.mjs";
14
14
  import { orchestrateFromCli } from "../engine-cli.mjs";
@@ -208,6 +208,32 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
208
208
  } finally { await h.cleanup(); }
209
209
  });
210
210
 
211
+ it("#4550: --requeue on a finished batch with a report reopens it through the daemon and runs the issue again; --add still skips it", async () => {
212
+ const h = host();
213
+ try {
214
+ await h.daemon();
215
+ const client = { ...h.client, io: { gh: async (a) => (a[0] === "issue" ? JSON.stringify({ state: "OPEN", labels: [] }) : "o/r\n") } };
216
+ await withRoot(h, () => capture(async () => {
217
+ writeFileSync(h.release, "");
218
+ const first = await orchestrateFromCli(["1", "--parallel", "2", "--repo", "o/r", "--no-prefilter"], h.client);
219
+ assert.deepEqual(first.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"]]);
220
+ const [id] = listBatches(h.root);
221
+ const b = openBatch(h.root, id);
222
+ // The lane ended needs-human and the operator decided it: the finished batch holds that terminal row.
223
+ b.store.save({ ...b.store.load(), results: { 1: { issue: 1, outcome: "needs-human", pr: null, cost_usd: 0, minutes: 0 } } });
224
+ const rep = b.readReport();
225
+ b.writeReport({ ...rep, rows: [{ issue: 1, outcome: "needs-human" }] });
226
+ assert.deepEqual((await orchestrateFromCli(["--add", id, "1"], client)).skipped.map((x) => x.reason), ["already in batch"]);
227
+ const res = await orchestrateFromCli(["--requeue", id, "1", "--reason", "decided"], client);
228
+ assert.deepEqual(res.requeued, [1]);
229
+ await until(() => h.launched().length === 2);
230
+ await until(() => b.store.load()?.results?.[1]?.outcome === "merged" && b.readReport()?.rows?.[0]?.outcome === "merged");
231
+ assert.deepEqual(listBatches(h.root), [id], "no second batch");
232
+ assert.equal(b.readReport().rows[0].requeued.reason, "decided");
233
+ }));
234
+ } finally { await h.cleanup(); }
235
+ });
236
+
211
237
  it("#4376: --cancel stops one batch (its lane torn down, the rest held, report written); the other batch and the daemon run on", async () => {
212
238
  const h = host();
213
239
  try {
@@ -481,9 +507,109 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
481
507
  });
482
508
 
483
509
  it("lane env: allowlisted keys come from the client only, the rest from the daemon", () => {
484
- const kept = batchEnv({ PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", SECRET_X: "no", FORGEDOCK_SCHED_DIR: "/s" });
485
- assert.deepEqual(kept, { PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s" });
510
+ const kept = batchEnv({ PATH: "/", CLAUDE_CONFIG_DIR: "/acct-b", SECRET_X: "no", FORGEDOCK_SCHED_DIR: "/s", LC_CTYPE: "C" });
511
+ assert.deepEqual(kept, { PATH: "/", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s", LC_CTYPE: "C" });
486
512
  const env = mergeEnv({ PATH: "/d", ANTHROPIC_API_KEY: "daemon-key", NODE_OPTIONS: "x" }, kept);
487
- assert.deepEqual(env, { NODE_OPTIONS: "x", PATH: "/c", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s" });
513
+ assert.equal(mergeEnv({ GH_TOKEN: "d" }, kept).GH_TOKEN, "d", "a secret-named daemon key (never pinned) stays for the lane");
514
+ assert.deepEqual(env, { NODE_OPTIONS: "x", ANTHROPIC_API_KEY: "daemon-key", PATH: "/", CLAUDE_CONFIG_DIR: "/acct-b", FORGEDOCK_SCHED_DIR: "/s", LC_CTYPE: "C" });
515
+ });
516
+
517
+ it("#4734: the pin keeps non-secret provider selectors, drops secrets and session ids, and droppedEnv names what was left out", () => {
518
+ const client = { PATH: "/", GH_TOKEN: "fresh", CLAUDE_CODE_USE_BEDROCK: "1", ANTHROPIC_BASE_URL: "http://proxy", ANTHROPIC_MODEL: "m", ANTHROPIC_API_KEY: "k",
519
+ FORGEDOCK_POOL_TOKEN: "p", CLAUDE_CONFIG_DIR: "/acct", CLAUDE_CODE_SESSION_ID: "s", CLAUDE_PID: "1" };
520
+ const kept = batchEnv(client);
521
+ assert.deepEqual(Object.keys(kept).sort(), ["ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CONFIG_DIR", "PATH"]);
522
+ assert.deepEqual(droppedEnv(client, kept), ["ANTHROPIC_API_KEY", "FORGEDOCK_POOL_TOKEN", "GH_TOKEN"]);
523
+ assert.deepEqual(droppedEnv(client, batchEnv(client, { passthrough: ["GH_TOKEN"] })), ["ANTHROPIC_API_KEY", "FORGEDOCK_POOL_TOKEN"]);
524
+ assert.deepEqual(droppedEnv({ PATH: "/", CLAUDE_CODE_SESSION_ID: "s" }, {}), [], "nothing to name: no warning");
525
+ assert.equal(mergeEnv({ ANTHROPIC_BASE_URL: "daemon", GH_TOKEN: "d" }, kept).ANTHROPIC_BASE_URL, "http://proxy");
526
+ });
527
+
528
+ it("#4691: the pinned env drops session tokens and ids, session temp PATH dirs and path_exclude dirs", () => {
529
+ const work = tmpDir("schedd-env-");
530
+ const keep = join(work, "bin"), session = join(work, "claude-1", "arg0"), wrapper = join(work, "wrap", "shims");
531
+ for (const d of [keep, session, wrapper]) mkdirSync(d, { recursive: true });
532
+ const session_env = { PATH: [keep, session, wrapper, join(work, "gone")].join(":"), HOME: "/h", CLAUDE_CODE_MESSAGING_TOKEN: "tok", CLAUDE_CODE_SESSION_ID: "s", CLAUDE_PID: "1",
533
+ CLAUDE_CODE_SESSION_ATTENDED: "1", ANTHROPIC_API_KEY: "k", FORGEDOCK_SANDBOX_TOKEN: "t", MY_FLAG: "1", MY_SECRET_PASS: "p", GH_HOST: "h" };
534
+ const pin = batchEnv(session_env, { passthrough: ["MY_FLAG", "MY_SECRET_PASS"], pathExclude: [`${work}/wrap/*`], tmpRoots: [work] });
535
+ assert.equal(pin.PATH, keep, "only the live, non-session, non-excluded dir stays");
536
+ assert.deepEqual(Object.keys(pin).sort(), ["GH_HOST", "HOME", "MY_FLAG", "MY_SECRET_PASS", "PATH"]);
537
+ assert.equal(sanitizePath(`${session}:${keep}`, { tmpRoots: [work] }), keep);
538
+ assert.equal(sanitizePath(`${session}:${keep}`, { tmpRoots: ["/nowhere"] }), `${session}:${keep}`, "an arg0 dir outside a temp root is not session-scoped");
539
+ assert.deepEqual(redactEnv({ A: "1", GH_TOKEN: "x" }), { A: "1", GH_TOKEN: "<redacted>" });
540
+ });
541
+
542
+ it("#4691: plan.json holds none of the session token, temp PATH dir or excluded wrapper dir; --show/--set/--unset-env edit it", async () => {
543
+ const h = host();
544
+ const work = tmpDir("schedd-pin-");
545
+ const keep = join(work, "bin"), session = join(work, "claude-9", "arg0"), wrapper = join(work, "wrap", "shims");
546
+ for (const d of [keep, session, wrapper]) mkdirSync(d, { recursive: true });
547
+ writeFileSync(join(work, "forge.yaml"), `orchestration:\n path_exclude: ${work}/wrap/*\n`);
548
+ const saved = { cwd: process.cwd(), path: process.env.PATH, tmp: process.env.TMPDIR };
549
+ let a;
550
+ try {
551
+ await h.daemon();
552
+ process.chdir(work);
553
+ process.env.PATH = [keep, session, wrapper].join(":");
554
+ process.env.TMPDIR = work;
555
+ process.env.CLAUDE_CODE_MESSAGING_TOKEN = "msg-token";
556
+ process.env.CLAUDE_CODE_SESSION_ID = "sess";
557
+ await withRoot(h, async () => {
558
+ a = capture(() => orchestrateFromCli(["1", "--repo", "o/r", "--no-prefilter"], { ...h.client, reconnectMs: 0 })).catch((e) => e);
559
+ await until(() => h.stubPids().length === 1 && h.lanePids().length === 1);
560
+ });
561
+ const [id] = listBatches(h.root);
562
+ const raw = readFileSync(join(h.root, id, "plan.json"), "utf8");
563
+ const env = JSON.parse(raw).env;
564
+ assert.equal(env.PATH, keep);
565
+ for (const bad of ["msg-token", "sess", "CLAUDE_CODE_", session, wrapper]) assert.equal(raw.includes(bad), false, `${bad} not pinned`);
566
+ await withRoot(h, async () => {
567
+ await capture(() => orchestrateFromCli(["--set-env", id, "MY_VAR=1"], h.client));
568
+ await capture(() => orchestrateFromCli(["--set-env", id, "GH_TOKEN=abc"], h.client));
569
+ assert.equal(openBatch(h.root, id).readPlan().env.MY_VAR, "1");
570
+ const { lines } = await capture(() => orchestrateFromCli(["--show-env", id], h.client));
571
+ assert.match(lines.join("\n"), /^GH_TOKEN=<redacted>$/m);
572
+ assert.match(lines.join("\n"), /^MY_VAR=1$/m);
573
+ assert.equal(lines.join("\n").includes("abc"), false);
574
+ await capture(() => orchestrateFromCli(["--unset-env", id, "MY_VAR"], h.client));
575
+ assert.equal("MY_VAR" in openBatch(h.root, id).readPlan().env, false);
576
+ await assert.rejects(() => orchestrateFromCli(["--set-env", id, "NOEQUALS"], h.client), /KEY=VAL/);
577
+ });
578
+ writeFileSync(h.release, "");
579
+ } finally {
580
+ process.chdir(saved.cwd); process.env.PATH = saved.path;
581
+ if (saved.tmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = saved.tmp;
582
+ delete process.env.CLAUDE_CODE_MESSAGING_TOKEN; delete process.env.CLAUDE_CODE_SESSION_ID;
583
+ await h.cleanup();
584
+ }
585
+ await a;
586
+ });
587
+ });
588
+
589
+ describe("schedd due timer (#4627)", () => {
590
+ const boot = async (opts) => startDaemon({ dir: tmpDir("schedd-due-"), root: tmpDir("schedd-due-b-"), runBatch: async () => ({}), resumeOnStart: false, log: () => {}, ...opts });
591
+
592
+ it("runs the due callback on its interval, never overlapped, and survives a throwing callback", async () => {
593
+ let calls = 0, active = 0, overlap = false;
594
+ const logs = [];
595
+ const d = await boot({ dueMs: 15, log: (m) => logs.push(m), due: async () => {
596
+ calls++; active++; if (active > 1) overlap = true;
597
+ await new Promise((r) => setTimeout(r, 40));
598
+ active--; if (calls === 1) throw new Error("boom");
599
+ } });
600
+ try {
601
+ await until(() => calls >= 3);
602
+ assert.equal(overlap, false);
603
+ assert.ok(logs.some((m) => /due run failed: boom/.test(m)));
604
+ assert.ok(existsSync(d.sock));
605
+ } finally { await d.close(); }
606
+ const n = calls;
607
+ await new Promise((r) => setTimeout(r, 80));
608
+ assert.equal(calls, n, "the timer is cleared on close");
609
+ });
610
+
611
+ it("without a due callback there is no timer", async () => {
612
+ const d = await boot({ dueMs: 10 });
613
+ await d.close();
488
614
  });
489
615
  });
@@ -457,3 +457,27 @@ export async function generatorProblems({ git, base, ref = "HEAD" }) {
457
457
  }
458
458
  return problems;
459
459
  }
460
+
461
+ /**
462
+ * #4661: journal `when` ordering. Newest-only migrators (drizzle `migrate()`) skip an entry stamped older than one already
463
+ * applied, so every entry the lane adds to a drizzle-style journal (`<dir>/meta/_journal.json`) must have `when` above the
464
+ * base journal's max, and `when` must increase within the lane journal. No journal or base journal: []. @returns {Promise<string[]>}
465
+ */
466
+ export async function journalOrderProblems({ git, base, ref = "HEAD" }) {
467
+ const g = out(git), problems = [];
468
+ const changed = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", `${base}...${ref}`])).filter((f) => /(?:^|\/)meta\/_journal\.json$/.test(f) && !SKIP_RE.test(f));
469
+ const entriesAt = async (r, jp) => { try { const e = JSON.parse(await g(["show", `${r}:${jp}`])).entries; return Array.isArray(e) ? e : null; } catch { return null; } };
470
+ for (const jp of changed) {
471
+ const now = await entriesAt(ref, jp);
472
+ if (!now) continue;
473
+ const was = (await entriesAt(base, jp)) ?? [], wasTags = new Set(was.map((e) => e?.tag));
474
+ const max = Math.max(-Infinity, ...was.map((e) => Number(e?.when)).filter(Number.isFinite));
475
+ for (let i = 0; i < now.length; i++) {
476
+ const e = now[i] ?? {}, w = Number(e.when);
477
+ if (wasTags.has(e.tag)) continue;
478
+ if (Number.isFinite(max) && !(w > max)) problems.push(`${jp}: ${e.tag} when ${e.when} is not above the base max when ${max} (stale stamp: a newest-only migrator skips it on a persistent database)`);
479
+ else if (i > 0 && Number.isFinite(Number(now[i - 1]?.when)) && !(w > Number(now[i - 1].when))) problems.push(`${jp}: ${e.tag} when ${e.when} is not after ${now[i - 1].tag} (${now[i - 1].when})`);
480
+ }
481
+ }
482
+ return problems;
483
+ }
@@ -8,7 +8,7 @@ import { execFileSync } from "node:child_process";
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { tmpDir } from "../tests/_tmp.mjs";
11
- import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, headerOnlyChange, renumberLane, resolveSequenceConflicts, generatorProblems } from "./sequence.mjs";
11
+ import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, headerOnlyChange, renumberLane, resolveSequenceConflicts, generatorProblems, journalOrderProblems } from "./sequence.mjs";
12
12
  import { makeSequenceLeaseIo } from "./seqlease.mjs";
13
13
  import { sequencePrompt, sequenceSyncNote, migrationGeneratorPrompt, builderImplementPrompt } from "./prompts.mjs";
14
14
 
@@ -492,3 +492,33 @@ describe("hand-named migrations (#4591)", () => {
492
492
  assert.match(builderImplementPrompt("local"), /Never name or number migration files by hand/);
493
493
  });
494
494
  });
495
+
496
+ describe("journal when ordering across parallel milestone branches (#4661)", () => {
497
+ const J = (...es) => `${JSON.stringify({ version: "7", entries: es.map(([tag, when], idx) => ({ idx, tag, when })) }, null, 2)}\n`;
498
+ function repo(laneEntries) {
499
+ const d = tmpDir("when-");
500
+ sh(d, "init", "-q", "-b", "main");
501
+ put(d, { "db/meta/_journal.json": J(["0_a", 5000], ["1_b", 9000]) });
502
+ commit(d, "init");
503
+ sh(d, "checkout", "-qb", "lane");
504
+ if (laneEntries) { put(d, { "db/meta/_journal.json": J(["0_a", 5000], ["1_b", 9000], ...laneEntries) }); commit(d, "lane"); }
505
+ return d;
506
+ }
507
+ it("flags an added entry stamped at or below the base max when", async () => {
508
+ const p = await journalOrderProblems({ git: gitIn(repo([["2_c", 7000]])), base: "main" });
509
+ assert.equal(p.length, 1);
510
+ assert.match(p[0], /db\/meta\/_journal\.json: 2_c when 7000 is not above the base max when 9000/);
511
+ });
512
+ it("passes entries above the base max, an unchanged journal, and no journal", async () => {
513
+ assert.deepEqual(await journalOrderProblems({ git: gitIn(repo([["2_c", 9001]])), base: "main" }), []);
514
+ assert.deepEqual(await journalOrderProblems({ git: gitIn(repo(null)), base: "main" }), []);
515
+ const d = tmpDir("when-none-");
516
+ sh(d, "init", "-q", "-b", "main"); put(d, { "x.txt": "x" }); commit(d, "i"); sh(d, "checkout", "-qb", "lane");
517
+ assert.deepEqual(await journalOrderProblems({ git: gitIn(d), base: "main" }), []);
518
+ });
519
+ it("flags non-increasing when inside the lane's added entries; the prompt asks for a re-stamp", async () => {
520
+ const p = await journalOrderProblems({ git: gitIn(repo([["2_c", 9100], ["3_d", 9050]])), base: "main" });
521
+ assert.match(p[0], /3_d when 9050 is not after 2_c/);
522
+ assert.match(migrationGeneratorPrompt(p), /re-stamp/);
523
+ });
524
+ });
@@ -0,0 +1,66 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * DCO sign-off for a lane's own unpushed commits (#4651). A resumed lane keeps the commits of an earlier run; one from a
5
+ * pool agent that predates the identity fix is authored `*@localhost` with no `Signed-off-by`, and DCO fails on the release
6
+ * PR. Before a push, the commits the push would add (reachable from HEAD, not from origin/<branch> nor origin/<lane>) are
7
+ * checked, and the offenders are rewritten with the lane identity and --signoff. Commits already on either remote ref are
8
+ * never in the range, so they are never rewritten. Deterministic, 0 LLM calls.
9
+ */
10
+ const SEP = "\x1f", END = "\x1e";
11
+ const PLACEHOLDER_RE = /@localhost$/i;
12
+
13
+ /** True when the commit must be rewritten: placeholder author or committer, or no sign-off naming its author. */
14
+ export function needsSignoff({ authorEmail, committerEmail, signedOff }) {
15
+ if (PLACEHOLDER_RE.test(authorEmail ?? "") || PLACEHOLDER_RE.test(committerEmail ?? "")) return true;
16
+ const a = String(authorEmail ?? "").toLowerCase();
17
+ return !signedOff.some((s) => s.toLowerCase().includes(`<${a}>`));
18
+ }
19
+
20
+ /** Parse `git log` output of FORMAT into the commits that need a rewrite, oldest first (the log is newest first). */
21
+ export function offendersOf(out) {
22
+ return String(out ?? "").split(END).map((r) => r.replace(/^\n/, "")).filter(Boolean).map((r) => {
23
+ const [sha, authorEmail, committerEmail, trailers = "", parents = ""] = r.split(SEP);
24
+ return { sha, authorEmail, committerEmail, signedOff: trailers.split("\n").map((l) => l.trim()).filter(Boolean), merge: parents.trim().split(/\s+/).filter(Boolean).length > 1 };
25
+ }).filter((c) => !c.merge && needsSignoff(c)).reverse();
26
+ }
27
+
28
+ const FORMAT = `%H${SEP}%ae${SEP}%ce${SEP}%(trailers:key=Signed-off-by,valueonly,separator=%x0a)${SEP}%P${END}`;
29
+
30
+ // filter-branch rewrites only the commits in the range (the excluded remote refs stay as they are), and only the listed ones change.
31
+ const IS_BAD = 'case " $FD_BAD " in *" $GIT_COMMIT "*)';
32
+ const ENV_FILTER = `${IS_BAD} export GIT_AUTHOR_NAME="$FD_NAME" GIT_AUTHOR_EMAIL="$FD_EMAIL" GIT_COMMITTER_NAME="$FD_NAME" GIT_COMMITTER_EMAIL="$FD_EMAIL";; esac`;
33
+ const MSG_FILTER = `${IS_BAD} git interpret-trailers --trailer "Signed-off-by: $FD_NAME <$FD_EMAIL>";; *) cat;; esac`;
34
+
35
+ /**
36
+ * @param {object} o
37
+ * @param {(args: string[], opts?: {env?: object}) => Promise<string>} o.git runs git in the lane worktree
38
+ * @param {string} o.lane the lane (base) branch
39
+ * @param {string} o.branch the lane's own branch
40
+ * @param {object} [o.env] where GIT_AUTHOR_NAME/EMAIL are read from (default process.env)
41
+ * @returns {Promise<{rewritten: number}>}
42
+ * @throws Error with `.laneCode` unset: callers type it. `.noIdentity` when a rewrite is needed but no usable identity exists.
43
+ */
44
+ export async function ensureSignedOff({ git, lane, branch, env = process.env }) {
45
+ const ok = async (args) => { try { return String(await git(args)).trim(); } catch { return ""; } };
46
+ try { await git(["fetch", "origin", branch]); } catch { /* not pushed yet, or offline: the tracking refs we have decide */ }
47
+ const exclude = [];
48
+ for (const ref of [`origin/${branch}`, `origin/${lane}`]) if (await ok(["rev-parse", "--verify", "-q", `refs/remotes/${ref}`])) exclude.push(`^${ref}`);
49
+ const log = await ok(["log", `--format=${FORMAT}`, "HEAD", ...exclude]);
50
+ const bad = offendersOf(log);
51
+ if (!bad.length) return { rewritten: 0 };
52
+ // the lane identity: GIT_AUTHOR_* (what the sandbox sets and what a commit would use), else the worktree config
53
+ let name = env.GIT_AUTHOR_NAME || await ok(["config", "user.name"]), email = env.GIT_AUTHOR_EMAIL || await ok(["config", "user.email"]);
54
+ if (!name || !email) { // the runner's own git env (what a commit here would use), e.g. GIT_AUTHOR_* set only on the git runner
55
+ const m = /^(.*?)\s*<([^>]*)>/.exec(await ok(["var", "GIT_AUTHOR_IDENT"]));
56
+ if (m) { name ||= m[1]; email ||= m[2]; }
57
+ }
58
+ if (!name || !email || PLACEHOLDER_RE.test(email)) {
59
+ throw Object.assign(new Error(`${bad.length} unpushed commit(s) fail DCO (placeholder author or no sign-off: ${bad.slice(0, 5).map((c) => c.sha.slice(0, 8)).join(", ")}) and the worktree has no usable git identity to rewrite them`), { noIdentity: true });
60
+ }
61
+ const fenv = { FD_BAD: bad.map((c) => c.sha).join(" "), FD_NAME: name, FD_EMAIL: email, FILTER_BRANCH_SQUELCH_WARNING: "1" };
62
+ try { await git(["filter-branch", "-f", "--env-filter", ENV_FILTER, "--msg-filter", MSG_FILTER, "--", "HEAD", ...exclude], { env: fenv }); }
63
+ catch (e) { throw Object.assign(new Error(`could not rewrite ${bad.length} unpushed commit(s) for DCO: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`), { noIdentity: false }); }
64
+ await ok(["for-each-ref", "--format=delete %(refname)", "refs/original/"]).then(async (l) => { for (const r of l.split("\n").map((x) => x.replace(/^delete /, "")).filter(Boolean)) await ok(["update-ref", "-d", r]); });
65
+ return { rewritten: bad.length };
66
+ }