@nanobpm/nano-workforce 0.30.0 → 0.32.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/CHANGELOG.md +21 -0
- package/SPEC.md +4 -1
- package/app/plan.test.ts +2 -2
- package/app/plan.ts +4 -3
- package/app/retro.test.ts +115 -2
- package/app/retro.ts +98 -14
- package/app/service.test.ts +153 -1
- package/app/service.ts +119 -4
- package/db/migrations/017_pr_incident.sql +14 -0
- package/package.json +1 -1
- package/pages/epic.page.json +22 -1
- package/pages/home.page.json +17 -0
- package/resources/processes/plan-fanout.bpmn +1 -1
- package/scripts/pages-contract.test.ts +228 -0
- package/workers/record-plan-review/worker.test.ts +82 -0
- package/workers/record-plan-review/worker.ts +31 -15
- package/workers/record-results/worker.test.ts +91 -0
- package/workers/record-results/worker.ts +37 -7
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Red/green for the no-work terminal guard (issue #86).
|
|
2
|
+
//
|
|
3
|
+
// `record-results` is the epic's finalizer. Before this guard it always marked the plan `done` and
|
|
4
|
+
// completed the process GREEN — even when ZERO PRs were opened (empty plan, or every task
|
|
5
|
+
// blocked/skipped). A no-op run was indistinguishable from success (instance 21). It now raises a
|
|
6
|
+
// non-retryable `NO_WORK_DISPATCHED` BpmnError (→ incident) when the epic finalizes with no opened
|
|
7
|
+
// PR, recording a `failed` terminal status + outcome first, so "accomplished nothing" surfaces
|
|
8
|
+
// instead of masquerading as a completed epic.
|
|
9
|
+
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
|
10
|
+
import { BpmnError } from "@nanobpm/urban";
|
|
11
|
+
import handler from "./worker.ts";
|
|
12
|
+
import type { PlanTaskStatus } from "../../app/plan.ts";
|
|
13
|
+
|
|
14
|
+
interface Row {
|
|
15
|
+
id: number;
|
|
16
|
+
plan_key: string;
|
|
17
|
+
task_id: string;
|
|
18
|
+
status: PlanTaskStatus;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fakeApp(rows: Row[]) {
|
|
22
|
+
const plans: Record<string, unknown>[] = [];
|
|
23
|
+
return {
|
|
24
|
+
data: {
|
|
25
|
+
table(name: string, key: string) {
|
|
26
|
+
if (name === "plans") {
|
|
27
|
+
return {
|
|
28
|
+
// deno-lint-ignore no-explicit-any
|
|
29
|
+
update: (k: any, patch: any) => {
|
|
30
|
+
plans.push({ [key]: k, ...patch });
|
|
31
|
+
return Promise.resolve(patch);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// plan_tasks
|
|
36
|
+
return {
|
|
37
|
+
// deno-lint-ignore no-explicit-any
|
|
38
|
+
find: (q: any) =>
|
|
39
|
+
Promise.resolve(
|
|
40
|
+
rows.filter((r) =>
|
|
41
|
+
Object.entries(q).every(([f, v]) =>
|
|
42
|
+
(r as unknown as Record<string, unknown>)[f] === v
|
|
43
|
+
)
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
log: () => {},
|
|
50
|
+
_plans: plans,
|
|
51
|
+
// deno-lint-ignore no-explicit-any
|
|
52
|
+
} as any;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const call = async (app: unknown, planKey = "o/r#1") =>
|
|
56
|
+
// deno-lint-ignore no-explicit-any
|
|
57
|
+
await handler({ variables: { planKey } } as any, app as any);
|
|
58
|
+
|
|
59
|
+
Deno.test("no opened PRs (empty plan) hard-fails with NO_WORK_DISPATCHED", async () => {
|
|
60
|
+
const app = fakeApp([]);
|
|
61
|
+
const err = await assertRejects(() => call(app), BpmnError);
|
|
62
|
+
assertEquals((err as BpmnError).errorCode, "NO_WORK_DISPATCHED");
|
|
63
|
+
// The failure outcome + terminal `failed` status must be recorded before throwing, so the DB
|
|
64
|
+
// state matches the parked incident and startPlan can re-plan it.
|
|
65
|
+
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
66
|
+
assertEquals(plan.status, "failed");
|
|
67
|
+
assertEquals(plan.outcome, "no work dispatched — the planner produced no tasks");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
Deno.test("tasks present but none opened (all skipped/blocked) hard-fails", async () => {
|
|
71
|
+
const app = fakeApp([
|
|
72
|
+
{ id: 1, plan_key: "o/r#1", task_id: "a", status: "skipped" },
|
|
73
|
+
{ id: 2, plan_key: "o/r#1", task_id: "b", status: "blocked" },
|
|
74
|
+
]);
|
|
75
|
+
const err = await assertRejects(() => call(app), BpmnError);
|
|
76
|
+
assertEquals((err as BpmnError).errorCode, "NO_WORK_DISPATCHED");
|
|
77
|
+
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
78
|
+
assertEquals(plan.status, "failed");
|
|
79
|
+
assertEquals(plan.outcome, "no work dispatched — every task was blocked or skipped");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
Deno.test("at least one opened PR finalizes cleanly (no throw)", async () => {
|
|
83
|
+
const app = fakeApp([
|
|
84
|
+
{ id: 1, plan_key: "o/r#1", task_id: "a", status: "opened" },
|
|
85
|
+
{ id: 2, plan_key: "o/r#1", task_id: "b", status: "skipped" },
|
|
86
|
+
]);
|
|
87
|
+
await call(app);
|
|
88
|
+
const plan = app._plans.at(-1) as Record<string, unknown>;
|
|
89
|
+
assertEquals(plan.status, "done");
|
|
90
|
+
assertEquals(plan.outcome, "1 PR(s) dispatched to convergence");
|
|
91
|
+
});
|
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// PR enrollment now happens per wave in `pr.record-wave` (so later waves can declare earlier
|
|
4
4
|
// waves' PRs as dependencies), leaving this worker as the terminal finalizer: it summarizes the
|
|
5
|
-
// plan from `plan_tasks
|
|
6
|
-
//
|
|
5
|
+
// plan from `plan_tasks`. It reads no `results` — every task's outcome was already recorded by
|
|
6
|
+
// `record-wave` (opened / blocked) or `select-wave` (skipped).
|
|
7
|
+
//
|
|
8
|
+
// Two terminal outcomes:
|
|
9
|
+
// • at least one PR opened → mark the plan `done` (dispatched to convergence).
|
|
10
|
+
// • zero PRs opened → record a `failed` outcome for observability, then throw the non-retryable
|
|
11
|
+
// `NO_WORK_DISPATCHED` BpmnError so the engine parks the instance on an incident instead of
|
|
12
|
+
// completing green (issue #86). The `failed` status is terminal, so `startPlan` can re-plan it.
|
|
13
|
+
import { BpmnError } from "@nanobpm/urban";
|
|
7
14
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
8
15
|
import { planTasks } from "../../app/plan.ts";
|
|
9
16
|
|
|
@@ -18,11 +25,34 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
18
25
|
const rows = await planTasks(app.data).find({ plan_key: planKey });
|
|
19
26
|
const opened = rows.filter((r) => r.status === "opened").length;
|
|
20
27
|
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
// Categorical no-work guard (issue #86): the epic reached its finalizer having opened ZERO PRs —
|
|
29
|
+
// an empty plan (e.g. the planner agent could not persist its result), or every task
|
|
30
|
+
// blocked/skipped. It accomplished nothing, so it must NOT complete green and masquerade as
|
|
31
|
+
// success. Record a `failed` terminal outcome for observability (so the DB state matches the
|
|
32
|
+
// parked incident and `startPlan` can re-plan it — `dispatched`/`done` would otherwise block a
|
|
33
|
+
// restart), then raise a non-retryable BpmnError so the engine parks the instance on an incident
|
|
34
|
+
// (no boundary catches `NO_WORK_DISPATCHED`) instead of completing the process. This backstops
|
|
35
|
+
// any zero-work path regardless of the review outcome.
|
|
36
|
+
if (opened === 0) {
|
|
37
|
+
const outcome = rows.length === 0
|
|
38
|
+
? "no work dispatched — the planner produced no tasks"
|
|
39
|
+
: "no work dispatched — every task was blocked or skipped";
|
|
40
|
+
await app.data.table("plans", "plan_key").update(planKey, {
|
|
41
|
+
status: "failed",
|
|
42
|
+
outcome,
|
|
43
|
+
updated_at: ts,
|
|
44
|
+
});
|
|
45
|
+
app.log("error", `record-results: ${planKey} finalized with 0 opened PRs`, {
|
|
46
|
+
taskCount: rows.length,
|
|
47
|
+
});
|
|
48
|
+
throw new BpmnError("NO_WORK_DISPATCHED", `${planKey}: ${outcome}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await app.data.table("plans", "plan_key").update(planKey, {
|
|
52
|
+
status: "done",
|
|
53
|
+
outcome: `${opened} PR(s) dispatched to convergence`,
|
|
54
|
+
updated_at: ts,
|
|
55
|
+
});
|
|
26
56
|
|
|
27
57
|
return {};
|
|
28
58
|
};
|