forgedock 1.36.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 (112) hide show
  1. package/bin/bot-token-status.mjs +26 -0
  2. package/bin/engine/events.mjs +10 -1
  3. package/bin/engine/events.test.mjs +15 -1
  4. package/bin/engine/outcome-codes.mjs +39 -1
  5. package/bin/engine/phases.mjs +65 -5
  6. package/bin/engine/telemetry-seam.mjs +1 -1
  7. package/bin/engine/telemetry-seam.test.mjs +4 -0
  8. package/bin/engine-cli.mjs +268 -37
  9. package/bin/engine.mjs +3 -1
  10. package/bin/forgedock.mjs +108 -5
  11. package/bin/labels.json +50 -0
  12. package/bin/lean/batch.mjs +10 -4
  13. package/bin/lean/batch.test.mjs +75 -1
  14. package/bin/lean/conformance.e2e.test.mjs +1 -1
  15. package/bin/lean/conformance.mjs +125 -11
  16. package/bin/lean/conformance.test.mjs +122 -1
  17. package/bin/lean/decisions.mjs +181 -0
  18. package/bin/lean/decisions.test.mjs +71 -0
  19. package/bin/lean/effects.mjs +29 -14
  20. package/bin/lean/execution.mjs +60 -16
  21. package/bin/lean/execution.test.mjs +59 -2
  22. package/bin/lean/gatewatch.mjs +31 -0
  23. package/bin/lean/gatewatch.test.mjs +59 -0
  24. package/bin/lean/guardgate.mjs +170 -0
  25. package/bin/lean/guardgate.test.mjs +153 -0
  26. package/bin/lean/guards.mjs +243 -0
  27. package/bin/lean/guards.test.mjs +177 -0
  28. package/bin/lean/heavy.mjs +54 -8
  29. package/bin/lean/heavy.test.mjs +74 -1
  30. package/bin/lean/hostkill.mjs +1 -1
  31. package/bin/lean/integration.mjs +276 -0
  32. package/bin/lean/integration.test.mjs +189 -0
  33. package/bin/lean/lean.mjs +134 -28
  34. package/bin/lean/lean.test.mjs +239 -22
  35. package/bin/lean/lifecycle.mjs +129 -0
  36. package/bin/lean/lifecycle.test.mjs +118 -0
  37. package/bin/lean/light.mjs +1 -1
  38. package/bin/lean/milestonesync.mjs +130 -0
  39. package/bin/lean/milestonesync.test.mjs +133 -0
  40. package/bin/lean/orchestrate.mjs +83 -15
  41. package/bin/lean/parked-sweep.mjs +45 -0
  42. package/bin/lean/parked-sweep.test.mjs +71 -0
  43. package/bin/lean/postdeploy-check.mjs +38 -6
  44. package/bin/lean/postdeploy-check.test.mjs +62 -2
  45. package/bin/lean/postdeploy-monitor.mjs +12 -2
  46. package/bin/lean/postdeploy-monitor.test.mjs +27 -1
  47. package/bin/lean/prefilter.mjs +83 -23
  48. package/bin/lean/prefilter.test.mjs +73 -1
  49. package/bin/lean/probe.mjs +2 -2
  50. package/bin/lean/probe.test.mjs +1 -1
  51. package/bin/lean/prompts.mjs +15 -9
  52. package/bin/lean/resume.test.mjs +1 -1
  53. package/bin/lean/retry.mjs +28 -0
  54. package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
  55. package/bin/lean/sandbox-remote.mjs +4 -4
  56. package/bin/lean/schedd.mjs +53 -9
  57. package/bin/lean/schedd.test.mjs +77 -5
  58. package/bin/lean/sequence.mjs +24 -0
  59. package/bin/lean/sequence.test.mjs +31 -1
  60. package/bin/lean/signoff.mjs +66 -0
  61. package/bin/lean/signoff.test.mjs +102 -0
  62. package/bin/lean/train.mjs +177 -0
  63. package/bin/lean/train.test.mjs +113 -0
  64. package/bin/pool/cli.mjs +2 -0
  65. package/bin/pool/health.mjs +30 -0
  66. package/bin/review-context.mjs +298 -0
  67. package/bin/runner.mjs +26 -3
  68. package/bin/stats/compounding.mjs +157 -0
  69. package/bin/stats/compounding.test.mjs +74 -0
  70. package/bin/stats/index.mjs +21 -2
  71. package/commands/bootstrap.md +10 -8
  72. package/commands/deploy-info.md +3 -0
  73. package/commands/issue.md +2 -0
  74. package/commands/milestone.md +43 -54
  75. package/commands/orchestrate/phase-4-execution.md +2 -0
  76. package/commands/orchestrate.md +8 -1
  77. package/commands/pipeline-status.md +20 -0
  78. package/commands/review-pr.md +1 -0
  79. package/commands/work-on/build/implement.md +1 -1
  80. package/commands/work-on/build.md +4 -2
  81. package/commands/work-on/investigate.md +3 -1
  82. package/commands/work-on/remediate.md +1 -1
  83. package/docs/CONFIG.md +71 -2
  84. package/docs/EVENTS.md +1 -4
  85. package/docs/OUTCOME-CODES.md +9 -1
  86. package/docs/STATS.md +20 -0
  87. package/docs/site/start-a-new-project.md +1 -1
  88. package/docs/spec/label-state-machine.md +6 -0
  89. package/package.json +1 -1
  90. package/pool/install/install-agent.sh +4 -2
  91. package/pool/install/install-router.sh +4 -2
  92. package/pool/install/install-schedd.sh +28 -0
  93. package/pool/install/unit-hardening.sh +34 -0
  94. package/scripts/acceptance-red-green.sh +19 -2
  95. package/scripts/acceptance-red-green.test.sh +2 -0
  96. package/scripts/bootstrap-create.mjs +3 -2
  97. package/scripts/bootstrap-validate.mjs +31 -4
  98. package/scripts/file-deferred-followups.sh +285 -77
  99. package/scripts/file-deferred-followups.test.sh +111 -4
  100. package/scripts/hotspots.sh +23 -2
  101. package/scripts/hotspots.test.sh +9 -0
  102. package/scripts/plan-validate-live.mjs +111 -0
  103. package/scripts/readiness.mjs +141 -0
  104. package/scripts/review-prepare.sh +17 -1
  105. package/scripts/review-prepare.test.sh +17 -0
  106. package/scripts/settle-scope.mjs +74 -0
  107. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  108. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  109. package/scripts/wait-ci-green.sh +14 -3
  110. package/scripts/wait-ci-green.test.sh +3 -1
  111. package/templates/bootstrap/needs-human-issue.md +4 -1
  112. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -0,0 +1,71 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { tmpDir } from "../tests/_tmp.mjs";
