@nanobpm/nano-workforce 0.29.0 → 0.31.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,83 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" xmlns:nano="https://nanobpm.io/schema/shapes/1.0" id="Definitions_nano_workforce_retro" targetNamespace="http://nanobpm.io/nano-workforce">
3
+ <bpmn:process id="retro" name="Epic Retrospective" isExecutable="true">
4
+ <bpmn:startEvent id="Start" name="Epic complete">
5
+ <bpmn:outgoing>f_start</bpmn:outgoing>
6
+ </bpmn:startEvent>
7
+ <bpmn:serviceTask id="gather" name="Gather learnings">
8
+ <bpmn:extensionElements>
9
+ <zeebe:taskDefinition type="pr.retro-gather" />
10
+ </bpmn:extensionElements>
11
+ <bpmn:incoming>f_start</bpmn:incoming>
12
+ <bpmn:outgoing>f_toSynthesize</bpmn:outgoing>
13
+ </bpmn:serviceTask>
14
+ <bpmn:serviceTask id="synthesize" name="Synthesize &#38; promote (agent)">
15
+ <bpmn:extensionElements>
16
+ <zeebe:taskDefinition type="senior:retro" />
17
+ <zeebe:taskHeaders>
18
+ <zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{retro}}" />
19
+ </zeebe:taskHeaders>
20
+ <zeebe:ioMapping>
21
+ <zeebe:input source="=retroDigest" target="appendPrompt" />
22
+ </zeebe:ioMapping>
23
+ </bpmn:extensionElements>
24
+ <bpmn:incoming>f_toSynthesize</bpmn:incoming>
25
+ <bpmn:outgoing>f_toRecord</bpmn:outgoing>
26
+ </bpmn:serviceTask>
27
+ <bpmn:serviceTask id="record" name="Record retro">
28
+ <bpmn:extensionElements>
29
+ <zeebe:taskDefinition type="pr.retro-record" />
30
+ </bpmn:extensionElements>
31
+ <bpmn:incoming>f_toRecord</bpmn:incoming>
32
+ <bpmn:outgoing>f_toEnd</bpmn:outgoing>
33
+ </bpmn:serviceTask>
34
+ <bpmn:endEvent id="End" name="Retro filed">
35
+ <bpmn:incoming>f_toEnd</bpmn:incoming>
36
+ </bpmn:endEvent>
37
+ <bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="gather" />
38
+ <bpmn:sequenceFlow id="f_toSynthesize" sourceRef="gather" targetRef="synthesize" />
39
+ <bpmn:sequenceFlow id="f_toRecord" sourceRef="synthesize" targetRef="record" />
40
+ <bpmn:sequenceFlow id="f_toEnd" sourceRef="record" targetRef="End" />
41
+ </bpmn:process>
42
+ <bpmndi:BPMNDiagram id="BPMNDiagram_retro">
43
+ <bpmndi:BPMNPlane id="BPMNPlane_retro" bpmnElement="retro">
44
+ <bpmndi:BPMNShape id="BPMNShape_Start" bpmnElement="Start">
45
+ <dc:Bounds x="80" y="102" width="36" height="36" />
46
+ <bpmndi:BPMNLabel>
47
+ <dc:Bounds x="67" y="143" width="63" height="28" />
48
+ </bpmndi:BPMNLabel>
49
+ </bpmndi:BPMNShape>
50
+ <bpmndi:BPMNShape id="BPMNShape_gather" bpmnElement="gather">
51
+ <dc:Bounds x="216" y="80" width="100" height="80" />
52
+ </bpmndi:BPMNShape>
53
+ <bpmndi:BPMNShape id="BPMNShape_synthesize" bpmnElement="synthesize">
54
+ <dc:Bounds x="416" y="80" width="100" height="80" />
55
+ </bpmndi:BPMNShape>
56
+ <bpmndi:BPMNShape id="BPMNShape_record" bpmnElement="record">
57
+ <dc:Bounds x="616" y="80" width="100" height="80" />
58
+ </bpmndi:BPMNShape>
59
+ <bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
60
+ <dc:Bounds x="816" y="102" width="36" height="36" />
61
+ <bpmndi:BPMNLabel>
62
+ <dc:Bounds x="794" y="143" width="80" height="14" />
63
+ </bpmndi:BPMNLabel>
64
+ </bpmndi:BPMNShape>
65
+ <bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
66
+ <di:waypoint x="116" y="120" />
67
+ <di:waypoint x="216" y="120" />
68
+ </bpmndi:BPMNEdge>
69
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toSynthesize" bpmnElement="f_toSynthesize">
70
+ <di:waypoint x="316" y="120" />
71
+ <di:waypoint x="416" y="120" />
72
+ </bpmndi:BPMNEdge>
73
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
74
+ <di:waypoint x="516" y="120" />
75
+ <di:waypoint x="616" y="120" />
76
+ </bpmndi:BPMNEdge>
77
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
78
+ <di:waypoint x="716" y="120" />
79
+ <di:waypoint x="816" y="120" />
80
+ </bpmndi:BPMNEdge>
81
+ </bpmndi:BPMNPlane>
82
+ </bpmndi:BPMNDiagram>
83
+ </bpmn:definitions>
@@ -0,0 +1,228 @@
1
+ // Static contract guard between the declarative pages (`pages/*.page.json`) and the app schema
2
+ // (`db/migrations/*.sql`).
3
+ //
4
+ // The Urban page runtime whitelists every datasource `table` and `column` against the LIVE schema
5
+ // (`PRAGMA table_info`): a grid that binds to a table or column the migrations never created 400s
6
+ // at request time — an invisible, runtime-only failure with no compile or `urban check` signal.
7
+ // This test closes that drift surface: every table and column referenced by any page must be
8
+ // derivable from the migrations, so a rename/typo/removed column fails CI instead of a live page.
9
+ //
10
+ // It also pins the issue #87 surfaces: the plan-review audit log (`plan_reviews`) — which is
11
+ // persisted but was surfaced on no page — must appear on the epic page (flat grid) and inside the
12
+ // home page's plan detail (child grid). Feature coverage so the trace can't silently regress out.
13
+ import { assert } from "jsr:@std/assert@1";
14
+
15
+ // Percent-decode the pathname: `new URL(..).pathname` can contain encoded characters (e.g. a space
16
+ // as `%20`), which `Deno.readDir`/`readTextFile` would fail to resolve. Matches the repo convention
17
+ // (see scripts/check-agent-prompts.test.ts).
18
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
19
+
20
+ // ---- migrations -> { table -> Set<column> } -----------------------------------------------------
21
+
22
+ function parseSchema(sql: string, schema: Map<string, Set<string>>): void {
23
+ // Strip SQL comments first: an inline `-- ...` trailing one column line would otherwise become
24
+ // the leading token of the NEXT comma-split fragment, hiding the real column name.
25
+ sql = sql.replace(/--[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
26
+ // CREATE TABLE [IF NOT EXISTS] <name> ( <body> )
27
+ const createRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?\s*\(/gi;
28
+ let m: RegExpExecArray | null;
29
+ while ((m = createRe.exec(sql)) !== null) {
30
+ const table = m[1];
31
+ const body = balancedBody(sql, createRe.lastIndex - 1); // start at the "("
32
+ if (body === null) continue;
33
+ const cols = schema.get(table) ?? new Set<string>();
34
+ for (const frag of splitTopLevel(body)) {
35
+ const first = frag.trim().split(/[\s(]/)[0];
36
+ if (!first) continue;
37
+ const upper = first.toUpperCase();
38
+ if (["PRIMARY", "FOREIGN", "UNIQUE", "CHECK", "CONSTRAINT"].includes(upper)) continue;
39
+ cols.add(first.replace(/["`]/g, ""));
40
+ }
41
+ schema.set(table, cols);
42
+ }
43
+ // ALTER TABLE <name> ADD [COLUMN] <col>
44
+ const alterRe = /ALTER\s+TABLE\s+["`]?(\w+)["`]?\s+ADD\s+(?:COLUMN\s+)?["`]?(\w+)["`]?/gi;
45
+ while ((m = alterRe.exec(sql)) !== null) {
46
+ const cols = schema.get(m[1]) ?? new Set<string>();
47
+ cols.add(m[2]);
48
+ schema.set(m[1], cols);
49
+ }
50
+ }
51
+
52
+ // Return the text inside the parentheses whose opener is at `openIdx`, honouring nesting.
53
+ function balancedBody(s: string, openIdx: number): string | null {
54
+ let depth = 0;
55
+ for (let i = openIdx; i < s.length; i++) {
56
+ if (s[i] === "(") depth++;
57
+ else if (s[i] === ")") {
58
+ depth--;
59
+ if (depth === 0) return s.slice(openIdx + 1, i);
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ // Split a CREATE TABLE body on top-level commas (commas inside nested parens stay attached).
66
+ function splitTopLevel(body: string): string[] {
67
+ const out: string[] = [];
68
+ let depth = 0, start = 0;
69
+ for (let i = 0; i < body.length; i++) {
70
+ const c = body[i];
71
+ if (c === "(") depth++;
72
+ else if (c === ")") depth--;
73
+ else if (c === "," && depth === 0) {
74
+ out.push(body.slice(start, i));
75
+ start = i + 1;
76
+ }
77
+ }
78
+ out.push(body.slice(start));
79
+ return out;
80
+ }
81
+
82
+ async function loadSchema(): Promise<Map<string, Set<string>>> {
83
+ const schema = new Map<string, Set<string>>();
84
+ const files: string[] = [];
85
+ for await (const e of Deno.readDir(`${ROOT}db/migrations`)) {
86
+ if (e.isFile && e.name.endsWith(".sql")) files.push(e.name);
87
+ }
88
+ files.sort(); // migration order doesn't matter for the union, but keep it deterministic
89
+ for (const f of files) {
90
+ parseSchema(await Deno.readTextFile(`${ROOT}db/migrations/${f}`), schema);
91
+ }
92
+ return schema;
93
+ }
94
+
95
+ // ---- pages -> datasource references -------------------------------------------------------------
96
+
97
+ // deno-lint-ignore no-explicit-any
98
+ type Json = any;
99
+
100
+ interface Ref {
101
+ page: string;
102
+ table: string;
103
+ source: string;
104
+ fields: string[]; // every column that must exist on `table` (displayed columns + binding fields)
105
+ columns: string[]; // only the visibly displayed grid columns (`columns[].field`)
106
+ }
107
+
108
+ // Pull `field` names out of a `filter` array ([{ field, in/eq/... }, ...]).
109
+ function filterFields(filter: Json): string[] {
110
+ if (!Array.isArray(filter)) return [];
111
+ return filter.map((f: Json) => f?.field).filter(Boolean);
112
+ }
113
+
114
+ function collectRefs(page: string, node: Json, out: Ref[]): void {
115
+ if (Array.isArray(node)) {
116
+ for (const v of node) collectRefs(page, v, out);
117
+ return;
118
+ }
119
+ if (!node || typeof node !== "object") return;
120
+
121
+ // Top-level datasource grid: the datasource lives at `node.data`, while `columns`, `rowKey`,
122
+ // `filter`/`tabs`, and `detail` are siblings on the same `node` (the grid props).
123
+ const data = node.data;
124
+ if (data && data.kind === "datasource" && typeof data.table === "string") {
125
+ const columns: string[] = (node.columns ?? []).map((c: Json) => c.field).filter(Boolean);
126
+ // Every reference that resolves to a column on this table — the runtime 400s on any of them if
127
+ // it names a column the migrations never created, so all must be guarded, not just displayed
128
+ // columns. `detail.fields`/`detail.linkField` render columns of the same top-level row.
129
+ const detail = node.detail ?? {};
130
+ const fields: string[] = [
131
+ ...columns,
132
+ ...(node.columns ?? []).map((c: Json) => c.linkField),
133
+ node.rowKey,
134
+ data.orderBy?.field,
135
+ ...filterFields(data.filter),
136
+ ...(node.tabs ?? []).flatMap((t: Json) => filterFields(t.filter)),
137
+ detail.linkField,
138
+ ...(detail.fields ?? []).flatMap((f: Json) => [f.field, f.linkField]),
139
+ // `detail.children[].parentField` joins each child grid back to a column on THIS (parent)
140
+ // table, so a rename/typo there 400s at request time — guard it against the parent schema.
141
+ ...(detail.children ?? []).map((c: Json) => c.parentField),
142
+ ].filter(Boolean);
143
+ out.push({ page, table: data.table, source: data.source ?? "app", fields, columns });
144
+ }
145
+
146
+ // Child grid inside a detail: { table, childField, parentField, orderBy, columns }
147
+ if (typeof node.table === "string" && typeof node.childField === "string") {
148
+ const columns: string[] = (node.columns ?? []).map((c: Json) => c.field).filter(Boolean);
149
+ out.push({
150
+ page,
151
+ table: node.table,
152
+ source: node.source ?? "app",
153
+ fields: [
154
+ ...columns,
155
+ ...(node.columns ?? []).map((c: Json) => c.linkField),
156
+ node.childField,
157
+ node.orderBy?.field,
158
+ node.lazyField?.field,
159
+ ].filter(Boolean),
160
+ columns,
161
+ });
162
+ }
163
+
164
+ for (const v of Object.values(node)) collectRefs(page, v, out);
165
+ }
166
+
167
+ async function loadRefs(): Promise<Ref[]> {
168
+ const refs: Ref[] = [];
169
+ for await (const e of Deno.readDir(`${ROOT}pages`)) {
170
+ if (!e.isFile || !e.name.endsWith(".page.json")) continue;
171
+ const page = JSON.parse(await Deno.readTextFile(`${ROOT}pages/${e.name}`));
172
+ collectRefs(e.name, page, refs);
173
+ }
174
+ return refs;
175
+ }
176
+
177
+ // ---- guards -------------------------------------------------------------------------------------
178
+
179
+ Deno.test("every page datasource table exists in the migrations", async () => {
180
+ const schema = await loadSchema();
181
+ const refs = await loadRefs();
182
+ assert(refs.length > 0, "no datasource references found — collector or pages are broken");
183
+ for (const r of refs) {
184
+ // Only the default app SQLite source is schema-backed; other sources aren't migration-defined.
185
+ if (r.source !== "app") continue;
186
+ assert(
187
+ schema.has(r.table),
188
+ `${r.page}: datasource table "${r.table}" has no CREATE TABLE in db/migrations/*.sql`,
189
+ );
190
+ }
191
+ });
192
+
193
+ Deno.test("every page datasource column exists on its table", async () => {
194
+ const schema = await loadSchema();
195
+ const refs = await loadRefs();
196
+ for (const r of refs) {
197
+ if (r.source !== "app") continue;
198
+ const cols = schema.get(r.table);
199
+ if (!cols) continue; // table-existence is asserted by the sibling test
200
+ for (const f of r.fields) {
201
+ assert(
202
+ cols.has(f),
203
+ `${r.page}: column "${f}" referenced on table "${r.table}" is not defined by any migration`,
204
+ );
205
+ }
206
+ }
207
+ });
208
+
209
+ Deno.test("issue #87: plan_reviews is surfaced on the epic and home pages", async () => {
210
+ const refs = await loadRefs();
211
+ const onEpic = refs.some((r) => r.page === "epic.page.json" && r.table === "plan_reviews");
212
+ const onHome = refs.some((r) => r.page === "home.page.json" && r.table === "plan_reviews");
213
+ assert(onEpic, "epic.page.json must bind a grid to plan_reviews (plan-review trace)");
214
+ assert(onHome, "home.page.json plan detail must include a plan_reviews child grid");
215
+
216
+ // The trace is only useful with the verdict + critique columns, so pin them. Assert against the
217
+ // visibly displayed `columns` (not `fields`, which also holds binding refs like orderBy.field) so
218
+ // a column silently dropped from the grid UI can't pass by being referenced elsewhere.
219
+ const required = ["round", "approved", "findings"];
220
+ for (const r of refs.filter((x) => x.table === "plan_reviews")) {
221
+ for (const col of required) {
222
+ assert(
223
+ r.columns.includes(col),
224
+ `${r.page}: plan_reviews grid must expose the "${col}" column`,
225
+ );
226
+ }
227
+ }
228
+ });
@@ -3,6 +3,7 @@
3
3
  // is on, or (b) close the PR out as `converged` (review-only mode).
4
4
  import type { AppJobHandler } from "@nanobpm/urban";
5
5
  import { AUTO_MERGE, startMerge } from "../../app/service.ts";
6
+ import { maybeStartRetro } from "../../app/retro.ts";
6
7
 
7
8
  // Extends Record so the declared fields are typed while the job may still carry
8
9
  // other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
@@ -83,6 +84,14 @@ const handler: AppJobHandler<In> = async (job, app) => {
83
84
  open_escalation_question: null,
84
85
  });
85
86
 
87
+ // Only the review-only terminal path ends the PR here as `converged` — in auto-merge mode the
88
+ // terminal point is pr.mark-merged (which triggers the retro), and a PR parked in `waiting_deps`
89
+ // is still in flight. So fire the retro trigger only when this PR actually reached its terminal
90
+ // state in finalize. Best-effort: must never fail the finalize job.
91
+ if (status === "converged") {
92
+ await maybeStartRetro(app.data, app.engine, prKey, app.log);
93
+ }
94
+
86
95
  return {};
87
96
  };
88
97
 
@@ -1,6 +1,7 @@
1
1
  // pr.mark-merged — the PR has landed (directly or via the merge queue). Record the terminal
2
2
  // `merged` state; the merge audit trail is written by pr.merge, so this only closes the row out.
3
3
  import type { AppJobHandler } from "@nanobpm/urban";
4
+ import { maybeStartRetro } from "../../app/retro.ts";
4
5
 
5
6
  interface In extends Record<string, unknown> {
6
7
  prKey: string;
@@ -15,6 +16,11 @@ const handler: AppJobHandler<In> = async (job, app) => {
15
16
  open_escalation_id: null,
16
17
  open_escalation_question: null,
17
18
  });
19
+
20
+ // If this PR was the last of its epic to land, kick off the retrospective. Best-effort: a
21
+ // failure here (or no epic) must never fail marking the PR merged — the retro is advisory.
22
+ await maybeStartRetro(app.data, app.engine, job.variables.prKey, app.log);
23
+
18
24
  return {};
19
25
  };
20
26
 
@@ -0,0 +1,82 @@
1
+ // Red/green for the plan-review gate (issue #86).
2
+ //
3
+ // Previously the fan-out PROCEEDED when the review-round cap was reached without approval
4
+ // ("proceed regardless rather than dead-lock"). That dispatched an un-vetted plan and — when the
5
+ // plan was empty (e.g. the planner agent couldn't persist its result) — completed the whole epic
6
+ // GREEN having done nothing (instance 21). We now HARD-FAIL: the terminal, unapproved round raises
7
+ // a non-retryable `PLAN_REJECTED` BpmnError (→ incident), so an un-approved plan never dispatches.
8
+ import { assertEquals, assertRejects } from "jsr:@std/assert@1";
9
+ import { BpmnError } from "@nanobpm/urban";
10
+ import handler from "./worker.ts";
11
+ import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview } from "../../app/plan.ts";
12
+
13
+ function fakeApp(existing: PlanReview[] = []) {
14
+ const rows: PlanReview[] = [...existing];
15
+ const match = (r: PlanReview, q: Record<string, unknown>) =>
16
+ Object.entries(q).every(([f, v]) => (r as unknown as Record<string, unknown>)[f] === v);
17
+ return {
18
+ data: {
19
+ table() {
20
+ return {
21
+ // deno-lint-ignore no-explicit-any
22
+ findOne: (q: any) => Promise.resolve(rows.find((r) => match(r, q)) ?? null),
23
+ // deno-lint-ignore no-explicit-any
24
+ count: (q: any) => Promise.resolve(rows.filter((r) => match(r, q)).length),
25
+ insert: (row: PlanReview) => {
26
+ rows.push(row);
27
+ return Promise.resolve(row);
28
+ },
29
+ };
30
+ },
31
+ },
32
+ log: () => {},
33
+ _rows: rows,
34
+ // deno-lint-ignore no-explicit-any
35
+ } as any;
36
+ }
37
+
38
+ // Seed `n` prior recorded rounds for a plan so the next job lands on round `n` (0-based).
39
+ function priorRounds(planKey: string, n: number): PlanReview[] {
40
+ return Array.from({ length: n }, (_, i) => ({
41
+ plan_key: planKey,
42
+ round: i,
43
+ approved: 0,
44
+ findings: null,
45
+ created_at: "2026-01-01T00:00:00.000Z",
46
+ job_key: `prior-${i}`,
47
+ }));
48
+ }
49
+
50
+ const call = async (app: unknown, vars: Record<string, unknown>, jobKey = "j-new") =>
51
+ // deno-lint-ignore no-explicit-any
52
+ await handler({ variables: vars, jobKey } as any, app as any);
53
+
54
+ Deno.test("approved round proceeds (planApproved=true, no throw)", async () => {
55
+ const app = fakeApp(priorRounds("o/r#1", 0));
56
+ const out = await call(app, { planKey: "o/r#1", approved: true });
57
+ assertEquals((out as { planApproved: boolean }).planApproved, true);
58
+ });
59
+
60
+ Deno.test("unapproved, non-final round revises (planApproved=false, no throw)", async () => {
61
+ // First round of a 3-round cap: not final, so revise.
62
+ const app = fakeApp(priorRounds("o/r#2", 0));
63
+ const out = await call(app, { planKey: "o/r#2", approved: false, findings: "fix X" });
64
+ assertEquals((out as { planApproved: boolean; planFindings: string }).planApproved, false);
65
+ assertEquals((out as { planFindings: string }).planFindings, "fix X");
66
+ });
67
+
68
+ Deno.test("unapproved FINAL round hard-fails with PLAN_REJECTED incident", async () => {
69
+ // Seed cap-1 prior rounds so this job is the last permitted round; unapproved ⇒ must throw.
70
+ const app = fakeApp(priorRounds("o/r#3", MAX_PLAN_REVIEW_ROUNDS - 1));
71
+ const err = await assertRejects(
72
+ () => call(app, { planKey: "o/r#3", approved: false, findings: "still wrong" }),
73
+ BpmnError,
74
+ );
75
+ assertEquals((err as BpmnError).errorCode, "PLAN_REJECTED");
76
+ });
77
+
78
+ Deno.test("approved on the FINAL round still proceeds (no throw)", async () => {
79
+ const app = fakeApp(priorRounds("o/r#4", MAX_PLAN_REVIEW_ROUNDS - 1));
80
+ const out = await call(app, { planKey: "o/r#4", approved: true });
81
+ assertEquals((out as { planApproved: boolean }).planApproved, true);
82
+ });
@@ -6,13 +6,17 @@
6
6
  // • derives the current round from the append-only `plan_reviews` log (no counter variable),
7
7
  // using the engine jobKey as an idempotency guard so a retried job reuses its row,
8
8
  // • records this round's verdict + findings,
9
- // • decides the loop: `planApproved` (reviewer said yes) and `reviewExhausted` (the round cap
10
- // is reached, so we proceed regardless rather than dead-lock on a reviewer that never
11
- // approves), and re-emits the findings as `planFindings` so a revise round feeds the planner.
9
+ // • decides the loop: emits `planApproved` (reviewer said yes the BPMN gateway proceeds to
10
+ // `select-wave`) or, when unapproved, re-emits the findings as `planFindings` so a revise
11
+ // round feeds the planner and loops back to `plan`.
12
12
  //
13
- // The BPMN gateway proceeds to `select-wave` when `planApproved or reviewExhausted`, else loops
14
- // back to `plan`. A missing/ambiguous `approved` is treated as NOT approved (revise) — but the
15
- // round cap still bounds the loop, so the plan can never wedge.
13
+ // When the review-round cap is reached WITHOUT approval, this worker HARD-FAILS: it throws a
14
+ // non-retryable `PLAN_REJECTED` BpmnError (→ incident) rather than proceeding regardless
15
+ // (issue #86). Proceeding used to dispatch an un-vetted plan and — when the plan was empty — let
16
+ // the whole epic complete GREEN having done nothing. The cap still bounds the loop; it now bounds
17
+ // it into an incident, not a silent proceed. A missing/ambiguous `approved` is treated as NOT
18
+ // approved (revise until the cap).
19
+ import { BpmnError } from "@nanobpm/urban";
16
20
  import type { AppJobHandler } from "@nanobpm/urban";
17
21
  import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview, planReviews } from "../../app/plan.ts";
18
22
 
@@ -23,7 +27,6 @@ interface In extends Record<string, unknown> {
23
27
  }
24
28
  interface Out extends Record<string, unknown> {
25
29
  planApproved: boolean;
26
- reviewExhausted: boolean;
27
30
  planFindings: string;
28
31
  }
29
32
 
@@ -56,7 +59,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
56
59
  // Idempotency guard: deriving the round from count(plan_reviews) is not retry-safe on its own.
57
60
  // A job retried after the insert (crash/timeout post-write) re-runs with the SAME jobKey — if
58
61
  // this job already recorded a row, reuse it rather than appending a duplicate, which would
59
- // inflate the count and trip `reviewExhausted` early. Otherwise this is the first attempt:
62
+ // inflate the count and reach the review-round cap early. Otherwise this is the first attempt:
60
63
  // derive the 0-based next round from the append-only log and record it under this jobKey.
61
64
  const recorded: PlanReview = (await reviews.findOne({ plan_key: planKey, job_key: jobKey })) ??
62
65
  await (async () => {
@@ -77,16 +80,29 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
77
80
  const roundApproved = recorded.approved === 1;
78
81
  const roundFindings = recorded.findings ?? "";
79
82
 
80
- // Exhausted once this round is the last permitted one (round is 0-based).
81
- const reviewExhausted = round + 1 >= MAX_PLAN_REVIEW_ROUNDS;
82
- if (!roundApproved) {
83
- app.log(reviewExhausted ? "warn" : "info", `record-plan-review: ${planKey} round ${round}`, {
84
- approved: roundApproved,
85
- reviewExhausted,
83
+ if (roundApproved) {
84
+ return { planApproved: true, planFindings: roundFindings };
85
+ }
86
+
87
+ // Not approved this round. Hard-fail once the round cap is reached (issue #86): previously the
88
+ // fan-out PROCEEDED regardless ("don't dead-lock on a reviewer that never approves"), which
89
+ // dispatched an un-vetted plan and — when the plan was empty — completed the epic GREEN having
90
+ // done nothing (instance 21). Instead raise a non-retryable BpmnError: no boundary catches
91
+ // `PLAN_REJECTED`, so the engine parks the instance on an incident rather than dispatching an
92
+ // un-approved plan. The round is 0-based, so `round + 1 >= cap` is the last permitted round.
93
+ if (round + 1 >= MAX_PLAN_REVIEW_ROUNDS) {
94
+ app.log("error", `record-plan-review: ${planKey} not approved after ${MAX_PLAN_REVIEW_ROUNDS} round(s)`, {
95
+ round,
86
96
  });
97
+ throw new BpmnError(
98
+ "PLAN_REJECTED",
99
+ `${planKey}: plan not approved after ${MAX_PLAN_REVIEW_ROUNDS} review round(s)`,
100
+ );
87
101
  }
88
102
 
89
- return { planApproved: roundApproved, reviewExhausted, planFindings: roundFindings };
103
+ // Otherwise loop: the planner revises against this round's findings.
104
+ app.log("info", `record-plan-review: ${planKey} round ${round} — revise`, { approved: false });
105
+ return { planApproved: false, planFindings: roundFindings };
90
106
  };
91
107
 
92
108
  export default handler;
@@ -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
+ });