@nanobpm/nano-workforce 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/SPEC.md +4 -1
- package/app/blackboard.test.ts +17 -0
- package/app/blackboard.ts +8 -4
- package/app/plan.test.ts +2 -2
- package/app/plan.ts +4 -3
- package/app/retro.test.ts +466 -0
- package/app/retro.ts +365 -0
- package/db/migrations/016_plan_retro.sql +38 -0
- package/nano.app.json +8 -0
- package/package.json +1 -1
- package/pages/epic.page.json +22 -1
- package/pages/home.page.json +14 -0
- package/prompts/retro.md +86 -0
- package/resources/processes/plan-fanout.bpmn +1 -1
- package/resources/processes/retro.bpmn +83 -0
- package/scripts/pages-contract.test.ts +228 -0
- package/workers/finalize/worker.ts +9 -0
- package/workers/mark-merged/worker.ts +6 -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/workers/retro-gather/worker.test.ts +78 -0
- package/workers/retro-gather/worker.ts +28 -0
- package/workers/retro-record/worker.test.ts +127 -0
- package/workers/retro-record/worker.ts +60 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
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
|
+
|
|
15
|
+
# [0.30.0](https://github.com/nanobpm/nano-workforce/compare/v0.29.0...v0.30.0) (2026-08-09)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **retro:** epic retrospective workflow that promotes shared learnings ([#84](https://github.com/nanobpm/nano-workforce/issues/84)) ([306e46f](https://github.com/nanobpm/nano-workforce/commit/306e46ff333bc5baff199713bf3a71f0c294e897)), closes [#82](https://github.com/nanobpm/nano-workforce/issues/82)
|
|
21
|
+
|
|
1
22
|
# [0.29.0](https://github.com/nanobpm/nano-workforce/compare/v0.28.0...v0.29.0) (2026-08-08)
|
|
2
23
|
|
|
3
24
|
|
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/blackboard.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
appendEntry,
|
|
6
6
|
blackboardUrl,
|
|
7
7
|
detectFileClaimConflicts,
|
|
8
|
+
isUniqueViolation,
|
|
8
9
|
mintBlackboardToken,
|
|
9
10
|
normalizeKind,
|
|
10
11
|
planKeyForToken,
|
|
@@ -297,3 +298,19 @@ Deno.test("detectFileClaimConflicts: beforeId restricts to strictly prior claims
|
|
|
297
298
|
assertEquals(conflicts[0].author_task, "gap-2");
|
|
298
299
|
assert(Number(later.id) > Number(mine.id));
|
|
299
300
|
});
|
|
301
|
+
|
|
302
|
+
Deno.test("isUniqueViolation: true for UNIQUE/PK, false for FOREIGN KEY and unrelated errors", () => {
|
|
303
|
+
// Extended SQLite codes.
|
|
304
|
+
assert(isUniqueViolation(Object.assign(new Error("x"), { code: "SQLITE_CONSTRAINT_UNIQUE" })));
|
|
305
|
+
assert(isUniqueViolation(Object.assign(new Error("x"), { code: "SQLITE_CONSTRAINT_PRIMARYKEY" })));
|
|
306
|
+
// Message-only (driver surfaced no code).
|
|
307
|
+
assert(isUniqueViolation(new Error("UNIQUE constraint failed: plan_retros.plan_key")));
|
|
308
|
+
assert(isUniqueViolation(new Error("PRIMARY KEY constraint failed")));
|
|
309
|
+
// The bug this guards: a bare "constraint" match would swallow an FK failure.
|
|
310
|
+
assert(!isUniqueViolation(new Error("FOREIGN KEY constraint failed")));
|
|
311
|
+
assert(!isUniqueViolation(Object.assign(new Error("fk"), { code: "SQLITE_CONSTRAINT_FOREIGNKEY" })));
|
|
312
|
+
// Unrelated / non-errors.
|
|
313
|
+
assert(!isUniqueViolation(new Error("network down")));
|
|
314
|
+
assert(!isUniqueViolation(null));
|
|
315
|
+
assert(!isUniqueViolation("nope"));
|
|
316
|
+
});
|
package/app/blackboard.ts
CHANGED
|
@@ -307,11 +307,15 @@ export async function appendEntry(
|
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
/** True
|
|
311
|
-
|
|
310
|
+
/** True only for a UNIQUE / PRIMARY-KEY / duplicate violation — never a foreign-key or other
|
|
311
|
+
* constraint failure. We match the *specific* violation (extended SQLite codes, or the specific
|
|
312
|
+
* words) rather than the bare word "constraint", so a `FOREIGN KEY constraint failed` (real data
|
|
313
|
+
* corruption, not a benign duplicate) is always rethrown rather than silently swallowed. */
|
|
314
|
+
export function isUniqueViolation(err: unknown): boolean {
|
|
312
315
|
if (!err || typeof err !== "object") return false;
|
|
313
316
|
const code = (err as { code?: unknown }).code;
|
|
314
|
-
if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "
|
|
317
|
+
if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "SQLITE_CONSTRAINT_PRIMARYKEY") return true;
|
|
315
318
|
const message = (err as { message?: unknown }).message;
|
|
316
|
-
return typeof message === "string" &&
|
|
319
|
+
return typeof message === "string" &&
|
|
320
|
+
/(unique|primary key) constraint failed|duplicate/i.test(message);
|
|
317
321
|
}
|
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);
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// Unit tests for the epic retrospective stage (app/retro.ts, 016_plan_retro.sql).
|
|
2
|
+
import { assert, assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
|
3
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
4
|
+
import { appendEntry } from "./blackboard.ts";
|
|
5
|
+
import { recordTaskDelta } from "./taskDelta.ts";
|
|
6
|
+
import {
|
|
7
|
+
autoRetroEnabled,
|
|
8
|
+
gatherRetro,
|
|
9
|
+
isDigestEmpty,
|
|
10
|
+
isPlanComplete,
|
|
11
|
+
maybeStartRetro,
|
|
12
|
+
planKeyForPr,
|
|
13
|
+
recordRetro,
|
|
14
|
+
renderRetroBrief,
|
|
15
|
+
} from "./retro.ts";
|
|
16
|
+
|
|
17
|
+
// In-memory record gateway matching the Table<T> subset retro.ts uses: insert/find/findOne/get/update.
|
|
18
|
+
// deno-lint-ignore no-explicit-any
|
|
19
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
20
|
+
// deno-lint-ignore no-explicit-any
|
|
21
|
+
const stores: Record<string, any[]> = {};
|
|
22
|
+
const seq: Record<string, number> = {};
|
|
23
|
+
function tbl(name: string, pk = "id") {
|
|
24
|
+
// deno-lint-ignore no-explicit-any
|
|
25
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
26
|
+
// deno-lint-ignore no-explicit-any
|
|
27
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
28
|
+
return {
|
|
29
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
30
|
+
async insert(row: any) {
|
|
31
|
+
if (pk !== "id" && rows.some((r) => r[pk] === row[pk])) {
|
|
32
|
+
throw new Error(`UNIQUE constraint failed: ${name}.${pk}`);
|
|
33
|
+
}
|
|
34
|
+
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
35
|
+
rows.push(pk === "id" ? { id, ...row } : { ...row });
|
|
36
|
+
return pk === "id" ? id : row[pk];
|
|
37
|
+
},
|
|
38
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
39
|
+
async find(where: any = {}) {
|
|
40
|
+
return rows.filter((r) => match(r, where));
|
|
41
|
+
},
|
|
42
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
43
|
+
async findOne(where: any = {}) {
|
|
44
|
+
return rows.find((r) => match(r, where));
|
|
45
|
+
},
|
|
46
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
47
|
+
async get(id: any) {
|
|
48
|
+
return rows.find((row) => row[pk] === id);
|
|
49
|
+
},
|
|
50
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
51
|
+
async update(id: any, patch: any) {
|
|
52
|
+
const r = rows.find((row) => row[pk] === id);
|
|
53
|
+
if (r) Object.assign(r, patch);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// deno-lint-ignore no-explicit-any
|
|
58
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
59
|
+
return { data, stores };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A fake engine recording createInstance calls.
|
|
63
|
+
function fakeEngine(): { engine: EngineClient; started: { processDefinitionId: string; variables: Record<string, unknown> }[] } {
|
|
64
|
+
const started: { processDefinitionId: string; variables: Record<string, unknown> }[] = [];
|
|
65
|
+
// deno-lint-ignore no-explicit-any
|
|
66
|
+
const engine = {
|
|
67
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
68
|
+
async createInstance(req: any) {
|
|
69
|
+
started.push({ processDefinitionId: req.processDefinitionId, variables: req.variables });
|
|
70
|
+
return { processInstanceKey: `PI-${started.length}` };
|
|
71
|
+
},
|
|
72
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
73
|
+
} as any as EngineClient;
|
|
74
|
+
return { engine, started };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const PLAN = "acme/widgets#7";
|
|
78
|
+
|
|
79
|
+
// deno-lint-ignore no-explicit-any
|
|
80
|
+
function seedPlan(stores: Record<string, any[]>, over: Record<string, unknown> = {}) {
|
|
81
|
+
stores["plans"] = [{
|
|
82
|
+
plan_key: PLAN,
|
|
83
|
+
repo: "acme/widgets",
|
|
84
|
+
issue_url: "https://github.com/acme/widgets/issues/7",
|
|
85
|
+
title: "Widgets epic",
|
|
86
|
+
status: "done",
|
|
87
|
+
retro_started_at: null,
|
|
88
|
+
...over,
|
|
89
|
+
}];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// deno-lint-ignore no-explicit-any
|
|
93
|
+
function seedTask(stores: Record<string, any[]>, task: Record<string, unknown>) {
|
|
94
|
+
(stores["plan_tasks"] ??= []).push({ plan_key: PLAN, ...task });
|
|
95
|
+
}
|
|
96
|
+
// deno-lint-ignore no-explicit-any
|
|
97
|
+
function seedPr(stores: Record<string, any[]>, pr_key: string, status: string) {
|
|
98
|
+
(stores["pull_requests"] ??= []).push({ pr_key, status });
|
|
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
|
+
}
|
|
104
|
+
|
|
105
|
+
Deno.test("autoRetroEnabled: on by default; disabled by 0/false/off/no", () => {
|
|
106
|
+
const prev = process.env.NANO_AUTO_RETRO;
|
|
107
|
+
try {
|
|
108
|
+
delete process.env.NANO_AUTO_RETRO;
|
|
109
|
+
assert(autoRetroEnabled());
|
|
110
|
+
for (const v of ["0", "false", "off", "no", "FALSE"]) {
|
|
111
|
+
process.env.NANO_AUTO_RETRO = v;
|
|
112
|
+
assertEquals(autoRetroEnabled(), false, `"${v}" should disable`);
|
|
113
|
+
}
|
|
114
|
+
process.env.NANO_AUTO_RETRO = "1";
|
|
115
|
+
assert(autoRetroEnabled());
|
|
116
|
+
} finally {
|
|
117
|
+
if (prev == null) delete process.env.NANO_AUTO_RETRO;
|
|
118
|
+
else process.env.NANO_AUTO_RETRO = prev;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
Deno.test("planKeyForPr: resolves the plan a PR's task belongs to; undefined when unlinked", async () => {
|
|
123
|
+
const { data, stores } = memData();
|
|
124
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
125
|
+
assertEquals(await planKeyForPr(data, "acme/widgets#10"), PLAN);
|
|
126
|
+
assertEquals(await planKeyForPr(data, "acme/widgets#99"), undefined);
|
|
127
|
+
assertEquals(await planKeyForPr(data, ""), undefined);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
Deno.test("isPlanComplete: false while any task is still in flight", async () => {
|
|
131
|
+
const { data, stores } = memData();
|
|
132
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
133
|
+
seedTask(stores, { id: "t2", status: "pending", pr_key: null });
|
|
134
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
135
|
+
// t2 is pending with no PR → not done.
|
|
136
|
+
assertEquals(await isPlanComplete(data, PLAN), false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
Deno.test("isPlanComplete: false when an opened task's PR is not yet terminal", async () => {
|
|
140
|
+
const { data, stores } = memData();
|
|
141
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
142
|
+
seedPr(stores, "acme/widgets#10", "waiting_deps"); // in the merge stage, not terminal
|
|
143
|
+
assertEquals(await isPlanComplete(data, PLAN), false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
Deno.test("isPlanComplete: true when every task is settled (terminal PR or skipped/blocked)", async () => {
|
|
147
|
+
const { data, stores } = memData();
|
|
148
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
149
|
+
seedTask(stores, { id: "t2", status: "skipped", pr_key: null });
|
|
150
|
+
seedTask(stores, { id: "t3", status: "opened", pr_key: "acme/widgets#11" });
|
|
151
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
152
|
+
seedPr(stores, "acme/widgets#11", "converged");
|
|
153
|
+
assertEquals(await isPlanComplete(data, PLAN), true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
Deno.test("isPlanComplete: an empty plan has nothing to retrospect", async () => {
|
|
157
|
+
const { data } = memData();
|
|
158
|
+
assertEquals(await isPlanComplete(data, PLAN), false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
Deno.test("gatherRetro: separates learnings from notes and folds in deltas", async () => {
|
|
162
|
+
const { data, stores } = memData();
|
|
163
|
+
seedPlan(stores);
|
|
164
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen the API surface before building" });
|
|
165
|
+
await appendEntry(data, PLAN, { author_task: "t2", kind: "learning", body: "use nextest not cargo test" });
|
|
166
|
+
await appendEntry(data, PLAN, { author_task: "t3", kind: "note", body: "just an FYI" });
|
|
167
|
+
await recordTaskDelta(data, PLAN, "t1", {
|
|
168
|
+
contractChange: "changed the envelope shape",
|
|
169
|
+
newlyTouches: ["shared/env.ts"],
|
|
170
|
+
affectsTasks: ["t2"],
|
|
171
|
+
constraint: "envelope must carry results[]",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const d = await gatherRetro(data, PLAN);
|
|
175
|
+
assertEquals(d.counts.learnings, 2);
|
|
176
|
+
assertEquals(d.learnings.map((l) => l.author_task).sort(), ["t1", "t2"]);
|
|
177
|
+
assertEquals(d.notes.length, 1);
|
|
178
|
+
assertEquals(d.constraints.length, 1);
|
|
179
|
+
assertEquals(d.contractChanges.length, 1);
|
|
180
|
+
assert(d.touchedFiles.includes("shared/env.ts"));
|
|
181
|
+
assertEquals(d.repo, "acme/widgets");
|
|
182
|
+
});
|
|
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
|
+
|
|
214
|
+
Deno.test("renderRetroBrief: renders learnings + constraints; states 'none' with no learnings", () => {
|
|
215
|
+
const empty = renderRetroBrief({
|
|
216
|
+
planKey: PLAN, repo: "acme/widgets", issueUrl: "", title: null,
|
|
217
|
+
learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [],
|
|
218
|
+
reviewRounds: 0, reviewRejections: [], planApproved: false,
|
|
219
|
+
taskOutcomes: { total: 0, byStatus: {} },
|
|
220
|
+
counts: { learnings: 0, deltas: 0, notes: 0 },
|
|
221
|
+
});
|
|
222
|
+
assertStringIncludes(empty, "none");
|
|
223
|
+
|
|
224
|
+
const brief = renderRetroBrief({
|
|
225
|
+
planKey: PLAN, repo: "acme/widgets", issueUrl: "https://x/7", title: "Epic",
|
|
226
|
+
learnings: [{ author_task: "t1", body: "regen first", created_at: "now" }],
|
|
227
|
+
touchedFiles: ["a.ts"],
|
|
228
|
+
contractChanges: [{ taskId: "t1", change: "shape" }],
|
|
229
|
+
constraints: [{ taskId: "t1", constraint: "must X" }],
|
|
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 } },
|
|
235
|
+
counts: { learnings: 1, deltas: 1, notes: 1 },
|
|
236
|
+
});
|
|
237
|
+
assertStringIncludes(brief, "regen first");
|
|
238
|
+
assertStringIncludes(brief, "must X");
|
|
239
|
+
assertStringIncludes(brief, "watch the release lane");
|
|
240
|
+
assertStringIncludes(brief, "acme/widgets");
|
|
241
|
+
assertStringIncludes(brief, "Plan review");
|
|
242
|
+
assertStringIncludes(brief, "wrong dependency order");
|
|
243
|
+
assertStringIncludes(brief, "Task outcomes");
|
|
244
|
+
});
|
|
245
|
+
|
|
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: {} } };
|
|
248
|
+
assert(isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 0, notes: 0 } }));
|
|
249
|
+
assert(!isDigestEmpty({ ...base, counts: { learnings: 1, deltas: 0, notes: 0 } }));
|
|
250
|
+
assert(!isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 2, notes: 0 } }));
|
|
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 } }));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
Deno.test("recordRetro: inserts then updates the same plan_key row in place", async () => {
|
|
259
|
+
const { data, stores } = memData();
|
|
260
|
+
await recordRetro(data, PLAN, { status: "filed", prKey: "acme/widgets#20", learnings: 3, summary: "promoted 2" });
|
|
261
|
+
assertEquals(stores["plan_retros"].length, 1);
|
|
262
|
+
assertEquals(stores["plan_retros"][0].status, "filed");
|
|
263
|
+
assertEquals(stores["plan_retros"][0].pr_key, "acme/widgets#20");
|
|
264
|
+
|
|
265
|
+
await recordRetro(data, PLAN, { status: "skipped", summary: "nothing to promote" });
|
|
266
|
+
assertEquals(stores["plan_retros"].length, 1, "same plan_key must not duplicate");
|
|
267
|
+
assertEquals(stores["plan_retros"][0].status, "skipped");
|
|
268
|
+
assertEquals(stores["plan_retros"][0].pr_key, null);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
Deno.test("recordRetro: rethrows a non-unique (FOREIGN KEY) constraint error instead of swallowing it", async () => {
|
|
272
|
+
// A FK failure (e.g. plan_key missing in plans) must NOT be treated as a benign duplicate and
|
|
273
|
+
// fall through to a silent update — that would make the write look successful while doing nothing.
|
|
274
|
+
let updated = false;
|
|
275
|
+
const table = {
|
|
276
|
+
// deno-lint-ignore require-await
|
|
277
|
+
async insert() {
|
|
278
|
+
throw new Error("FOREIGN KEY constraint failed");
|
|
279
|
+
},
|
|
280
|
+
// deno-lint-ignore require-await
|
|
281
|
+
async update() {
|
|
282
|
+
updated = true;
|
|
283
|
+
},
|
|
284
|
+
// deno-lint-ignore require-await
|
|
285
|
+
async get() {
|
|
286
|
+
return undefined;
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
// deno-lint-ignore no-explicit-any
|
|
290
|
+
const data = { table: () => table } as any as DataLayer;
|
|
291
|
+
let threw = false;
|
|
292
|
+
try {
|
|
293
|
+
await recordRetro(data, PLAN, { status: "filed", prKey: "acme/widgets#20" });
|
|
294
|
+
} catch (err) {
|
|
295
|
+
threw = true;
|
|
296
|
+
assertStringIncludes(String(err), "FOREIGN KEY");
|
|
297
|
+
}
|
|
298
|
+
assert(threw, "the FK error must propagate");
|
|
299
|
+
assertEquals(updated, false, "must not silently fall back to update on a non-unique error");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
Deno.test("maybeStartRetro: starts the retro exactly once when the last PR lands with material", async () => {
|
|
303
|
+
const { data, stores } = memData();
|
|
304
|
+
seedPlan(stores);
|
|
305
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
306
|
+
seedTask(stores, { id: "t2", status: "opened", pr_key: "acme/widgets#11" });
|
|
307
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
308
|
+
seedPr(stores, "acme/widgets#11", "merged");
|
|
309
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
|
|
310
|
+
const { engine, started } = fakeEngine();
|
|
311
|
+
|
|
312
|
+
const r1 = await maybeStartRetro(data, engine, "acme/widgets#11");
|
|
313
|
+
assertEquals(r1.started, true);
|
|
314
|
+
assertEquals(r1.planKey, PLAN);
|
|
315
|
+
assertEquals(started.length, 1);
|
|
316
|
+
assertEquals(started[0].processDefinitionId, "retro");
|
|
317
|
+
assertEquals(started[0].variables.planKey, PLAN);
|
|
318
|
+
assert(stores["plans"][0].retro_started_at, "retro_started_at must be stamped");
|
|
319
|
+
assertEquals(stores["plan_retro_starts"].length, 1);
|
|
320
|
+
|
|
321
|
+
// A sibling terminal PR of the same plan must NOT start a second retro.
|
|
322
|
+
const r2 = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
323
|
+
assertEquals(r2.started, false);
|
|
324
|
+
assertEquals(r2.reason, "already-started");
|
|
325
|
+
assertEquals(started.length, 1, "fire-once guard");
|
|
326
|
+
});
|
|
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
|
+
|
|
377
|
+
Deno.test("maybeStartRetro: a pre-claimed retro start does not start a duplicate process", async () => {
|
|
378
|
+
const { data, stores } = memData();
|
|
379
|
+
seedPlan(stores);
|
|
380
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
381
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
382
|
+
stores["plan_retro_starts"] = [{ plan_key: PLAN, started_at: "already" }];
|
|
383
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
|
|
384
|
+
const { engine, started } = fakeEngine();
|
|
385
|
+
|
|
386
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
387
|
+
assertEquals(r, { started: false, planKey: PLAN, reason: "already-started" });
|
|
388
|
+
assertEquals(started.length, 0);
|
|
389
|
+
assertEquals(stores["plans"][0].retro_started_at, null);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
Deno.test("maybeStartRetro: bails while the plan is incomplete", async () => {
|
|
393
|
+
const { data, stores } = memData();
|
|
394
|
+
seedPlan(stores);
|
|
395
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
396
|
+
seedTask(stores, { id: "t2", status: "pending", pr_key: null });
|
|
397
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
398
|
+
const { engine, started } = fakeEngine();
|
|
399
|
+
|
|
400
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
401
|
+
assertEquals(r.started, false);
|
|
402
|
+
assertEquals(r.reason, "incomplete");
|
|
403
|
+
assertEquals(started.length, 0);
|
|
404
|
+
assertEquals(stores["plans"][0].retro_started_at, null, "must not stamp an incomplete plan");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
Deno.test("maybeStartRetro: complete but empty → records a skipped retro, does not start the process", async () => {
|
|
408
|
+
const { data, stores } = memData();
|
|
409
|
+
seedPlan(stores);
|
|
410
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
411
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
412
|
+
const { engine, started } = fakeEngine();
|
|
413
|
+
|
|
414
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
415
|
+
assertEquals(r.started, false);
|
|
416
|
+
assertEquals(r.reason, "nothing-to-retro");
|
|
417
|
+
assertEquals(started.length, 0);
|
|
418
|
+
assert(stores["plans"][0].retro_started_at, "stamped so we don't re-check forever");
|
|
419
|
+
assertEquals(stores["plan_retros"][0].status, "skipped");
|
|
420
|
+
});
|
|
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
|
+
|
|
439
|
+
Deno.test("maybeStartRetro: a PR not part of any plan is a no-op", async () => {
|
|
440
|
+
const { data } = memData();
|
|
441
|
+
const { engine, started } = fakeEngine();
|
|
442
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#99");
|
|
443
|
+
assertEquals(r.started, false);
|
|
444
|
+
assertEquals(r.reason, "no-plan");
|
|
445
|
+
assertEquals(started.length, 0);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
Deno.test("maybeStartRetro: honours NANO_AUTO_RETRO=0", async () => {
|
|
449
|
+
const prev = process.env.NANO_AUTO_RETRO;
|
|
450
|
+
process.env.NANO_AUTO_RETRO = "0";
|
|
451
|
+
try {
|
|
452
|
+
const { data, stores } = memData();
|
|
453
|
+
seedPlan(stores);
|
|
454
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
455
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
456
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "x" });
|
|
457
|
+
const { engine, started } = fakeEngine();
|
|
458
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
459
|
+
assertEquals(r.started, false);
|
|
460
|
+
assertEquals(r.reason, "disabled");
|
|
461
|
+
assertEquals(started.length, 0);
|
|
462
|
+
} finally {
|
|
463
|
+
if (prev == null) delete process.env.NANO_AUTO_RETRO;
|
|
464
|
+
else process.env.NANO_AUTO_RETRO = prev;
|
|
465
|
+
}
|
|
466
|
+
});
|