6
+ import { normaliseDecision, renderDecision, parseDecisionComment, foldDecisions, anchorMatches, deadAnchors, staleDecisions, historyRows, withDecisionRows, supersede, readDecisionIndex, recordDecisions, HISTORY_ROWS_MAX } from "./decisions.mjs";
7
+
8
+ const D = (o) => normaliseDecision({ choice: "use A not B", why: "B leaks", anchors: ["src/a/**"], ...o }, { issue: 10, n: 1 });
9
+
10
+ describe("decisions (#4695)", () => {
11
+ it("round-trips through the marker line and ignores prose FORGE:DECISION comments", () => {
12
+ const d = D({ symbols: ["foo"], anchors: ["src/a/x.js", "lib/-->evil"] });
13
+ const m = renderDecision(d);
14
+ const body = `<!-- ${m.marker} -->\n${m.body}`;
15
+ assert.deepEqual(parseDecisionComment(body), d);
16
+ assert.equal(parseDecisionComment("<!-- FORGE:DECISION -->\n## Decision (batch 1)\n**What**: x"), null);
17
+ assert.equal(parseDecisionComment("<!-- FORGE:DECISION {nope} -->"), null);
18
+ assert.equal(normaliseDecision({ choice: "x", anchors: [] }), null);
19
+ });
20
+ it("matches files, directory prefixes and globs", () => {
21
+ assert.ok(anchorMatches("src/a.js", "src/a.js"));
22
+ assert.ok(anchorMatches("src/a/", "src/a/b/c.js"));
23
+ assert.ok(anchorMatches("src/**", "src/a/b.js"));
24
+ assert.ok(anchorMatches("src/*.js", "src/a.js"));
25
+ assert.ok(!anchorMatches("src/*.js", "src/a/b.js"));
26
+ assert.deepEqual(deadAnchors(["src/a.js", "gone/**"], ["src/a.js"]), ["gone/**"]);
27
+ });
28
+ it("history rows: only anchored deliverables, capped at 5, deterministic", () => {
29
+ const recs = Array.from({ length: 8 }, (_, i) => normaliseDecision({ id: `d${i}`, choice: `c${i}`, anchors: ["src/**"], issue: i + 1 }));
30
+ const a = historyRows(recs, ["src/x.js"]), b = historyRows([...recs].reverse(), ["src/x.js"]);
31
+ assert.equal(a.length, HISTORY_ROWS_MAX);
32
+ assert.deepEqual(a, b);
33
+ assert.deepEqual(a.map((r) => r.id), ["H1", "H2", "H3", "H4", "H5"]);
34
+ assert.equal(a[0].decision, "d7");
35
+ assert.ok(a.every((r) => r.source === "decision" && r.what.includes("supersedes")));
36
+ assert.deepEqual(historyRows(recs, ["docs/x.md"]), []);
37
+ assert.deepEqual(historyRows([], ["src/x.js"]), []);
38
+ });
39
+ it("withDecisionRows leaves a contract with no hit untouched (same object)", () => {
40
+ const c = { preserve: [{ id: "P1", what: "w" }], deliverables: [{ id: "D1", path: "docs/x.md" }] };
41
+ assert.equal(withDecisionRows(c, [D()]), c);
42
+ const c2 = withDecisionRows({ ...c, deliverables: [{ id: "D1", path: "src/a/x.js" }] }, [D()]);
43
+ assert.deepEqual(c2.preserve.map((p) => p.id), ["P1", "H1"]);
44
+ });
45
+ it("supersession links both records and the old one stops binding", () => {
46
+ const old = D({ id: "d10-1" }), next = D({ id: "d11-1", choice: "use B" });
47
+ const r = supersede(old, next);
48
+ assert.equal(r.old.status, "superseded");
49
+ assert.equal(r.old.superseded_by, "d11-1");
50
+ assert.equal(r.next.supersedes, "d10-1");
51
+ assert.deepEqual(historyRows([old, r.old], ["src/a/x.js"]), []);
52
+ assert.equal(foldDecisions([old, r.old]).length, 1);
53
+ });
54
+ it("lists stale decisions with dead anchors, never superseded ones", () => {
55
+ const live = D({ id: "a" }), dead = D({ id: "b", anchors: ["src/a/**", "old/x.js"] }), gone = D({ id: "c", anchors: ["old/y.js"], status: "superseded", superseded_by: "z" });
56
+ const s = staleDecisions([live, dead, gone], ["src/a/q.js"]);
57
+ assert.deepEqual(s.map((x) => [x.decision.id, x.dead]), [["b", ["old/x.js"]]]);
58
+ });
59
+ it("recordDecisions posts the new and the superseded old record and updates the index", async () => {
60
+ process.env.FORGEDOCK_DECISIONS_DIR = tmpDir("dec-");
61
+ const posts = [], post = async (marker, body, on) => { posts.push({ marker, on }); };
62
+ const first = await recordDecisions([{ choice: "use A", anchors: ["src/**"], why: "w" }], { issue: 10, repo: "o/r", post });
63
+ assert.equal(first[0].id, "d10-1");
64
+ const second = await recordDecisions([{ choice: "use B", anchors: ["src/**"], why: "w2", supersedes: "d10-1" }], { issue: 11, repo: "o/r", post });
65
+ assert.equal(second[0].supersedes, "d10-1");
66
+ assert.deepEqual(posts.map((p) => p.on), [10, 11, 10]);
67
+ const idx = foldDecisions(readDecisionIndex("o/r"));
68
+ assert.equal(idx.find((d) => d.id === "d10-1").status, "superseded");
69
+ assert.equal(idx.find((d) => d.id === "d11-1").supersedes, "d10-1");
70
+ });
71
+ });
@@ -18,7 +18,7 @@ import { gatherContextFacts } from "./context.mjs";
18
18
  import { makePitfallStore } from "./pitfalls.mjs";
