@nanobpm/nano-workforce 0.30.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.31.0](https://github.com/nanobpm/nano-workforce/compare/v0.30.0...v0.31.0) (2026-08-09)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **retro:** record a blocked retro when the retro process fails to start ([#92](https://github.com/nanobpm/nano-workforce/issues/92)) ([22bf171](https://github.com/nanobpm/nano-workforce/commit/22bf17114160bcd269b26fe635afdcc44c249dbe)), closes [#84](https://github.com/nanobpm/nano-workforce/issues/84)
7
+ * surface no-op plan-fanout epics as incidents instead of green ([#86](https://github.com/nanobpm/nano-workforce/issues/86)) ([#88](https://github.com/nanobpm/nano-workforce/issues/88)) ([8178aad](https://github.com/nanobpm/nano-workforce/commit/8178aad61fbf409990d331a4b08c786782d44cd1))
8
+
9
+
10
+ ### Features
11
+
12
+ * **retro:** fold plan-review trace and task outcomes into the retro digest ([#91](https://github.com/nanobpm/nano-workforce/issues/91)) ([00d37e5](https://github.com/nanobpm/nano-workforce/commit/00d37e5a8c19238736d14b46401e7a3a41ee2083)), closes [#84](https://github.com/nanobpm/nano-workforce/issues/84) [#87](https://github.com/nanobpm/nano-workforce/issues/87) [#90](https://github.com/nanobpm/nano-workforce/issues/90)
13
+ * surface plan_reviews audit trace on epic and home pages ([#89](https://github.com/nanobpm/nano-workforce/issues/89)) ([2abaa25](https://github.com/nanobpm/nano-workforce/commit/2abaa253ac5bdd4d31a7bee1a0de412e5df56aae)), closes [#87](https://github.com/nanobpm/nano-workforce/issues/87) [#87](https://github.com/nanobpm/nano-workforce/issues/87) [#87](https://github.com/nanobpm/nano-workforce/issues/87) [#87](https://github.com/nanobpm/nano-workforce/issues/87) [#87](https://github.com/nanobpm/nano-workforce/issues/87)
14
+
1
15
  # [0.30.0](https://github.com/nanobpm/nano-workforce/compare/v0.29.0...v0.30.0) (2026-08-09)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -420,7 +420,10 @@ Start(issue) → plan → record-plan → implement (parallel MI) → record-res
420
420
  - **`record-results`** — app worker `pr.record-results`. Zips `results` back onto
421
421
  `plan_tasks` by index, and for each opened `pr` calls the same idempotent
422
422
  `submitPr` as §4 — **the handoff**: every fleet-produced PR enrols into the
423
- review-convergence loop. Sets `plans` status `done`.
423
+ review-convergence loop. On success it sets `plans` status `done`; if the epic
424
+ finalizes having opened **zero** PRs (empty plan, or every task blocked/skipped)
425
+ it raises a non-retryable `NO_WORK_DISPATCHED` incident instead of completing
426
+ green — a no-op run must not masquerade as success (issue #86).
424
427
 
425
428
  **Payloads are untyped** (no `nano:shapes`/`io.nanobpm.dataEnvelope`): the vocab is
426
429
  scalar-only and cannot express the `tasks`/`results` lists, so the workers self-type
package/app/plan.test.ts CHANGED
@@ -53,8 +53,8 @@ Deno.test("valid positive integer → honoured", () => {
53
53
  //
54
54
  // `plan_reviews` is append-only and the review round is derived from `count(plan_reviews)`.
55
55
  // When `startPlan` re-plans a previously finished issue it must clear the prior review rows,
56
- // otherwise the stale count inflates the next round index and can trip `reviewExhausted` early,
57
- // bypassing the adversarial gate. This drives `startPlan` against an in-memory data layer and
56
+ // otherwise the stale count inflates the next round index and can reach the review-round cap
57
+ // early, bypassing the adversarial gate. This drives `startPlan` against an in-memory data layer and
58
58
  // asserts the `plan_reviews` rows for the plan key are gone after a re-plan.
59
59
  import { startPlan } from "./plan.ts";
60
60
 
package/app/plan.ts CHANGED
@@ -150,8 +150,9 @@ export function positiveIntEnv(name: string, fallback: number): number {
150
150
  return Number.isInteger(n) && n > 0 ? n : fallback;
151
151
  }
152
152
 
153
- /** Max adversarial plan-review rounds before the fan-out proceeds regardless (so a reviewer that
154
- * never approves can't dead-lock the plan). The last round's findings are still recorded. */
153
+ /** Max adversarial plan-review rounds. Reaching the cap WITHOUT approval is a hard failure: the
154
+ * fan-out raises a `PLAN_REJECTED` incident rather than dispatching an un-approved plan (issue
155
+ * #86). The last round's findings are still recorded. */
155
156
  export const MAX_PLAN_REVIEW_ROUNDS = positiveIntEnv("NANO_PLAN_REVIEW_ROUNDS", 3);
156
157
 
157
158
  /** A plan is "done" in exactly these states; everything else (planning, dispatched)
@@ -205,7 +206,7 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
205
206
  }
206
207
  // `plan_reviews` is append-only and the review round is derived from
207
208
  // `count(plan_reviews)`, so stale rows from the prior run would inflate the
208
- // next round index and trip `reviewExhausted` early (bypassing the gate).
209
+ // next round index and reach the review-round cap early (bypassing the gate).
209
210
  // Clear them here — the table is keyed on `plan_key`, so one delete drops the
210
211
  // whole set (mirrors how record-plan clears `plan_task_deps`).
211
212
  await planReviews(data).delete(parsed.planKey);
package/app/retro.test.ts CHANGED
@@ -97,6 +97,10 @@ function seedTask(stores: Record<string, any[]>, task: Record<string, unknown>)
97
97
  function seedPr(stores: Record<string, any[]>, pr_key: string, status: string) {
98
98
  (stores["pull_requests"] ??= []).push({ pr_key, status });
99
99
  }
100
+ // deno-lint-ignore no-explicit-any
101
+ function seedReview(stores: Record<string, any[]>, round: number, approved: number, findings: string | null) {
102
+ (stores["plan_reviews"] ??= []).push({ plan_key: PLAN, round, approved, findings, created_at: `t${round}`, job_key: null });
103
+ }
100
104
 
101
105
  Deno.test("autoRetroEnabled: on by default; disabled by 0/false/off/no", () => {
102
106
  const prev = process.env.NANO_AUTO_RETRO;
@@ -177,10 +181,42 @@ Deno.test("gatherRetro: separates learnings from notes and folds in deltas", asy
177
181
  assertEquals(d.repo, "acme/widgets");
178
182
  });
179
183
 
184
+ Deno.test("gatherRetro: folds in the plan-review trace and task-outcome shape", async () => {
185
+ const { data, stores } = memData();
186
+ seedPlan(stores);
187
+ seedTask(stores, { id: "t1", task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
188
+ seedTask(stores, { id: "t2", task_id: "t2", status: "skipped", pr_key: null });
189
+ seedTask(stores, { id: "t3", task_id: "t3", status: "opened", pr_key: "acme/widgets#11" });
190
+ // Two rejected rounds then an approval — out of order to prove the sort.
191
+ seedReview(stores, 1, 0, "still missing the migration ordering");
192
+ seedReview(stores, 0, 0, "task t2 depends on t3 but is sequenced first");
193
+ seedReview(stores, 2, 1, "");
194
+
195
+ const d = await gatherRetro(data, PLAN);
196
+ assertEquals(d.reviewRounds, 3);
197
+ assertEquals(d.planApproved, true);
198
+ assertEquals(d.reviewRejections.map((r) => r.round), [0, 1]);
199
+ assertEquals(d.reviewRejections[0].findings, "task t2 depends on t3 but is sequenced first");
200
+ assertEquals(d.taskOutcomes.total, 3);
201
+ assertEquals(d.taskOutcomes.byStatus, { opened: 2, skipped: 1 });
202
+ });
203
+
204
+ Deno.test("gatherRetro: no reviews → zero rounds, not approved, empty rejections", async () => {
205
+ const { data, stores } = memData();
206
+ seedPlan(stores);
207
+ const d = await gatherRetro(data, PLAN);
208
+ assertEquals(d.reviewRounds, 0);
209
+ assertEquals(d.planApproved, false);
210
+ assertEquals(d.reviewRejections, []);
211
+ assertEquals(d.taskOutcomes, { total: 0, byStatus: {} });
212
+ });
213
+
180
214
  Deno.test("renderRetroBrief: renders learnings + constraints; states 'none' with no learnings", () => {
181
215
  const empty = renderRetroBrief({
182
216
  planKey: PLAN, repo: "acme/widgets", issueUrl: "", title: null,
183
217
  learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [],
218
+ reviewRounds: 0, reviewRejections: [], planApproved: false,
219
+ taskOutcomes: { total: 0, byStatus: {} },
184
220
  counts: { learnings: 0, deltas: 0, notes: 0 },
185
221
  });
186
222
  assertStringIncludes(empty, "none");
@@ -192,20 +228,31 @@ Deno.test("renderRetroBrief: renders learnings + constraints; states 'none' with
192
228
  contractChanges: [{ taskId: "t1", change: "shape" }],
193
229
  constraints: [{ taskId: "t1", constraint: "must X" }],
194
230
  notes: [{ author_task: "t2", kind: "note", body: "watch the release lane" }],
231
+ reviewRounds: 2,
232
+ reviewRejections: [{ round: 0, findings: "wrong dependency order" }],
233
+ planApproved: true,
234
+ taskOutcomes: { total: 2, byStatus: { opened: 1, skipped: 1 } },
195
235
  counts: { learnings: 1, deltas: 1, notes: 1 },
196
236
  });
197
237
  assertStringIncludes(brief, "regen first");
198
238
  assertStringIncludes(brief, "must X");
199
239
  assertStringIncludes(brief, "watch the release lane");
200
240
  assertStringIncludes(brief, "acme/widgets");
241
+ assertStringIncludes(brief, "Plan review");
242
+ assertStringIncludes(brief, "wrong dependency order");
243
+ assertStringIncludes(brief, "Task outcomes");
201
244
  });
202
245
 
203
- Deno.test("isDigestEmpty: true only when there are no learnings, deltas, or notes", () => {
204
- const base = { planKey: PLAN, repo: "", issueUrl: "", title: null, learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [] };
246
+ Deno.test("isDigestEmpty: empty only with no learnings/deltas/notes AND no review rejections", () => {
247
+ const base = { planKey: PLAN, repo: "", issueUrl: "", title: null, learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [], reviewRounds: 0, reviewRejections: [], planApproved: false, taskOutcomes: { total: 0, byStatus: {} } };
205
248
  assert(isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 0, notes: 0 } }));
206
249
  assert(!isDigestEmpty({ ...base, counts: { learnings: 1, deltas: 0, notes: 0 } }));
207
250
  assert(!isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 2, notes: 0 } }));
208
251
  assert(!isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 0, notes: 1 } }));
252
+ // A rejected review round is reflection material even with no learnings/deltas/notes.
253
+ assert(!isDigestEmpty({ ...base, reviewRounds: 2, reviewRejections: [{ round: 0, findings: "bad seq" }], counts: { learnings: 0, deltas: 0, notes: 0 } }));
254
+ // ...but task-outcome shape alone (a cleanly-approved plan) is not.
255
+ assert(isDigestEmpty({ ...base, reviewRounds: 1, planApproved: true, taskOutcomes: { total: 3, byStatus: { opened: 3 } }, counts: { learnings: 0, deltas: 0, notes: 0 } }));
209
256
  });
210
257
 
211
258
  Deno.test("recordRetro: inserts then updates the same plan_key row in place", async () => {
@@ -278,6 +325,55 @@ Deno.test("maybeStartRetro: starts the retro exactly once when the last PR lands
278
325
  assertEquals(started.length, 1, "fire-once guard");
279
326
  });
280
327
 
328
+ Deno.test("maybeStartRetro: a createInstance failure records a blocked retro (fire-once guard already consumed)", async () => {
329
+ const { data, stores } = memData();
330
+ seedPlan(stores);
331
+ seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
332
+ seedPr(stores, "acme/widgets#10", "merged");
333
+ await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
334
+ // deno-lint-ignore no-explicit-any require-await
335
+ const engine = { async createInstance() { throw new Error("gateway down"); } } as any as EngineClient;
336
+
337
+ const r = await maybeStartRetro(data, engine, "acme/widgets#10");
338
+ assertEquals(r, { started: false, planKey: PLAN, reason: "start-failed" });
339
+ // The guard is consumed (stamp + claim), so it will never retry...
340
+ assert(stores["plans"][0].retro_started_at, "retro_started_at is stamped");
341
+ assertEquals(stores["plan_retro_starts"].length, 1);
342
+ // ...but the failure is now visible as a blocked retro rather than a silent gap.
343
+ assertEquals(stores["plan_retros"].length, 1);
344
+ assertEquals(stores["plan_retros"][0].status, "blocked");
345
+ assertEquals(stores["plan_retros"][0].pr_key, null);
346
+ assertStringIncludes(String(stores["plan_retros"][0].summary), "gateway down");
347
+ });
348
+
349
+ Deno.test("maybeStartRetro: a secondary blocked-retro persistence failure still returns start-failed (not error)", async () => {
350
+ const { data, stores } = memData();
351
+ seedPlan(stores);
352
+ seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
353
+ seedPr(stores, "acme/widgets#10", "merged");
354
+ await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
355
+ // deno-lint-ignore no-explicit-any require-await
356
+ const engine = { async createInstance() { throw new Error("gateway down"); } } as any as EngineClient;
357
+ // recordRetro rethrows non-unique DB errors; simulate the blocked-retro insert hitting a
358
+ // FOREIGN KEY failure so the persistence in the createInstance-failure handler throws.
359
+ // deno-lint-ignore no-explicit-any
360
+ const failingData = {
361
+ // deno-lint-ignore no-explicit-any
362
+ table: (name: string, pk?: string) => {
363
+ const t = (data as any).table(name, pk);
364
+ if (name !== "plan_retros") return t;
365
+ return { ...t, insert: () => Promise.reject(new Error("FOREIGN KEY constraint failed")) };
366
+ },
367
+ } as any as DataLayer;
368
+
369
+ const r = await maybeStartRetro(failingData, engine, "acme/widgets#10");
370
+ // The secondary persistence failure must NOT be masked as `error` — the guard is still consumed,
371
+ // so the caller must see `start-failed`, and no phantom retro row is written.
372
+ assertEquals(r, { started: false, planKey: PLAN, reason: "start-failed" });
373
+ assert(stores["plans"][0].retro_started_at, "retro_started_at is stamped");
374
+ assertEquals(stores["plan_retros"].length, 0);
375
+ });
376
+
281
377
  Deno.test("maybeStartRetro: a pre-claimed retro start does not start a duplicate process", async () => {
282
378
  const { data, stores } = memData();
283
379
  seedPlan(stores);
@@ -323,6 +419,23 @@ Deno.test("maybeStartRetro: complete but empty → records a skipped retro, does
323
419
  assertEquals(stores["plan_retros"][0].status, "skipped");
324
420
  });
325
421
 
422
+ Deno.test("maybeStartRetro: a rejected review round alone is enough to fire the retro", async () => {
423
+ const { data, stores } = memData();
424
+ seedPlan(stores);
425
+ seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
426
+ seedPr(stores, "acme/widgets#10", "merged");
427
+ // No learnings/deltas/notes — only the plan-review trace carries a rejection.
428
+ seedReview(stores, 0, 0, "task ordering is wrong");
429
+ seedReview(stores, 1, 1, "");
430
+ const { engine, started } = fakeEngine();
431
+
432
+ const r = await maybeStartRetro(data, engine, "acme/widgets#10");
433
+ assertEquals(r.started, true);
434
+ assertEquals(r.planKey, PLAN);
435
+ assertEquals(started.length, 1);
436
+ assert(stores["plans"][0].retro_started_at, "retro_started_at must be stamped");
437
+ });
438
+
326
439
  Deno.test("maybeStartRetro: a PR not part of any plan is a no-op", async () => {
327
440
  const { data } = memData();
328
441
  const { engine, started } = fakeEngine();
package/app/retro.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
15
15
  // app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
16
16
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
17
- import { planTasks } from "./plan.ts";
17
+ import { planReviews, planTasks } from "./plan.ts";
18
18
  import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
19
19
  import { aggregateEpicDeltas } from "./taskDelta.ts";
20
20
  import { TERMINAL_STATUSES } from "./service.ts";
@@ -92,12 +92,24 @@ export interface RetroDigest {
92
92
  contractChanges: { taskId: string; change: string }[];
93
93
  constraints: { taskId: string; constraint: string }[];
94
94
  notes: { author_task: string; kind: string; body: string }[];
95
+ // Plan-review trace (006_plan_review.sql): how many adversarial review rounds the plan needed
96
+ // before fan-out, and the critique from every rejected round — the "what was wrong with the
97
+ // first cut of the decomposition" signal, which is prime retro material even when implementers
98
+ // posted no learnings of their own. `planApproved` reflects the final round's verdict.
99
+ reviewRounds: number;
100
+ reviewRejections: { round: number; findings: string }[];
101
+ planApproved: boolean;
102
+ // Execution shape: how the plan's tasks actually resolved (opened a PR, were skipped/blocked,
103
+ // etc.). Lets the retro reason over decomposition accuracy — e.g. a high skipped/blocked ratio
104
+ // hints the plan over-decomposed.
105
+ taskOutcomes: { total: number; byStatus: Record<string, number> };
95
106
  counts: { learnings: number; deltas: number; notes: number };
96
107
  }
97
108
 
98
109
  /** Gather a plan's reflection material: the `learning` blackboard entries (the headline), plus the
99
- * task-delta rollup (contract changes, discovered constraints, cross-slice file touches) and any
100
- * other non-learning blackboard notes for colour. Reads only no writes. */
110
+ * task-delta rollup (contract changes, discovered constraints, cross-slice file touches), the
111
+ * plan-review trace (rounds + rejection findings), the task-outcome shape, and any other
112
+ * non-learning blackboard notes for colour. Reads only — no writes. */
101
113
  export async function gatherRetro(data: DataLayer, planKey: string): Promise<RetroDigest> {
102
114
  const plan = await plansTbl(data).get(planKey);
103
115
  const entries = await readBlackboard(data, planKey);
@@ -108,6 +120,22 @@ export async function gatherRetro(data: DataLayer, planKey: string): Promise<Ret
108
120
  .filter((e) => e.kind !== "learning")
109
121
  .map((e) => ({ author_task: e.author_task, kind: e.kind, body: e.body }));
110
122
  const deltas = await aggregateEpicDeltas(data, planKey);
123
+
124
+ // Plan-review trace, ordered by round. A rejected round is `approved === 0`; only rounds that
125
+ // carry findings are worth quoting (an empty rejection has nothing to teach).
126
+ const reviews = (await planReviews(data).find({ plan_key: planKey }))
127
+ .slice()
128
+ .sort((a, b) => a.round - b.round);
129
+ const reviewRejections = reviews
130
+ .filter((r) => r.approved === 0 && (r.findings ?? "").trim() !== "")
131
+ .map((r) => ({ round: r.round, findings: r.findings as string }));
132
+ const planApproved = reviews.length > 0 && reviews[reviews.length - 1].approved === 1;
133
+
134
+ // Task-outcome shape (counts by final status).
135
+ const tasks = await planTasks(data).find({ plan_key: planKey });
136
+ const byStatus: Record<string, number> = {};
137
+ for (const t of tasks) byStatus[t.status] = (byStatus[t.status] ?? 0) + 1;
138
+
111
139
  return {
112
140
  planKey,
113
141
  repo: plan?.repo ?? planKey.split("#")[0] ?? "",
@@ -118,6 +146,10 @@ export async function gatherRetro(data: DataLayer, planKey: string): Promise<Ret
118
146
  contractChanges: deltas.contractChanges,
119
147
  constraints: deltas.constraints,
120
148
  notes,
149
+ reviewRounds: reviews.length,
150
+ reviewRejections,
151
+ planApproved,
152
+ taskOutcomes: { total: tasks.length, byStatus },
121
153
  counts: {
122
154
  learnings: learnings.length,
123
155
  deltas: deltas.deltas.length,
@@ -139,8 +171,29 @@ export function renderRetroBrief(d: RetroDigest): string {
139
171
  `Target repo: **${d.repo}**${d.issueUrl ? ` · issue: ${d.issueUrl}` : ""}`,
140
172
  d.title ? `Epic: ${d.title}` : "",
141
173
  "",
142
- `### Learnings agents posted while implementing (${d.learnings.length})`,
174
+ `### Plan review ${d.reviewRounds} round(s), ${d.planApproved ? "approved" : "not approved"}`,
143
175
  ];
176
+ if (d.reviewRejections.length === 0) {
177
+ lines.push(
178
+ d.reviewRounds === 0
179
+ ? "_(no review rounds recorded)_"
180
+ : "_(approved with no recorded rejections)_",
181
+ );
182
+ } else {
183
+ lines.push(`The plan was revised after ${d.reviewRejections.length} rejected round(s):`);
184
+ for (const r of d.reviewRejections) lines.push(`- **round ${r.round}**: ${r.findings}`);
185
+ }
186
+ if (d.taskOutcomes.total > 0) {
187
+ const shape = Object.entries(d.taskOutcomes.byStatus)
188
+ .sort((a, b) => a[0].localeCompare(b[0]))
189
+ .map(([s, n]) => `${s}: ${n}`)
190
+ .join(", ");
191
+ lines.push("", `### Task outcomes (${d.taskOutcomes.total} task(s))`, shape);
192
+ }
193
+ lines.push(
194
+ "",
195
+ `### Learnings agents posted while implementing (${d.learnings.length})`,
196
+ );
144
197
  if (d.learnings.length === 0) {
145
198
  lines.push("_(none — agents posted no `learning` entries for this epic)_");
146
199
  } else {
@@ -164,9 +217,14 @@ export function renderRetroBrief(d: RetroDigest): string {
164
217
  return lines.join("\n");
165
218
  }
166
219
 
167
- /** True when a digest carries nothing worth an agent run no learnings, no deltas, no notes. */
220
+ /** True when a digest carries nothing worth an agent run. A plan is worth retrospecting when
221
+ * implementers shared material (learnings/deltas/notes) OR the plan itself needed revision — a
222
+ * rejected review round's findings are reflection material in their own right, even when no
223
+ * learning was posted. (Task-outcome shape alone is NOT: a cleanly-approved plan whose tasks all
224
+ * ran has nothing to teach.) */
168
225
  export function isDigestEmpty(d: RetroDigest): boolean {
169
- return d.counts.learnings === 0 && d.counts.deltas === 0 && d.counts.notes === 0;
226
+ return d.counts.learnings === 0 && d.counts.deltas === 0 && d.counts.notes === 0 &&
227
+ d.reviewRejections.length === 0;
170
228
  }
171
229
 
172
230
  /** The persisted retro shape (written by pr.retro-record). */
@@ -264,14 +322,40 @@ export async function maybeStartRetro(
264
322
  // Stamp before starting so restarts/retries can take the cheap already-started path.
265
323
  await plansTbl(data).update(planKey, { retro_started_at: now(), updated_at: now() });
266
324
 
267
- const { processInstanceKey } = await engine.createInstance({
268
- processDefinitionId: RETRO_PROCESS_ID,
269
- variables: {
270
- planKey,
271
- repo: digest.repo,
272
- issueUrl: digest.issueUrl,
273
- },
274
- });
325
+ let processInstanceKey: string | number | undefined;
326
+ try {
327
+ ({ processInstanceKey } = await engine.createInstance({
328
+ processDefinitionId: RETRO_PROCESS_ID,
329
+ variables: {
330
+ planKey,
331
+ repo: digest.repo,
332
+ issueUrl: digest.issueUrl,
333
+ },
334
+ }));
335
+ } catch (err) {
336
+ // The fire-once guard is already consumed (retro_started_at stamped, plan_retro_starts
337
+ // claimed), so this plan will never re-enter the start path. If we returned here with no
338
+ // record, the epic surface would show a plan that "started a retro" with nothing to show and
339
+ // no way to retry. Persist a `blocked` retro instead so the failure stays visible and the
340
+ // system state is consistent. Recording must not mask the original error in the log.
341
+ log?.("error", `retro: could not start process for epic ${planKey}`, { err: String(err) });
342
+ // Persisting the blocked record is best-effort: recordRetro rethrows non-unique DB errors, and
343
+ // if that escaped here it would fall through to the outer catch and return `error` instead of
344
+ // `start-failed` — reintroducing the very silent-gap failure this path guards against (guard
345
+ // consumed, no durable record). Swallow a secondary persistence failure and log it separately
346
+ // so the createInstance failure path always reports `start-failed`.
347
+ try {
348
+ await recordRetro(data, planKey, {
349
+ status: "blocked",
350
+ summary: `Retro process could not be started: ${String(err)}`,
351
+ });
352
+ } catch (persistErr) {
353
+ log?.("error", `retro: could not persist blocked retro for epic ${planKey}`, {
354
+ err: String(persistErr),
355
+ });
356
+ }
357
+ return { started: false, planKey, reason: "start-failed" };
358
+ }
275
359
  log?.("info", `retro: started for epic ${planKey}`, { processInstanceKey, learnings: digest.counts.learnings });
276
360
  return { started: true, planKey };
277
361
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -23,7 +23,7 @@
23
23
  "type": "text",
24
24
  "id": "subtitle",
25
25
  "props": {
26
- "text": "Read-only observability over each plan's wave state, merge-exclusion graph, coordination notes, and trial-merge gate results.",
26
+ "text": "Read-only observability over each plan's review trace, wave state, merge-exclusion graph, coordination notes, and trial-merge gate results.",
27
27
  "variant": "sub"
28
28
  }
29
29
  },
@@ -70,6 +70,27 @@
70
70
  }
71
71
  }
72
72
  },
73
+ {
74
+ "type": "dataGrid",
75
+ "id": "plan-reviews",
76
+ "props": {
77
+ "title": "Plan review trace",
78
+ "refreshMs": 5000,
79
+ "data": {
80
+ "kind": "datasource",
81
+ "source": "app",
82
+ "table": "plan_reviews",
83
+ "orderBy": { "field": "round", "dir": "asc" }
84
+ },
85
+ "columns": [
86
+ { "field": "plan_key", "header": "Plan" },
87
+ { "field": "round", "header": "Round" },
88
+ { "field": "approved", "header": "Approved? (1/0)" },
89
+ { "field": "findings", "header": "Reviewer findings" },
90
+ { "field": "created_at", "header": "Recorded" }
91
+ ]
92
+ }
93
+ },
73
94
  {
74
95
  "type": "dataGrid",
75
96
  "id": "wave-state",
@@ -228,6 +228,20 @@
228
228
  { "field": "summary", "header": "Summary" }
229
229
  ]
230
230
  },
231
+ {
232
+ "title": "Plan reviews",
233
+ "source": "app",
234
+ "table": "plan_reviews",
235
+ "parentField": "plan_key",
236
+ "childField": "plan_key",
237
+ "orderBy": { "field": "round", "dir": "asc" },
238
+ "columns": [
239
+ { "field": "round", "header": "Round" },
240
+ { "field": "approved", "header": "Approved? (1/0)" },
241
+ { "field": "findings", "header": "Reviewer findings" },
242
+ { "field": "created_at", "header": "Recorded" }
243
+ ]
244
+ },
231
245
  {
232
246
  "title": "Escalations",
233
247
  "source": "app",
@@ -216,7 +216,7 @@
216
216
  <bpmn:sequenceFlow id="f_toRecordPlanReview" sourceRef="review-plan" targetRef="record-plan-review" />
217
217
  <bpmn:sequenceFlow id="f_toGwPlanReview" sourceRef="record-plan-review" targetRef="gw-plan-review" />
218
218
  <bpmn:sequenceFlow id="f_plan_proceed" name="approved" sourceRef="gw-plan-review" targetRef="select-wave">
219
- <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=planApproved or reviewExhausted</bpmn:conditionExpression>
219
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=planApproved</bpmn:conditionExpression>
220
220
  </bpmn:sequenceFlow>
221
221
  <bpmn:sequenceFlow id="f_plan_revise" name="revise" sourceRef="gw-plan-review" targetRef="plan" />
222
222
  <bpmn:sequenceFlow id="f_toImplement" sourceRef="select-wave" targetRef="implement" />
@@ -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
+ });
@@ -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
+ });
@@ -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
  };