@nanobpm/nano-workforce 0.26.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 (115) hide show
  1. package/.github/workflows/ci.yml +60 -0
  2. package/.github/workflows/release.yml +58 -0
  3. package/.releaserc.json +17 -0
  4. package/AGENTS.md +168 -0
  5. package/CHANGELOG.md +231 -0
  6. package/LICENSE +202 -0
  7. package/README.md +303 -0
  8. package/SPEC.md +492 -0
  9. package/actions/abandon.test.ts +93 -0
  10. package/actions/abandon.ts +23 -0
  11. package/actions/blackboard.test.ts +195 -0
  12. package/actions/blackboard.ts +76 -0
  13. package/actions/cancel.ts +29 -0
  14. package/actions/feature-answer-hook.ts +44 -0
  15. package/actions/message.ts +49 -0
  16. package/actions/plan-hook.ts +19 -0
  17. package/actions/plan-start.ts +17 -0
  18. package/actions/start.ts +19 -0
  19. package/actions/status.ts +22 -0
  20. package/actions/webhook-submit.ts +21 -0
  21. package/app/abandon.test.ts +97 -0
  22. package/app/abandon.ts +105 -0
  23. package/app/baseGuard.test.ts +35 -0
  24. package/app/baseGuard.ts +62 -0
  25. package/app/blackboard.test.ts +295 -0
  26. package/app/blackboard.ts +301 -0
  27. package/app/github.test.ts +59 -0
  28. package/app/github.ts +647 -0
  29. package/app/mergeExclusion.test.ts +168 -0
  30. package/app/mergeExclusion.ts +211 -0
  31. package/app/mergeProtocol.test.ts +124 -0
  32. package/app/mergeProtocol.ts +193 -0
  33. package/app/mergeRebaseArm.test.ts +72 -0
  34. package/app/mergeTrain.test.ts +91 -0
  35. package/app/mergeTrain.ts +117 -0
  36. package/app/persist-escalation.test.ts +119 -0
  37. package/app/persist-round.test.ts +65 -0
  38. package/app/plan.test.ts +317 -0
  39. package/app/plan.ts +321 -0
  40. package/app/record-plan-review.test.ts +38 -0
  41. package/app/reviewWait.test.ts +70 -0
  42. package/app/reviewWait.ts +59 -0
  43. package/app/rounds.test.ts +74 -0
  44. package/app/rounds.ts +48 -0
  45. package/app/service.test.ts +101 -0
  46. package/app/service.ts +895 -0
  47. package/app/taskDelta.test.ts +144 -0
  48. package/app/taskDelta.ts +175 -0
  49. package/app/trialMerge.test.ts +15 -0
  50. package/app/trialMerge.ts +102 -0
  51. package/app/waves.test.ts +128 -0
  52. package/app/waves.ts +116 -0
  53. package/assets/icon.svg +13 -0
  54. package/components/review-round.json +69 -0
  55. package/db/migrations/001_init.sql +46 -0
  56. package/db/migrations/002_transcript.sql +7 -0
  57. package/db/migrations/003_open_escalation.sql +8 -0
  58. package/db/migrations/004_merge.sql +36 -0
  59. package/db/migrations/004_planning.sql +37 -0
  60. package/db/migrations/005_job_activation.sql +15 -0
  61. package/db/migrations/005_plan_deps.sql +20 -0
  62. package/db/migrations/006_plan_review.sql +22 -0
  63. package/db/migrations/006_task_escalation.sql +52 -0
  64. package/db/migrations/007_plan_review_job_key.sql +14 -0
  65. package/db/migrations/007_wave_gate.sql +16 -0
  66. package/db/migrations/008_review_nudge.sql +9 -0
  67. package/db/migrations/009_plan_blackboard.sql +46 -0
  68. package/db/migrations/010_plan_task_deltas.sql +27 -0
  69. package/db/migrations/011_plan_merge_exclusions.sql +26 -0
  70. package/db/migrations/012_merge_protocol_attempt.sql +4 -0
  71. package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
  72. package/db/migrations/014_plan_trial_merges.sql +21 -0
  73. package/db/migrations/015_pr_abandon_token.sql +9 -0
  74. package/deno.json +24 -0
  75. package/deno.lock +1776 -0
  76. package/main.ts +71 -0
  77. package/nano-ide.ext.json +7 -0
  78. package/nano.app.json +138 -0
  79. package/nanobpm.project.json +20 -0
  80. package/package.json +56 -0
  81. package/pages/epic.page.json +195 -0
  82. package/pages/home.page.json +296 -0
  83. package/prompts/feature.md +132 -0
  84. package/prompts/fix-ci.md +65 -0
  85. package/prompts/plan-review.md +69 -0
  86. package/prompts/plan.md +183 -0
  87. package/prompts/rebase.md +82 -0
  88. package/prompts/review-round.md +171 -0
  89. package/prompts/trial-merge.md +43 -0
  90. package/renovate.json +21 -0
  91. package/resources/processes/convergence-loop.bpmn +399 -0
  92. package/resources/processes/merge-loop.bpmn +585 -0
  93. package/resources/processes/plan-fanout.bpmn +546 -0
  94. package/scripts/check-agent-prompts.test.ts +84 -0
  95. package/scripts/check-agent-prompts.ts +143 -0
  96. package/scripts/layout-bpmn.ts +99 -0
  97. package/scripts/purge-db.ts +57 -0
  98. package/scripts/upgrade-from-pack.ts +334 -0
  99. package/tsconfig.json +51 -0
  100. package/workers/arm-merge/worker.ts +18 -0
  101. package/workers/finalize/worker.ts +89 -0
  102. package/workers/mark-merged/worker.ts +21 -0
  103. package/workers/merge/worker.ts +119 -0
  104. package/workers/persist-escalation/worker.ts +107 -0
  105. package/workers/persist-round/worker.ts +52 -0
  106. package/workers/persist-task-escalation/worker.ts +112 -0
  107. package/workers/record-plan/worker.ts +135 -0
  108. package/workers/record-plan-review/worker.ts +92 -0
  109. package/workers/record-results/worker.ts +30 -0
  110. package/workers/record-trial-merge/worker.test.ts +104 -0
  111. package/workers/record-trial-merge/worker.ts +88 -0
  112. package/workers/record-wave/worker.test.ts +221 -0
  113. package/workers/record-wave/worker.ts +308 -0
  114. package/workers/select-wave/worker.test.ts +130 -0
  115. package/workers/select-wave/worker.ts +84 -0