19
19
  import { enumeratingTests, missingEntries } from "./enumerating.mjs";
20
20
  import { resolveBaseConflicts } from "./conflictres.mjs";
21
- import { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts, generatorProblems } from "./sequence.mjs";
21
+ import { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts, generatorProblems, journalOrderProblems } from "./sequence.mjs";
22
22
  import { duplicateExports } from "./postmerge.mjs";
23
23
  import { englishCopies } from "./i18n.mjs";
24
24
  import { scopedChecks } from "./checks.mjs";
@@ -30,9 +30,9 @@ import { detectApiShape } from "./apishape.mjs";
30
30
  const pexec = promisify(execFile);
31
31
  const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
32
32
  /** Argv-only child with stdin input; resolves stdout, rejects on a non-zero exit with stderr attached. */
33
- export function runWithInput(bin, args, input, timeout = 120000) {
33
+ export function runWithInput(bin, args, input, timeout = 120000, env = undefined) {
34
34
  return new Promise((res, rej) => {
35
- const child = execFile(bin, args, { timeout, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
35
+ const child = execFile(bin, args, { timeout, maxBuffer: 10 * 1024 * 1024, ...(env ? { env } : {}) }, (err, stdout, stderr) => {
36
36
  if (err) { err.stdout = stdout; err.stderr = stderr; rej(err); } else res(String(stdout));
37
37
  });
38
38
  // A child that exits before reading stdin closes the pipe: EPIPE must reject via the exit callback, never crash the lane.
@@ -50,14 +50,25 @@ export function systemdRunAvailable() {
50
50
  }
51
51
  return systemdProbe;
52
52
  }
53
- const MEM_RE = /^\d+(\.\d+)?[KMGT]?$/i, CPU_RE = /^\d+%$/;
54
- /** Cap the whole lane process tree: spawn under `systemd-run --user --scope -p MemoryMax=.. -p CPUQuota=..`. Unset or invalid limits leave the spawn unchanged. */
55
- export function wrapLaneLimits(bin, args, limits) {
53
+ const MEM_RE = /^\d+(\.\d+)?[KMGT]?$/i, CPU_RE = /^\d+%$/, TASKS_RE = /^\d+$/;
54
+ /** #4690: default `TasksMax` of a lane scope; `tasks_max: 0|off` (or any non-count) turns the cap off. */
55
+ export const DEFAULT_LANE_TASKS_MAX = 1024;
56
+ let scopeSeq = 0;
57
+ /**
58
+ * Cap the whole lane process tree: spawn under `systemd-run --user --scope --unit=.. -p TasksMax=.. [-p MemoryMax=..] [-p CPUQuota=..]`.
59
+ * #4690: `TasksMax` is on by default (one runaway lane cannot fork-bomb the host); unset limits use the default, invalid ones are dropped.
60
+ * Returns `unit` (the scope name) when wrapped, so the runner can read the scope's limit events.
61
+ */
62
+ export function wrapLaneLimits(bin, args, limits, unit = `forgedock-lane-${process.pid}-${++scopeSeq}`) {
56
63
  const mem = limits?.memory_max && MEM_RE.test(String(limits.memory_max)) ? String(limits.memory_max) : null;
57
64
  const cpu = limits?.cpu_quota && CPU_RE.test(String(limits.cpu_quota)) ? String(limits.cpu_quota) : null;
58
- if (!mem && !cpu) return { bin, args };
59
- return { bin: "systemd-run", args: ["--user", "--scope", "-q", ...(mem ? ["-p", `MemoryMax=${mem}`] : []), ...(cpu ? ["-p", `CPUQuota=${cpu}`] : []), "--", bin, ...args] };
65
+ const raw = limits?.tasks_max === undefined || limits?.tasks_max === null || limits?.tasks_max === "" ? DEFAULT_LANE_TASKS_MAX : String(limits.tasks_max).trim();
66
+ const tasks = TASKS_RE.test(raw) && Number(raw) > 0 ? Number(raw) : null;
67
+ if (!mem && !cpu && !tasks) return { bin, args };
68
+ return { bin: "systemd-run", unit, args: ["--user", "--scope", "-q", `--unit=${unit}`, ...(tasks ? ["-p", `TasksMax=${tasks}`] : []), ...(mem ? ["-p", `MemoryMax=${mem}`] : []), ...(cpu ? ["-p", `CPUQuota=${cpu}`] : []), "--", bin, ...args] };
60
69
  }
70
+ /** #4690: the one line a run logs when lanes get no scope (systemd-run --user --scope unavailable); null when they are capped. */
71
+ export const laneCapNotice = (probe = systemdRunAvailable) => (probe() ? null : "lanes are uncapped: `systemd-run --user --scope` is unavailable here, so no TasksMax/MemoryMax/CPUQuota applies to lane process trees");
61
72
 
62
73
  /** #4211: a judgment call over data in the prompt only (the refactor:split grouping call): no tool can read a file. */
63
74
  export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
@@ -81,7 +92,7 @@ export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRun
81
92
  const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
82
93
  args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
83
94
  logger: { log() {} },
84
- idleMs, maxMs, stream: idleMs > 0, control, onSpawn, busy, busyMs, onIdleExtend, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}), wrap: laneLimits && probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
95
+ idleMs, maxMs, stream: idleMs > 0, control, onSpawn, busy, busyMs, onIdleExtend, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}), wrap: probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
85
96
  // #4395: a resumed session's first turn carries the prior history, so only a fresh session reports a baseline.
86
97
  return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default",
87
98
  baselineTokens: resume ? null : (r.baselineTokens ?? null), tools: toolStats(r.toolUses) }; // #4410: tool calls per turn, re-reads
@@ -328,11 +339,14 @@ export function makeLeanIo({ repo }) {
328
339
  async transitionLabel(issue, state) { await pexec("bash", [script("transition-label.sh"), String(issue), "-R", repo, state], { timeout: 60000 }); },
329
340
  /**
330
341
  * File (or append to) the parent's ONE batched follow-up issue (scripts/file-deferred-followups.sh: trusted lookup,
331
- * append, create, claim marker, duplicate re-check). items: [{key, why}]. Returns {key: issueNumber}; throws on exit 3/4.
342
+ * append, create, claim marker, duplicate re-check; one issue per class, #4664). items: [{key, why, class?}]; the key is the
343
+ * item's identity (column 5, column 1 is `-`). milestone / defaultMilestone: the parent's and forge.yaml followups.default_milestone; lane: the release lane of the post-deploy rollup (#4724).
344
+ * Returns {key: issueNumber}; throws on exit 2/3/4.
332
345
  */
333
- async fileFollowups({ parent, items }) {
334
- const tsv = items.map((i) => `${i.key}\t${i.why}\t`).join("\n") + "\n";
335
- const out = await runWithInput("bash", [script("file-deferred-followups.sh"), "--parent", String(parent), "--repo", repo], tsv, 180000);
346
+ async fileFollowups({ parent, items, milestone = null, defaultMilestone = null, lane = null }) {
347
+ const tsv = items.map((i) => `-\t${i.why}\t\t${i.class ?? ""}\t${i.key}`).join("\n") + "\n";
348
+ const env = { ...process.env, ...(milestone ? { FORGE_FOLLOWUP_MILESTONE: String(milestone) } : {}), ...(defaultMilestone ? { FORGE_FOLLOWUPS_DEFAULT_MILESTONE: String(defaultMilestone) } : {}), ...(lane ? { FORGE_RELEASE_LANE: String(lane) } : {}) }; // #4724: the post-deploy rollup is one per release lane
349
+ const out = await runWithInput("bash", [script("file-deferred-followups.sh"), "--parent", String(parent), "--repo", repo], tsv, 180000, env);
336
350
  return followupMap(out, items);
337
351
  },
338
352
  /**
@@ -381,7 +395,8 @@ export function makeLeanIo({ repo }) {
381
395
  conflictResolve: ({ cwd, files, cfg }) => resolveBaseConflicts({ git: (a) => run("git")(a, { cwd }), cwd, files, cfg }),
382
396
  sequenceLease: makeSequenceLeaseIo(),
383
397
  /** #4591: lane-added migrations the repo's generator did not produce (no journal entry or snapshot); [] on failure. */
384
- migrationGenerator: ({ cwd, base }) => generatorProblems({ git: (a) => run("git")(a, { cwd }), base }),
398
+ /** #4661: plus journal `when` stamps not above the base max (newest-only migrators skip them). */
399
+ migrationGenerator: async ({ cwd, base }) => { const git = (a) => run("git")(a, { cwd }); return [...await generatorProblems({ git, base }), ...await journalOrderProblems({ git, base })]; },
385
400
  /** #4406: the lane's scoped checks after a base merge (bin/lean/checks.mjs; [] on failure), and same-app duplicate exports (bin/lean/postmerge.mjs). */
386
401
  postMergeChecks: async ({ cwd, base }) => { try { return scopedChecks({ repo: cwd, base }); } catch { return []; } },
387
402
  duplicateExports: ({ cwd, base }) => duplicateExports({ git: (a) => run("git")(a, { cwd }), cwd, base }),
@@ -62,6 +62,16 @@ const safeRegExp = (src) => { try { return new RegExp(src, "i"); } catch { retur
62
62
  const ENV_RE = /^[A-Z_][A-Z0-9_]*$/;
63
63
  const IMAGE_RE = /^[A-Za-z0-9][A-Za-z0-9._\/:@-]*$/;
64
64
  const readJson = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
65
+ const TRANSPORT_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EAI_AGAIN", "EPIPE", "ENOTFOUND", "EHOSTUNREACH", "ENETUNREACH", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
66
+ /** #4672: a failure of the link to the provider (connection refused/reset, `fetch failed`, timeout, 502/503/504), not of the lane. */
67
+ export function isTransportError(e) {
68
+ if (!e || e.unreachable) return false;
69
+ if (e.transient === true || [502, 503, 504].includes(e.status)) return true;
70
+ const code = e.code ?? e.cause?.code;
71
+ if (TRANSPORT_CODES.has(code)) return true;
72
+ if (e.name === "AbortError" || e.name === "TimeoutError") return true;
73
+ return /fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket hang up|network error|timed? ?out/i.test(String(e.message ?? ""));
74
+ }
65
75
  const errText = (e) => String(e?.stderr || e?.message || e).split("\n")[0].slice(0, 300);
66
76
 
67
77
  /**
@@ -98,7 +108,9 @@ export function parseExecution(sec = {}) {
98
108
  const patRaw = Array.isArray(s.host_capability_patterns) ? s.host_capability_patterns : str(s.host_capability_patterns) ? str(s.host_capability_patterns).split(",") : [];
99
109
  const hostCapabilityPatterns = patRaw.map((x) => { const t = str(x), i = t.indexOf(":"); return { name: t.slice(0, i).trim().toLowerCase(), pattern: t.slice(i + 1).trim() }; })
100
110
  .filter((x) => CAPABILITY_RE.test(x.name) && x.pattern && safeRegExp(x.pattern));
101
- return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}) };
111
+ const uw = Number(str(s.unreachable_window_min));
112
+ const unreachableWindowMin = str(s.unreachable_window_min) !== "" && Number.isFinite(uw) && uw >= 0 ? uw : null; // unset: providerLanes defaults to 5
113
+ return { mode, maxRemote, provider, image, secrets, budget, ...(hostCapabilities.length ? { hostCapabilities } : {}), ...(hostCapabilityPatterns.length ? { hostCapabilityPatterns } : {}), ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(cacheStore ? { cacheStore } : {}), ...(allowActions ? { allowActions } : {}), ...(fallbackLocal ? { fallbackLocal } : {}), ...(heavy !== null ? { heavy } : {}), ...(unreachableWindowMin !== null ? { unreachableWindowMin } : {}) };
102
114
  }
103
115
 
104
116
  const round1 = (x) => Math.round(x * 10) / 10;
@@ -429,7 +441,8 @@ export function providerByName(name, opts) {
429
441
  try { forgeYaml = opts?.cwd ? readFileSync(join(opts.cwd, "forge.yaml"), "utf8") : null; } catch { forgeYaml = null; }
430
442
  // The router's token is resolved under a fixed name so remoteProvider redacts it like any `token_env` credential.
431
443
  const tokenEnv = exec.tokenEnv ?? (router?.token ? "FORGEDOCK_POOL_TOKEN" : undefined);
432
- const credentials = (n) => (n === "FORGEDOCK_POOL_TOKEN" && !exec.tokenEnv ? router?.token ?? null : resolveCredential(n, { env }));
444
+ // #4685: an empty env/keychain lookup of a configured token_env falls back to the router's token file.
445
+ const credentials = (n) => (n === "FORGEDOCK_POOL_TOKEN" && !exec.tokenEnv ? router?.token ?? null : resolveCredential(n, { env }) ?? router?.token ?? null);
433
446
  const settings = { ...exec, ...(exec.endpoint ? {} : router ? { endpoint: router.endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(forgeYaml ? { forgeYaml } : {}) };
434
447
  return remoteProvider({ batch: opts.batch, repo: opts.repo, settings, name: "pool", env, credentials, transport: remoteTransportFor(settings, { transport: opts?.transport, env, credentials }), ...(opts?.now ? { now: opts.now } : {}) });
435
448
  }
@@ -443,7 +456,7 @@ export function providerByName(name, opts) {
443
456
  * returns that sandbox's ledger (ledger.mjs `makeLedger`).
444
457
  */
445
458
  export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env, secrets = [], pollMs = 5000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
446
- costPerMinute = 0, now = () => Date.now() }) {
459
+ costPerMinute = 0, now = () => Date.now(), unreachableWindowMs = 300000 }) {
447
460
  const cpm = nonNeg(Number(costPerMinute)) ?? 0;
448
461
  // #4255: the sandbox's usage: the provider's `usage()`, else the billable minutes it put on the handle (#4254), else wall
449
462
  // clock since the handle's startedAt; cost is the provider's, else minutes x cost_per_minute.
@@ -463,20 +476,38 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
463
476
  if (ok) { try { led?.release("sandbox", h.id); led?.remove(); } catch { /* the reaper retries */ } }
464
477
  return ok;
465
478
  };
466
- const wait = async (h) => {
479
+ // #4672: a transport failure (router restart, refused/reset connection, 502/503/504) is retried with backoff until the lane has
480
+ // waited `unreachableWindowMs` in a row without one successful poll; then it ends sandbox.unreachable with the sandbox kept.
481
+ const guard = (st) => async (fn) => {
482
+ for (;;) {
483
+ try { const v = await fn(); st.waited = 0; st.tries = 0; return v; } catch (e) {
484
+ if (!isTransportError(e)) throw e;
485
+ if (st.waited >= unreachableWindowMs) throw Object.assign(new Error(`unreachable for ${Math.round(st.waited / 1000)}s: ${errText(e)}`), { unreachable: true });
486
+ const d = Math.min(pollMs * 2 ** Math.min(st.tries++, 10), 30000, Math.max(1, unreachableWindowMs - st.waited));
487
+ st.waited += d;
488
+ await sleep(d);
489
+ }
490
+ }
491
+ };
492
+ const unreachable = (h, e) => ({ terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_UNREACHABLE, retryInSeconds: 60,
493
+ detail: `sandbox ${h.id} ${errText(e)}; kept for adopt on resume` });
494
+ const wait = async (h, g = guard({ waited: 0, tries: 0 })) => {
467
495
  for (;;) {
468
- const r = await provider.fetchResult(h);
496
+ const r = await g(() => provider.fetchResult(h));
469
497
  if (r) return r;
470
- const st = await provider.status(h);
498
+ const st = await g(() => provider.status(h));
471
499
  if (st !== "running") {
472
- const last = await provider.fetchResult(h); // it may have written the result just before exiting
500
+ const last = await g(() => provider.fetchResult(h)); // it may have written the result just before exiting
473
501
  return last ?? { terminalReason: "engine-error", detail: `sandbox ${h.id} ${st} without a result` };
474
502
  }
475
503
  await sleep(pollMs);
476
504
  }
477
505
  };
478
506
  // A start failure repeats on a relaunch: typed, never engine-error (#4254). Retryable (capacity) defers with backoff.
479
- const startFailed = (id, e) => (e?.retryable === true
507
+ // #4685: a refused or missing credential defers with its own code (a fixed token is picked up on the retry), never needs-human.
508
+ const startFailed = (id, e) => ([401, 403].includes(e?.status)
509
+ ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_AUTH, retryInSeconds: 300, detail: `sandbox ${id} could not start (auth): ${errText(e)}; check the pool token (COMPUTESHARE_TOKEN or ~/.config/computeshare/forgedock.token)` }
510
+ : e?.retryable === true
480
511
  ? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_CAPACITY, detail: `sandbox ${id} could not start (capacity): ${errText(e)}`, ...(Number(e.retryInSeconds) > 0 ? { retryInSeconds: Number(e.retryInSeconds) } : {}) }
481
512
  : { terminalReason: "needs-human", code: OUTCOME_CODES.SANDBOX_START_FAILED, detail: `sandbox ${id} failed to start: ${errText(e)}` });
482
513
  return {
@@ -486,8 +517,12 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
486
517
  const led = ledgerFor(n);
487
518
  const base = { provider: provider.name, id: provider.idFor(n), issue: n, startedAt: now() };
488
519
  // A sandbox an earlier attempt left behind (same id) goes first, or the new one could not start.
489
- if ((led?.entries("sandbox") ?? []).some((e) => e.value === base.id) && !(await tear(led, base))) {
490
- return { terminalReason: "engine-error", detail: `sandbox ${base.id} from an earlier attempt could not be torn down` };
520
+ const left = (led?.entries("sandbox") ?? []).find((e) => e.value === base.id);
521
+ if (left) {
522
+ // #4672: a sandbox kept as unreachable is adopted when the provider answers again; it is torn down only when it is gone.
523
+ const kept = await adoptLeft(n, left, ctx);
524
+ if (kept) return kept;
525
+ if (!(await tear(led, base))) return { terminalReason: "engine-error", detail: `sandbox ${base.id} from an earlier attempt could not be torn down` };
491
526
  }
492
527
  led?.register("sandbox", base.id, { provider: provider.name, handle: base }); // before it exists
493
528
  let h = base, r, started = false;
@@ -498,12 +533,15 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
498
533
  ctx.onStart?.({ ...(Number.isInteger(h.pid) ? { pid: h.pid } : {}), handle: h, remote: true });
499
534
  r = await wait(h);
500
535
  } catch (e) {
536
+ if (started && e?.unreachable) return withUsage(unreachable(h, e), h); // #4672: no tear, the ledger keeps it
501
537
  r = started ? { terminalReason: "engine-error", detail: `sandbox ${base.id}: ${errText(e)}` } : startFailed(base.id, e);
502
538
  }
503
539
  await tear(led, h); // every path, after the result is in (tear never throws)
504
540
  return started ? withUsage(r, h) : r; // a sandbox that never started used nothing
505
541
  },
506
- adopt: async (n, rec, ctx = {}) => {
542
+ adopt: (n, rec, ctx = {}) => adoptSaved(n, rec, ctx),
543
+ };
544
+ async function adoptSaved(n, rec, ctx = {}) {
507
545
  const saved = rec?.handle;
508
546
  if (!saved?.id || saved.provider !== provider.name) return null;
509
547
  // Usage runs from the sandbox's real start (persisted on the handle), not from this adopt.
@@ -511,15 +549,21 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
511
549
  const led = ledgerFor(n);
512
550
  led?.register("sandbox", h.id, { provider: provider.name, handle: { provider: h.provider, id: h.id, issue: h.issue, ...(Number.isFinite(h.startedAt) ? { startedAt: h.startedAt } : {}) } }); // now owned by this dispatcher
513
551
  let r;
552
+ const g = guard({ waited: 0, tries: 0 });
514
553
  try {
515
- const ended = await provider.fetchResult(h);
516
- if (!ended && (await provider.status(h)) !== "running") r = null; // relaunched
517
- else { if (!ended) ctx.onLive?.(); r = await wait(h); } // #4504: a live sandbox is awaited in the background
554
+ const ended = await g(() => provider.fetchResult(h));
555
+ if (!ended && (await g(() => provider.status(h))) !== "running") r = null; // relaunched
556
+ else { if (!ended) ctx.onLive?.(); r = await wait(h, g); } // #4504: a live sandbox is awaited in the background
518
557
  } catch (e) {
558
+ if (e?.unreachable) return withUsage(unreachable(h, e), h); // #4672: same tolerance; no tear, left for the next adopt
519
559
  r = { terminalReason: "engine-error", detail: `sandbox ${h.id}: ${errText(e)}` };
520
560
  }
521
561
  await tear(led, h);
522
562
  return withUsage(r, h);
523
- },
524
- };
563
+ }
564
+ // A leftover ledger entry of this lane's sandbox: its result if it can still be had, else null (torn down by the caller).
565
+ async function adoptLeft(n, left, ctx) {
566
+ const r = await adoptSaved(n, { handle: left.handle }, ctx).catch(() => null);
567
+ return r ?? null;
568
+ }
525
569
  }
