@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 +14 -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/package.json +1 -1
- package/pages/epic.page.json +22 -1
- package/pages/home.page.json +14 -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
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.
|
|
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
|
|
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
|
|
154
|
-
*
|
|
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
|
|
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:
|
|
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)
|
|
100
|
-
*
|
|
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
|
-
`###
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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.
|
|
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",
|
package/pages/epic.page.json
CHANGED
|
@@ -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",
|
package/pages/home.page.json
CHANGED
|
@@ -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
|
|
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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
};
|