@@ -0,0 +1,168 @@
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";
3
+ import type { DataLayer } from "@nanobpm/urban";
4
+ import {
5
+ clearExclusions,
6
+ deriveExclusions,
7
+ type ExclusionEdge,
8
+ mergeLanes,
9
+ normalizePair,
10
+ readExclusions,
11
+ recordExclusions,
12
+ } from "./mergeExclusion.ts";
13
+ import { computeWaves } from "./waves.ts";
14
+
15
+ // In-memory record-gateway fake (insert/find/findOne/update/delete), mirroring the app tests.
16
+ // deno-lint-ignore no-explicit-any
17
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
18
+ // deno-lint-ignore no-explicit-any
19
+ const stores: Record<string, any[]> = {};
20
+ const seq: Record<string, number> = {};
21
+ function tbl(name: string, pk = "id") {
22
+ // deno-lint-ignore no-explicit-any
23
+ const rows = (stores[name] ??= [] as any[]);
24
+ // deno-lint-ignore no-explicit-any
25
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
26
+ return {
27
+ // deno-lint-ignore no-explicit-any require-await
28
+ async insert(row: any) {
29
+ const id = (seq[name] = (seq[name] ?? 0) + 1);
30
+ rows.push({ id, ...row });
31
+ return id;
32
+ },
33
+ // deno-lint-ignore no-explicit-any require-await
34
+ async find(where: any = {}) {
35
+ return rows.filter((r) => match(r, where));
36
+ },
37
+ // deno-lint-ignore no-explicit-any require-await
38
+ async findOne(where: any = {}) {
39
+ return rows.find((r) => match(r, where));
40
+ },
41
+ // deno-lint-ignore no-explicit-any require-await
42
+ async update(id: any, patch: any) {
43
+ const r = rows.find((row) => row.id === id);
44
+ if (r) Object.assign(r, patch);
45
+ },
46
+ // deno-lint-ignore no-explicit-any require-await
47
+ async delete(id: any) {
48
+ const i = rows.findIndex((row) => row.id === id);
49
+ if (i >= 0) rows.splice(i, 1);
50
+ },
51
+ };
52
+ }
53
+ // deno-lint-ignore no-explicit-any
54
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
55
+ return { data, stores };
56
+ }
57
+
58
+ const files = (e: ExclusionEdge) => e.files;
59
+
60
+ Deno.test("normalizePair: orders deterministically and rejects self/blank pairs", () => {
61
+ assertEquals(normalizePair("b", "a"), ["a", "b"]);
62
+ assertEquals(normalizePair("a", "b"), ["a", "b"]);
63
+ assertEquals(normalizePair("a", "a"), null, "a task never excludes itself");
64
+ assertEquals(normalizePair("", "a"), null);
65
+ });
66
+
67
+ Deno.test("deriveExclusions: an edge per file-overlapping pair, carrying the sorted overlap", () => {
68
+ const edges = deriveExclusions(
69
+ new Map([
70
+ ["gap-2", ["engine/tests.rs", "engine/state.rs"]],
71
+ ["gap-8", ["engine/tests.rs"]],
72
+ ["gap-9", ["engine/tests.rs", "engine/state.rs"]],
73
+ ["gap-5", ["docs/readme.md"]], // no overlap with anyone
74
+ ]),
75
+ );
76
+ // Pairs with overlap: (gap-2,gap-8) share tests.rs; (gap-2,gap-9) share both; (gap-8,gap-9) tests.rs.
77
+ assertEquals(edges.map((e) => [e.taskA, e.taskB]), [
78
+ ["gap-2", "gap-8"],
79
+ ["gap-2", "gap-9"],
80
+ ["gap-8", "gap-9"],
81
+ ]);
82
+ assertEquals(files(edges[1]), ["engine/state.rs", "engine/tests.rs"], "overlap sorted");
83
+ assert(!edges.some((e) => e.taskA === "gap-5" || e.taskB === "gap-5"), "gap-5 excluded (no overlap)");
84
+ });
85
+
86
+ Deno.test("deriveExclusions: no overlap → no edges; blank paths ignored", () => {
87
+ assertEquals(
88
+ deriveExclusions(new Map([["a", ["x.rs"]], ["b", ["y.rs"]], ["c", ["", " "]]])),
89
+ [],
90
+ );
91
+ });
92
+
93
+ Deno.test("recordExclusions: upserts per unordered pair — a re-scan refreshes files, never duplicates", async () => {
94
+ const { data, stores } = memData();
95
+ const first = await recordExclusions(data, "p", deriveExclusions(
96
+ new Map([["gap-2", ["a.rs"]], ["gap-8", ["a.rs"]]]),
97
+ ));
98
+ assertEquals(first, { inserted: 1, updated: 0 });
99
+
100
+ // Re-scan with a larger overlap and the pair given in the OTHER order → same row, updated.
101
+ const second = await recordExclusions(data, "p", [
102
+ { taskA: "gap-8", taskB: "gap-2", files: ["a.rs", "b.rs"], source: "file-overlap" },
103
+ ]);
104
+ assertEquals(second, { inserted: 0, updated: 1 });
105
+ assertEquals(stores["plan_merge_exclusions"].length, 1, "exactly one row for the pair");
106
+
107
+ const [edge] = await readExclusions(data, "p");
108
+ assertEquals([edge.taskA, edge.taskB], ["gap-2", "gap-8"]);
109
+ assertEquals(edge.files, ["a.rs", "b.rs"], "files refreshed in place");
110
+ });
111
+
112
+ Deno.test("recordExclusions: a duplicate pair within one batch folds into an update, never a second row", async () => {
113
+ const { data, stores } = memData();
114
+ // The same unordered pair appears twice in one call (second given in the other order + more files).
115
+ // The in-memory map must fold the newly inserted id back so the second occurrence updates in place.
116
+ const res = await recordExclusions(data, "p", [
117
+ { taskA: "a", taskB: "b", files: ["x.rs"], source: "file-overlap" },
118
+ { taskA: "b", taskB: "a", files: ["x.rs", "y.rs"], source: "file-overlap" },
119
+ ]);
120
+ assertEquals(res, { inserted: 1, updated: 1 });
121
+ assertEquals(stores["plan_merge_exclusions"].length, 1, "exactly one row for the pair");
122
+ const [edge] = await readExclusions(data, "p");
123
+ assertEquals(edge.files, ["x.rs", "y.rs"], "later occurrence refreshed files in place");
124
+ });
125
+
126
+ Deno.test("clearExclusions: drops one plan's graph, leaving others intact", async () => {
127
+ const { data } = memData();
128
+ await recordExclusions(data, "p", deriveExclusions(new Map([["a", ["x"]], ["b", ["x"]]])));
129
+ await recordExclusions(data, "q", deriveExclusions(new Map([["c", ["y"]], ["d", ["y"]]])));
130
+ await clearExclusions(data, "p");
131
+ assertEquals((await readExclusions(data, "p")).length, 0);
132
+ assertEquals((await readExclusions(data, "q")).length, 1, "the other plan survives");
133
+ });
134
+
135
+ Deno.test("mergeLanes: connected components are serial landing lanes; singletons land in parallel", () => {
136
+ // gap-2—gap-8—gap-9 form one chain (transitive shared surface); gap-5 stands alone.
137
+ const edges = deriveExclusions(
138
+ new Map([
139
+ ["gap-2", ["a.rs"]],
140
+ ["gap-8", ["a.rs", "b.rs"]],
141
+ ["gap-9", ["b.rs"]],
142
+ ["gap-5", ["z.rs"]],
143
+ ]),
144
+ );
145
+ const lanes = mergeLanes(edges, ["gap-2", "gap-8", "gap-9", "gap-5"]);
146
+ assertEquals(lanes, [["gap-2", "gap-8", "gap-9"], ["gap-5"]]);
147
+ });
148
+
149
+ Deno.test("mergeLanes: with no edges, every task is its own lane (fully parallel landing)", () => {
150
+ assertEquals(mergeLanes([], ["b", "a", "c"]), [["a"], ["b"], ["c"]]);
151
+ });
152
+
153
+ Deno.test("D1 invariant: a merge-exclusion is NOT a dispatch dependency", () => {
154
+ // Two tasks that collide on a shared file but declare no build-on dependency.
155
+ const overlap = new Map([["gap-2", ["engine/tests.rs"]], ["gap-8", ["engine/tests.rs"]]]);
156
+ const edges = deriveExclusions(overlap);
157
+ const lanes = mergeLanes(edges, ["gap-2", "gap-8"]);
158
+
159
+ // Landing: they share ONE lane → must land serially.
160
+ assertEquals(lanes, [["gap-2", "gap-8"]]);
161
+
162
+ // Dispatch: computeWaves sees only `dependsOn` (never the exclusion graph), so with no build-on
163
+ // edge both tasks are in wave 0 → dispatched in PARALLEL. This is the whole point of D1: the
164
+ // exclusion never over-encodes into a dispatch barrier.
165
+ const waves = computeWaves([{ id: "gap-2" }, { id: "gap-8" }]);
166
+ assertEquals(waves.waveCount, 1);
167
+ assertEquals(waves.waves[0].sort(), ["gap-2", "gap-8"]);
168
+ });
@@ -0,0 +1,211 @@
1
+ // nano-workforce — the merge-exclusion graph + conflict-scan (D1/D2, issues #57 #58 / #49).
2
+ //
3
+ // The planner already models the DISPATCH-DAG (`plan_task_deps` → waves): edges that gate when a
4
+ // task may *start*. This is the SECOND graph the retro (nano-bpm#614) exposed: **merge-exclusion**
5
+ // — undirected edges between tasks that can run in parallel but touch the same surface and so can't
6
+ // *land* independently. They gate ordering at merge time only and MUST NEVER enter `computeWaves`.
7
+ //
8
+ // D1 (#57) — the graph as data: record/read/clear, and `mergeLanes` (connected components =
9
+ // the serial landing lanes the merge-train, D6, will drive).
10
+ // D2 (#58) — derive the edges MECHANICALLY from file-overlap (`deriveExclusions`): any two tasks
11
+ // whose known touched-file sets intersect get an edge carrying the overlap. Fed from
12
+ // D5's reported `newlyTouches` and/or a PR's actual changed files. This is a
13
+ // conservative over-approximation (file-overlap flags a *potential* landing collision;
14
+ // a textual `git merge-tree` / trial-merge, D3, would confirm/prune it later).
15
+ //
16
+ // Data access goes through the record gateway (`data.table`), never hand-written SQL.
17
+ import type { DataLayer } from "@nanobpm/urban";
18
+
19
+ const now = () => new Date().toISOString();
20
+
21
+ /** The default provenance tag for a file-overlap-derived edge. */
22
+ export const EXCLUSION_SOURCE_FILE_OVERLAP = "file-overlap";
23
+
24
+ /** The stored row shape. `task_a < task_b` (normalised); `files` is JSON-encoded, or NULL. */
25
+ export interface MergeExclusionRow {
26
+ id: number;
27
+ plan_key: string;
28
+ task_a: string;
29
+ task_b: string;
30
+ files: string | null;
31
+ source: string;
32
+ created_at: string;
33
+ updated_at: string;
34
+ }
35
+
36
+ /** One undirected merge-exclusion edge (agent-facing: files decoded, pair normalised). */
37
+ export interface ExclusionEdge {
38
+ taskA: string;
39
+ taskB: string;
40
+ files: string[];
41
+ source: string;
42
+ }
43
+
44
+ /** Order a pair deterministically so an unordered `{a, b}` maps to exactly one `(task_a, task_b)`
45
+ * row (the upsert key). Returns `null` for a self-pair — a task never excludes itself. */
46
+ export function normalizePair(a: string, b: string): [string, string] | null {
47
+ const x = a.trim();
48
+ const y = b.trim();
49
+ if (!x || !y || x === y) return null;
50
+ return x < y ? [x, y] : [y, x];
51
+ }
52
+
53
+ function decodeFiles(raw: string | null): string[] {
54
+ if (!raw) return [];
55
+ try {
56
+ const v = JSON.parse(raw);
57
+ return Array.isArray(v) ? v.map(String) : [];
58
+ } catch {
59
+ return [];
60
+ }
61
+ }
62
+
63
+ /** Derive merge-exclusion edges from a map of `taskId → touched files`: an edge for every pair of
64
+ * distinct tasks whose file sets intersect, carrying the sorted overlap. Pure and deterministic —
65
+ * the same input always yields the same edges in the same order. */
66
+ export function deriveExclusions(
67
+ taskFiles: Map<string, Iterable<string>>,
68
+ source: string = EXCLUSION_SOURCE_FILE_OVERLAP,
69
+ ): ExclusionEdge[] {
70
+ // Normalise to sorted, de-duplicated, non-blank file sets keyed by task, in stable task order.
71
+ const sets: { task: string; files: Set<string> }[] = [];
72
+ for (const [task, files] of taskFiles) {
73
+ const t = task.trim();
74
+ if (!t) continue;
75
+ const set = new Set<string>();
76
+ for (const f of files) {
77
+ const p = String(f).trim();
78
+ if (p) set.add(p);
79
+ }
80
+ if (set.size > 0) sets.push({ task: t, files: set });
81
+ }
82
+ sets.sort((a, b) => (a.task < b.task ? -1 : a.task > b.task ? 1 : 0));
83
+
84
+ const edges: ExclusionEdge[] = [];
85
+ for (let i = 0; i < sets.length; i++) {
86
+ for (let j = i + 1; j < sets.length; j++) {
87
+ const overlap: string[] = [];
88
+ for (const f of sets[i].files) if (sets[j].files.has(f)) overlap.push(f);
89
+ if (overlap.length === 0) continue;
90
+ const pair = normalizePair(sets[i].task, sets[j].task);
91
+ if (!pair) continue;
92
+ edges.push({ taskA: pair[0], taskB: pair[1], files: overlap.sort(), source });
93
+ }
94
+ }
95
+ return edges;
96
+ }
97
+
98
+ const exclusionTable = (data: DataLayer) =>
99
+ data.table<MergeExclusionRow>("plan_merge_exclusions", "id");
100
+
101
+ /** Upsert derived edges for a plan: one row per unordered pair, `files` refreshed in place on a
102
+ * re-scan. Idempotent — re-running the scan never duplicates a pair. Existing pairs are preloaded
103
+ * with a single `find({plan_key})` into an in-memory map (keyed by the normalised pair) so a dense
104
+ * wave costs O(edges) writes instead of an `await findOne(...)` round-trip per edge (worst-case
105
+ * O(n²) queries). Newly inserted ids are folded back into the map so a duplicate pair in the same
106
+ * batch updates rather than inserts twice. */
107
+ export async function recordExclusions(
108
+ data: DataLayer,
109
+ planKey: string,
110
+ edges: ExclusionEdge[],
111
+ ): Promise<{ inserted: number; updated: number }> {
112
+ const table = exclusionTable(data);
113
+ const ts = now();
114
+ let inserted = 0;
115
+ let updated = 0;
116
+ const key = (a: string, b: string) => `${a}\u0000${b}`;
117
+ const byPair = new Map<string, number>();
118
+ for (const r of await table.find({ plan_key: planKey })) {
119
+ byPair.set(key(r.task_a, r.task_b), r.id);
120
+ }
121
+ for (const e of edges) {
122
+ const pair = normalizePair(e.taskA, e.taskB);
123
+ if (!pair) continue;
124
+ const files = e.files.length ? JSON.stringify(e.files) : null;
125
+ const existingId = byPair.get(key(pair[0], pair[1]));
126
+ if (existingId !== undefined) {
127
+ await table.update(existingId, { files, source: e.source, updated_at: ts });
128
+ updated++;
129
+ } else {
130
+ const id = await table.insert({
131
+ plan_key: planKey,
132
+ task_a: pair[0],
133
+ task_b: pair[1],
134
+ files,
135
+ source: e.source,
136
+ created_at: ts,
137
+ updated_at: ts,
138
+ });
139
+ byPair.set(key(pair[0], pair[1]), Number(id));
140
+ inserted++;
141
+ }
142
+ }
143
+ return { inserted, updated };
144
+ }
145
+
146
+ /** A plan's exclusion graph in write order (files decoded). */
147
+ export async function readExclusions(data: DataLayer, planKey: string): Promise<ExclusionEdge[]> {
148
+ const rows = await exclusionTable(data).find({ plan_key: planKey });
149
+ return rows
150
+ .slice()
151
+ .sort((a, b) => a.id - b.id)
152
+ .map((r) => ({ taskA: r.task_a, taskB: r.task_b, files: decodeFiles(r.files), source: r.source }));
153
+ }
154
+
155
+ /** Delete a plan's whole exclusion graph (re-plan cleanup). */
156
+ export async function clearExclusions(data: DataLayer, planKey: string): Promise<void> {
157
+ for (const r of await exclusionTable(data).find({ plan_key: planKey })) {
158
+ await exclusionTable(data).delete(r.id);
159
+ }
160
+ }
161
+
162
+ /** Group tasks into serial LANDING LANES: each connected component of the exclusion graph is a
163
+ * lane whose members must land one-at-a-time (they collide on a shared surface), while separate
164
+ * lanes land in parallel. `allTasks` (optional) seeds singleton lanes for tasks with no exclusion
165
+ * so a caller gets the full partition. Lanes and their members are sorted for determinism.
166
+ *
167
+ * This is the merge-train's (D6) input; it is a LANDING order, never a dispatch order — these
168
+ * lanes must not be fed back into `computeWaves`. */
169
+ export function mergeLanes(edges: ExclusionEdge[], allTasks: Iterable<string> = []): string[][] {
170
+ const parent = new Map<string, string>();
171
+ const find = (x: string): string => {
172
+ let root = x;
173
+ while (parent.get(root) !== root) root = parent.get(root)!;
174
+ // Path-compress so repeated finds stay near-flat.
175
+ let cur = x;
176
+ while (parent.get(cur) !== root) {
177
+ const next = parent.get(cur)!;
178
+ parent.set(cur, root);
179
+ cur = next;
180
+ }
181
+ return root;
182
+ };
183
+ const add = (x: string) => {
184
+ if (!parent.has(x)) parent.set(x, x);
185
+ };
186
+ const union = (a: string, b: string) => {
187
+ add(a);
188
+ add(b);
189
+ const ra = find(a);
190
+ const rb = find(b);
191
+ if (ra !== rb) parent.set(ra < rb ? rb : ra, ra < rb ? ra : rb);
192
+ };
193
+
194
+ for (const t of allTasks) add(t.trim());
195
+ for (const e of edges) {
196
+ const pair = normalizePair(e.taskA, e.taskB);
197
+ if (pair) union(pair[0], pair[1]);
198
+ }
199
+
200
+ const byRoot = new Map<string, string[]>();
201
+ for (const task of parent.keys()) {
202
+ if (!task) continue;
203
+ const root = find(task);
204
+ const lane = byRoot.get(root) ?? [];
205
+ lane.push(task);
206
+ byRoot.set(root, lane);
207
+ }
208
+ return [...byRoot.values()]
209
+ .map((lane) => lane.sort())
210
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
211
+ }
@@ -0,0 +1,124 @@
1
+ // Contract for the per-repo merge protocol (#43): the descriptor parser must be total (any
2
+ // malformed/partial input degrades to DEFAULT_MERGE_PROTOCOL, never throws), the AGENTS.md block
3
+ // extractor must find the fenced ```merge-protocol JSON, and the fresh-head-run decision must fire
4
+ // exactly once per landing attempt — only in the frugal-CI stuck state (no head run + waiting) —
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";
8
+ import {
9
+ DEFAULT_MERGE_PROTOCOL,
10
+ extractProtocolBlock,
11
+ freshHeadRunAction,
12
+ type MergeProtocol,
13
+ parseMergeProtocol,
14
+ } from "./mergeProtocol.ts";
15
+
16
+ Deno.test("parseMergeProtocol: non-object / junk → defaults (total, never throws)", () => {
17
+ assertEquals(parseMergeProtocol(undefined), { ...DEFAULT_MERGE_PROTOCOL });
18
+ assertEquals(parseMergeProtocol(null), { ...DEFAULT_MERGE_PROTOCOL });
19
+ assertEquals(parseMergeProtocol("nope"), { ...DEFAULT_MERGE_PROTOCOL });
20
+ assertEquals(parseMergeProtocol([1, 2]), { ...DEFAULT_MERGE_PROTOCOL });
21
+ });
22
+
23
+ Deno.test("parseMergeProtocol: full nano-bpm-style descriptor", () => {
24
+ const got = parseMergeProtocol({
25
+ autoMerge: false,
26
+ freshHeadRun: "ready-or-reopen",
27
+ waitForChecks: true,
28
+ land: { method: "mergify-queue", comment: "@mergifyio queue" },
29
+ requiredChecks: ["rustfmt (pinned nightly)", "server (clippy + test)"],
30
+ doc: "AGENTS.md#merging-prs",
31
+ });
32
+ assertEquals(got.autoMerge, false);
33
+ assertEquals(got.freshHeadRun, "ready-or-reopen");
34
+ assertEquals(got.waitForChecks, true);
35
+ assertEquals(got.land, { method: "mergify-queue", comment: "@mergifyio queue" });
36
+ assertEquals(got.requiredChecks.length, 2);
37
+ assertEquals(got.doc, "AGENTS.md#merging-prs");
38
+ });
39
+
40
+ Deno.test("parseMergeProtocol: invalid enums / wrong types fall back per-field", () => {
41
+ const got = parseMergeProtocol({
42
+ autoMerge: "yes", // not a boolean → default
43
+ freshHeadRun: "sometimes", // not in the enum → default (none)
44
+ land: { method: "teleport" }, // not in the enum → default (gh-merge)
45
+ requiredChecks: ["ok", 7, null], // keep only strings
46
+ });
47
+ assertEquals(got.autoMerge, DEFAULT_MERGE_PROTOCOL.autoMerge);
48
+ assertEquals(got.freshHeadRun, "none");
49
+ assertEquals(got.land.method, "gh-merge");
50
+ assertEquals(got.requiredChecks, ["ok"]);
51
+ });
52
+
53
+ Deno.test("parseMergeProtocol: comment dropped when absent", () => {
54
+ const got = parseMergeProtocol({ land: { method: "admin" } });
55
+ assertEquals(got.land, { method: "admin" });
56
+ });
57
+
58
+ Deno.test("extractProtocolBlock: finds the fenced merge-protocol JSON (with info word)", () => {
59
+ const md = [
60
+ "## Merging PRs",
61
+ "Some prose about how to merge.",
62
+ "",
63
+ "```merge-protocol json",
64
+ '{ "autoMerge": false, "land": { "method": "mergify-queue" } }',
65
+ "```",
66
+ "",
67
+ "More prose.",
68
+ ].join("\n");
69
+ const block = extractProtocolBlock(md);
70
+ assertEquals(block !== null, true);
71
+ const p = parseMergeProtocol(JSON.parse(block ?? "{}"));
72
+ assertEquals(p.autoMerge, false);
73
+ assertEquals(p.land.method, "mergify-queue");
74
+ });
75
+
76
+ Deno.test("extractProtocolBlock: none present → null", () => {
77
+ assertEquals(extractProtocolBlock("# Doc\n```json\n{}\n```\n"), null);
78
+ });
79
+
80
+ const NANO: MergeProtocol = parseMergeProtocol({
81
+ freshHeadRun: "ready-or-reopen",
82
+ land: { method: "mergify-queue" },
83
+ });
84
+
85
+ Deno.test("freshHeadRunAction: fires in the frugal-CI stuck state (no run + waiting)", () => {
86
+ // ready PR, no head run at all → reopen (ready-or-reopen, not a draft)
87
+ assertEquals(freshHeadRunAction(NANO, "waiting", 0, false), "reopen");
88
+ // draft PR, no head run → mark ready
89
+ assertEquals(freshHeadRunAction(NANO, "waiting", 0, true), "ready");
90
+ });
91
+
92
+ Deno.test("freshHeadRunAction: fires once per landing-attempt head, then re-fires after rebase", () => {
93
+ assertEquals(
94
+ freshHeadRunAction(NANO, "waiting", 0, false, { headRefOid: "h1", lastActionHeadRefOid: null }),
95
+ "reopen",
96
+ );
97
+ assertEquals(
98
+ freshHeadRunAction(NANO, "waiting", 0, false, { headRefOid: "h1", lastActionHeadRefOid: "h1" }),
99
+ null,
100
+ );
101
+ assertEquals(
102
+ freshHeadRunAction(NANO, "waiting", 0, false, { headRefOid: "h2", lastActionHeadRefOid: "h1" }),
103
+ "reopen",
104
+ );
105
+ });
106
+
107
+ Deno.test("freshHeadRunAction: never fires once a run exists, or when not waiting", () => {
108
+ assertEquals(freshHeadRunAction(NANO, "waiting", 1, false), null); // run already in flight
109
+ assertEquals(freshHeadRunAction(NANO, "waiting", -1, false), null); // token mode (unknown) → conservative
110
+ assertEquals(freshHeadRunAction(NANO, "ready", 0, false), null); // already landable
111
+ assertEquals(freshHeadRunAction(NANO, "blocked", 0, false), null); // failed check → fix-ci arm
112
+ assertEquals(freshHeadRunAction(NANO, "blocked", 0, false, { headRefOid: "h2", lastActionHeadRefOid: "h1" }), null);
113
+ assertEquals(freshHeadRunAction(NANO, "conflict", 0, false), null); // conflict → rebase arm (#42)
114
+ });
115
+
116
+ Deno.test("freshHeadRunAction: protocol.freshHeadRun=none is a no-op (default repos unchanged)", () => {
117
+ assertEquals(freshHeadRunAction(DEFAULT_MERGE_PROTOCOL, "waiting", 0, false), null);
118
+ });
119
+
120
+ Deno.test("freshHeadRunAction: mode=ready only acts on drafts", () => {
121
+ const readyOnly = parseMergeProtocol({ freshHeadRun: "ready", land: { method: "gh-merge" } });
122
+ assertEquals(freshHeadRunAction(readyOnly, "waiting", 0, true), "ready");
123
+ assertEquals(freshHeadRunAction(readyOnly, "waiting", 0, false), null); // not a draft → nothing to ready
124
+ });