@@ -6,7 +6,7 @@ import assert from "node:assert/strict";
6
6
  import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
- import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
9
+ import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, isTransportError, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
10
10
  import { makeLedger, countLiveLanes } from "./ledger.mjs";
11
11
  import { openBatch } from "./batch.mjs";
12
12
  import { orchestrate, formatTable, formatSandboxSummary } from "./orchestrate.mjs";
@@ -24,7 +24,7 @@ function fakeProvider(opts = {}) {
24
24
  async start({ issue, env, secrets }) {
25
25
  p.calls.push(["start", issue]);
26
26
  p.ledgerAtStart = opts.ledger?.entries("sandbox").map((e) => e.value) ?? null;
27
- if (opts.startThrows) throw new Error("boom: no capacity");
27
+ if (opts.startThrows) throw Object.assign(new Error("boom: no capacity"), opts.startThrows === 401 ? { status: 401 } : {});
28
28
  p.live.add(issue);
29
29
  p.env = env; p.secrets = secrets;
30
30
  if (opts.finish) { const r = opts.finish(issue); if (r !== undefined) { p.results[issue] = r; p.live.delete(issue); } }
@@ -81,6 +81,56 @@ describe("execution config (#4253)", () => {
81
81
  });
82
82
  });
83
83
 
84
+ describe("providerLanes: an unreachable provider is retried, then deferred without teardown (#4672)", () => {
85
+ const flaky = (failPolls) => {
86
+ const prov = fakeProvider(); let polls = 0;
87
+ prov.live.add(7);
88
+ const gate = () => { if (polls++ < failPolls) throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } }); };
89
+ prov.fetchResult = async (h) => { gate(); return prov.results[h.issue] ?? null; };
90
+ prov.status = async (h) => { gate(); prov.calls.push(["status", h.issue]); return prov.live.has(h.issue) ? "running" : "exited"; };
91
+ return prov;
92
+ };
93
+ const lanes = (prov, extra = {}) => { const root = tmpDir("exec-unreach-"); const ledgerFor = ledgerIn(root); const slept = [];
94
+ return { slept, ledgerFor, l: providerLanes({ provider: prov, laneArgs: () => [], ledgerFor, pollMs: 1000, now: () => 1000, sleep: async (ms) => { slept.push(ms); if (slept.length === 6) { prov.results[7] = { terminalReason: "merged", pr: 9 }; prov.live.delete(7); } }, ...extra }) }; };
95
+
96
+ it("unreachable: N failed polls then recovery yields the real result", async () => {
97
+ const prov = flaky(3); prov.start = async () => ({ provider: "fake", id: "fake-7", issue: 7 });
98
+ const { l, slept } = lanes(prov);
99
+ const r = await l.runOne(7, "staging");
100
+ assert.equal(r.terminalReason, "merged");
101
+ assert.ok(slept.length >= 3 && slept[1] > slept[0]); // backoff
102
+ assert.equal(prov.calls.filter((c) => c[0] === "teardown").length, 1); // only after the result
103
+ });
104
+
105
+ it("unreachable: down past the window yields sandbox.unreachable, no teardown, ledger kept", async () => {
106
+ const prov = flaky(1e9); prov.start = async () => ({ provider: "fake", id: "fake-7", issue: 7 });
107
+ const { l, ledgerFor } = lanes(prov, { unreachableWindowMs: 5000 });
108
+ const r = await l.runOne(7, "staging");
109
+ assert.deepEqual([r.terminalReason, r.code], ["deferred", "sandbox.unreachable"]);
110
+ assert.equal(prov.calls.some((c) => c[0] === "teardown"), false);
111
+ assert.deepEqual(ledgerFor(7).entries("sandbox").map((e) => e.value), ["fake-7"]);
112
+ });
113
+
114
+ it("unreachable: adopt applies the same tolerance, and a non-transport error is still engine-error", async () => {
115
+ const down = flaky(1e9);
116
+ const { l } = lanes(down, { unreachableWindowMs: 3000 });
117
+ const r = await l.adopt(7, { handle: { provider: "fake", id: "fake-7", issue: 7 } });
118
+ assert.equal(r.code, "sandbox.unreachable");
119
+ assert.equal(down.calls.some((c) => c[0] === "teardown"), false);
120
+ const ok = flaky(2); ok.results[7] = { terminalReason: "merged", pr: 1 };
121
+ assert.equal((await lanes(ok).l.adopt(7, { handle: { provider: "fake", id: "fake-7", issue: 7 } })).terminalReason, "merged");
122
+ const bad = fakeProvider(); bad.fetchResult = async () => { throw new Error("boom"); };
123
+ assert.equal((await lanes(bad).l.adopt(7, { handle: { provider: "fake", id: "fake-7", issue: 7 } })).terminalReason, "engine-error");
124
+ });
125
+
126
+ it("unreachable: classifies transport errors and parses the window key", () => {
127
+ for (const e of [new TypeError("fetch failed"), Object.assign(new Error("x"), { code: "ECONNRESET" }), Object.assign(new Error("x"), { status: 502 }), Object.assign(new Error("x"), { name: "AbortError" })]) assert.equal(isTransportError(e), true);
128
+ for (const e of [new Error("boom"), Object.assign(new Error("HTTP 404"), { status: 404 })]) assert.equal(isTransportError(e), false);
129
+ assert.equal(parseExecution({}).unreachableWindowMin, undefined); // providerLanes defaults it to 5 min
130
+ assert.equal(parseExecution({ unreachable_window_min: "2" }).unreachableWindowMin, 2);
131
+ });
132
+ });
133
+
84
134
  describe("providerLanes: sandbox ledger and teardown on every exit path (#4253)", () => {
85
135
  const run = async ({ finish, startThrows, teardownOk, pollMs = 1 } = {}) => {
86
136
  const root = tmpDir("exec-ledger-");
@@ -118,6 +168,13 @@ describe("providerLanes: sandbox ledger and teardown on every exit path (#4253)"
118
168
  assert.equal(led.read(), null);
119
169
  });
120
170
 
171
+ it("#4685 start refused with HTTP 401: deferred sandbox.auth with a long retry, never needs-human", async () => {
172
+ const { r } = await run({ startThrows: 401 });
173
+ assert.equal(r.terminalReason, "deferred");
174
+ assert.equal(r.code, "sandbox.auth");
175
+ assert.equal(r.retryInSeconds, 300);
176
+ });
177
+
121
178
  it("start throws: typed needs-human sandbox.start_failed row (#4254), teardown still runs", async () => {
122
179
  const { r, prov, led } = await run({ startThrows: true });
123
180
  assert.equal(r.terminalReason, "needs-human");
@@ -0,0 +1,31 @@
1
+ /**
2
+ * forge#4656: ship-gate watchdog. A gate (a settle pass, a hold list, `/milestone ship` pre-flight) that waits on an issue the
3
+ * scheduler will not admit never resolves; this pure module names those waits so the batch reports `GATE_DEADLOCK` within one pass
4
+ * instead of polling silently. No I/O.
5
+ */
6
+
7
+ /**
8
+ * `gates`: [{ name, waitsOn: [issue...], since?: ms }]; `admissible(n)`: true when the scheduler could still run or finish `n`;
9
+ * `now`: ms. Returns one { issue, gate, ageMs } per (gate, issue) wait that cannot resolve, in gate order.
10
+ */
11
+ export function detectGateDeadlock({ gates, admissible, now = Date.now(), firstSeen = null }) {
12
+ const out = [];
13
+ for (const g of Array.isArray(gates) ? gates : []) {
14
+ for (const n of Array.isArray(g?.waitsOn) ? g.waitsOn : []) {
15
+ let ok = true;
16
+ try { ok = admissible(n) !== false; } catch { ok = true; }
17
+ if (ok) continue;
18
+ const key = `${g.name}#${n}`;
19
+ if (firstSeen && !firstSeen.has(key)) firstSeen.set(key, Number.isFinite(g.since) ? g.since : now);
20
+ const since = firstSeen?.get(key) ?? (Number.isFinite(g.since) ? g.since : now);
21
+ out.push({ issue: n, gate: String(g.name), ageMs: Math.max(0, now - since) });
22
+ }
23
+ }
24
+ return out;
25
+ }
26
+
27
+ /** One `pipeline-status` line: `GATE_DEADLOCK issue=#N gate=G age=12m`. */
28
+ export function formatGateDeadlock(d) {
29
+ const m = Math.round((Number(d.ageMs) || 0) / 60000);
30
+ return `GATE_DEADLOCK issue=#${d.issue} gate=${d.gate} age=${m}m`;
31
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { detectGateDeadlock, formatGateDeadlock } from "./gatewatch.mjs";
4
+ import { orchestrate } from "./orchestrate.mjs";
5
+ import { classifyNeedsHuman, classifyLegacyNeedsHuman, parkedLabel } from "../engine/outcome-codes.mjs";
6
+
7
+ describe("#4656 gate watchdog", () => {
8
+ it("a milestone ship gate waiting on a parked issue yields GATE_DEADLOCK with issue, gate and age", () => {
9
+ const firstSeen = new Map([["ship#7", 0]]);
10
+ const d = detectGateDeadlock({ gates: [{ name: "ship", waitsOn: [7, 8] }], admissible: (n) => n !== 7, now: 120000, firstSeen });
11
+ assert.deepEqual(d, [{ issue: 7, gate: "ship", ageMs: 120000 }]);
12
+ assert.equal(formatGateDeadlock(d[0]), "GATE_DEADLOCK issue=#7 gate=ship age=2m");
13
+ });
14
+ it("an admissible wait is not a deadlock", () => {
15
+ assert.deepEqual(detectGateDeadlock({ gates: [{ name: "ship", waitsOn: [8] }], admissible: () => true }), []);
16
+ });
17
+ it("the batch runner reports GATE_DEADLOCK once per (gate, issue) for a parked issue on the gate, instead of waiting", async () => {
18
+ const rows = [], decisions = [];
19
+ const runOne = async (n) => n === 1
20
+ ? { terminalReason: "needs-human", code: "conformance.unimplemented", detail: "unknown requirement", pr: 9 }
21
+ : { terminalReason: "merged", pr: 10 };
22
+ const _unused = async (n) => n === 1
23
+ ? { terminalReason: "needs-human", code: "ci.red", detail: "lint failed", pr: 9 }
24
+ : { terminalReason: "merged", pr: 10 };
25
+ const relabels = [];
26
+ const res = await orchestrate({ issues: [1, 2], parallel: 1, runOne, readBody: async () => "", onUpdate: (r) => rows.push(r), decide: (d) => decisions.push(d),
27
+ getGates: () => [{ name: "ship", waitsOn: [1] }], recovery: { maxAttempts: 1 }, relabelNeedsHuman: async (a) => { relabels.push(a); return true; }, pollMs: 1, sleep: async () => {} });
28
+ const dl = rows.filter((r) => r.outcome === "GATE_DEADLOCK");
29
+ assert.equal(dl.length, 1);
30
+ assert.match(dl[0].detail, /GATE_DEADLOCK issue=#1 gate=ship/);
31
+ assert.ok(Array.isArray(res));
32
+ assert.deepEqual(relabels.map((r) => r.parked?.code), ["conformance.unimplemented"]);
33
+ });
34
+ });
35
+
36
+ describe("#4656 parked class and migration", () => {
37
+ it("parked is opt-in and leaves the default classes untouched", () => {
38
+ const row = { outcome: "needs-human", code: "conformance.unimplemented" };
39
+ assert.equal(classifyNeedsHuman(row), "terminal");
40
+ assert.equal(classifyNeedsHuman(row, { parked: true }), "parked");
41
+ assert.equal(classifyNeedsHuman({ outcome: "needs-human", code: "builder.blocked" }, { parked: true }), "human");
42
+ assert.equal(classifyNeedsHuman({ outcome: "needs-human", code: "ci.red" }, { parked: true }), "recoverable");
43
+ assert.equal(classifyNeedsHuman({ outcome: "needs-human" }, { parked: true }), "terminal");
44
+ });
45
+ it("legacy needs-human classifies from the terminal annotation and defaults to external", () => {
46
+ assert.deepEqual(classifyLegacyNeedsHuman("terminal: ci.red after final round"), { kind: "parked", code: "ci.red" });
47
+ assert.deepEqual(classifyLegacyNeedsHuman("waiting for the AWS account"), { kind: "external" });
48
+ assert.deepEqual(classifyLegacyNeedsHuman(null), { kind: "external" });
49
+ assert.equal(parkedLabel("ci.red"), "parked:ci.red");
50
+ });
51
+ });
52
+
53
+ describe("#4656 report routing", () => {
54
+ it("a parked row past recovery is listed on the remediation queue line, not unrecovered or escalations", async () => {
55
+ const { formatOutcomeLists } = await import("./orchestrate.mjs");
56
+ const lines = formatOutcomeLists([{ issue: 1, outcome: "needs-human", code: "conformance.unimplemented", pr: 9, parkedQueued: true }]);
57
+ assert.deepEqual(lines, ["remediation queue (/work-on:remediate): #1 (parked:conformance.unimplemented PR #9)"]);
58
+ });
59
+ });