@nanobpm/nano-workforce 0.29.0 → 0.30.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 +7 -0
- package/app/blackboard.test.ts +17 -0
- package/app/blackboard.ts +8 -4
- package/app/retro.test.ts +353 -0
- package/app/retro.ts +281 -0
- package/db/migrations/016_plan_retro.sql +38 -0
- package/nano.app.json +8 -0
- package/package.json +1 -1
- package/prompts/retro.md +86 -0
- package/resources/processes/retro.bpmn +83 -0
- package/workers/finalize/worker.ts +9 -0
- package/workers/mark-merged/worker.ts +6 -0
- 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,10 @@
|
|
|
1
|
+
# [0.30.0](https://github.com/nanobpm/nano-workforce/compare/v0.29.0...v0.30.0) (2026-08-09)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **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)
|
|
7
|
+
|
|
1
8
|
# [0.29.0](https://github.com/nanobpm/nano-workforce/compare/v0.28.0...v0.29.0) (2026-08-08)
|
|
2
9
|
|
|
3
10
|
|
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
|
}
|
|
@@ -0,0 +1,353 @@
|
|
|
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
|
+
|
|
101
|
+
Deno.test("autoRetroEnabled: on by default; disabled by 0/false/off/no", () => {
|
|
102
|
+
const prev = process.env.NANO_AUTO_RETRO;
|
|
103
|
+
try {
|
|
104
|
+
delete process.env.NANO_AUTO_RETRO;
|
|
105
|
+
assert(autoRetroEnabled());
|
|
106
|
+
for (const v of ["0", "false", "off", "no", "FALSE"]) {
|
|
107
|
+
process.env.NANO_AUTO_RETRO = v;
|
|
108
|
+
assertEquals(autoRetroEnabled(), false, `"${v}" should disable`);
|
|
109
|
+
}
|
|
110
|
+
process.env.NANO_AUTO_RETRO = "1";
|
|
111
|
+
assert(autoRetroEnabled());
|
|
112
|
+
} finally {
|
|
113
|
+
if (prev == null) delete process.env.NANO_AUTO_RETRO;
|
|
114
|
+
else process.env.NANO_AUTO_RETRO = prev;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
Deno.test("planKeyForPr: resolves the plan a PR's task belongs to; undefined when unlinked", async () => {
|
|
119
|
+
const { data, stores } = memData();
|
|
120
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
121
|
+
assertEquals(await planKeyForPr(data, "acme/widgets#10"), PLAN);
|
|
122
|
+
assertEquals(await planKeyForPr(data, "acme/widgets#99"), undefined);
|
|
123
|
+
assertEquals(await planKeyForPr(data, ""), undefined);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
Deno.test("isPlanComplete: false while any task is still in flight", async () => {
|
|
127
|
+
const { data, stores } = memData();
|
|
128
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
129
|
+
seedTask(stores, { id: "t2", status: "pending", pr_key: null });
|
|
130
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
131
|
+
// t2 is pending with no PR → not done.
|
|
132
|
+
assertEquals(await isPlanComplete(data, PLAN), false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
Deno.test("isPlanComplete: false when an opened task's PR is not yet terminal", async () => {
|
|
136
|
+
const { data, stores } = memData();
|
|
137
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
138
|
+
seedPr(stores, "acme/widgets#10", "waiting_deps"); // in the merge stage, not terminal
|
|
139
|
+
assertEquals(await isPlanComplete(data, PLAN), false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
Deno.test("isPlanComplete: true when every task is settled (terminal PR or skipped/blocked)", async () => {
|
|
143
|
+
const { data, stores } = memData();
|
|
144
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
145
|
+
seedTask(stores, { id: "t2", status: "skipped", pr_key: null });
|
|
146
|
+
seedTask(stores, { id: "t3", status: "opened", pr_key: "acme/widgets#11" });
|
|
147
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
148
|
+
seedPr(stores, "acme/widgets#11", "converged");
|
|
149
|
+
assertEquals(await isPlanComplete(data, PLAN), true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
Deno.test("isPlanComplete: an empty plan has nothing to retrospect", async () => {
|
|
153
|
+
const { data } = memData();
|
|
154
|
+
assertEquals(await isPlanComplete(data, PLAN), false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
Deno.test("gatherRetro: separates learnings from notes and folds in deltas", async () => {
|
|
158
|
+
const { data, stores } = memData();
|
|
159
|
+
seedPlan(stores);
|
|
160
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen the API surface before building" });
|
|
161
|
+
await appendEntry(data, PLAN, { author_task: "t2", kind: "learning", body: "use nextest not cargo test" });
|
|
162
|
+
await appendEntry(data, PLAN, { author_task: "t3", kind: "note", body: "just an FYI" });
|
|
163
|
+
await recordTaskDelta(data, PLAN, "t1", {
|
|
164
|
+
contractChange: "changed the envelope shape",
|
|
165
|
+
newlyTouches: ["shared/env.ts"],
|
|
166
|
+
affectsTasks: ["t2"],
|
|
167
|
+
constraint: "envelope must carry results[]",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const d = await gatherRetro(data, PLAN);
|
|
171
|
+
assertEquals(d.counts.learnings, 2);
|
|
172
|
+
assertEquals(d.learnings.map((l) => l.author_task).sort(), ["t1", "t2"]);
|
|
173
|
+
assertEquals(d.notes.length, 1);
|
|
174
|
+
assertEquals(d.constraints.length, 1);
|
|
175
|
+
assertEquals(d.contractChanges.length, 1);
|
|
176
|
+
assert(d.touchedFiles.includes("shared/env.ts"));
|
|
177
|
+
assertEquals(d.repo, "acme/widgets");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
Deno.test("renderRetroBrief: renders learnings + constraints; states 'none' with no learnings", () => {
|
|
181
|
+
const empty = renderRetroBrief({
|
|
182
|
+
planKey: PLAN, repo: "acme/widgets", issueUrl: "", title: null,
|
|
183
|
+
learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [],
|
|
184
|
+
counts: { learnings: 0, deltas: 0, notes: 0 },
|
|
185
|
+
});
|
|
186
|
+
assertStringIncludes(empty, "none");
|
|
187
|
+
|
|
188
|
+
const brief = renderRetroBrief({
|
|
189
|
+
planKey: PLAN, repo: "acme/widgets", issueUrl: "https://x/7", title: "Epic",
|
|
190
|
+
learnings: [{ author_task: "t1", body: "regen first", created_at: "now" }],
|
|
191
|
+
touchedFiles: ["a.ts"],
|
|
192
|
+
contractChanges: [{ taskId: "t1", change: "shape" }],
|
|
193
|
+
constraints: [{ taskId: "t1", constraint: "must X" }],
|
|
194
|
+
notes: [{ author_task: "t2", kind: "note", body: "watch the release lane" }],
|
|
195
|
+
counts: { learnings: 1, deltas: 1, notes: 1 },
|
|
196
|
+
});
|
|
197
|
+
assertStringIncludes(brief, "regen first");
|
|
198
|
+
assertStringIncludes(brief, "must X");
|
|
199
|
+
assertStringIncludes(brief, "watch the release lane");
|
|
200
|
+
assertStringIncludes(brief, "acme/widgets");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
Deno.test("isDigestEmpty: true only when there are no learnings, deltas, or notes", () => {
|
|
204
|
+
const base = { planKey: PLAN, repo: "", issueUrl: "", title: null, learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [] };
|
|
205
|
+
assert(isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 0, notes: 0 } }));
|
|
206
|
+
assert(!isDigestEmpty({ ...base, counts: { learnings: 1, deltas: 0, notes: 0 } }));
|
|
207
|
+
assert(!isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 2, notes: 0 } }));
|
|
208
|
+
assert(!isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 0, notes: 1 } }));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
Deno.test("recordRetro: inserts then updates the same plan_key row in place", async () => {
|
|
212
|
+
const { data, stores } = memData();
|
|
213
|
+
await recordRetro(data, PLAN, { status: "filed", prKey: "acme/widgets#20", learnings: 3, summary: "promoted 2" });
|
|
214
|
+
assertEquals(stores["plan_retros"].length, 1);
|
|
215
|
+
assertEquals(stores["plan_retros"][0].status, "filed");
|
|
216
|
+
assertEquals(stores["plan_retros"][0].pr_key, "acme/widgets#20");
|
|
217
|
+
|
|
218
|
+
await recordRetro(data, PLAN, { status: "skipped", summary: "nothing to promote" });
|
|
219
|
+
assertEquals(stores["plan_retros"].length, 1, "same plan_key must not duplicate");
|
|
220
|
+
assertEquals(stores["plan_retros"][0].status, "skipped");
|
|
221
|
+
assertEquals(stores["plan_retros"][0].pr_key, null);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
Deno.test("recordRetro: rethrows a non-unique (FOREIGN KEY) constraint error instead of swallowing it", async () => {
|
|
225
|
+
// A FK failure (e.g. plan_key missing in plans) must NOT be treated as a benign duplicate and
|
|
226
|
+
// fall through to a silent update — that would make the write look successful while doing nothing.
|
|
227
|
+
let updated = false;
|
|
228
|
+
const table = {
|
|
229
|
+
// deno-lint-ignore require-await
|
|
230
|
+
async insert() {
|
|
231
|
+
throw new Error("FOREIGN KEY constraint failed");
|
|
232
|
+
},
|
|
233
|
+
// deno-lint-ignore require-await
|
|
234
|
+
async update() {
|
|
235
|
+
updated = true;
|
|
236
|
+
},
|
|
237
|
+
// deno-lint-ignore require-await
|
|
238
|
+
async get() {
|
|
239
|
+
return undefined;
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
// deno-lint-ignore no-explicit-any
|
|
243
|
+
const data = { table: () => table } as any as DataLayer;
|
|
244
|
+
let threw = false;
|
|
245
|
+
try {
|
|
246
|
+
await recordRetro(data, PLAN, { status: "filed", prKey: "acme/widgets#20" });
|
|
247
|
+
} catch (err) {
|
|
248
|
+
threw = true;
|
|
249
|
+
assertStringIncludes(String(err), "FOREIGN KEY");
|
|
250
|
+
}
|
|
251
|
+
assert(threw, "the FK error must propagate");
|
|
252
|
+
assertEquals(updated, false, "must not silently fall back to update on a non-unique error");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
Deno.test("maybeStartRetro: starts the retro exactly once when the last PR lands with material", async () => {
|
|
256
|
+
const { data, stores } = memData();
|
|
257
|
+
seedPlan(stores);
|
|
258
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
259
|
+
seedTask(stores, { id: "t2", status: "opened", pr_key: "acme/widgets#11" });
|
|
260
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
261
|
+
seedPr(stores, "acme/widgets#11", "merged");
|
|
262
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
|
|
263
|
+
const { engine, started } = fakeEngine();
|
|
264
|
+
|
|
265
|
+
const r1 = await maybeStartRetro(data, engine, "acme/widgets#11");
|
|
266
|
+
assertEquals(r1.started, true);
|
|
267
|
+
assertEquals(r1.planKey, PLAN);
|
|
268
|
+
assertEquals(started.length, 1);
|
|
269
|
+
assertEquals(started[0].processDefinitionId, "retro");
|
|
270
|
+
assertEquals(started[0].variables.planKey, PLAN);
|
|
271
|
+
assert(stores["plans"][0].retro_started_at, "retro_started_at must be stamped");
|
|
272
|
+
assertEquals(stores["plan_retro_starts"].length, 1);
|
|
273
|
+
|
|
274
|
+
// A sibling terminal PR of the same plan must NOT start a second retro.
|
|
275
|
+
const r2 = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
276
|
+
assertEquals(r2.started, false);
|
|
277
|
+
assertEquals(r2.reason, "already-started");
|
|
278
|
+
assertEquals(started.length, 1, "fire-once guard");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
Deno.test("maybeStartRetro: a pre-claimed retro start does not start a duplicate process", async () => {
|
|
282
|
+
const { data, stores } = memData();
|
|
283
|
+
seedPlan(stores);
|
|
284
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
285
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
286
|
+
stores["plan_retro_starts"] = [{ plan_key: PLAN, started_at: "already" }];
|
|
287
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
|
|
288
|
+
const { engine, started } = fakeEngine();
|
|
289
|
+
|
|
290
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
291
|
+
assertEquals(r, { started: false, planKey: PLAN, reason: "already-started" });
|
|
292
|
+
assertEquals(started.length, 0);
|
|
293
|
+
assertEquals(stores["plans"][0].retro_started_at, null);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
Deno.test("maybeStartRetro: bails while the plan is incomplete", async () => {
|
|
297
|
+
const { data, stores } = memData();
|
|
298
|
+
seedPlan(stores);
|
|
299
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
300
|
+
seedTask(stores, { id: "t2", status: "pending", pr_key: null });
|
|
301
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
302
|
+
const { engine, started } = fakeEngine();
|
|
303
|
+
|
|
304
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
305
|
+
assertEquals(r.started, false);
|
|
306
|
+
assertEquals(r.reason, "incomplete");
|
|
307
|
+
assertEquals(started.length, 0);
|
|
308
|
+
assertEquals(stores["plans"][0].retro_started_at, null, "must not stamp an incomplete plan");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
Deno.test("maybeStartRetro: complete but empty → records a skipped retro, does not start the process", async () => {
|
|
312
|
+
const { data, stores } = memData();
|
|
313
|
+
seedPlan(stores);
|
|
314
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
315
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
316
|
+
const { engine, started } = fakeEngine();
|
|
317
|
+
|
|
318
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
319
|
+
assertEquals(r.started, false);
|
|
320
|
+
assertEquals(r.reason, "nothing-to-retro");
|
|
321
|
+
assertEquals(started.length, 0);
|
|
322
|
+
assert(stores["plans"][0].retro_started_at, "stamped so we don't re-check forever");
|
|
323
|
+
assertEquals(stores["plan_retros"][0].status, "skipped");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
Deno.test("maybeStartRetro: a PR not part of any plan is a no-op", async () => {
|
|
327
|
+
const { data } = memData();
|
|
328
|
+
const { engine, started } = fakeEngine();
|
|
329
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#99");
|
|
330
|
+
assertEquals(r.started, false);
|
|
331
|
+
assertEquals(r.reason, "no-plan");
|
|
332
|
+
assertEquals(started.length, 0);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
Deno.test("maybeStartRetro: honours NANO_AUTO_RETRO=0", async () => {
|
|
336
|
+
const prev = process.env.NANO_AUTO_RETRO;
|
|
337
|
+
process.env.NANO_AUTO_RETRO = "0";
|
|
338
|
+
try {
|
|
339
|
+
const { data, stores } = memData();
|
|
340
|
+
seedPlan(stores);
|
|
341
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
342
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
343
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "x" });
|
|
344
|
+
const { engine, started } = fakeEngine();
|
|
345
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
346
|
+
assertEquals(r.started, false);
|
|
347
|
+
assertEquals(r.reason, "disabled");
|
|
348
|
+
assertEquals(started.length, 0);
|
|
349
|
+
} finally {
|
|
350
|
+
if (prev == null) delete process.env.NANO_AUTO_RETRO;
|
|
351
|
+
else process.env.NANO_AUTO_RETRO = prev;
|
|
352
|
+
}
|
|
353
|
+
});
|
package/app/retro.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Epic retrospective — the post-completion reflection stage (016_plan_retro.sql).
|
|
2
|
+
//
|
|
3
|
+
// The blackboard's `learning` kind (app/blackboard.ts) lets implementer agents share reusable
|
|
4
|
+
// gotchas *while they work*. This module closes the loop: when an epic finishes, a retro agent
|
|
5
|
+
// distils those learnings (plus task deltas and escalations) and promotes the recurring ones into
|
|
6
|
+
// the target repo's AGENTS.md / a script / a CI step, via a human-reviewed PR.
|
|
7
|
+
//
|
|
8
|
+
// "Epic finished" is emergent, not a single BPMN node: `plan-fanout` only DISPATCHES the fleet
|
|
9
|
+
// (it marks the plan `done` at dispatch time), after which each PR lands asynchronously on its own
|
|
10
|
+
// `merge-loop`. So the true completion signal is *the last of a plan's PRs reaching a terminal
|
|
11
|
+
// state*. `maybeStartRetro` is called from the two terminal points — `pr.mark-merged` (auto-merge)
|
|
12
|
+
// and `pr.finalize`'s review-only `converged` path — and fires the retro exactly once.
|
|
13
|
+
//
|
|
14
|
+
// Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
|
|
15
|
+
// app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
|
|
16
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
17
|
+
import { planTasks } from "./plan.ts";
|
|
18
|
+
import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
19
|
+
import { aggregateEpicDeltas } from "./taskDelta.ts";
|
|
20
|
+
import { TERMINAL_STATUSES } from "./service.ts";
|
|
21
|
+
|
|
22
|
+
export const RETRO_PROCESS_ID = "retro";
|
|
23
|
+
|
|
24
|
+
/** Opt-out env toggle. Retro runs by default; set NANO_AUTO_RETRO=0/false to disable (e.g. in a
|
|
25
|
+
* review-only deployment that doesn't want the fleet opening promotion PRs). */
|
|
26
|
+
export function autoRetroEnabled(): boolean {
|
|
27
|
+
const v = (globalThis as { process?: { env?: Record<string, string | undefined> } })
|
|
28
|
+
.process?.env?.NANO_AUTO_RETRO;
|
|
29
|
+
if (v == null) return true;
|
|
30
|
+
const s = v.trim().toLowerCase();
|
|
31
|
+
return s !== "0" && s !== "false" && s !== "off" && s !== "no";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const now = () => new Date().toISOString();
|
|
35
|
+
|
|
36
|
+
/** A PR is in a terminal state (derived from app/service.ts TERMINAL_STATUSES, the single source of
|
|
37
|
+
* truth). A plan is settled only once every PR-producing task has reached one of these. */
|
|
38
|
+
const TERMINAL_PR_STATUSES = new Set(TERMINAL_STATUSES);
|
|
39
|
+
|
|
40
|
+
/** Task statuses that are settled WITHOUT a landed PR: the planner/dispatcher decided not to (or
|
|
41
|
+
* could not) produce one, so they never block epic completion. `escalated`/`waiting-for-lane` are
|
|
42
|
+
* still in flight; `pending`/`opened` are checked against their PR. */
|
|
43
|
+
const SETTLED_TASKLESS = new Set(["skipped", "blocked"]);
|
|
44
|
+
|
|
45
|
+
interface PlanRow extends Record<string, unknown> {
|
|
46
|
+
plan_key: string;
|
|
47
|
+
repo: string;
|
|
48
|
+
issue_url: string;
|
|
49
|
+
title: string | null;
|
|
50
|
+
status: string;
|
|
51
|
+
retro_started_at?: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
|
|
55
|
+
const prsTbl = (data: DataLayer) =>
|
|
56
|
+
data.table<{ pr_key: string; status: string }>("pull_requests", "pr_key");
|
|
57
|
+
const retroStartsTbl = (data: DataLayer) =>
|
|
58
|
+
data.table<{ plan_key: string; started_at: string }>("plan_retro_starts", "plan_key");
|
|
59
|
+
|
|
60
|
+
/** Resolve the plan a PR belongs to, or undefined when the PR was submitted standalone (not part
|
|
61
|
+
* of a fan-out). A PR is linked to a plan via the `plan_tasks.pr_key` it produced. */
|
|
62
|
+
export async function planKeyForPr(data: DataLayer, prKey: string): Promise<string | undefined> {
|
|
63
|
+
if (!prKey) return undefined;
|
|
64
|
+
const row = await planTasks(data).findOne({ pr_key: prKey });
|
|
65
|
+
return row?.plan_key;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Is every one of a plan's tasks settled? Settled = skipped/blocked (never produced a landing
|
|
69
|
+
* PR), or a task whose PR has reached a terminal state. A `pending`/`escalated`/`waiting-for-lane`
|
|
70
|
+
* task, or an `opened` task whose PR is still in flight, means the epic is not done yet. */
|
|
71
|
+
export async function isPlanComplete(data: DataLayer, planKey: string): Promise<boolean> {
|
|
72
|
+
const tasks = await planTasks(data).find({ plan_key: planKey });
|
|
73
|
+
if (tasks.length === 0) return false; // an empty plan has nothing to retrospect
|
|
74
|
+
for (const t of tasks) {
|
|
75
|
+
if (SETTLED_TASKLESS.has(t.status)) continue;
|
|
76
|
+
// Any task that is meant to yield a PR must have a terminal PR to be settled.
|
|
77
|
+
if (!t.pr_key) return false; // pending/escalated/etc. with no PR yet → still in flight
|
|
78
|
+
const pr = await prsTbl(data).get(t.pr_key);
|
|
79
|
+
if (!pr || !TERMINAL_PR_STATUSES.has(pr.status)) return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The material a retro reflects on, assembled from a plan's advisory knowledge. */
|
|
85
|
+
export interface RetroDigest {
|
|
86
|
+
planKey: string;
|
|
87
|
+
repo: string;
|
|
88
|
+
issueUrl: string;
|
|
89
|
+
title: string | null;
|
|
90
|
+
learnings: { author_task: string; body: string; created_at: string }[];
|
|
91
|
+
touchedFiles: string[];
|
|
92
|
+
contractChanges: { taskId: string; change: string }[];
|
|
93
|
+
constraints: { taskId: string; constraint: string }[];
|
|
94
|
+
notes: { author_task: string; kind: string; body: string }[];
|
|
95
|
+
counts: { learnings: number; deltas: number; notes: number };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Gather a plan's reflection material: the `learning` blackboard entries (the headline), plus the
|
|
99
|
+
* task-delta rollup (contract changes, discovered constraints, cross-slice file touches) and any
|
|
100
|
+
* other non-learning blackboard notes for colour. Reads only — no writes. */
|
|
101
|
+
export async function gatherRetro(data: DataLayer, planKey: string): Promise<RetroDigest> {
|
|
102
|
+
const plan = await plansTbl(data).get(planKey);
|
|
103
|
+
const entries = await readBlackboard(data, planKey);
|
|
104
|
+
const learnings = entries
|
|
105
|
+
.filter((e) => e.kind === "learning")
|
|
106
|
+
.map((e) => ({ author_task: e.author_task, body: e.body, created_at: e.created_at }));
|
|
107
|
+
const notes = entries
|
|
108
|
+
.filter((e) => e.kind !== "learning")
|
|
109
|
+
.map((e) => ({ author_task: e.author_task, kind: e.kind, body: e.body }));
|
|
110
|
+
const deltas = await aggregateEpicDeltas(data, planKey);
|
|
111
|
+
return {
|
|
112
|
+
planKey,
|
|
113
|
+
repo: plan?.repo ?? planKey.split("#")[0] ?? "",
|
|
114
|
+
issueUrl: plan?.issue_url ?? "",
|
|
115
|
+
title: plan?.title ?? null,
|
|
116
|
+
learnings,
|
|
117
|
+
touchedFiles: deltas.touchedFiles,
|
|
118
|
+
contractChanges: deltas.contractChanges,
|
|
119
|
+
constraints: deltas.constraints,
|
|
120
|
+
notes,
|
|
121
|
+
counts: {
|
|
122
|
+
learnings: learnings.length,
|
|
123
|
+
deltas: deltas.deltas.length,
|
|
124
|
+
notes: notes.length,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Render the digest as the compact markdown brief handed to the retro agent (rides `appendPrompt`,
|
|
130
|
+
* concatenated after the base `{{retro}}` prompt — so it owns its own leading separator). */
|
|
131
|
+
export function renderRetroBrief(d: RetroDigest): string {
|
|
132
|
+
const lines: string[] = [
|
|
133
|
+
"",
|
|
134
|
+
"",
|
|
135
|
+
"---",
|
|
136
|
+
"",
|
|
137
|
+
`## Retro input — epic ${d.planKey}`,
|
|
138
|
+
"",
|
|
139
|
+
`Target repo: **${d.repo}**${d.issueUrl ? ` · issue: ${d.issueUrl}` : ""}`,
|
|
140
|
+
d.title ? `Epic: ${d.title}` : "",
|
|
141
|
+
"",
|
|
142
|
+
`### Learnings agents posted while implementing (${d.learnings.length})`,
|
|
143
|
+
];
|
|
144
|
+
if (d.learnings.length === 0) {
|
|
145
|
+
lines.push("_(none — agents posted no `learning` entries for this epic)_");
|
|
146
|
+
} else {
|
|
147
|
+
for (const l of d.learnings) lines.push(`- **[${l.author_task}]** ${l.body}`);
|
|
148
|
+
}
|
|
149
|
+
if (d.constraints.length > 0) {
|
|
150
|
+
lines.push("", `### Constraints discovered (${d.constraints.length})`);
|
|
151
|
+
for (const c of d.constraints) lines.push(`- **[${c.taskId}]** ${c.constraint}`);
|
|
152
|
+
}
|
|
153
|
+
if (d.contractChanges.length > 0) {
|
|
154
|
+
lines.push("", `### Contract changes (${d.contractChanges.length})`);
|
|
155
|
+
for (const c of d.contractChanges) lines.push(`- **[${c.taskId}]** ${c.change}`);
|
|
156
|
+
}
|
|
157
|
+
if (d.touchedFiles.length > 0) {
|
|
158
|
+
lines.push("", `### Files touched beyond original slices`, d.touchedFiles.map((f) => `\`${f}\``).join(", "));
|
|
159
|
+
}
|
|
160
|
+
if (d.notes.length > 0) {
|
|
161
|
+
lines.push("", `### Other blackboard notes (${d.notes.length})`);
|
|
162
|
+
for (const n of d.notes) lines.push(`- **[${n.author_task}]** _${n.kind}_: ${n.body}`);
|
|
163
|
+
}
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** True when a digest carries nothing worth an agent run — no learnings, no deltas, no notes. */
|
|
168
|
+
export function isDigestEmpty(d: RetroDigest): boolean {
|
|
169
|
+
return d.counts.learnings === 0 && d.counts.deltas === 0 && d.counts.notes === 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The persisted retro shape (written by pr.retro-record). */
|
|
173
|
+
export interface RetroInput {
|
|
174
|
+
status: string; // filed | skipped | blocked
|
|
175
|
+
prKey?: string | null;
|
|
176
|
+
learnings?: number;
|
|
177
|
+
summary?: string | null;
|
|
178
|
+
report?: string | null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const retrosTbl = (data: DataLayer) =>
|
|
182
|
+
data.table<{ plan_key: string } & Record<string, unknown>>("plan_retros", "plan_key");
|
|
183
|
+
|
|
184
|
+
async function claimRetroStart(data: DataLayer, planKey: string): Promise<boolean> {
|
|
185
|
+
try {
|
|
186
|
+
await retroStartsTbl(data).insert({ plan_key: planKey, started_at: now() });
|
|
187
|
+
return true;
|
|
188
|
+
} catch (err) {
|
|
189
|
+
// Only a UNIQUE/PK collision means "another starter already elected itself" — a benign
|
|
190
|
+
// duplicate the fire-once guard exists to detect. Any other constraint (e.g. a FOREIGN KEY
|
|
191
|
+
// failure from a missing plan row) is real corruption and must propagate, not be swallowed.
|
|
192
|
+
if (isUniqueViolation(err)) return false;
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Upsert a plan's retro row (idempotent on plan_key, so a job retry overwrites in place).
|
|
198
|
+
*
|
|
199
|
+
* Insert-first, then fall back to update only on a verified unique/PK violation: a get-then-insert
|
|
200
|
+
* can race (two concurrent retries both see no row, then one insert wins and the other throws), so
|
|
201
|
+
* we treat "row already exists" as the update path rather than an error. */
|
|
202
|
+
export async function recordRetro(
|
|
203
|
+
data: DataLayer,
|
|
204
|
+
planKey: string,
|
|
205
|
+
input: RetroInput,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
const ts = now();
|
|
208
|
+
const fields = {
|
|
209
|
+
status: input.status,
|
|
210
|
+
pr_key: input.prKey ?? null,
|
|
211
|
+
learnings: input.learnings ?? 0,
|
|
212
|
+
summary: input.summary ?? null,
|
|
213
|
+
report: input.report ?? null,
|
|
214
|
+
updated_at: ts,
|
|
215
|
+
};
|
|
216
|
+
try {
|
|
217
|
+
await retrosTbl(data).insert({ plan_key: planKey, created_at: ts, ...fields });
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// Fall back to update only on a verified UNIQUE/PK violation (the get-then-insert race, or a
|
|
220
|
+
// job retry). Restrict to unique/duplicate/primary-key: a FOREIGN KEY (or other) constraint
|
|
221
|
+
// failure would otherwise be swallowed here, making the write look successful while doing
|
|
222
|
+
// nothing — so rethrow anything that isn't a duplicate-row collision.
|
|
223
|
+
if (!isUniqueViolation(err)) throw err;
|
|
224
|
+
await retrosTbl(data).update(planKey, fields);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Called from a PR's terminal point (mark-merged / finalize review-only). If that PR was the last
|
|
229
|
+
* of its plan to land AND there is anything to reflect on, start the `retro` process exactly once.
|
|
230
|
+
*
|
|
231
|
+
* Best-effort and non-blocking: any failure here must never fail the terminal job that called it —
|
|
232
|
+
* the retro is advisory. The fire-once guard is `plan_retro_starts`: a PRIMARY KEY insert
|
|
233
|
+
* atomically elects one starter across app processes before we stamp `plans.retro_started_at`
|
|
234
|
+
* and start the instance. */
|
|
235
|
+
export async function maybeStartRetro(
|
|
236
|
+
data: DataLayer,
|
|
237
|
+
engine: EngineClient,
|
|
238
|
+
prKey: string,
|
|
239
|
+
log?: (level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>) => void,
|
|
240
|
+
): Promise<{ started: boolean; planKey?: string; reason?: string }> {
|
|
241
|
+
if (!autoRetroEnabled()) return { started: false, reason: "disabled" };
|
|
242
|
+
try {
|
|
243
|
+
const planKey = await planKeyForPr(data, prKey);
|
|
244
|
+
if (!planKey) return { started: false, reason: "no-plan" };
|
|
245
|
+
|
|
246
|
+
const plan = await plansTbl(data).get(planKey);
|
|
247
|
+
if (!plan) return { started: false, reason: "no-plan" };
|
|
248
|
+
if (plan.retro_started_at) return { started: false, planKey, reason: "already-started" };
|
|
249
|
+
|
|
250
|
+
if (!(await isPlanComplete(data, planKey))) return { started: false, planKey, reason: "incomplete" };
|
|
251
|
+
|
|
252
|
+
const digest = await gatherRetro(data, planKey);
|
|
253
|
+
if (isDigestEmpty(digest)) {
|
|
254
|
+
if (!(await claimRetroStart(data, planKey))) return { started: false, planKey, reason: "already-started" };
|
|
255
|
+
// Nothing to reflect on — stamp anyway so we don't re-check on every future terminal PR of a
|
|
256
|
+
// (now settled) plan, and record a skipped retro for visibility.
|
|
257
|
+
await plansTbl(data).update(planKey, { retro_started_at: now(), updated_at: now() });
|
|
258
|
+
await recordRetro(data, planKey, { status: "skipped", summary: "No learnings, deltas, or notes to retrospect." });
|
|
259
|
+
return { started: false, planKey, reason: "nothing-to-retro" };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!(await claimRetroStart(data, planKey))) return { started: false, planKey, reason: "already-started" };
|
|
263
|
+
|
|
264
|
+
// Stamp before starting so restarts/retries can take the cheap already-started path.
|
|
265
|
+
await plansTbl(data).update(planKey, { retro_started_at: now(), updated_at: now() });
|
|
266
|
+
|
|
267
|
+
const { processInstanceKey } = await engine.createInstance({
|
|
268
|
+
processDefinitionId: RETRO_PROCESS_ID,
|
|
269
|
+
variables: {
|
|
270
|
+
planKey,
|
|
271
|
+
repo: digest.repo,
|
|
272
|
+
issueUrl: digest.issueUrl,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
log?.("info", `retro: started for epic ${planKey}`, { processInstanceKey, learnings: digest.counts.learnings });
|
|
276
|
+
return { started: true, planKey };
|
|
277
|
+
} catch (err) {
|
|
278
|
+
log?.("error", `retro: could not start for PR ${prKey}`, { err: String(err) });
|
|
279
|
+
return { started: false, reason: "error" };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- Epic retrospective — the post-completion reflection stage (blackboard `learning` follow-up).
|
|
2
|
+
--
|
|
3
|
+
-- An epic (a `plans` row) is dispatched by `plan-fanout` and its slices land asynchronously,
|
|
4
|
+
-- each PR riding its own `merge-loop`. "Epic complete" is therefore emergent: it is the moment
|
|
5
|
+
-- the LAST of a plan's PRs reaches a terminal state (merged / converged / abandoned). When that
|
|
6
|
+
-- happens, `maybeStartRetro` (app/retro.ts) starts one `retro` process instance for the plan.
|
|
7
|
+
--
|
|
8
|
+
-- The retro agent (`senior:retro`) reads the plan's accumulated coordination knowledge — the
|
|
9
|
+
-- `learning` blackboard entries agents posted while implementing, plus their task deltas and
|
|
10
|
+
-- escalations — clusters and ranks it, and opens a PR against the TARGET repo promoting the
|
|
11
|
+
-- recurring gotchas into that repo's AGENTS.md / a script / a CI step (human-reviewed, never
|
|
12
|
+
-- auto-committed). The report it produces is recorded here.
|
|
13
|
+
|
|
14
|
+
-- Fire-once guard. Set (to an ISO timestamp) the instant `maybeStartRetro` starts the retro
|
|
15
|
+
-- process for this plan, so a second PR of the same plan reaching terminal state near-simultaneously
|
|
16
|
+
-- cannot start a duplicate retro. NULL = no retro has been started for this plan.
|
|
17
|
+
ALTER TABLE plans ADD COLUMN retro_started_at TEXT;
|
|
18
|
+
|
|
19
|
+
-- Atomic start election for multi-process deployments. `maybeStartRetro` first inserts here; the
|
|
20
|
+
-- PRIMARY KEY lets exactly one worker claim a plan before it stamps `plans.retro_started_at` and
|
|
21
|
+
-- starts the BPMN instance.
|
|
22
|
+
CREATE TABLE plan_retro_starts (
|
|
23
|
+
plan_key TEXT PRIMARY KEY REFERENCES plans(plan_key),
|
|
24
|
+
started_at TEXT NOT NULL
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
-- One row per epic retrospective. Written by `pr.retro-record` from the `senior:retro` agent's
|
|
28
|
+
-- result. Advisory knowledge, like the blackboard it distils — it gates no control flow.
|
|
29
|
+
CREATE TABLE plan_retros (
|
|
30
|
+
plan_key TEXT PRIMARY KEY REFERENCES plans(plan_key),
|
|
31
|
+
status TEXT NOT NULL, -- filed | skipped | blocked (the agent's result status)
|
|
32
|
+
pr_key TEXT, -- the promotion PR the agent opened on the target repo ("<owner>/<repo>#<n>"), or NULL
|
|
33
|
+
learnings INTEGER NOT NULL DEFAULT 0, -- raw count of `learning` blackboard entries included in the retro digest
|
|
34
|
+
summary TEXT, -- the agent's human-readable retro summary
|
|
35
|
+
report TEXT, -- the full retro report / transcript (nullable)
|
|
36
|
+
created_at TEXT NOT NULL,
|
|
37
|
+
updated_at TEXT NOT NULL
|
|
38
|
+
);
|
package/nano.app.json
CHANGED
|
@@ -74,6 +74,14 @@
|
|
|
74
74
|
{
|
|
75
75
|
"taskType": "pr.persist-task-escalation",
|
|
76
76
|
"handler": "workers/persist-task-escalation/worker.ts"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"taskType": "pr.retro-gather",
|
|
80
|
+
"handler": "workers/retro-gather/worker.ts"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"taskType": "pr.retro-record",
|
|
84
|
+
"handler": "workers/retro-record/worker.ts"
|
|
77
85
|
}
|
|
78
86
|
],
|
|
79
87
|
"surfaces": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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/prompts/retro.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Retro agent — distil an epic's learnings and promote the recurring ones
|
|
2
|
+
|
|
3
|
+
You are the **retrospective agent**. An epic (a fan-out `plan`) has finished — every one of its
|
|
4
|
+
slices has landed (merged, converged, or abandoned). While the fleet worked, its agents shared
|
|
5
|
+
reusable gotchas on a coordination **blackboard** as `learning` entries ("regenerate the API
|
|
6
|
+
surface before building", "nextest, not `cargo test`, for the console suite", …). Your job is to
|
|
7
|
+
turn that scattered, per-agent knowledge into a **durable improvement** to the target repository,
|
|
8
|
+
so the *next* fleet — and human contributors — never re-learn it the hard way.
|
|
9
|
+
|
|
10
|
+
You are the mechanism that lifts a lesson from "a thing one agent happened to hit" to "a thing the
|
|
11
|
+
repo now tells everyone up front."
|
|
12
|
+
|
|
13
|
+
## Input
|
|
14
|
+
|
|
15
|
+
The job payload (stdin JSON) carries:
|
|
16
|
+
|
|
17
|
+
- `variables.planKey` — the epic's key, e.g. `owner/repo#123`.
|
|
18
|
+
- `variables.repo` — the **target repo** `owner/repo` you will open a promotion PR against.
|
|
19
|
+
- `variables.issueUrl` — the epic's source issue, for context (`gh issue view`).
|
|
20
|
+
- **`variables.retroDigest`** — appended to this prompt below the `---` separator: the epic's
|
|
21
|
+
accumulated knowledge already gathered for you — the `learning` entries agents posted, the
|
|
22
|
+
constraints and contract changes they discovered, and the files they touched beyond their
|
|
23
|
+
original slices. **This is your primary material.** You do not need to reconstruct it.
|
|
24
|
+
|
|
25
|
+
You have `gh` / git authenticated for the target repository.
|
|
26
|
+
|
|
27
|
+
## What to do
|
|
28
|
+
|
|
29
|
+
1. **Read the digest** (below the separator). Also read prior retros for cross-epic recurrence:
|
|
30
|
+
look for an `AGENTS.md` "Learnings" / "Gotchas" section already in the repo, and skim recent
|
|
31
|
+
merged PRs titled like `retro:` — a lesson that keeps recurring across epics is the highest-
|
|
32
|
+
value promotion.
|
|
33
|
+
2. **Cluster and dedupe.** Group the raw learnings into distinct lessons. A lesson mentioned by
|
|
34
|
+
several agents, or one that also appears in a prior retro, ranks highest. Drop one-offs that are
|
|
35
|
+
genuinely specific to a single slice and won't recur.
|
|
36
|
+
3. **Rank by recurrence × severity.** Promote the lessons that are both *reusable* (a future agent
|
|
37
|
+
or contributor would hit them) and *costly* (they broke a build, wasted a wave, or caused a
|
|
38
|
+
merge collision). A single well-placed line beats an exhaustive dump.
|
|
39
|
+
4. **Choose the right home for each promoted lesson** — the whole point is to make the knowledge
|
|
40
|
+
*load-bearing*, not just written down:
|
|
41
|
+
- **`AGENTS.md`** (or `CONTRIBUTING.md`) — a convention, a "before you build, run X", a
|
|
42
|
+
non-obvious constraint. The default home.
|
|
43
|
+
- **A script** — if the lesson is "always run these steps in this order", encode it as a
|
|
44
|
+
script (or a `make`/`npm`/`deno task` target) so it can't be forgotten.
|
|
45
|
+
- **A CI step** — if the lesson is "this class of mistake should never merge", add a guard/gate
|
|
46
|
+
so CI catches it mechanically. Prefer this for anything a machine can check.
|
|
47
|
+
Pick the *most enforceable* home a lesson supports: CI gate > script > doc.
|
|
48
|
+
5. **Open ONE pull request** against `variables.repo` with `gh pr create`, collecting your
|
|
49
|
+
promotions. Keep it small and reviewable — this is a **human-reviewed** PR; you propose, a human
|
|
50
|
+
decides. Sign off (DCO: `git commit -s`). Link the epic issue. Title it `retro: <short summary>`.
|
|
51
|
+
Do **not** request Copilot review yourself and do **not** merge it.
|
|
52
|
+
6. Clean up any scratch clone/worktree you created.
|
|
53
|
+
|
|
54
|
+
## When there's nothing worth promoting
|
|
55
|
+
|
|
56
|
+
If, after clustering, no lesson is durable enough to justify a change to the repo — the learnings
|
|
57
|
+
were all slice-specific noise, or already documented — **do not manufacture a PR**. Emit
|
|
58
|
+
`status: "skipped"` with a one-line reason. A retro that correctly files nothing is a success, not
|
|
59
|
+
a failure; a low-signal PR that wastes a human's review is the bad outcome.
|
|
60
|
+
|
|
61
|
+
## Output contract
|
|
62
|
+
|
|
63
|
+
Write a JSON object of **result variables** to the file named by the `AGENT_RESULT_FILE`
|
|
64
|
+
environment variable:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"status": "filed",
|
|
69
|
+
"summary": "Promoted 3 lessons: regen-before-build (AGENTS.md), nextest gate (CI), migration-order note (AGENTS.md).",
|
|
70
|
+
"pr": "owner/repo#789"
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Rules:
|
|
75
|
+
|
|
76
|
+
- `status` — one of:
|
|
77
|
+
- `filed` — you opened a promotion PR. Set `pr`.
|
|
78
|
+
- `skipped` — nothing durable enough to promote. Explain in `summary`; omit `pr`.
|
|
79
|
+
- `blocked` — you could not proceed (e.g. no write access to the target repo). Explain in
|
|
80
|
+
`summary`; omit `pr`.
|
|
81
|
+
- `pr` — the promotion PR as `owner/repo#<number>` (or its URL), for `filed`. Omit / null it
|
|
82
|
+
otherwise.
|
|
83
|
+
- `summary` — a short human-readable result naming the lessons you promoted (or why you skipped).
|
|
84
|
+
|
|
85
|
+
You are advisory: you never block a fleet, and every change you propose is a human's to accept.
|
|
86
|
+
Promote what will genuinely save the next contributor time; leave the rest.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" xmlns:nano="https://nanobpm.io/schema/shapes/1.0" id="Definitions_nano_workforce_retro" targetNamespace="http://nanobpm.io/nano-workforce">
|
|
3
|
+
<bpmn:process id="retro" name="Epic Retrospective" isExecutable="true">
|
|
4
|
+
<bpmn:startEvent id="Start" name="Epic complete">
|
|
5
|
+
<bpmn:outgoing>f_start</bpmn:outgoing>
|
|
6
|
+
</bpmn:startEvent>
|
|
7
|
+
<bpmn:serviceTask id="gather" name="Gather learnings">
|
|
8
|
+
<bpmn:extensionElements>
|
|
9
|
+
<zeebe:taskDefinition type="pr.retro-gather" />
|
|
10
|
+
</bpmn:extensionElements>
|
|
11
|
+
<bpmn:incoming>f_start</bpmn:incoming>
|
|
12
|
+
<bpmn:outgoing>f_toSynthesize</bpmn:outgoing>
|
|
13
|
+
</bpmn:serviceTask>
|
|
14
|
+
<bpmn:serviceTask id="synthesize" name="Synthesize & promote (agent)">
|
|
15
|
+
<bpmn:extensionElements>
|
|
16
|
+
<zeebe:taskDefinition type="senior:retro" />
|
|
17
|
+
<zeebe:taskHeaders>
|
|
18
|
+
<zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{retro}}" />
|
|
19
|
+
</zeebe:taskHeaders>
|
|
20
|
+
<zeebe:ioMapping>
|
|
21
|
+
<zeebe:input source="=retroDigest" target="appendPrompt" />
|
|
22
|
+
</zeebe:ioMapping>
|
|
23
|
+
</bpmn:extensionElements>
|
|
24
|
+
<bpmn:incoming>f_toSynthesize</bpmn:incoming>
|
|
25
|
+
<bpmn:outgoing>f_toRecord</bpmn:outgoing>
|
|
26
|
+
</bpmn:serviceTask>
|
|
27
|
+
<bpmn:serviceTask id="record" name="Record retro">
|
|
28
|
+
<bpmn:extensionElements>
|
|
29
|
+
<zeebe:taskDefinition type="pr.retro-record" />
|
|
30
|
+
</bpmn:extensionElements>
|
|
31
|
+
<bpmn:incoming>f_toRecord</bpmn:incoming>
|
|
32
|
+
<bpmn:outgoing>f_toEnd</bpmn:outgoing>
|
|
33
|
+
</bpmn:serviceTask>
|
|
34
|
+
<bpmn:endEvent id="End" name="Retro filed">
|
|
35
|
+
<bpmn:incoming>f_toEnd</bpmn:incoming>
|
|
36
|
+
</bpmn:endEvent>
|
|
37
|
+
<bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="gather" />
|
|
38
|
+
<bpmn:sequenceFlow id="f_toSynthesize" sourceRef="gather" targetRef="synthesize" />
|
|
39
|
+
<bpmn:sequenceFlow id="f_toRecord" sourceRef="synthesize" targetRef="record" />
|
|
40
|
+
<bpmn:sequenceFlow id="f_toEnd" sourceRef="record" targetRef="End" />
|
|
41
|
+
</bpmn:process>
|
|
42
|
+
<bpmndi:BPMNDiagram id="BPMNDiagram_retro">
|
|
43
|
+
<bpmndi:BPMNPlane id="BPMNPlane_retro" bpmnElement="retro">
|
|
44
|
+
<bpmndi:BPMNShape id="BPMNShape_Start" bpmnElement="Start">
|
|
45
|
+
<dc:Bounds x="80" y="102" width="36" height="36" />
|
|
46
|
+
<bpmndi:BPMNLabel>
|
|
47
|
+
<dc:Bounds x="67" y="143" width="63" height="28" />
|
|
48
|
+
</bpmndi:BPMNLabel>
|
|
49
|
+
</bpmndi:BPMNShape>
|
|
50
|
+
<bpmndi:BPMNShape id="BPMNShape_gather" bpmnElement="gather">
|
|
51
|
+
<dc:Bounds x="216" y="80" width="100" height="80" />
|
|
52
|
+
</bpmndi:BPMNShape>
|
|
53
|
+
<bpmndi:BPMNShape id="BPMNShape_synthesize" bpmnElement="synthesize">
|
|
54
|
+
<dc:Bounds x="416" y="80" width="100" height="80" />
|
|
55
|
+
</bpmndi:BPMNShape>
|
|
56
|
+
<bpmndi:BPMNShape id="BPMNShape_record" bpmnElement="record">
|
|
57
|
+
<dc:Bounds x="616" y="80" width="100" height="80" />
|
|
58
|
+
</bpmndi:BPMNShape>
|
|
59
|
+
<bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
|
|
60
|
+
<dc:Bounds x="816" y="102" width="36" height="36" />
|
|
61
|
+
<bpmndi:BPMNLabel>
|
|
62
|
+
<dc:Bounds x="794" y="143" width="80" height="14" />
|
|
63
|
+
</bpmndi:BPMNLabel>
|
|
64
|
+
</bpmndi:BPMNShape>
|
|
65
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
|
|
66
|
+
<di:waypoint x="116" y="120" />
|
|
67
|
+
<di:waypoint x="216" y="120" />
|
|
68
|
+
</bpmndi:BPMNEdge>
|
|
69
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toSynthesize" bpmnElement="f_toSynthesize">
|
|
70
|
+
<di:waypoint x="316" y="120" />
|
|
71
|
+
<di:waypoint x="416" y="120" />
|
|
72
|
+
</bpmndi:BPMNEdge>
|
|
73
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
|
|
74
|
+
<di:waypoint x="516" y="120" />
|
|
75
|
+
<di:waypoint x="616" y="120" />
|
|
76
|
+
</bpmndi:BPMNEdge>
|
|
77
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
|
|
78
|
+
<di:waypoint x="716" y="120" />
|
|
79
|
+
<di:waypoint x="816" y="120" />
|
|
80
|
+
</bpmndi:BPMNEdge>
|
|
81
|
+
</bpmndi:BPMNPlane>
|
|
82
|
+
</bpmndi:BPMNDiagram>
|
|
83
|
+
</bpmn:definitions>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// is on, or (b) close the PR out as `converged` (review-only mode).
|
|
4
4
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
5
5
|
import { AUTO_MERGE, startMerge } from "../../app/service.ts";
|
|
6
|
+
import { maybeStartRetro } from "../../app/retro.ts";
|
|
6
7
|
|
|
7
8
|
// Extends Record so the declared fields are typed while the job may still carry
|
|
8
9
|
// other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
|
|
@@ -83,6 +84,14 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
83
84
|
open_escalation_question: null,
|
|
84
85
|
});
|
|
85
86
|
|
|
87
|
+
// Only the review-only terminal path ends the PR here as `converged` — in auto-merge mode the
|
|
88
|
+
// terminal point is pr.mark-merged (which triggers the retro), and a PR parked in `waiting_deps`
|
|
89
|
+
// is still in flight. So fire the retro trigger only when this PR actually reached its terminal
|
|
90
|
+
// state in finalize. Best-effort: must never fail the finalize job.
|
|
91
|
+
if (status === "converged") {
|
|
92
|
+
await maybeStartRetro(app.data, app.engine, prKey, app.log);
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
return {};
|
|
87
96
|
};
|
|
88
97
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// pr.mark-merged — the PR has landed (directly or via the merge queue). Record the terminal
|
|
2
2
|
// `merged` state; the merge audit trail is written by pr.merge, so this only closes the row out.
|
|
3
3
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
4
|
+
import { maybeStartRetro } from "../../app/retro.ts";
|
|
4
5
|
|
|
5
6
|
interface In extends Record<string, unknown> {
|
|
6
7
|
prKey: string;
|
|
@@ -15,6 +16,11 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
15
16
|
open_escalation_id: null,
|
|
16
17
|
open_escalation_question: null,
|
|
17
18
|
});
|
|
19
|
+
|
|
20
|
+
// If this PR was the last of its epic to land, kick off the retrospective. Best-effort: a
|
|
21
|
+
// failure here (or no epic) must never fail marking the PR merged — the retro is advisory.
|
|
22
|
+
await maybeStartRetro(app.data, app.engine, job.variables.prKey, app.log);
|
|
23
|
+
|
|
18
24
|
return {};
|
|
19
25
|
};
|
|
20
26
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { assert, assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
|
2
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
3
|
+
import { appendEntry } from "../../app/blackboard.ts";
|
|
4
|
+
import handler from "./worker.ts";
|
|
5
|
+
|
|
6
|
+
// deno-lint-ignore no-explicit-any
|
|
7
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
8
|
+
// deno-lint-ignore no-explicit-any
|
|
9
|
+
const stores: Record<string, any[]> = {};
|
|
10
|
+
const seq: Record<string, number> = {};
|
|
11
|
+
function tbl(name: string, pk = "id") {
|
|
12
|
+
// deno-lint-ignore no-explicit-any
|
|
13
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
14
|
+
// deno-lint-ignore no-explicit-any
|
|
15
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
16
|
+
return {
|
|
17
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
18
|
+
async insert(row: any) {
|
|
19
|
+
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
20
|
+
rows.push(pk === "id" ? { id, ...row } : { ...row });
|
|
21
|
+
return pk === "id" ? id : row[pk];
|
|
22
|
+
},
|
|
23
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
24
|
+
async find(where: any = {}) {
|
|
25
|
+
return rows.filter((r) => match(r, where));
|
|
26
|
+
},
|
|
27
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
28
|
+
async findOne(where: any = {}) {
|
|
29
|
+
return rows.find((r) => match(r, where));
|
|
30
|
+
},
|
|
31
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
32
|
+
async get(id: any) {
|
|
33
|
+
return rows.find((row) => row[pk] === id);
|
|
34
|
+
},
|
|
35
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
36
|
+
async update() {},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// deno-lint-ignore no-explicit-any
|
|
40
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
41
|
+
return { data, stores };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Deno.test("retro-gather: emits a digest brief + learning count for the plan", async () => {
|
|
45
|
+
const { data, stores } = memData();
|
|
46
|
+
stores["plans"] = [{ plan_key: "o/r#3", repo: "o/r", issue_url: "https://x/3", title: "Epic" }];
|
|
47
|
+
await appendEntry(data, "o/r#3", { author_task: "t1", kind: "learning", body: "regen before build" });
|
|
48
|
+
await appendEntry(data, "o/r#3", { author_task: "t2", kind: "learning", body: "use nextest" });
|
|
49
|
+
|
|
50
|
+
const app = { data, log: () => undefined };
|
|
51
|
+
const out = await handler(
|
|
52
|
+
// deno-lint-ignore no-explicit-any
|
|
53
|
+
{ variables: { planKey: "o/r#3" } } as any,
|
|
54
|
+
// deno-lint-ignore no-explicit-any
|
|
55
|
+
app as any,
|
|
56
|
+
) as Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
assertEquals(out.retroLearnings, 2);
|
|
59
|
+
assertStringIncludes(String(out.retroDigest), "regen before build");
|
|
60
|
+
assertStringIncludes(String(out.retroDigest), "use nextest");
|
|
61
|
+
assertStringIncludes(String(out.retroDigest), "o/r#3");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
Deno.test("retro-gather: an epic with no learnings still renders a valid brief", async () => {
|
|
65
|
+
const { data, stores } = memData();
|
|
66
|
+
stores["plans"] = [{ plan_key: "o/r#4", repo: "o/r", issue_url: "", title: null }];
|
|
67
|
+
const app = { data, log: () => undefined };
|
|
68
|
+
const out = await handler(
|
|
69
|
+
// deno-lint-ignore no-explicit-any
|
|
70
|
+
{ variables: { planKey: "o/r#4" } } as any,
|
|
71
|
+
// deno-lint-ignore no-explicit-any
|
|
72
|
+
app as any,
|
|
73
|
+
) as Record<string, unknown>;
|
|
74
|
+
|
|
75
|
+
assertEquals(out.retroLearnings, 0);
|
|
76
|
+
assert(typeof out.retroDigest === "string");
|
|
77
|
+
assertStringIncludes(String(out.retroDigest), "none");
|
|
78
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// pr.retro-gather — first step of the `retro` process. Assemble the plan's accumulated
|
|
2
|
+
// coordination knowledge (the `learning` blackboard entries agents posted while implementing, plus
|
|
3
|
+
// the task-delta rollup and any other blackboard notes) into a compact markdown brief, and emit it
|
|
4
|
+
// as `retroDigest`. The next step maps that onto the `senior:retro` agent's `appendPrompt`, so the
|
|
5
|
+
// agent reflects on real material rather than re-deriving it.
|
|
6
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
7
|
+
import { gatherRetro, renderRetroBrief } from "../../app/retro.ts";
|
|
8
|
+
|
|
9
|
+
interface In extends Record<string, unknown> {
|
|
10
|
+
planKey: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Out extends Record<string, unknown> {
|
|
14
|
+
retroDigest: string;
|
|
15
|
+
retroLearnings: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
19
|
+
const planKey = job.variables.planKey;
|
|
20
|
+
const digest = await gatherRetro(app.data, planKey);
|
|
21
|
+
app.log("info", `retro-gather: ${planKey} — ${digest.counts.learnings} learnings, ${digest.counts.deltas} deltas`);
|
|
22
|
+
return {
|
|
23
|
+
retroDigest: renderRetroBrief(digest),
|
|
24
|
+
retroLearnings: digest.counts.learnings,
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default handler;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { assertEquals } from "jsr:@std/assert@1";
|
|
2
|
+
import handler from "./worker.ts";
|
|
3
|
+
|
|
4
|
+
function fakeApp() {
|
|
5
|
+
// deno-lint-ignore no-explicit-any
|
|
6
|
+
const stores: Record<string, any[]> = { plan_retros: [] };
|
|
7
|
+
const seq: Record<string, number> = {};
|
|
8
|
+
function tbl(name: string, pk = "id") {
|
|
9
|
+
// deno-lint-ignore no-explicit-any
|
|
10
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
11
|
+
// deno-lint-ignore no-explicit-any
|
|
12
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
13
|
+
return {
|
|
14
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
15
|
+
async insert(row: any) {
|
|
16
|
+
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
17
|
+
rows.push(pk === "id" ? { id, ...row } : { ...row });
|
|
18
|
+
return pk === "id" ? id : row[pk];
|
|
19
|
+
},
|
|
20
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
21
|
+
async find(where: any = {}) {
|
|
22
|
+
return rows.filter((r) => match(r, where));
|
|
23
|
+
},
|
|
24
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
25
|
+
async findOne(where: any = {}) {
|
|
26
|
+
return rows.find((r) => match(r, where));
|
|
27
|
+
},
|
|
28
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
29
|
+
async get(id: any) {
|
|
30
|
+
return rows.find((row) => row[pk] === id);
|
|
31
|
+
},
|
|
32
|
+
// deno-lint-ignore no-explicit-any require-await
|
|
33
|
+
async update(id: any, patch: any) {
|
|
34
|
+
const r = rows.find((row) => row[pk] === id);
|
|
35
|
+
if (r) Object.assign(r, patch);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) }, log: () => undefined };
|
|
40
|
+
return { app, stores };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Deno.test("retro-record: persists a filed retro from hoisted result vars", async () => {
|
|
44
|
+
const { app, stores } = fakeApp();
|
|
45
|
+
await handler(
|
|
46
|
+
// deno-lint-ignore no-explicit-any
|
|
47
|
+
{
|
|
48
|
+
variables: {
|
|
49
|
+
planKey: "o/r#5",
|
|
50
|
+
retroLearnings: 4,
|
|
51
|
+
status: "filed",
|
|
52
|
+
pr: "o/r#42",
|
|
53
|
+
summary: "promoted 2 lessons",
|
|
54
|
+
"io.nanobpm.agentResult": { output: "the full report" },
|
|
55
|
+
},
|
|
56
|
+
} as any,
|
|
57
|
+
// deno-lint-ignore no-explicit-any
|
|
58
|
+
app as any,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
assertEquals(stores.plan_retros.length, 1);
|
|
62
|
+
const row = stores.plan_retros[0];
|
|
63
|
+
assertEquals(row.status, "filed");
|
|
64
|
+
assertEquals(row.pr_key, "o/r#42");
|
|
65
|
+
assertEquals(row.learnings, 4);
|
|
66
|
+
assertEquals(row.summary, "promoted 2 lessons");
|
|
67
|
+
assertEquals(row.report, "the full report");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
Deno.test("retro-record: defaults to skipped when the agent filed no PR", async () => {
|
|
71
|
+
const { app, stores } = fakeApp();
|
|
72
|
+
await handler(
|
|
73
|
+
// deno-lint-ignore no-explicit-any
|
|
74
|
+
{ variables: { planKey: "o/r#6", summary: "nothing durable" } } as any,
|
|
75
|
+
// deno-lint-ignore no-explicit-any
|
|
76
|
+
app as any,
|
|
77
|
+
);
|
|
78
|
+
assertEquals(stores.plan_retros[0].status, "skipped");
|
|
79
|
+
assertEquals(stores.plan_retros[0].pr_key, null);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
Deno.test("retro-record: honours an explicit blocked status", async () => {
|
|
83
|
+
const { app, stores } = fakeApp();
|
|
84
|
+
await handler(
|
|
85
|
+
// deno-lint-ignore no-explicit-any
|
|
86
|
+
{ variables: { planKey: "o/r#7", status: "blocked", summary: "no write access" } } as any,
|
|
87
|
+
// deno-lint-ignore no-explicit-any
|
|
88
|
+
app as any,
|
|
89
|
+
);
|
|
90
|
+
assertEquals(stores.plan_retros[0].status, "blocked");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
Deno.test("retro-record: ignores a PR unless the status is filed", async () => {
|
|
94
|
+
const { app, stores } = fakeApp();
|
|
95
|
+
await handler(
|
|
96
|
+
// deno-lint-ignore no-explicit-any
|
|
97
|
+
{ variables: { planKey: "o/r#8", status: "skipped", pr: "o/r#43", summary: "not durable" } } as any,
|
|
98
|
+
// deno-lint-ignore no-explicit-any
|
|
99
|
+
app as any,
|
|
100
|
+
);
|
|
101
|
+
assertEquals(stores.plan_retros[0].status, "skipped");
|
|
102
|
+
assertEquals(stores.plan_retros[0].pr_key, null);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
Deno.test("retro-record: defaults invalid status from PR presence", async () => {
|
|
106
|
+
const { app, stores } = fakeApp();
|
|
107
|
+
await handler(
|
|
108
|
+
// deno-lint-ignore no-explicit-any
|
|
109
|
+
{ variables: { planKey: "o/r#9", status: "done", pr: "o/r#44" } } as any,
|
|
110
|
+
// deno-lint-ignore no-explicit-any
|
|
111
|
+
app as any,
|
|
112
|
+
);
|
|
113
|
+
assertEquals(stores.plan_retros[0].status, "filed");
|
|
114
|
+
assertEquals(stores.plan_retros[0].pr_key, "o/r#44");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
Deno.test("retro-record: coerces filed without a PR to skipped", async () => {
|
|
118
|
+
const { app, stores } = fakeApp();
|
|
119
|
+
await handler(
|
|
120
|
+
// deno-lint-ignore no-explicit-any
|
|
121
|
+
{ variables: { planKey: "o/r#10", status: "filed", summary: "forgot the PR" } } as any,
|
|
122
|
+
// deno-lint-ignore no-explicit-any
|
|
123
|
+
app as any,
|
|
124
|
+
);
|
|
125
|
+
assertEquals(stores.plan_retros[0].status, "skipped");
|
|
126
|
+
assertEquals(stores.plan_retros[0].pr_key, null);
|
|
127
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// pr.retro-record — final step of the `retro` process. Persist the `senior:retro` agent's result
|
|
2
|
+
// into `plan_retros` (016_plan_retro.sql): the outcome status, the promotion PR it opened on the
|
|
3
|
+
// target repo (if any), the learning count it distilled, and its summary/report. Advisory only —
|
|
4
|
+
// this gates no control flow; it exists so the epic surface can show what the retro concluded.
|
|
5
|
+
//
|
|
6
|
+
// The agentTask runner hoists the agent's result-JSON keys (`status`, `pr`, `summary`) into
|
|
7
|
+
// top-level process variables (same as pr.record-plan-review reads `job.variables.approved`), and
|
|
8
|
+
// exposes the raw transcript under the `io.nanobpm.agentResult` envelope's `.output`.
|
|
9
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
10
|
+
import { recordRetro } from "../../app/retro.ts";
|
|
11
|
+
|
|
12
|
+
const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
|
|
13
|
+
const VALID_STATUSES = new Set(["filed", "skipped", "blocked"]);
|
|
14
|
+
|
|
15
|
+
interface In extends Record<string, unknown> {
|
|
16
|
+
planKey: string;
|
|
17
|
+
retroLearnings?: number;
|
|
18
|
+
status?: unknown; // filed | skipped | blocked
|
|
19
|
+
pr?: unknown; // "<owner>/<repo>#<n>" of the promotion PR, when filed
|
|
20
|
+
summary?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function asStr(v: unknown): string | null {
|
|
24
|
+
return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function asStatus(v: unknown, hasPr: boolean): "filed" | "skipped" | "blocked" {
|
|
28
|
+
const s = asStr(v);
|
|
29
|
+
if (s && VALID_STATUSES.has(s)) {
|
|
30
|
+
if (s === "filed" && !hasPr) return "skipped";
|
|
31
|
+
return s as "filed" | "skipped" | "blocked";
|
|
32
|
+
}
|
|
33
|
+
return hasPr ? "filed" : "skipped";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const handler: AppJobHandler<In> = async (job, app) => {
|
|
37
|
+
const planKey = job.variables.planKey;
|
|
38
|
+
|
|
39
|
+
const rawPrKey = asStr(job.variables.pr);
|
|
40
|
+
// Default to "filed" only when a PR is present; otherwise the agent decided not to file.
|
|
41
|
+
const status = asStatus(job.variables.status, rawPrKey !== null);
|
|
42
|
+
const prKey = status === "filed" ? rawPrKey : null;
|
|
43
|
+
const summary = asStr(job.variables.summary);
|
|
44
|
+
|
|
45
|
+
const env = job.variables[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
|
|
46
|
+
const report = typeof env?.output === "string" ? env.output : null;
|
|
47
|
+
|
|
48
|
+
await recordRetro(app.data, planKey, {
|
|
49
|
+
status,
|
|
50
|
+
prKey,
|
|
51
|
+
learnings: typeof job.variables.retroLearnings === "number" ? job.variables.retroLearnings : 0,
|
|
52
|
+
summary,
|
|
53
|
+
report,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
app.log("info", `retro-record: ${planKey} — status=${status}${prKey ? ` pr=${prKey}` : ""}`);
|
|
57
|
+
return {};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export default handler;
|