@nanobpm/nano-workforce 0.35.2 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +10 -17
- package/AGENTS.md +8 -7
- package/CHANGELOG.md +14 -0
- package/README.md +1 -21
- package/actions/abandon.test.ts +8 -16
- package/actions/blackboard.test.ts +13 -21
- package/actions/blackboard.ts +1 -0
- package/actions/feature-answer-hook.ts +1 -0
- package/actions/plan-hook.ts +2 -1
- package/actions/webhook-submit.ts +2 -1
- package/app/abandon.test.ts +10 -15
- package/app/baseGuard.test.ts +7 -6
- package/app/blackboard.test.ts +22 -32
- package/app/blackboard.ts +7 -6
- package/app/ensure-pr.test.ts +10 -12
- package/app/github.test.ts +9 -8
- package/app/github.ts +24 -22
- package/app/instance-tracking.test.ts +8 -6
- package/app/mergeExclusion.test.ts +11 -20
- package/app/mergeExclusion.ts +8 -3
- package/app/mergeProtocol.test.ts +14 -13
- package/app/mergeProtocol.ts +1 -0
- 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/plan.ts +1 -1
- package/app/record-plan-review.test.ts +8 -7
- package/app/retro.test.ts +24 -48
- package/app/retro.ts +4 -2
- 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 +8 -4
- package/app/taskDelta.test.ts +8 -17
- package/app/taskDelta.ts +1 -0
- package/app/trialMerge.test.ts +4 -3
- package/app/version.ts +9 -21
- package/app/waves.test.ts +17 -16
- package/biome.json +143 -0
- 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/postMessage.ts +3 -3
- package/operations/startAndMessage.test.ts +6 -12
- package/package.json +9 -4
- package/plugins/no-unsafe-type-assertion.grit +12 -0
- package/scripts/check-agent-prompts.test.ts +15 -11
- package/scripts/check-agent-prompts.ts +4 -2
- package/scripts/layout-bpmn.ts +6 -26
- package/scripts/pages-contract.test.ts +14 -13
- package/scripts/purge-db.ts +3 -2
- package/scripts/upgrade-from-pack.ts +9 -5
- package/test/assert.ts +74 -0
- package/tsconfig.json +4 -1
- package/workers/finalize/worker.ts +2 -1
- package/workers/merge/worker.ts +3 -3
- package/workers/persist-escalation/worker.ts +2 -1
- package/workers/persist-round/worker.ts +2 -1
- package/workers/record-plan-review/worker.test.ts +6 -9
- package/workers/record-plan-review/worker.ts +2 -1
- package/workers/record-results/worker.test.ts +5 -8
- package/workers/record-results/worker.ts +2 -1
- package/workers/record-trial-merge/worker.test.ts +7 -18
- package/workers/record-trial-merge/worker.ts +6 -6
- package/workers/record-wave/worker.test.ts +13 -20
- package/workers/record-wave/worker.ts +7 -8
- package/workers/retro-gather/worker.test.ts +4 -17
- package/workers/retro-record/worker.test.ts +8 -27
- package/workers/retro-record/worker.ts +3 -3
- package/workers/select-wave/worker.test.ts +5 -8
- package/deno.json +0 -24
- package/deno.lock +0 -1776
package/workers/merge/worker.ts
CHANGED
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
// calls live in app/github.ts; this worker records the attempt in the `merges` audit table and
|
|
10
10
|
// shapes the escalation payload on a block.
|
|
11
11
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
12
|
-
import { enqueueViaComment, mergePr } from "../../app/github.ts";
|
|
13
|
-
import { MERGE_ADMIN, MERGE_METHOD, ensurePr } from "../../app/service.ts";
|
|
14
12
|
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
15
|
-
import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
|
|
16
13
|
import { checkBaseTarget } from "../../app/baseGuard.ts";
|
|
14
|
+
import { enqueueViaComment, mergePr } from "../../app/github.ts";
|
|
15
|
+
import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
|
|
16
|
+
import { ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
|
|
17
17
|
|
|
18
18
|
interface In extends Record<string, unknown> {
|
|
19
19
|
prKey: string;
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// and the MAX_ROUNDS guard (status = blocked, question set by the process). Returns
|
|
4
4
|
// `escalationId` for the UI.
|
|
5
5
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
6
|
-
import { ensurePr, parsePr } from "../../app/service.ts";
|
|
7
6
|
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
7
|
+
import { ensurePr, parsePr } from "../../app/service.ts";
|
|
8
8
|
|
|
9
9
|
// Extends Record so the declared fields are typed while the job may still carry
|
|
10
10
|
// other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
|
|
@@ -39,6 +39,7 @@ function nonBlank(v: unknown): string | undefined {
|
|
|
39
39
|
|
|
40
40
|
const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
|
|
41
41
|
function transcriptOf(vars: Record<string, unknown>): string | null {
|
|
42
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
42
43
|
const env = vars[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
|
|
43
44
|
return typeof env?.output === "string" ? env.output : null;
|
|
44
45
|
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// Data access goes through the injected app datasource gateway (`app.data.table<T>`), the RAD
|
|
6
6
|
// `Table<T>` surface — `rounds.insert(...)` / `pull_requests.update(...)`, not hand-written SQL.
|
|
7
7
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
8
|
-
import { ensurePr, parsePr } from "../../app/service.ts";
|
|
9
8
|
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
9
|
+
import { ensurePr, parsePr } from "../../app/service.ts";
|
|
10
10
|
|
|
11
11
|
// Extends Record so the declared fields are typed while the job may still carry
|
|
12
12
|
// other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
|
|
@@ -29,6 +29,7 @@ interface In extends Record<string, unknown> {
|
|
|
29
29
|
// for audit so a human can see what the agent did this round.
|
|
30
30
|
const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
|
|
31
31
|
function transcriptOf(vars: Record<string, unknown>): string | null {
|
|
32
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
32
33
|
const env = vars[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
|
|
33
34
|
return typeof env?.output === "string" ? env.output : null;
|
|
34
35
|
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// plan was empty (e.g. the planner agent couldn't persist its result) — completed the whole epic
|
|
6
6
|
// GREEN having done nothing (instance 21). We now HARD-FAIL: the terminal, unapproved round raises
|
|
7
7
|
// a non-retryable `PLAN_REJECTED` BpmnError (→ incident), so an un-approved plan never dispatches.
|
|
8
|
-
import {
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
9
10
|
import { BpmnError } from "@nanobpm/urban";
|
|
10
11
|
import handler from "./worker.ts";
|
|
11
12
|
import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview } from "../../app/plan.ts";
|
|
@@ -18,9 +19,7 @@ function fakeApp(existing: PlanReview[] = []) {
|
|
|
18
19
|
data: {
|
|
19
20
|
table() {
|
|
20
21
|
return {
|
|
21
|
-
// deno-lint-ignore no-explicit-any
|
|
22
22
|
findOne: (q: any) => Promise.resolve(rows.find((r) => match(r, q)) ?? null),
|
|
23
|
-
// deno-lint-ignore no-explicit-any
|
|
24
23
|
count: (q: any) => Promise.resolve(rows.filter((r) => match(r, q)).length),
|
|
25
24
|
insert: (row: PlanReview) => {
|
|
26
25
|
rows.push(row);
|
|
@@ -31,7 +30,6 @@ function fakeApp(existing: PlanReview[] = []) {
|
|
|
31
30
|
},
|
|
32
31
|
log: () => {},
|
|
33
32
|
_rows: rows,
|
|
34
|
-
// deno-lint-ignore no-explicit-any
|
|
35
33
|
} as any;
|
|
36
34
|
}
|
|
37
35
|
|
|
@@ -48,16 +46,15 @@ function priorRounds(planKey: string, n: number): PlanReview[] {
|
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
const call = async (app: unknown, vars: Record<string, unknown>, jobKey = "j-new") =>
|
|
51
|
-
// deno-lint-ignore no-explicit-any
|
|
52
49
|
await handler({ variables: vars, jobKey } as any, app as any);
|
|
53
50
|
|
|
54
|
-
|
|
51
|
+
test("approved round proceeds (planApproved=true, no throw)", async () => {
|
|
55
52
|
const app = fakeApp(priorRounds("o/r#1", 0));
|
|
56
53
|
const out = await call(app, { planKey: "o/r#1", approved: true });
|
|
57
54
|
assertEquals((out as { planApproved: boolean }).planApproved, true);
|
|
58
55
|
});
|
|
59
56
|
|
|
60
|
-
|
|
57
|
+
test("unapproved, non-final round revises (planApproved=false, no throw)", async () => {
|
|
61
58
|
// First round of a 3-round cap: not final, so revise.
|
|
62
59
|
const app = fakeApp(priorRounds("o/r#2", 0));
|
|
63
60
|
const out = await call(app, { planKey: "o/r#2", approved: false, findings: "fix X" });
|
|
@@ -65,7 +62,7 @@ Deno.test("unapproved, non-final round revises (planApproved=false, no throw)",
|
|
|
65
62
|
assertEquals((out as { planFindings: string }).planFindings, "fix X");
|
|
66
63
|
});
|
|
67
64
|
|
|
68
|
-
|
|
65
|
+
test("unapproved FINAL round hard-fails with PLAN_REJECTED incident", async () => {
|
|
69
66
|
// Seed cap-1 prior rounds so this job is the last permitted round; unapproved ⇒ must throw.
|
|
70
67
|
const app = fakeApp(priorRounds("o/r#3", MAX_PLAN_REVIEW_ROUNDS - 1));
|
|
71
68
|
const err = await assertRejects(
|
|
@@ -75,7 +72,7 @@ Deno.test("unapproved FINAL round hard-fails with PLAN_REJECTED incident", async
|
|
|
75
72
|
assertEquals((err as BpmnError).errorCode, "PLAN_REJECTED");
|
|
76
73
|
});
|
|
77
74
|
|
|
78
|
-
|
|
75
|
+
test("approved on the FINAL round still proceeds (no throw)", async () => {
|
|
79
76
|
const app = fakeApp(priorRounds("o/r#4", MAX_PLAN_REVIEW_ROUNDS - 1));
|
|
80
77
|
const out = await call(app, { planKey: "o/r#4", approved: true });
|
|
81
78
|
assertEquals((out as { planApproved: boolean }).planApproved, true);
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
// the whole epic complete GREEN having done nothing. The cap still bounds the loop; it now bounds
|
|
17
17
|
// it into an incident, not a silent proceed. A missing/ambiguous `approved` is treated as NOT
|
|
18
18
|
// approved (revise until the cap).
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
21
|
+
import { BpmnError } from "@nanobpm/urban";
|
|
21
22
|
import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview, planReviews } from "../../app/plan.ts";
|
|
22
23
|
|
|
23
24
|
interface In extends Record<string, unknown> {
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// non-retryable `NO_WORK_DISPATCHED` BpmnError (→ incident) when the epic finalizes with no opened
|
|
7
7
|
// PR, recording a `failed` terminal status + outcome first, so "accomplished nothing" surfaces
|
|
8
8
|
// instead of masquerading as a completed epic.
|
|
9
|
-
import {
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
10
11
|
import { BpmnError } from "@nanobpm/urban";
|
|
11
12
|
import handler from "./worker.ts";
|
|
12
13
|
import type { PlanTaskStatus } from "../../app/plan.ts";
|
|
@@ -25,7 +26,6 @@ function fakeApp(rows: Row[]) {
|
|
|
25
26
|
table(name: string, key: string) {
|
|
26
27
|
if (name === "plans") {
|
|
27
28
|
return {
|
|
28
|
-
// deno-lint-ignore no-explicit-any
|
|
29
29
|
update: (k: any, patch: any) => {
|
|
30
30
|
plans.push({ [key]: k, ...patch });
|
|
31
31
|
return Promise.resolve(patch);
|
|
@@ -34,7 +34,6 @@ function fakeApp(rows: Row[]) {
|
|
|
34
34
|
}
|
|
35
35
|
// plan_tasks
|
|
36
36
|
return {
|
|
37
|
-
// deno-lint-ignore no-explicit-any
|
|
38
37
|
find: (q: any) =>
|
|
39
38
|
Promise.resolve(
|
|
40
39
|
rows.filter((r) =>
|
|
@@ -48,15 +47,13 @@ function fakeApp(rows: Row[]) {
|
|
|
48
47
|
},
|
|
49
48
|
log: () => {},
|
|
50
49
|
_plans: plans,
|
|
51
|
-
// deno-lint-ignore no-explicit-any
|
|
52
50
|
} as any;
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
const call = async (app: unknown, planKey = "o/r#1") =>
|
|
56
|
-
// deno-lint-ignore no-explicit-any
|
|
57
54
|
await handler({ variables: { planKey } } as any, app as any);
|
|
58
55
|
|
|
59
|
-
|
|
56
|
+
test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async () => {
|
|
60
57
|
const app = fakeApp([]);
|
|
61
58
|
const err = await assertRejects(() => call(app), BpmnError);
|
|
62
59
|
assertEquals((err as BpmnError).errorCode, "NO_WORK_DISPATCHED");
|
|
@@ -67,7 +64,7 @@ Deno.test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async
|
|
|
67
64
|
assertEquals(plan.outcome, "no work dispatched — the planner produced no tasks");
|
|
68
65
|
});
|
|
69
66
|
|
|
70
|
-
|
|
67
|
+
test("tasks present but none opened (all skipped/blocked) hard-fails", async () => {
|
|
71
68
|
const app = fakeApp([
|
|
72
69
|
{ id: 1, plan_key: "o/r#1", task_id: "a", status: "skipped" },
|
|
73
70
|
{ id: 2, plan_key: "o/r#1", task_id: "b", status: "blocked" },
|
|
@@ -79,7 +76,7 @@ Deno.test("tasks present but none opened (all skipped/blocked) hard-fails", asyn
|
|
|
79
76
|
assertEquals(plan.outcome, "no work dispatched — every task was blocked or skipped");
|
|
80
77
|
});
|
|
81
78
|
|
|
82
|
-
|
|
79
|
+
test("at least one opened PR finalizes cleanly (no throw)", async () => {
|
|
83
80
|
const app = fakeApp([
|
|
84
81
|
{ id: 1, plan_key: "o/r#1", task_id: "a", status: "opened" },
|
|
85
82
|
{ id: 2, plan_key: "o/r#1", task_id: "b", status: "skipped" },
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
// • zero PRs opened → record a `failed` outcome for observability, then throw the non-retryable
|
|
11
11
|
// `NO_WORK_DISPATCHED` BpmnError so the engine parks the instance on an incident instead of
|
|
12
12
|
// completing green (issue #86). The `failed` status is terminal, so `startPlan` can re-plan it.
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
15
|
+
import { BpmnError } from "@nanobpm/urban";
|
|
15
16
|
import { planTasks } from "../../app/plan.ts";
|
|
16
17
|
|
|
17
18
|
interface In extends Record<string, unknown> {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { assertEquals, assertStringIncludes } from "#test-assert";
|
|
2
3
|
import handler, { parseResult } from "./worker.ts";
|
|
3
4
|
|
|
4
5
|
function fakeApp() {
|
|
@@ -7,16 +8,13 @@ function fakeApp() {
|
|
|
7
8
|
data: {
|
|
8
9
|
table(name: string, _key: string) {
|
|
9
10
|
return {
|
|
10
|
-
// deno-lint-ignore require-await
|
|
11
11
|
async find(_q: unknown) {
|
|
12
12
|
return [];
|
|
13
13
|
},
|
|
14
|
-
// deno-lint-ignore require-await
|
|
15
14
|
async insert(row: unknown) {
|
|
16
15
|
(inserts[name] ??= []).push(row);
|
|
17
16
|
return 7;
|
|
18
17
|
},
|
|
19
|
-
// deno-lint-ignore require-await
|
|
20
18
|
async update(_key: unknown, _patch: unknown) {},
|
|
21
19
|
};
|
|
22
20
|
},
|
|
@@ -26,31 +24,27 @@ function fakeApp() {
|
|
|
26
24
|
return { app, inserts };
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
test("record-trial-merge shapes clean results as proceed", async () => {
|
|
30
28
|
const { app, inserts } = fakeApp();
|
|
31
29
|
const out = await handler(
|
|
32
|
-
// deno-lint-ignore no-explicit-any
|
|
33
30
|
{ key: 11, variables: { planKey: "o/r#69", currentWave: 2, result: "clean", summary: "green" } } as any,
|
|
34
|
-
// deno-lint-ignore no-explicit-any
|
|
35
31
|
app as any,
|
|
36
32
|
) as Record<string, unknown>;
|
|
37
33
|
|
|
38
34
|
assertEquals(out.trialMergeRed, false);
|
|
39
35
|
assertEquals(inserts.plan_trial_merges.length, 1);
|
|
40
|
-
// deno-lint-ignore no-explicit-any
|
|
41
36
|
assertEquals((inserts.plan_trial_merges[0] as any).result, "clean");
|
|
42
37
|
});
|
|
43
38
|
|
|
44
|
-
|
|
39
|
+
test("parseResult trims valid agent result strings", () => {
|
|
45
40
|
assertEquals(parseResult("clean\n"), "clean");
|
|
46
41
|
assertEquals(parseResult(" merge-conflict "), "merge-conflict");
|
|
47
42
|
assertEquals(parseResult("unknown"), "suite-failed");
|
|
48
43
|
});
|
|
49
44
|
|
|
50
|
-
|
|
45
|
+
test("record-trial-merge escalates only suite-failed results", async () => {
|
|
51
46
|
const { app } = fakeApp();
|
|
52
47
|
const out = await handler(
|
|
53
|
-
// deno-lint-ignore no-explicit-any
|
|
54
48
|
{
|
|
55
49
|
key: 12,
|
|
56
50
|
variables: {
|
|
@@ -61,7 +55,6 @@ Deno.test("record-trial-merge escalates only suite-failed results", async () =>
|
|
|
61
55
|
failing: ["integration suite"],
|
|
62
56
|
},
|
|
63
57
|
} as any,
|
|
64
|
-
// deno-lint-ignore no-explicit-any
|
|
65
58
|
app as any,
|
|
66
59
|
) as Record<string, unknown>;
|
|
67
60
|
|
|
@@ -71,10 +64,9 @@ Deno.test("record-trial-merge escalates only suite-failed results", async () =>
|
|
|
71
64
|
assertStringIncludes(String(out.question ?? ""), "proceed");
|
|
72
65
|
});
|
|
73
66
|
|
|
74
|
-
|
|
67
|
+
test("record-trial-merge renders odd failing payloads without throwing", async () => {
|
|
75
68
|
const { app } = fakeApp();
|
|
76
69
|
const out = await handler(
|
|
77
|
-
// deno-lint-ignore no-explicit-any
|
|
78
70
|
{
|
|
79
71
|
variables: {
|
|
80
72
|
planKey: "o/r#69",
|
|
@@ -83,7 +75,6 @@ Deno.test("record-trial-merge renders odd failing payloads without throwing", as
|
|
|
83
75
|
failing: [1n],
|
|
84
76
|
},
|
|
85
77
|
} as any,
|
|
86
|
-
// deno-lint-ignore no-explicit-any
|
|
87
78
|
app as any,
|
|
88
79
|
) as Record<string, unknown>;
|
|
89
80
|
|
|
@@ -91,12 +82,10 @@ Deno.test("record-trial-merge renders odd failing payloads without throwing", as
|
|
|
91
82
|
assertStringIncludes(String(out.question ?? ""), "Failing:");
|
|
92
83
|
});
|
|
93
84
|
|
|
94
|
-
|
|
85
|
+
test("record-trial-merge treats textual conflicts as pass-through", async () => {
|
|
95
86
|
const { app } = fakeApp();
|
|
96
87
|
const out = await handler(
|
|
97
|
-
// deno-lint-ignore no-explicit-any
|
|
98
88
|
{ variables: { planKey: "o/r#69", result: "merge-conflict", conflicts: [{ prs: [1, 2] }] } } as any,
|
|
99
|
-
// deno-lint-ignore no-explicit-any
|
|
100
89
|
app as any,
|
|
101
90
|
) as Record<string, unknown>;
|
|
102
91
|
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
5
5
|
import {
|
|
6
6
|
recordTrialMergeAudit,
|
|
7
|
+
type TrialMergeResult,
|
|
7
8
|
trialMergeDecision,
|
|
8
9
|
trialMergeTaskId,
|
|
9
|
-
type TrialMergeResult,
|
|
10
10
|
} from "../../app/trialMerge.ts";
|
|
11
11
|
|
|
12
12
|
interface In extends Record<string, unknown> {
|
|
@@ -26,7 +26,6 @@ interface Out extends Record<string, unknown> {
|
|
|
26
26
|
summary?: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const RESULTS = new Set<TrialMergeResult>(["clean", "merge-conflict", "suite-failed"]);
|
|
30
29
|
const str = (v: unknown): string | undefined =>
|
|
31
30
|
typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
32
31
|
const waveNo = (v: unknown): number => {
|
|
@@ -43,7 +42,8 @@ const safeJson = (v: unknown): string => {
|
|
|
43
42
|
|
|
44
43
|
function parseResult(v: unknown): TrialMergeResult {
|
|
45
44
|
const s = typeof v === "string" ? v.trim() : "";
|
|
46
|
-
|
|
45
|
+
if (s === "clean" || s === "merge-conflict" || s === "suite-failed") return s;
|
|
46
|
+
return "suite-failed";
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
@@ -52,8 +52,8 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
52
52
|
const result = parseResult(job.variables.result);
|
|
53
53
|
const summary = str(job.variables.summary) ??
|
|
54
54
|
(result === "suite-failed" ? "Trial merge suite failed or returned no machine-readable result" : result);
|
|
55
|
-
const
|
|
56
|
-
const jobKey =
|
|
55
|
+
const legacyJobKey = Reflect.get(job, "key");
|
|
56
|
+
const jobKey = job.jobKey ?? (legacyJobKey == null ? null : String(legacyJobKey));
|
|
57
57
|
|
|
58
58
|
try {
|
|
59
59
|
await recordTrialMergeAudit(app.data, {
|
|
@@ -80,7 +80,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
80
80
|
trialMergeRed,
|
|
81
81
|
summary,
|
|
82
82
|
task: { id: trialMergeTaskId(wave), title: `Trial merge gate for wave ${wave}` },
|
|
83
|
-
question: `${summary}${failing}\n\nD3 needs a human decision: either the PR heads merged cleanly and the combined suite failed, or the trial-merge agent did not return a valid machine-readable result. Decide the design/infrastructure fix, update the PR heads if needed, then answer to rerun the trial merge; answer exactly
|
|
83
|
+
question: `${summary}${failing}\n\nD3 needs a human decision: either the PR heads merged cleanly and the combined suite failed, or the trial-merge agent did not return a valid machine-readable result. Decide the design/infrastructure fix, update the PR heads if needed, then answer to rerun the trial merge; answer exactly "proceed" only to override and continue without rerunning.`,
|
|
84
84
|
};
|
|
85
85
|
};
|
|
86
86
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Red/green regression for retrying a wave when `select-wave` deliberately left pending work
|
|
2
2
|
// behind a non-fatal wait (D7 / issue #63). Advancing past that wave would make `record-results`
|
|
3
3
|
// finish the plan with a still-pending task.
|
|
4
|
-
import {
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals } from "#test-assert";
|
|
5
6
|
import handler from "./worker.ts";
|
|
6
7
|
import type { PlanTaskStatus } from "../../app/plan.ts";
|
|
7
8
|
import { _clearMergeProtocolCache } from "../../app/mergeProtocol.ts";
|
|
@@ -32,10 +33,8 @@ function fakeApp(rows: Row[]) {
|
|
|
32
33
|
table(name: string, key: string) {
|
|
33
34
|
const store = stores[name] ??= [];
|
|
34
35
|
return {
|
|
35
|
-
// deno-lint-ignore no-explicit-any
|
|
36
36
|
get: (k: any) =>
|
|
37
37
|
Promise.resolve(store.find((r) => ((r as unknown) as Record<string, unknown>)[key] === k)),
|
|
38
|
-
// deno-lint-ignore no-explicit-any
|
|
39
38
|
find: (q: any) =>
|
|
40
39
|
Promise.resolve(
|
|
41
40
|
store.filter((r) =>
|
|
@@ -44,12 +43,10 @@ function fakeApp(rows: Row[]) {
|
|
|
44
43
|
)
|
|
45
44
|
),
|
|
46
45
|
),
|
|
47
|
-
// deno-lint-ignore no-explicit-any
|
|
48
46
|
insert: (row: any) => {
|
|
49
47
|
store.push(row);
|
|
50
48
|
return Promise.resolve(store.length);
|
|
51
49
|
},
|
|
52
|
-
// deno-lint-ignore no-explicit-any
|
|
53
50
|
update: (k: any, patch: any) => {
|
|
54
51
|
if (name === "plans") {
|
|
55
52
|
planUpdates.push({ key: k, patch });
|
|
@@ -68,7 +65,6 @@ function fakeApp(rows: Row[]) {
|
|
|
68
65
|
engine: {
|
|
69
66
|
createInstance: () => Promise.resolve({ processInstanceKey: "pi" }),
|
|
70
67
|
},
|
|
71
|
-
// deno-lint-ignore no-explicit-any
|
|
72
68
|
} as any,
|
|
73
69
|
planUpdates,
|
|
74
70
|
};
|
|
@@ -76,11 +72,11 @@ function fakeApp(rows: Row[]) {
|
|
|
76
72
|
|
|
77
73
|
function installGithubStub(method: "gh-merge" | "mergify-queue") {
|
|
78
74
|
_clearMergeProtocolCache();
|
|
79
|
-
const oldTransport =
|
|
80
|
-
const oldToken =
|
|
75
|
+
const oldTransport = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
76
|
+
const oldToken = process.env["GITHUB_TOKEN"];
|
|
81
77
|
const oldFetch = globalThis.fetch;
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
79
|
+
process.env["GITHUB_TOKEN"] = "test-token";
|
|
84
80
|
globalThis.fetch = ((input: string | URL | Request) => {
|
|
85
81
|
const url = String(input);
|
|
86
82
|
if (url.includes("/contents/AGENTS.md")) {
|
|
@@ -104,16 +100,16 @@ function installGithubStub(method: "gh-merge" | "mergify-queue") {
|
|
|
104
100
|
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
105
101
|
}) as typeof fetch;
|
|
106
102
|
return () => {
|
|
107
|
-
if (oldTransport == null)
|
|
108
|
-
else
|
|
109
|
-
if (oldToken == null)
|
|
110
|
-
else
|
|
103
|
+
if (oldTransport == null) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
104
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = oldTransport;
|
|
105
|
+
if (oldToken == null) delete process.env["GITHUB_TOKEN"];
|
|
106
|
+
else process.env["GITHUB_TOKEN"] = oldToken;
|
|
111
107
|
globalThis.fetch = oldFetch;
|
|
112
108
|
_clearMergeProtocolCache();
|
|
113
109
|
};
|
|
114
110
|
}
|
|
115
111
|
|
|
116
|
-
|
|
112
|
+
test("record-wave retries the same wave when a task is still pending", async () => {
|
|
117
113
|
const rows: Row[] = [{
|
|
118
114
|
id: 2,
|
|
119
115
|
plan_key: "owner/repo#63",
|
|
@@ -124,7 +120,6 @@ Deno.test("record-wave retries the same wave when a task is still pending", asyn
|
|
|
124
120
|
const { app, planUpdates } = fakeApp(rows);
|
|
125
121
|
|
|
126
122
|
const out = await handler(
|
|
127
|
-
// deno-lint-ignore no-explicit-any
|
|
128
123
|
{
|
|
129
124
|
variables: {
|
|
130
125
|
planKey: "owner/repo#63",
|
|
@@ -148,7 +143,7 @@ Deno.test("record-wave retries the same wave when a task is still pending", asyn
|
|
|
148
143
|
assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, 1);
|
|
149
144
|
});
|
|
150
145
|
|
|
151
|
-
|
|
146
|
+
test("record-wave skips trial merge for mergify-queue repos with 2+ heads", async () => {
|
|
152
147
|
const restore = installGithubStub("mergify-queue");
|
|
153
148
|
try {
|
|
154
149
|
const rows: Row[] = [
|
|
@@ -157,7 +152,6 @@ Deno.test("record-wave skips trial merge for mergify-queue repos with 2+ heads",
|
|
|
157
152
|
];
|
|
158
153
|
const { app } = fakeApp(rows);
|
|
159
154
|
const out = await handler(
|
|
160
|
-
// deno-lint-ignore no-explicit-any
|
|
161
155
|
{
|
|
162
156
|
variables: {
|
|
163
157
|
planKey: "owner/repo#69",
|
|
@@ -184,7 +178,7 @@ Deno.test("record-wave skips trial merge for mergify-queue repos with 2+ heads",
|
|
|
184
178
|
}
|
|
185
179
|
});
|
|
186
180
|
|
|
187
|
-
|
|
181
|
+
test("record-wave runs trial merge for non-queue repos with populated heads", async () => {
|
|
188
182
|
const restore = installGithubStub("gh-merge");
|
|
189
183
|
try {
|
|
190
184
|
const rows: Row[] = [
|
|
@@ -193,7 +187,6 @@ Deno.test("record-wave runs trial merge for non-queue repos with populated heads
|
|
|
193
187
|
];
|
|
194
188
|
const { app } = fakeApp(rows);
|
|
195
189
|
const out = await handler(
|
|
196
|
-
// deno-lint-ignore no-explicit-any
|
|
197
190
|
{
|
|
198
191
|
variables: {
|
|
199
192
|
planKey: "owner/repo#69",
|
|
@@ -14,19 +14,19 @@
|
|
|
14
14
|
// Enrollment lives here (not in the finalizer) so a PR is enrolled the moment its wave lands —
|
|
15
15
|
// and, crucially, so a later wave's `dependsOn` can reference the PR keys earlier waves produced.
|
|
16
16
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
17
|
+
import { appendEntry } from "../../app/blackboard.ts";
|
|
18
|
+
import { fetchPrFiles, fetchPrHead } from "../../app/github.ts";
|
|
19
|
+
import { deriveExclusions, recordExclusions } from "../../app/mergeExclusion.ts";
|
|
20
|
+
import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
|
|
17
21
|
import {
|
|
18
22
|
type PlanTask,
|
|
19
23
|
type PlanTaskStatus,
|
|
24
|
+
plans,
|
|
20
25
|
planTaskDeps,
|
|
21
26
|
planTasks,
|
|
22
|
-
plans,
|
|
23
27
|
} from "../../app/plan.ts";
|
|
24
28
|
import { parsePr, submitPr } from "../../app/service.ts";
|
|
25
29
|
import { parseTaskDelta, readTaskDeltas, recordTaskDelta } from "../../app/taskDelta.ts";
|
|
26
|
-
import { appendEntry } from "../../app/blackboard.ts";
|
|
27
|
-
import { deriveExclusions, recordExclusions } from "../../app/mergeExclusion.ts";
|
|
28
|
-
import { fetchPrFiles, fetchPrHead } from "../../app/github.ts";
|
|
29
|
-
import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
|
|
30
30
|
import { shouldRunTrialMerge, type TrialMergeHead } from "../../app/trialMerge.ts";
|
|
31
31
|
|
|
32
32
|
interface Result {
|
|
@@ -61,9 +61,8 @@ const str = (v: unknown): string | undefined =>
|
|
|
61
61
|
// including a missing status — is treated as `blocked`: we must not assume a PR was opened,
|
|
62
62
|
// and we only hand off / persist a PR when the status is `opened`.
|
|
63
63
|
type WaveResultStatus = Extract<PlanTaskStatus, "opened" | "blocked" | "skipped">;
|
|
64
|
-
const ALLOWED_STATUSES = new Set<WaveResultStatus>(["opened", "blocked", "skipped"]);
|
|
65
64
|
const isWaveResultStatus = (s: string): s is WaveResultStatus =>
|
|
66
|
-
|
|
65
|
+
s === "opened" || s === "blocked" || s === "skipped";
|
|
67
66
|
|
|
68
67
|
// Coerce a wave index/count to a non-negative integer, falling back to 0. A NaN here would make
|
|
69
68
|
// `nextWave < waveCount` mis-evaluate and end the loop early, leaving tasks `pending`.
|
|
@@ -103,7 +102,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
103
102
|
const readyHeadsThisWave: { repo: string; number: number | string }[] = [];
|
|
104
103
|
|
|
105
104
|
for (let i = 0; i < waveTasks.length; i++) {
|
|
106
|
-
const taskId = str(
|
|
105
|
+
const taskId = str(waveTasks[i]?.id);
|
|
107
106
|
if (!taskId) continue;
|
|
108
107
|
const res = results[i] ?? {};
|
|
109
108
|
const rawStatus = str(res.status);
|
|
@@ -1,47 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
2
3
|
import type { DataLayer } from "@nanobpm/urban";
|
|
3
4
|
import { appendEntry } from "../../app/blackboard.ts";
|
|
4
5
|
import handler from "./worker.ts";
|
|
5
6
|
|
|
6
|
-
// deno-lint-ignore no-explicit-any
|
|
7
7
|
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
8
|
-
// deno-lint-ignore no-explicit-any
|
|
9
8
|
const stores: Record<string, any[]> = {};
|
|
10
9
|
const seq: Record<string, number> = {};
|
|
11
10
|
function tbl(name: string, pk = "id") {
|
|
12
|
-
// deno-lint-ignore no-explicit-any
|
|
13
11
|
const rows = (stores[name] ??= [] as any[]);
|
|
14
|
-
// deno-lint-ignore no-explicit-any
|
|
15
12
|
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
16
13
|
return {
|
|
17
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
18
14
|
async insert(row: any) {
|
|
19
15
|
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
20
16
|
rows.push(pk === "id" ? { id, ...row } : { ...row });
|
|
21
17
|
return pk === "id" ? id : row[pk];
|
|
22
18
|
},
|
|
23
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
24
19
|
async find(where: any = {}) {
|
|
25
20
|
return rows.filter((r) => match(r, where));
|
|
26
21
|
},
|
|
27
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
28
22
|
async findOne(where: any = {}) {
|
|
29
23
|
return rows.find((r) => match(r, where));
|
|
30
24
|
},
|
|
31
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
32
25
|
async get(id: any) {
|
|
33
26
|
return rows.find((row) => row[pk] === id);
|
|
34
27
|
},
|
|
35
|
-
// deno-lint-ignore no-explicit-any require-await
|
|
36
28
|
async update() {},
|
|
37
29
|
};
|
|
38
30
|
}
|
|
39
|
-
// deno-lint-ignore no-explicit-any
|
|
40
31
|
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
41
32
|
return { data, stores };
|
|
42
33
|
}
|
|
43
34
|
|
|
44
|
-
|
|
35
|
+
test("retro-gather: emits a digest brief + learning count for the plan", async () => {
|
|
45
36
|
const { data, stores } = memData();
|
|
46
37
|
stores["plans"] = [{ plan_key: "o/r#3", repo: "o/r", issue_url: "https://x/3", title: "Epic" }];
|
|
47
38
|
await appendEntry(data, "o/r#3", { author_task: "t1", kind: "learning", body: "regen before build" });
|
|
@@ -49,9 +40,7 @@ Deno.test("retro-gather: emits a digest brief + learning count for the plan", as
|
|
|
49
40
|
|
|
50
41
|
const app = { data, log: () => undefined };
|
|
51
42
|
const out = await handler(
|
|
52
|
-
// deno-lint-ignore no-explicit-any
|
|
53
43
|
{ variables: { planKey: "o/r#3" } } as any,
|
|
54
|
-
// deno-lint-ignore no-explicit-any
|
|
55
44
|
app as any,
|
|
56
45
|
) as Record<string, unknown>;
|
|
57
46
|
|
|
@@ -61,14 +50,12 @@ Deno.test("retro-gather: emits a digest brief + learning count for the plan", as
|
|
|
61
50
|
assertStringIncludes(String(out.retroDigest), "o/r#3");
|
|
62
51
|
});
|
|
63
52
|
|
|
64
|
-
|
|
53
|
+
test("retro-gather: an epic with no learnings still renders a valid brief", async () => {
|
|
65
54
|
const { data, stores } = memData();
|
|
66
55
|
stores["plans"] = [{ plan_key: "o/r#4", repo: "o/r", issue_url: "", title: null }];
|
|
67
56
|
const app = { data, log: () => undefined };
|
|
68
57
|
const out = await handler(
|
|
69
|
-
// deno-lint-ignore no-explicit-any
|
|
70
58
|
{ variables: { planKey: "o/r#4" } } as any,
|
|
71
|
-
// deno-lint-ignore no-explicit-any
|
|
72
59
|
app as any,
|
|
73
60
|
) as Record<string, unknown>;
|
|
74
61
|
|