@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.
@@ -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` and marks it `done`. It reads no `results` — every task's outcome was
6
- // already recorded by `record-wave` (opened / blocked) or `select-wave` (skipped).
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
- const patch: Record<string, unknown> = { status: "done", updated_at: ts };
22
- // When the planner emitted no tasks, `record-plan` already set a meaningful outcome (its
23
- // note) and moved the plan to `done`. Preserve it rather than overwriting with "0 PR(s)…".
24
- if (rows.length > 0) patch.outcome = `${opened} PR(s) dispatched to convergence`;
25
- await app.data.table("plans", "plan_key").update(planKey, patch);
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
  };