@nanobpm/nano-workforce 0.36.0 → 0.38.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.
- package/.github/workflows/ci.yml +7 -17
- package/AGENTS.md +8 -7
- package/CHANGELOG.md +14 -0
- package/README.md +6 -27
- package/SPEC.md +15 -13
- package/app/abandon.test.ts +14 -19
- package/app/abandon.ts +2 -2
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +25 -35
- package/app/blackboard.ts +2 -2
- package/app/ensure-pr.test.ts +10 -12
- package/app/github.test.ts +9 -8
- package/app/github.ts +1 -21
- package/app/instance-tracking.test.ts +8 -6
- package/app/mergeExclusion.test.ts +11 -20
- package/app/mergeProtocol.test.ts +14 -13
- package/app/mergeRebaseArm.test.ts +8 -6
- package/app/mergeTrain.test.ts +10 -9
- package/app/persist-escalation.test.ts +10 -31
- package/app/persist-round.test.ts +7 -20
- package/app/plan.test.ts +19 -42
- package/app/record-plan-review.test.ts +8 -7
- package/app/retro.test.ts +24 -48
- package/app/reviewWait.test.ts +12 -11
- package/app/rounds.test.ts +13 -12
- package/app/service.test.ts +14 -27
- package/app/service.ts +1 -1
- package/app/taskDelta.test.ts +8 -17
- package/app/trialMerge.test.ts +4 -3
- package/app/version.ts +6 -21
- package/app/waves.test.ts +17 -16
- package/biome.json +0 -2
- package/main.ts +3 -3
- package/nano.app.json +2 -25
- package/openapi.yaml +649 -0
- package/operations/answerFeatureEscalation.test.ts +100 -0
- package/operations/answerFeatureEscalation.ts +49 -0
- package/operations/appendBlackboard.ts +61 -0
- package/{actions → operations}/blackboard.test.ts +28 -30
- package/{actions/abandon.test.ts → operations/checkAbandon.test.ts} +12 -26
- package/{actions/abandon.ts → operations/checkAbandon.ts} +8 -7
- package/operations/getVersion.test.ts +8 -12
- package/operations/getVersion.ts +2 -3
- package/operations/listActivePrs.test.ts +8 -14
- package/operations/listActivePrs.ts +3 -4
- package/operations/postMessage.ts +1 -1
- package/operations/readBlackboard.ts +29 -0
- package/operations/startAndMessage.test.ts +11 -15
- package/operations/startConvergenceLoop.ts +17 -11
- package/operations/startPlanFanout.ts +13 -7
- package/package.json +9 -7
- package/pages/epic.page.json +1 -1
- package/pages/home.page.json +1 -1
- package/scripts/check-agent-prompts.test.ts +15 -11
- package/scripts/layout-bpmn.ts +6 -28
- package/scripts/pages-contract.test.ts +14 -13
- package/scripts/purge-db.ts +1 -1
- package/scripts/upgrade-from-pack.ts +1 -1
- package/test/assert.ts +74 -0
- package/tsconfig.json +4 -1
- package/workers/persist-task-escalation/worker.ts +1 -1
- package/workers/record-plan-review/worker.test.ts +6 -9
- package/workers/record-results/worker.test.ts +5 -8
- package/workers/record-trial-merge/worker.test.ts +7 -18
- package/workers/record-wave/worker.test.ts +13 -20
- package/workers/retro-gather/worker.test.ts +4 -17
- package/workers/retro-record/worker.test.ts +8 -27
- package/workers/select-wave/worker.test.ts +5 -8
- package/actions/blackboard.ts +0 -77
- package/actions/feature-answer-hook.ts +0 -45
- package/actions/plan-hook.ts +0 -20
- package/actions/webhook-submit.ts +0 -22
- package/deno.json +0 -24
- package/deno.lock +0 -1777
- package/openapi.json +0 -248
package/app/blackboard.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// stable `dedupe_key` is a no-op (backed by a unique index; we also short-circuit here).
|
|
14
14
|
// - CAPABILITY URL. The per-plan token IS the credential; the agent curls the exact URL it was
|
|
15
15
|
// handed (delivered in `appendPrompt`). Delivery is in-band (rides the prompt the harness
|
|
16
|
-
// already forwards); use is out-of-band (a direct side-channel to `/hooks/blackboard`).
|
|
16
|
+
// already forwards); use is out-of-band (a direct side-channel to `/app/api/hooks/blackboard`).
|
|
17
17
|
//
|
|
18
18
|
// Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
|
|
19
19
|
// app/service.ts and app/plan.ts.
|
|
@@ -87,7 +87,7 @@ export function publicBaseUrl(env: string | undefined = process.env.NANO_PR_PUBL
|
|
|
87
87
|
/** The capability URL for a plan's blackboard: the token rides the query string, so the agent can
|
|
88
88
|
* GET/POST the exact string it was handed with no header assembly. */
|
|
89
89
|
export function blackboardUrl(token: string, base: string = publicBaseUrl()): string {
|
|
90
|
-
return `${base}/hooks/blackboard?token=${encodeURIComponent(token)}`;
|
|
90
|
+
return `${base}/app/api/hooks/blackboard?token=${encodeURIComponent(token)}`;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/** The coordination-protocol block appended (verbatim, via `appendPrompt`) to each implementer
|
package/app/ensure-pr.test.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// `pull_requests` FK parent before a child (`rounds`/`escalations`/`merges`) insert, so an
|
|
3
3
|
// engine/app.db store desync never parks an opaque `FOREIGN KEY constraint failed` incident
|
|
4
4
|
// (observed on convergence-loop instance 94).
|
|
5
|
-
import {
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
6
7
|
import type { DataLayer } from "@nanobpm/urban";
|
|
7
8
|
import { canonicalPrUrl, ensurePr } from "./service.ts";
|
|
8
9
|
|
|
@@ -18,11 +19,9 @@ function memData(opts: { throwOnInsert?: boolean; seedOnThrow?: boolean } = {}):
|
|
|
18
19
|
let insertCalls = 0;
|
|
19
20
|
function tbl(name: string, key: string) {
|
|
20
21
|
return {
|
|
21
|
-
// deno-lint-ignore require-await
|
|
22
22
|
async get(id: string) {
|
|
23
23
|
return rows.get(id);
|
|
24
24
|
},
|
|
25
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
26
25
|
async insert(row: any) {
|
|
27
26
|
insertCalls++;
|
|
28
27
|
if (opts.throwOnInsert) {
|
|
@@ -34,14 +33,13 @@ function memData(opts: { throwOnInsert?: boolean; seedOnThrow?: boolean } = {}):
|
|
|
34
33
|
},
|
|
35
34
|
};
|
|
36
35
|
}
|
|
37
|
-
// deno-lint-ignore no-explicit-any
|
|
38
36
|
const data = { table: (n: string, k: string) => tbl(n, k) } as any as DataLayer;
|
|
39
37
|
return { data, rows, get insertCalls() {
|
|
40
38
|
return insertCalls;
|
|
41
39
|
} };
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
|
|
42
|
+
test("ensurePr is a no-op when the parent already exists", async () => {
|
|
45
43
|
const mem = memData();
|
|
46
44
|
const { data, rows } = mem;
|
|
47
45
|
rows.set("o/r#1", { pr_key: "o/r#1", status: "converging" });
|
|
@@ -52,7 +50,7 @@ Deno.test("ensurePr is a no-op when the parent already exists", async () => {
|
|
|
52
50
|
assertEquals(mem.insertCalls, 0, "insert is never attempted — the no-write guarantee holds");
|
|
53
51
|
});
|
|
54
52
|
|
|
55
|
-
|
|
53
|
+
test("ensurePr reconstructs a minimal converging row when the parent is absent", async () => {
|
|
56
54
|
const { data, rows } = memData();
|
|
57
55
|
await ensurePr(data, { prKey: "o/r#2", repo: "o/r", number: 2, round: 3 });
|
|
58
56
|
const row = rows.get("o/r#2")!;
|
|
@@ -65,7 +63,7 @@ Deno.test("ensurePr reconstructs a minimal converging row when the parent is abs
|
|
|
65
63
|
assert(typeof row.abandon_token === "string" && (row.abandon_token as string).length > 0);
|
|
66
64
|
});
|
|
67
65
|
|
|
68
|
-
|
|
66
|
+
test("ensurePr defaults current_round to 1 (rounds are 1-based) when none is passed", async () => {
|
|
69
67
|
const { data, rows } = memData();
|
|
70
68
|
await ensurePr(data, { prKey: "o/r#5", repo: "o/r", number: 5 });
|
|
71
69
|
assertEquals(
|
|
@@ -75,7 +73,7 @@ Deno.test("ensurePr defaults current_round to 1 (rounds are 1-based) when none i
|
|
|
75
73
|
);
|
|
76
74
|
});
|
|
77
75
|
|
|
78
|
-
|
|
76
|
+
test("ensurePr reuses a supplied abandon token instead of minting a new one", async () => {
|
|
79
77
|
const { data, rows } = memData();
|
|
80
78
|
await ensurePr(data, { prKey: "o/r#7", repo: "o/r", number: 7, abandonToken: "TOK-en_123" });
|
|
81
79
|
assertEquals(
|
|
@@ -85,27 +83,27 @@ Deno.test("ensurePr reuses a supplied abandon token instead of minting a new one
|
|
|
85
83
|
);
|
|
86
84
|
});
|
|
87
85
|
|
|
88
|
-
|
|
86
|
+
test("ensurePr mints a token when none is supplied", async () => {
|
|
89
87
|
const { data, rows } = memData();
|
|
90
88
|
await ensurePr(data, { prKey: "o/r#8", repo: "o/r", number: 8 });
|
|
91
89
|
const tok = rows.get("o/r#8")!.abandon_token;
|
|
92
90
|
assert(typeof tok === "string" && (tok as string).length > 0, "a fresh token is minted as a fallback");
|
|
93
91
|
});
|
|
94
92
|
|
|
95
|
-
|
|
93
|
+
test("ensurePr prefers an explicit url over the canonical one", async () => {
|
|
96
94
|
const { data, rows } = memData();
|
|
97
95
|
const url = "https://github.com/o/r/pull/9";
|
|
98
96
|
await ensurePr(data, { prKey: "o/r#9", repo: "o/r", number: 9, url });
|
|
99
97
|
assertEquals(rows.get("o/r#9")!.url, url);
|
|
100
98
|
});
|
|
101
99
|
|
|
102
|
-
|
|
100
|
+
test("ensurePr swallows an insert race when the row appears anyway", async () => {
|
|
103
101
|
// insert throws (unique-violation / concurrent writer) but the row is now present → healed.
|
|
104
102
|
const { data } = memData({ throwOnInsert: true, seedOnThrow: true });
|
|
105
103
|
await ensurePr(data, { prKey: "o/r#3", repo: "o/r", number: 3 });
|
|
106
104
|
});
|
|
107
105
|
|
|
108
|
-
|
|
106
|
+
test("ensurePr rethrows when the insert fails and the row is still absent", async () => {
|
|
109
107
|
const { data } = memData({ throwOnInsert: true, seedOnThrow: false });
|
|
110
108
|
let threw = false;
|
|
111
109
|
try {
|
package/app/github.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Unit tests for `fetchPrFiles` token-transport paging (issue #58): the D2 conflict-scan must get
|
|
2
2
|
// a COMPLETE file list or a thrown error — never a silently truncated one that under-approximates
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
|
-
import {
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
5
6
|
import { fetchPrFiles } from "./github.ts";
|
|
6
7
|
|
|
7
8
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
@@ -25,31 +26,31 @@ function stubFetch(pages: number[]) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
async function withTokenTransport<T>(pages: number[], fn: () => Promise<T>): Promise<T> {
|
|
28
|
-
const prevMode =
|
|
29
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
29
30
|
const prevFetch = globalThis.fetch;
|
|
30
|
-
|
|
31
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
31
32
|
globalThis.fetch = stubFetch(pages) as typeof fetch;
|
|
32
33
|
try {
|
|
33
34
|
return await fn();
|
|
34
35
|
} finally {
|
|
35
36
|
globalThis.fetch = prevFetch;
|
|
36
|
-
if (prevMode === undefined)
|
|
37
|
-
else
|
|
37
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
38
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
test("fetchPrFiles: returns the complete list for a sub-cap PR (short final page)", async () => {
|
|
42
43
|
const files = await withTokenTransport([100, 42], () => fetchPrFiles("o/r", 1, "tok"));
|
|
43
44
|
assertEquals(files?.length, 142);
|
|
44
45
|
});
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
test("fetchPrFiles: exactly 500 files with no next page is complete, not truncated", async () => {
|
|
47
48
|
// 5 full pages, but no `rel="next"` on the last → the list is exactly complete at the cap.
|
|
48
49
|
const files = await withTokenTransport([100, 100, 100, 100, 100], () => fetchPrFiles("o/r", 2, "tok"));
|
|
49
50
|
assertEquals(files?.length, 500);
|
|
50
51
|
});
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
test("fetchPrFiles: throws when the cap genuinely truncates (full last page + next)", async () => {
|
|
53
54
|
// 6 pages available but only 5 fetched → the 5th page still advertises `rel="next"`.
|
|
54
55
|
await assertRejects(
|
|
55
56
|
() => withTokenTransport([100, 100, 100, 100, 100, 100], () => fetchPrFiles("o/r", 3, "tok")),
|
package/app/github.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// • auto — prefer `gh` when the binary is present; otherwise fall back to `token`.
|
|
10
10
|
//
|
|
11
11
|
// The poller is app-side host glue (main.ts), so host-specific subprocess I/O is allowed here.
|
|
12
|
-
// Cross-runtime: runs under Node (`node:child_process`)
|
|
12
|
+
// Cross-runtime: runs under Node (`node:child_process`).
|
|
13
13
|
|
|
14
14
|
/** A GitHub pull-request review, narrowed to the fields the poller needs. */
|
|
15
15
|
export interface GhReview {
|
|
@@ -26,30 +26,10 @@ export function githubTransport(): GithubTransport {
|
|
|
26
26
|
return t === "gh" || t === "token" ? t : "auto";
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
interface DenoCommandCtor {
|
|
30
|
-
new (
|
|
31
|
-
command: string,
|
|
32
|
-
options: { args: string[]; stdout: "piped"; stderr: "piped" },
|
|
33
|
-
): { output(): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }> };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
29
|
/** Run the host `gh` CLI with the given args (no shell — args are passed as a vector, so a
|
|
37
30
|
* `repo`/`number` from the datastore cannot inject a command). Resolves stdout, rejects on a
|
|
38
31
|
* non-zero exit with stderr as the message. */
|
|
39
32
|
async function runGh(args: string[]): Promise<string> {
|
|
40
|
-
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
41
|
-
const g = globalThis as { Deno?: { Command?: DenoCommandCtor } };
|
|
42
|
-
if (g.Deno?.Command) {
|
|
43
|
-
const { code, stdout, stderr } = await new g.Deno.Command("gh", {
|
|
44
|
-
args,
|
|
45
|
-
stdout: "piped",
|
|
46
|
-
stderr: "piped",
|
|
47
|
-
}).output();
|
|
48
|
-
if (code !== 0) {
|
|
49
|
-
throw new Error(new TextDecoder().decode(stderr).trim() || `gh exited ${code}`);
|
|
50
|
-
}
|
|
51
|
-
return new TextDecoder().decode(stdout);
|
|
52
|
-
}
|
|
53
33
|
const { execFile } = await import("node:child_process");
|
|
54
34
|
return await new Promise<string>((resolve, reject) => {
|
|
55
35
|
execFile(
|
|
@@ -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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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 `
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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 {
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { assert, assertStringIncludes } from "#test-assert";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
14
16
|
|
|
15
|
-
const 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
package/app/mergeTrain.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Red/green regression for D6's pure merge-train lane planner.
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|