@nanobpm/nano-workforce 0.36.0 → 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.
- package/.github/workflows/ci.yml +7 -17
- package/AGENTS.md +8 -7
- package/CHANGELOG.md +7 -0
- package/README.md +1 -21
- package/actions/abandon.test.ts +8 -16
- package/actions/blackboard.test.ts +13 -21
- package/app/abandon.test.ts +10 -15
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +22 -32
- 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 +9 -30
- package/app/persist-round.test.ts +6 -19
- 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/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/operations/getVersion.test.ts +8 -12
- package/operations/getVersion.ts +2 -3
- package/operations/listActivePrs.test.ts +8 -14
- package/operations/listActivePrs.ts +2 -3
- package/operations/startAndMessage.test.ts +6 -12
- package/package.json +6 -4
- 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/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/deno.json +0 -24
- package/deno.lock +0 -1777
|
@@ -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);
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// PR in `waiting_review` so the deterministic poller (app/service.ts) starts soliciting a review.
|
|
7
7
|
// A `waiting` round is what replaced the old failure mode where an agent with nothing to do
|
|
8
8
|
// re-requested the review destructively and escalated `blocked`.
|
|
9
|
-
import {
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assertEquals } from "#test-assert";
|
|
10
11
|
import handler from "../workers/persist-round/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 === "rounds" ? "id" : "pr_key";
|
|
29
|
-
// deno-lint-ignore no-explicit-any
|
|
30
28
|
store.set((row as any)[pk], row);
|
|
31
29
|
return 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
|
},
|
|
@@ -42,20 +39,17 @@ function fakeApp() {
|
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
for (const status of ["addressed", "waiting"]) {
|
|
45
|
-
|
|
42
|
+
test(`persist-round records a '${status}' round and parks the PR in waiting_review`, async () => {
|
|
46
43
|
const { app, inserts, updates } = fakeApp();
|
|
47
44
|
const job = { variables: { prKey: "o/r#1", round: 1, status, summary: `round was ${status}` } };
|
|
48
|
-
// deno-lint-ignore no-explicit-any
|
|
49
45
|
await handler(job as any, app as any);
|
|
50
46
|
|
|
51
47
|
assertEquals(inserts.rounds.length, 1, "the round is recorded");
|
|
52
|
-
// deno-lint-ignore no-explicit-any
|
|
53
48
|
const round = inserts.rounds[0] as any;
|
|
54
49
|
assertEquals(round.status, status, "the round carries the agent's status");
|
|
55
50
|
assertEquals(round.round_no, 1);
|
|
56
51
|
|
|
57
52
|
assertEquals(updates.pull_requests!.length, 1, "the PR is updated once");
|
|
58
|
-
// deno-lint-ignore no-explicit-any
|
|
59
53
|
const patch = (updates.pull_requests![0] as any).patch;
|
|
60
54
|
assertEquals(patch.status, "waiting_review", "the PR parks in waiting_review for the poller");
|
|
61
55
|
assertEquals(patch.current_round, 1);
|
|
@@ -65,7 +59,7 @@ for (const status of ["addressed", "waiting"]) {
|
|
|
65
59
|
// When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
|
|
66
60
|
// desync), persist-round reconstructs the `pull_requests` row before recording the round so the
|
|
67
61
|
// insert never dies with an opaque FOREIGN KEY constraint failure.
|
|
68
|
-
|
|
62
|
+
test("persist-round heals a missing pull_requests parent before recording the round", async () => {
|
|
69
63
|
const { app, inserts, updates } = fakeApp();
|
|
70
64
|
const job = {
|
|
71
65
|
variables: {
|
|
@@ -76,38 +70,33 @@ Deno.test("persist-round heals a missing pull_requests parent before recording t
|
|
|
76
70
|
prNumber: 7,
|
|
77
71
|
},
|
|
78
72
|
};
|
|
79
|
-
// deno-lint-ignore no-explicit-any
|
|
80
73
|
await handler(job as any, app as any);
|
|
81
74
|
|
|
82
75
|
assertEquals(inserts.pull_requests?.length, 1, "the missing parent is reconstructed");
|
|
83
76
|
// Assert against the reconstruction insert payload (ensurePr) rather than the stored row: the
|
|
84
77
|
// fake update() doesn't apply patches, so the row would otherwise still read the insert's
|
|
85
78
|
// "converging" status and mask the worker's real final state.
|
|
86
|
-
// deno-lint-ignore no-explicit-any
|
|
87
79
|
const healed = inserts.pull_requests![0] as any;
|
|
88
80
|
assertEquals(healed.status, "converging", "the healed parent starts in the converging aggregate");
|
|
89
81
|
assertEquals(healed.url, "https://github.com/o/r/pull/7", "URL is derived canonically");
|
|
90
82
|
assertEquals(inserts.rounds.length, 1, "the round is still recorded after the heal");
|
|
91
83
|
// And the worker still parks the (now-present) PR in waiting_review as its final state.
|
|
92
84
|
assertEquals(updates.pull_requests!.length, 1, "the PR is updated once after the heal");
|
|
93
|
-
// deno-lint-ignore no-explicit-any
|
|
94
85
|
assertEquals((updates.pull_requests![0] as any).patch.status, "waiting_review");
|
|
95
86
|
});
|
|
96
87
|
|
|
97
88
|
// rather than writing a NULL status — the round history stays readable.
|
|
98
|
-
|
|
89
|
+
test("persist-round defaults a missing status to 'addressed'", async () => {
|
|
99
90
|
const { app, inserts } = fakeApp();
|
|
100
91
|
const job = { variables: { prKey: "o/r#1", round: 2 } };
|
|
101
|
-
// deno-lint-ignore no-explicit-any
|
|
102
92
|
await handler(job as any, app as any);
|
|
103
|
-
// deno-lint-ignore no-explicit-any
|
|
104
93
|
assertEquals((inserts.rounds[0] as any).status, "addressed");
|
|
105
94
|
});
|
|
106
95
|
|
|
107
96
|
// When repo/prNumber process variables are absent (an older in-flight instance, or a regression)
|
|
108
97
|
// the heal still runs by parsing the canonical `owner/repo#N` prKey, so the FK-child insert is
|
|
109
98
|
// never left unguarded.
|
|
110
|
-
|
|
99
|
+
test("persist-round heals from the prKey when repo/prNumber are absent", async () => {
|
|
111
100
|
const { app, inserts } = fakeApp();
|
|
112
101
|
const job = {
|
|
113
102
|
variables: {
|
|
@@ -117,10 +106,8 @@ Deno.test("persist-round heals from the prKey when repo/prNumber are absent", as
|
|
|
117
106
|
abandonUrl: "https://host/hooks/abandon?token=TOK-en_123",
|
|
118
107
|
},
|
|
119
108
|
};
|
|
120
|
-
// deno-lint-ignore no-explicit-any
|
|
121
109
|
await handler(job as any, app as any);
|
|
122
110
|
assertEquals(inserts.pull_requests?.length, 1, "the parent is reconstructed from the prKey");
|
|
123
|
-
// deno-lint-ignore no-explicit-any
|
|
124
111
|
const healed = inserts.pull_requests![0] as any;
|
|
125
112
|
assertEquals(healed.repo, "o/r");
|
|
126
113
|
assertEquals(healed.number, 12);
|