@nanobpm/nano-workforce 0.35.2 → 0.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 (75) hide show
  1. package/.github/workflows/ci.yml +10 -17
  2. package/AGENTS.md +8 -7
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +1 -21
  5. package/actions/abandon.test.ts +8 -16
  6. package/actions/blackboard.test.ts +13 -21
  7. package/actions/blackboard.ts +1 -0
  8. package/actions/feature-answer-hook.ts +1 -0
  9. package/actions/plan-hook.ts +2 -1
  10. package/actions/webhook-submit.ts +2 -1
  11. package/app/abandon.test.ts +10 -15
  12. package/app/baseGuard.test.ts +7 -6
  13. package/app/blackboard.test.ts +22 -32
  14. package/app/blackboard.ts +7 -6
  15. package/app/ensure-pr.test.ts +10 -12
  16. package/app/github.test.ts +9 -8
  17. package/app/github.ts +24 -22
  18. package/app/instance-tracking.test.ts +8 -6
  19. package/app/mergeExclusion.test.ts +11 -20
  20. package/app/mergeExclusion.ts +8 -3
  21. package/app/mergeProtocol.test.ts +14 -13
  22. package/app/mergeProtocol.ts +1 -0
  23. package/app/mergeRebaseArm.test.ts +8 -6
  24. package/app/mergeTrain.test.ts +10 -9
  25. package/app/persist-escalation.test.ts +9 -30
  26. package/app/persist-round.test.ts +6 -19
  27. package/app/plan.test.ts +19 -42
  28. package/app/plan.ts +1 -1
  29. package/app/record-plan-review.test.ts +8 -7
  30. package/app/retro.test.ts +24 -48
  31. package/app/retro.ts +4 -2
  32. package/app/reviewWait.test.ts +12 -11
  33. package/app/rounds.test.ts +13 -12
  34. package/app/service.test.ts +14 -27
  35. package/app/service.ts +8 -4
  36. package/app/taskDelta.test.ts +8 -17
  37. package/app/taskDelta.ts +1 -0
  38. package/app/trialMerge.test.ts +4 -3
  39. package/app/version.ts +9 -21
  40. package/app/waves.test.ts +17 -16
  41. package/biome.json +143 -0
  42. package/operations/getVersion.test.ts +8 -12
  43. package/operations/getVersion.ts +2 -3
  44. package/operations/listActivePrs.test.ts +8 -14
  45. package/operations/listActivePrs.ts +2 -3
  46. package/operations/postMessage.ts +3 -3
  47. package/operations/startAndMessage.test.ts +6 -12
  48. package/package.json +9 -4
  49. package/plugins/no-unsafe-type-assertion.grit +12 -0
  50. package/scripts/check-agent-prompts.test.ts +15 -11
  51. package/scripts/check-agent-prompts.ts +4 -2
  52. package/scripts/layout-bpmn.ts +6 -26
  53. package/scripts/pages-contract.test.ts +14 -13
  54. package/scripts/purge-db.ts +3 -2
  55. package/scripts/upgrade-from-pack.ts +9 -5
  56. package/test/assert.ts +74 -0
  57. package/tsconfig.json +4 -1
  58. package/workers/finalize/worker.ts +2 -1
  59. package/workers/merge/worker.ts +3 -3
  60. package/workers/persist-escalation/worker.ts +2 -1
  61. package/workers/persist-round/worker.ts +2 -1
  62. package/workers/record-plan-review/worker.test.ts +6 -9
  63. package/workers/record-plan-review/worker.ts +2 -1
  64. package/workers/record-results/worker.test.ts +5 -8
  65. package/workers/record-results/worker.ts +2 -1
  66. package/workers/record-trial-merge/worker.test.ts +7 -18
  67. package/workers/record-trial-merge/worker.ts +6 -6
  68. package/workers/record-wave/worker.test.ts +13 -20
  69. package/workers/record-wave/worker.ts +7 -8
  70. package/workers/retro-gather/worker.test.ts +4 -17
  71. package/workers/retro-record/worker.test.ts +8 -27
  72. package/workers/retro-record/worker.ts +3 -3
  73. package/workers/select-wave/worker.test.ts +5 -8
  74. package/deno.json +0 -24
  75. package/deno.lock +0 -1776
@@ -4,7 +4,9 @@
4
4
  // crashed) run stuck "active" in the UI — the exact drift Copilot flagged on #96. This ties the
5
5
  // manifest to the code's single source of truth for "done" (TERMINAL_STATUSES / PLAN_TERMINAL_
6
6
  // STATUSES) so the two can't diverge silently.
7
- import { assert, assertEquals } from "jsr:@std/assert@1";
7
+ import { test } from "node:test";
8
+ import { assert, assertEquals } from "#test-assert";
9
+ import { readFileSync } from "node:fs";
8
10
  import { TERMINAL_STATUSES } from "./service.ts";
9
11
  import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
10
12
 
@@ -16,7 +18,7 @@ interface Binding {
16
18
  }
17
19
 
18
20
  async function bindings(): Promise<Binding[]> {
19
- const manifest = JSON.parse(await Deno.readTextFile(new URL("../nano.app.json", import.meta.url)));
21
+ const manifest = JSON.parse(readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"));
20
22
  return manifest.instanceTracking as Binding[];
21
23
  }
22
24
 
@@ -26,7 +28,7 @@ function bindingFor(all: Binding[], table: string): Binding {
26
28
  return b;
27
29
  }
28
30
 
29
- Deno.test("instanceTracking: pull_requests activeStatuses excludes every terminal status", async () => {
31
+ test("instanceTracking: pull_requests activeStatuses excludes every terminal status", async () => {
30
32
  const b = bindingFor(await bindings(), "pull_requests");
31
33
  for (const terminal of TERMINAL_STATUSES) {
32
34
  assert(
@@ -40,7 +42,7 @@ Deno.test("instanceTracking: pull_requests activeStatuses excludes every termina
40
42
  // pull_requests row can hold while a live engine instance still backs it (see app/service.ts merge
41
43
  // poller: converging/waiting_review/escalated + the merge-stage waiting_deps/waiting_merge/
42
44
  // waiting_lane/queued/merging). If a new one is added to the flow, add it here AND to the manifest.
43
- Deno.test("instanceTracking: pull_requests activeStatuses covers every in-flight status", async () => {
45
+ test("instanceTracking: pull_requests activeStatuses covers every in-flight status", async () => {
44
46
  const inFlight = [
45
47
  "converging",
46
48
  "waiting_review",
@@ -59,14 +61,14 @@ Deno.test("instanceTracking: pull_requests activeStatuses covers every in-flight
59
61
  for (const s of inFlight) assert(!TERMINAL_STATUSES.includes(s));
60
62
  });
61
63
 
62
- Deno.test("instanceTracking: plans activeStatuses excludes every terminal status", async () => {
64
+ test("instanceTracking: plans activeStatuses excludes every terminal status", async () => {
63
65
  const b = bindingFor(await bindings(), "plans");
64
66
  for (const terminal of PLAN_TERMINAL_STATUSES) {
65
67
  assert(!b.activeStatuses?.includes(terminal), `terminal status "${terminal}" must not be active`);
66
68
  }
67
69
  });
68
70
 
69
- Deno.test("instanceTracking: plans activeStatuses covers every in-flight status", async () => {
71
+ test("instanceTracking: plans activeStatuses covers every in-flight status", async () => {
70
72
  const inFlight = ["planning", "dispatched"];
71
73
  const b = bindingFor(await bindings(), "plans");
72
74
  assertEquals([...(b.activeStatuses ?? [])].sort(), [...inFlight].sort());
@@ -1,5 +1,6 @@
1
1
  // Unit tests for the merge-exclusion graph + conflict-scan (D1/D2, issues #57 #58 / #49).
2
- import { assert, assertEquals } from "jsr:@std/assert@1";
2
+ import { test } from "node:test";
3
+ import { assert, assertEquals } from "#test-assert";
3
4
  import type { DataLayer } from "@nanobpm/urban";
4
5
  import {
5
6
  clearExclusions,
@@ -13,58 +14,48 @@ import {
13
14
  import { computeWaves } from "./waves.ts";
14
15
 
15
16
  // In-memory record-gateway fake (insert/find/findOne/update/delete), mirroring the app tests.
16
- // deno-lint-ignore no-explicit-any
17
17
  function memData(): { data: DataLayer; stores: Record<string, any[]> } {
18
- // deno-lint-ignore no-explicit-any
19
18
  const stores: Record<string, any[]> = {};
20
19
  const seq: Record<string, number> = {};
21
20
  function tbl(name: string, pk = "id") {
22
- // deno-lint-ignore no-explicit-any
23
21
  const rows = (stores[name] ??= [] as any[]);
24
- // deno-lint-ignore no-explicit-any
25
22
  const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
26
23
  return {
27
- // deno-lint-ignore no-explicit-any require-await
28
24
  async insert(row: any) {
29
25
  const id = (seq[name] = (seq[name] ?? 0) + 1);
30
26
  rows.push({ id, ...row });
31
27
  return id;
32
28
  },
33
- // deno-lint-ignore no-explicit-any require-await
34
29
  async find(where: any = {}) {
35
30
  return rows.filter((r) => match(r, where));
36
31
  },
37
- // deno-lint-ignore no-explicit-any require-await
38
32
  async findOne(where: any = {}) {
39
33
  return rows.find((r) => match(r, where));
40
34
  },
41
- // deno-lint-ignore no-explicit-any require-await
42
35
  async update(id: any, patch: any) {
43
36
  const r = rows.find((row) => row.id === id);
44
37
  if (r) Object.assign(r, patch);
45
38
  },
46
- // deno-lint-ignore no-explicit-any require-await
47
39
  async delete(id: any) {
48
40
  const i = rows.findIndex((row) => row.id === id);
49
41
  if (i >= 0) rows.splice(i, 1);
50
42
  },
51
43
  };
52
44
  }
53
- // deno-lint-ignore no-explicit-any
54
45
  const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
55
46
  return { data, stores };
56
47
  }
57
48
 
58
49
  const files = (e: ExclusionEdge) => e.files;
59
50
 
60
- Deno.test("normalizePair: orders deterministically and rejects self/blank pairs", () => {
51
+ test("normalizePair: orders deterministically and rejects self/blank pairs", () => {
61
52
  assertEquals(normalizePair("b", "a"), ["a", "b"]);
62
53
  assertEquals(normalizePair("a", "b"), ["a", "b"]);
63
54
  assertEquals(normalizePair("a", "a"), null, "a task never excludes itself");
64
55
  assertEquals(normalizePair("", "a"), null);
65
56
  });
66
57
 
67
- Deno.test("deriveExclusions: an edge per file-overlapping pair, carrying the sorted overlap", () => {
58
+ test("deriveExclusions: an edge per file-overlapping pair, carrying the sorted overlap", () => {
68
59
  const edges = deriveExclusions(
69
60
  new Map([
70
61
  ["gap-2", ["engine/tests.rs", "engine/state.rs"]],
@@ -83,14 +74,14 @@ Deno.test("deriveExclusions: an edge per file-overlapping pair, carrying the sor
83
74
  assert(!edges.some((e) => e.taskA === "gap-5" || e.taskB === "gap-5"), "gap-5 excluded (no overlap)");
84
75
  });
85
76
 
86
- Deno.test("deriveExclusions: no overlap → no edges; blank paths ignored", () => {
77
+ test("deriveExclusions: no overlap → no edges; blank paths ignored", () => {
87
78
  assertEquals(
88
79
  deriveExclusions(new Map([["a", ["x.rs"]], ["b", ["y.rs"]], ["c", ["", " "]]])),
89
80
  [],
90
81
  );
91
82
  });
92
83
 
93
- Deno.test("recordExclusions: upserts per unordered pair — a re-scan refreshes files, never duplicates", async () => {
84
+ test("recordExclusions: upserts per unordered pair — a re-scan refreshes files, never duplicates", async () => {
94
85
  const { data, stores } = memData();
95
86
  const first = await recordExclusions(data, "p", deriveExclusions(
96
87
  new Map([["gap-2", ["a.rs"]], ["gap-8", ["a.rs"]]]),
@@ -109,7 +100,7 @@ Deno.test("recordExclusions: upserts per unordered pair — a re-scan refreshes
109
100
  assertEquals(edge.files, ["a.rs", "b.rs"], "files refreshed in place");
110
101
  });
111
102
 
112
- Deno.test("recordExclusions: a duplicate pair within one batch folds into an update, never a second row", async () => {
103
+ test("recordExclusions: a duplicate pair within one batch folds into an update, never a second row", async () => {
113
104
  const { data, stores } = memData();
114
105
  // The same unordered pair appears twice in one call (second given in the other order + more files).
115
106
  // The in-memory map must fold the newly inserted id back so the second occurrence updates in place.
@@ -123,7 +114,7 @@ Deno.test("recordExclusions: a duplicate pair within one batch folds into an upd
123
114
  assertEquals(edge.files, ["x.rs", "y.rs"], "later occurrence refreshed files in place");
124
115
  });
125
116
 
126
- Deno.test("clearExclusions: drops one plan's graph, leaving others intact", async () => {
117
+ test("clearExclusions: drops one plan's graph, leaving others intact", async () => {
127
118
  const { data } = memData();
128
119
  await recordExclusions(data, "p", deriveExclusions(new Map([["a", ["x"]], ["b", ["x"]]])));
129
120
  await recordExclusions(data, "q", deriveExclusions(new Map([["c", ["y"]], ["d", ["y"]]])));
@@ -132,7 +123,7 @@ Deno.test("clearExclusions: drops one plan's graph, leaving others intact", asyn
132
123
  assertEquals((await readExclusions(data, "q")).length, 1, "the other plan survives");
133
124
  });
134
125
 
135
- Deno.test("mergeLanes: connected components are serial landing lanes; singletons land in parallel", () => {
126
+ test("mergeLanes: connected components are serial landing lanes; singletons land in parallel", () => {
136
127
  // gap-2—gap-8—gap-9 form one chain (transitive shared surface); gap-5 stands alone.
137
128
  const edges = deriveExclusions(
138
129
  new Map([
@@ -146,11 +137,11 @@ Deno.test("mergeLanes: connected components are serial landing lanes; singletons
146
137
  assertEquals(lanes, [["gap-2", "gap-8", "gap-9"], ["gap-5"]]);
147
138
  });
148
139
 
149
- Deno.test("mergeLanes: with no edges, every task is its own lane (fully parallel landing)", () => {
140
+ test("mergeLanes: with no edges, every task is its own lane (fully parallel landing)", () => {
150
141
  assertEquals(mergeLanes([], ["b", "a", "c"]), [["a"], ["b"], ["c"]]);
151
142
  });
152
143
 
153
- Deno.test("D1 invariant: a merge-exclusion is NOT a dispatch dependency", () => {
144
+ test("D1 invariant: a merge-exclusion is NOT a dispatch dependency", () => {
154
145
  // Two tasks that collide on a shared file but declare no build-on dependency.
155
146
  const overlap = new Map([["gap-2", ["engine/tests.rs"]], ["gap-8", ["engine/tests.rs"]]]);
156
147
  const edges = deriveExclusions(overlap);
@@ -169,12 +169,17 @@ export async function clearExclusions(data: DataLayer, planKey: string): Promise
169
169
  export function mergeLanes(edges: ExclusionEdge[], allTasks: Iterable<string> = []): string[][] {
170
170
  const parent = new Map<string, string>();
171
171
  const find = (x: string): string => {
172
+ const parentOf = (node: string): string => {
173
+ const p = parent.get(node);
174
+ if (p === undefined) throw new Error(`missing union-find parent for ${node}`);
175
+ return p;
176
+ };
172
177
  let root = x;
173
- while (parent.get(root) !== root) root = parent.get(root)!;
178
+ while (parentOf(root) !== root) root = parentOf(root);
174
179
  // Path-compress so repeated finds stay near-flat.
175
180
  let cur = x;
176
- while (parent.get(cur) !== root) {
177
- const next = parent.get(cur)!;
181
+ while (parentOf(cur) !== root) {
182
+ const next = parentOf(cur);
178
183
  parent.set(cur, root);
179
184
  cur = next;
180
185
  }
@@ -3,8 +3,9 @@
3
3
  // extractor must find the fenced ```merge-protocol JSON, and the fresh-head-run decision must fire
4
4
  // exactly once per landing attempt — only in the frugal-CI stuck state (no head run + waiting) —
5
5
  // so a converged PR is nudged into a fresh CI run without disturbing a run already in flight.
6
- // Run with `deno test -A`.
7
- import { assertEquals } from "jsr:@std/assert@1";
6
+ // Run with `node --test`.
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
8
9
  import {
9
10
  DEFAULT_MERGE_PROTOCOL,
10
11
  extractProtocolBlock,
@@ -13,14 +14,14 @@ import {
13
14
  parseMergeProtocol,
14
15
  } from "./mergeProtocol.ts";
15
16
 
16
- Deno.test("parseMergeProtocol: non-object / junk → defaults (total, never throws)", () => {
17
+ test("parseMergeProtocol: non-object / junk → defaults (total, never throws)", () => {
17
18
  assertEquals(parseMergeProtocol(undefined), { ...DEFAULT_MERGE_PROTOCOL });
18
19
  assertEquals(parseMergeProtocol(null), { ...DEFAULT_MERGE_PROTOCOL });
19
20
  assertEquals(parseMergeProtocol("nope"), { ...DEFAULT_MERGE_PROTOCOL });
20
21
  assertEquals(parseMergeProtocol([1, 2]), { ...DEFAULT_MERGE_PROTOCOL });
21
22
  });
22
23
 
23
- Deno.test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
24
+ test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
24
25
  const got = parseMergeProtocol({
25
26
  autoMerge: false,
26
27
  freshHeadRun: "ready-or-reopen",
@@ -37,7 +38,7 @@ Deno.test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
37
38
  assertEquals(got.doc, "AGENTS.md#merging-prs");
38
39
  });
39
40
 
40
- Deno.test("parseMergeProtocol: invalid enums / wrong types fall back per-field", () => {
41
+ test("parseMergeProtocol: invalid enums / wrong types fall back per-field", () => {
41
42
  const got = parseMergeProtocol({
42
43
  autoMerge: "yes", // not a boolean → default
43
44
  freshHeadRun: "sometimes", // not in the enum → default (none)
@@ -50,12 +51,12 @@ Deno.test("parseMergeProtocol: invalid enums / wrong types fall back per-field",
50
51
  assertEquals(got.requiredChecks, ["ok"]);
51
52
  });
52
53
 
53
- Deno.test("parseMergeProtocol: comment dropped when absent", () => {
54
+ test("parseMergeProtocol: comment dropped when absent", () => {
54
55
  const got = parseMergeProtocol({ land: { method: "admin" } });
55
56
  assertEquals(got.land, { method: "admin" });
56
57
  });
57
58
 
58
- Deno.test("extractProtocolBlock: finds the fenced merge-protocol JSON (with info word)", () => {
59
+ test("extractProtocolBlock: finds the fenced merge-protocol JSON (with info word)", () => {
59
60
  const md = [
60
61
  "## Merging PRs",
61
62
  "Some prose about how to merge.",
@@ -73,7 +74,7 @@ Deno.test("extractProtocolBlock: finds the fenced merge-protocol JSON (with info
73
74
  assertEquals(p.land.method, "mergify-queue");
74
75
  });
75
76
 
76
- Deno.test("extractProtocolBlock: none present → null", () => {
77
+ test("extractProtocolBlock: none present → null", () => {
77
78
  assertEquals(extractProtocolBlock("# Doc\n```json\n{}\n```\n"), null);
78
79
  });
79
80
 
@@ -82,14 +83,14 @@ const NANO: MergeProtocol = parseMergeProtocol({
82
83
  land: { method: "mergify-queue" },
83
84
  });
84
85
 
85
- Deno.test("freshHeadRunAction: fires in the frugal-CI stuck state (no run + waiting)", () => {
86
+ test("freshHeadRunAction: fires in the frugal-CI stuck state (no run + waiting)", () => {
86
87
  // ready PR, no head run at all → reopen (ready-or-reopen, not a draft)
87
88
  assertEquals(freshHeadRunAction(NANO, "waiting", 0, false), "reopen");
88
89
  // draft PR, no head run → mark ready
89
90
  assertEquals(freshHeadRunAction(NANO, "waiting", 0, true), "ready");
90
91
  });
91
92
 
92
- Deno.test("freshHeadRunAction: fires once per landing-attempt head, then re-fires after rebase", () => {
93
+ test("freshHeadRunAction: fires once per landing-attempt head, then re-fires after rebase", () => {
93
94
  assertEquals(
94
95
  freshHeadRunAction(NANO, "waiting", 0, false, { headRefOid: "h1", lastActionHeadRefOid: null }),
95
96
  "reopen",
@@ -104,7 +105,7 @@ Deno.test("freshHeadRunAction: fires once per landing-attempt head, then re-fire
104
105
  );
105
106
  });
106
107
 
107
- Deno.test("freshHeadRunAction: never fires once a run exists, or when not waiting", () => {
108
+ test("freshHeadRunAction: never fires once a run exists, or when not waiting", () => {
108
109
  assertEquals(freshHeadRunAction(NANO, "waiting", 1, false), null); // run already in flight
109
110
  assertEquals(freshHeadRunAction(NANO, "waiting", -1, false), null); // token mode (unknown) → conservative
110
111
  assertEquals(freshHeadRunAction(NANO, "ready", 0, false), null); // already landable
@@ -113,11 +114,11 @@ Deno.test("freshHeadRunAction: never fires once a run exists, or when not waitin
113
114
  assertEquals(freshHeadRunAction(NANO, "conflict", 0, false), null); // conflict → rebase arm (#42)
114
115
  });
115
116
 
116
- Deno.test("freshHeadRunAction: protocol.freshHeadRun=none is a no-op (default repos unchanged)", () => {
117
+ test("freshHeadRunAction: protocol.freshHeadRun=none is a no-op (default repos unchanged)", () => {
117
118
  assertEquals(freshHeadRunAction(DEFAULT_MERGE_PROTOCOL, "waiting", 0, false), null);
118
119
  });
119
120
 
120
- Deno.test("freshHeadRunAction: mode=ready only acts on drafts", () => {
121
+ test("freshHeadRunAction: mode=ready only acts on drafts", () => {
121
122
  const readyOnly = parseMergeProtocol({ freshHeadRun: "ready", land: { method: "gh-merge" } });
122
123
  assertEquals(freshHeadRunAction(readyOnly, "waiting", 0, true), "ready");
123
124
  assertEquals(freshHeadRunAction(readyOnly, "waiting", 0, false), null); // not a draft → nothing to ready
@@ -72,6 +72,7 @@ function strArray(v: unknown): string[] | undefined {
72
72
  }
73
73
  function oneOf<T extends string>(v: unknown, allowed: ReadonlySet<string>): T | undefined {
74
74
  const s = str(v);
75
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
75
76
  return s !== undefined && allowed.has(s) ? (s as T) : undefined;
76
77
  }
77
78
 
@@ -10,9 +10,11 @@
10
10
  // way it originally shipped. It is a pure text assertion over the BPMN (no engine), matching the
11
11
  // repo's lightweight model-guard style.
12
12
 
13
- import { assert, assertStringIncludes } from "jsr:@std/assert@1";
13
+ import { test } from "node:test";
14
+ import { assert, assertStringIncludes } from "#test-assert";
15
+ import { readFileSync } from "node:fs";
14
16
 
15
- const bpmn = await Deno.readTextFile("resources/processes/merge-loop.bpmn");
17
+ const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
16
18
 
17
19
  // Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
18
20
  const flat = bpmn.replace(/\s+/g, " ");
@@ -27,14 +29,14 @@ function hasFlow(source: string, target: string): boolean {
27
29
  return re.test(flat);
28
30
  }
29
31
 
30
- Deno.test("conflict routes to the rebase budget gate, not straight to a human", () => {
32
+ test("conflict routes to the rebase budget gate, not straight to a human", () => {
31
33
  // The conflict verdict must reach the auto-rebase gate…
32
34
  assert(hasFlow("gw-mergeable", "gw-rebase"), "gw-mergeable → gw-rebase (conflict) missing");
33
35
  // …guarded by the exact conflict condition (DIRTY → mergeState = "conflict").
34
36
  assertStringIncludes(flat, 'mergeState = "conflict"');
35
37
  });
36
38
 
37
- Deno.test("rebase arm mirrors the fix-ci arm: budget gate → agent → result gate", () => {
39
+ test("rebase arm mirrors the fix-ci arm: budget gate → agent → result gate", () => {
38
40
  // Budget gate: within budget → the agent; exhausted → the (existing) conflict escalation.
39
41
  assert(hasFlow("gw-rebase", "rebase"), "gw-rebase → rebase (within budget) missing");
40
42
  assert(hasFlow("gw-rebase", "merge-esc-conflict"), "gw-rebase → merge-esc-conflict (budget exhausted) missing");
@@ -49,7 +51,7 @@ Deno.test("rebase arm mirrors the fix-ci arm: budget gate → agent → result g
49
51
  assertStringIncludes(flat, "=rebaseRound + 1");
50
52
  });
51
53
 
52
- Deno.test("rebase result: success re-arms the poller; unresolved escalates to a human", () => {
54
+ test("rebase result: success re-arms the poller; unresolved escalates to a human", () => {
53
55
  // Success loops back to re-attempt the merge — forward progress, no human needed.
54
56
  assert(hasFlow("gw-rebase-result", "arm-merge"), "gw-rebase-result → arm-merge (rebased) missing");
55
57
  assertStringIncludes(flat, 'status = "rebased"');
@@ -60,7 +62,7 @@ Deno.test("rebase result: success re-arms the poller; unresolved escalates to a
60
62
  );
61
63
  });
62
64
 
63
- Deno.test("regression: the conflict verdict passes through the rebase actor, not straight to escalation", () => {
65
+ test("regression: the conflict verdict passes through the rebase actor, not straight to escalation", () => {
64
66
  // The original #42 livelock had the conflict verdict escalate to a human with no remediation
65
67
  // actor. The primary target of the conflict verdict must now be the rebase gate.
66
68
  assert(
@@ -1,5 +1,6 @@
1
1
  // Red/green regression for D6's pure merge-train lane planner.
2
- import { assertEquals } from "jsr:@std/assert@1";
2
+ import { test } from "node:test";
3
+ import { assertEquals } from "#test-assert";
3
4
  import { planLane, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
4
5
 
5
6
  const taskToPr = new Map([
@@ -9,7 +10,7 @@ const taskToPr = new Map([
9
10
  ["gap-10", "o/r#10"],
10
11
  ]);
11
12
 
12
- Deno.test("planLane: dependency depth then task id picks the single lane head", () => {
13
+ test("planLane: dependency depth then task id picks the single lane head", () => {
13
14
  const depth = new Map([["gap-8", 2], ["gap-2", 0], ["gap-9", 0]]);
14
15
  assertEquals(planLane(["gap-8", "gap-9", "gap-2"], taskToPr, new Set(), depth), {
15
16
  headTaskId: "gap-2",
@@ -19,7 +20,7 @@ Deno.test("planLane: dependency depth then task id picks the single lane head",
19
20
  });
20
21
  });
21
22
 
22
- Deno.test("taskDependencyDepths: longest dependency path determines landing order depth", () => {
23
+ test("taskDependencyDepths: longest dependency path determines landing order depth", () => {
23
24
  const depths = taskDependencyDepths([
24
25
  { task_id: "b", depends_on_task_id: "a" },
25
26
  { task_id: "c", depends_on_task_id: "b" },
@@ -36,7 +37,7 @@ Deno.test("taskDependencyDepths: longest dependency path determines landing orde
36
37
  ]);
37
38
  });
38
39
 
39
- Deno.test("planLane: task id is the deterministic tiebreaker", () => {
40
+ test("planLane: task id is the deterministic tiebreaker", () => {
40
41
  assertEquals(planLane(["gap-9", "gap-2", "gap-8"], taskToPr, new Set()), {
41
42
  headTaskId: "gap-2",
42
43
  headPrKey: "o/r#2",
@@ -45,7 +46,7 @@ Deno.test("planLane: task id is the deterministic tiebreaker", () => {
45
46
  });
46
47
  });
47
48
 
48
- Deno.test("planLane: already-merged head advances to the next unmerged member", () => {
49
+ test("planLane: already-merged head advances to the next unmerged member", () => {
49
50
  assertEquals(planLane(["gap-2", "gap-8", "gap-9"], taskToPr, new Set(["o/r#2"])), {
50
51
  headTaskId: "gap-8",
51
52
  headPrKey: "o/r#8",
@@ -54,7 +55,7 @@ Deno.test("planLane: already-merged head advances to the next unmerged member",
54
55
  });
55
56
  });
56
57
 
57
- Deno.test("planLane: single-member lanes never hold their PR", () => {
58
+ test("planLane: single-member lanes never hold their PR", () => {
58
59
  assertEquals(planLane(["gap-10"], taskToPr, new Set()), {
59
60
  headTaskId: null,
60
61
  headPrKey: null,
@@ -63,7 +64,7 @@ Deno.test("planLane: single-member lanes never hold their PR", () => {
63
64
  });
64
65
  });
65
66
 
66
- Deno.test("planLane: tasks without PR keys do not block PR-backed members", () => {
67
+ test("planLane: tasks without PR keys do not block PR-backed members", () => {
67
68
  assertEquals(planLane(["scaffold", "gap-2", "gap-8"], taskToPr, new Set()), {
68
69
  headTaskId: "gap-2",
69
70
  headPrKey: "o/r#2",
@@ -72,7 +73,7 @@ Deno.test("planLane: tasks without PR keys do not block PR-backed members", () =
72
73
  });
73
74
  });
74
75
 
75
- Deno.test("planPrLane: held PRs point at the current lane head", () => {
76
+ test("planPrLane: held PRs point at the current lane head", () => {
76
77
  assertEquals(
77
78
  planPrLane([["gap-2", "gap-8"], ["gap-10"]], taskToPr, new Set(), "o/r#8"),
78
79
  {
@@ -84,7 +85,7 @@ Deno.test("planPrLane: held PRs point at the current lane head", () => {
84
85
  );
85
86
  });
86
87
 
87
- Deno.test("planPrLane: lane head and PRs outside exclusion lanes are not held", () => {
88
+ test("planPrLane: lane head and PRs outside exclusion lanes are not held", () => {
88
89
  assertEquals(planPrLane([["gap-2", "gap-8"], ["gap-10"]], taskToPr, new Set(), "o/r#2").isHeld, false);
89
90
  assertEquals(planPrLane([["gap-2", "gap-8"], ["gap-10"]], taskToPr, new Set(), "o/r#10").isHeld, false);
90
91
  assertEquals(planPrLane([["gap-2", "gap-8"]], taskToPr, new Set(), "o/r#404").isHeld, false);
@@ -6,7 +6,8 @@
6
6
  // passes `recordRound=false`, which must suppress the round insert while still opening the
7
7
  // escalation. The agent-raised / max-rounds arms omit the flag (no prior round row) and must
8
8
  // still record the round.
9
- import { assert, assertEquals } from "jsr:@std/assert@1";
9
+ import { test } from "node:test";
10
+ import { assert, assertEquals } from "#test-assert";
10
11
  import handler from "../workers/persist-escalation/worker.ts";
11
12
 
12
13
  function fakeApp() {
@@ -18,19 +19,15 @@ function fakeApp() {
18
19
  table(name: string, _key: string) {
19
20
  const store = (rows[name] ??= new Map());
20
21
  return {
21
- // deno-lint-ignore require-await
22
22
  async get(key: string) {
23
23
  return store.get(key);
24
24
  },
25
- // deno-lint-ignore require-await
26
25
  async insert(row: unknown) {
27
26
  (inserts[name] ??= []).push(row);
28
27
  const pk = name === "escalations" ? "id" : name === "rounds" ? "id" : "pr_key";
29
- // deno-lint-ignore no-explicit-any
30
28
  store.set((row as any)[pk], row);
31
29
  return name === "escalations" ? 42 : 1;
32
30
  },
33
- // deno-lint-ignore require-await
34
31
  async update(key: string, patch: unknown) {
35
32
  (updates[name] ??= []).push({ key, patch });
36
33
  },
@@ -41,33 +38,29 @@ function fakeApp() {
41
38
  return { app, inserts, updates, rows };
42
39
  }
43
40
 
44
- Deno.test("stalled arm (recordRound=false) does not insert a duplicate rounds row", async () => {
41
+ test("stalled arm (recordRound=false) does not insert a duplicate rounds row", async () => {
45
42
  const { app, inserts } = fakeApp();
46
43
  const job = {
47
44
  variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "stalled", recordRound: false },
48
45
  };
49
- // deno-lint-ignore no-explicit-any
50
46
  const out = await handler(job as any, app as any);
51
47
  assertEquals(inserts.rounds.length, 0, "no round row when the round was already recorded");
52
48
  assertEquals(inserts.escalations.length, 1, "escalation is still opened");
53
- // deno-lint-ignore no-explicit-any
54
49
  assertEquals((out as any).escalationId, 42);
55
50
  });
56
51
 
57
- Deno.test("escalation arm without the flag still records the round", async () => {
52
+ test("escalation arm without the flag still records the round", async () => {
58
53
  const { app, inserts } = fakeApp();
59
54
  const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds" } };
60
- // deno-lint-ignore no-explicit-any
61
55
  await handler(job as any, app as any);
62
56
  assertEquals(inserts.rounds.length, 1);
63
- // deno-lint-ignore no-explicit-any
64
57
  assertEquals((inserts.rounds[0] as any).round_no, 3);
65
58
  });
66
59
 
67
60
  // When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
68
61
  // desync), persist-escalation reconstructs the `pull_requests` row before the rounds/escalations
69
62
  // inserts so opening an escalation never dies with an opaque FOREIGN KEY constraint failure.
70
- Deno.test("persist-escalation heals a missing pull_requests parent before recording", async () => {
63
+ test("persist-escalation heals a missing pull_requests parent before recording", async () => {
71
64
  const { app, inserts, updates } = fakeApp();
72
65
  const job = {
73
66
  variables: {
@@ -79,32 +72,26 @@ Deno.test("persist-escalation heals a missing pull_requests parent before record
79
72
  prNumber: 8,
80
73
  },
81
74
  };
82
- // deno-lint-ignore no-explicit-any
83
75
  await handler(job as any, app as any);
84
76
  assertEquals(inserts.pull_requests?.length, 1, "the missing parent is reconstructed");
85
77
  // Assert against the reconstruction insert payload (ensurePr) rather than the stored row: the
86
78
  // fake update() doesn't apply patches, so the row would otherwise still read the insert's
87
79
  // "converging" status and mask the worker's real final state.
88
- // deno-lint-ignore no-explicit-any
89
80
  const healed = inserts.pull_requests![0] as any;
90
81
  assertEquals(healed.status, "converging", "the healed parent starts in the converging aggregate");
91
82
  assertEquals(inserts.rounds.length, 1, "the round is still recorded");
92
83
  assertEquals(inserts.escalations.length, 1, "the escalation is still opened");
93
84
  // And the worker still moves the (now-present) PR to escalated as its final state.
94
85
  assertEquals(updates.pull_requests!.length, 1, "the PR is updated once after the heal");
95
- // deno-lint-ignore no-explicit-any
96
86
  assertEquals((updates.pull_requests![0] as any).patch.status, "escalated");
97
87
  });
98
88
 
99
89
 
100
- Deno.test("a padded question is persisted trimmed (no whitespace drift)", async () => {
90
+ test("a padded question is persisted trimmed (no whitespace drift)", async () => {
101
91
  const { app, inserts, updates } = fakeApp();
102
92
  const job = { variables: { prKey: "o/r#1", round: 4, status: "needs_input", question: " needs a decision " } };
103
- // deno-lint-ignore no-explicit-any
104
93
  await handler(job as any, app as any);
105
- // deno-lint-ignore no-explicit-any
106
94
  assertEquals((inserts.escalations[0] as any).question, "needs a decision", "escalation stores the trimmed question");
107
- // deno-lint-ignore no-explicit-any
108
95
  assertEquals((updates.pull_requests![0] as any).patch.open_escalation_question, "needs a decision", "denormalised question is trimmed too");
109
96
  });
110
97
 
@@ -113,7 +100,7 @@ Deno.test("a padded question is persisted trimmed (no whitespace drift)", async
113
100
  // on Magikcraft/nano-bpm #597/#599) must NOT throw (which parked an un-remediable JobNoRetries
114
101
  // incident). It now opens an *answerable* escalation with a fabricated, concrete question and the
115
102
  // agent's transcript attached, so a human can unblock the loop entirely from the UI.
116
- Deno.test("blank question fabricates an answerable escalation (no throw, no incident)", async () => {
103
+ test("blank question fabricates an answerable escalation (no throw, no incident)", async () => {
117
104
  for (const question of [undefined, "", " "]) {
118
105
  const { app, inserts, updates } = fakeApp();
119
106
  const job = {
@@ -124,12 +111,9 @@ Deno.test("blank question fabricates an answerable escalation (no throw, no inci
124
111
  "io.nanobpm.agentResult": { output: "the agent's prose review, no result file" },
125
112
  },
126
113
  };
127
- // deno-lint-ignore no-explicit-any
128
114
  const out = await handler(job as any, app as any);
129
- // deno-lint-ignore no-explicit-any
130
115
  assertEquals((out as any).escalationId, 42, "an escalation is opened, not refused");
131
116
  assertEquals(inserts.escalations.length, 1, "escalation row written");
132
- // deno-lint-ignore no-explicit-any
133
117
  const esc = inserts.escalations[0] as any;
134
118
  assert(esc.question.trim().length > 0, "fabricated question is concrete/non-blank");
135
119
  assert(
@@ -139,7 +123,6 @@ Deno.test("blank question fabricates an answerable escalation (no throw, no inci
139
123
  assertEquals(esc.transcript, "the agent's prose review, no result file", "transcript attached");
140
124
  // Default status for an unclassified escalation is a question needing input.
141
125
  assertEquals(esc.kind, "question");
142
- // deno-lint-ignore no-explicit-any
143
126
  const pr = updates.pull_requests![0] as any;
144
127
  assertEquals(pr.patch.open_escalation_question, esc.question, "denormalised question set");
145
128
  }
@@ -147,12 +130,10 @@ Deno.test("blank question fabricates an answerable escalation (no throw, no inci
147
130
 
148
131
  // When a non-empty-but-unclassified status arrives with no question, the fabricated question
149
132
  // names the status so the human sees what the agent reported.
150
- Deno.test("unclassified status without a question names the status in the fabricated question", async () => {
133
+ test("unclassified status without a question names the status in the fabricated question", async () => {
151
134
  const { app, inserts } = fakeApp();
152
135
  const job = { variables: { prKey: "o/r#1", round: 3, status: "in_progress" } };
153
- // deno-lint-ignore no-explicit-any
154
136
  await handler(job as any, app as any);
155
- // deno-lint-ignore no-explicit-any
156
137
  const esc = inserts.escalations[0] as any;
157
138
  assert(esc.question.includes("in_progress"), "fabricated question references the raw status");
158
139
  assertEquals(esc.kind, "blocker", "a non needs_input status is a blocker escalation");
@@ -160,7 +141,7 @@ Deno.test("unclassified status without a question names the status in the fabric
160
141
 
161
142
  // When repo/prNumber process variables are absent the heal still runs by parsing the canonical
162
143
  // `owner/repo#N` prKey, so the escalation's FK parent is never left unguarded.
163
- Deno.test("persist-escalation heals from the prKey when repo/prNumber are absent", async () => {
144
+ test("persist-escalation heals from the prKey when repo/prNumber are absent", async () => {
164
145
  const { app, inserts } = fakeApp();
165
146
  const job = {
166
147
  variables: {
@@ -171,10 +152,8 @@ Deno.test("persist-escalation heals from the prKey when repo/prNumber are absent
171
152
  abandonUrl: "https://host/hooks/abandon?token=TOK-en_123",
172
153
  },
173
154
  };
174
- // deno-lint-ignore no-explicit-any
175
155
  await handler(job as any, app as any);
176
156
  assertEquals(inserts.pull_requests?.length, 1, "the parent is reconstructed from the prKey");
177
- // deno-lint-ignore no-explicit-any
178
157
  const healed = inserts.pull_requests![0] as any;
179
158
  assertEquals(healed.repo, "o/r");
180
159
  assertEquals(healed.number, 12);