@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/app/retro.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
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 { planReviews, 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
|
+
// Plan-review trace (006_plan_review.sql): how many adversarial review rounds the plan needed
|
|
96
|
+
// before fan-out, and the critique from every rejected round — the "what was wrong with the
|
|
97
|
+
// first cut of the decomposition" signal, which is prime retro material even when implementers
|
|
98
|
+
// posted no learnings of their own. `planApproved` reflects the final round's verdict.
|
|
99
|
+
reviewRounds: number;
|
|
100
|
+
reviewRejections: { round: number; findings: string }[];
|
|
101
|
+
planApproved: boolean;
|
|
102
|
+
// Execution shape: how the plan's tasks actually resolved (opened a PR, were skipped/blocked,
|
|
103
|
+
// etc.). Lets the retro reason over decomposition accuracy — e.g. a high skipped/blocked ratio
|
|
104
|
+
// hints the plan over-decomposed.
|
|
105
|
+
taskOutcomes: { total: number; byStatus: Record<string, number> };
|
|
106
|
+
counts: { learnings: number; deltas: number; notes: number };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Gather a plan's reflection material: the `learning` blackboard entries (the headline), plus the
|
|
110
|
+
* task-delta rollup (contract changes, discovered constraints, cross-slice file touches), the
|
|
111
|
+
* plan-review trace (rounds + rejection findings), the task-outcome shape, and any other
|
|
112
|
+
* non-learning blackboard notes for colour. Reads only — no writes. */
|
|
113
|
+
export async function gatherRetro(data: DataLayer, planKey: string): Promise<RetroDigest> {
|
|
114
|
+
const plan = await plansTbl(data).get(planKey);
|
|
115
|
+
const entries = await readBlackboard(data, planKey);
|
|
116
|
+
const learnings = entries
|
|
117
|
+
.filter((e) => e.kind === "learning")
|
|
118
|
+
.map((e) => ({ author_task: e.author_task, body: e.body, created_at: e.created_at }));
|
|
119
|
+
const notes = entries
|
|
120
|
+
.filter((e) => e.kind !== "learning")
|
|
121
|
+
.map((e) => ({ author_task: e.author_task, kind: e.kind, body: e.body }));
|
|
122
|
+
const deltas = await aggregateEpicDeltas(data, planKey);
|
|
123
|
+
|
|
124
|
+
// Plan-review trace, ordered by round. A rejected round is `approved === 0`; only rounds that
|
|
125
|
+
// carry findings are worth quoting (an empty rejection has nothing to teach).
|
|
126
|
+
const reviews = (await planReviews(data).find({ plan_key: planKey }))
|
|
127
|
+
.slice()
|
|
128
|
+
.sort((a, b) => a.round - b.round);
|
|
129
|
+
const reviewRejections = reviews
|
|
130
|
+
.filter((r) => r.approved === 0 && (r.findings ?? "").trim() !== "")
|
|
131
|
+
.map((r) => ({ round: r.round, findings: r.findings as string }));
|
|
132
|
+
const planApproved = reviews.length > 0 && reviews[reviews.length - 1].approved === 1;
|
|
133
|
+
|
|
134
|
+
// Task-outcome shape (counts by final status).
|
|
135
|
+
const tasks = await planTasks(data).find({ plan_key: planKey });
|
|
136
|
+
const byStatus: Record<string, number> = {};
|
|
137
|
+
for (const t of tasks) byStatus[t.status] = (byStatus[t.status] ?? 0) + 1;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
planKey,
|
|
141
|
+
repo: plan?.repo ?? planKey.split("#")[0] ?? "",
|
|
142
|
+
issueUrl: plan?.issue_url ?? "",
|
|
143
|
+
title: plan?.title ?? null,
|
|
144
|
+
learnings,
|
|
145
|
+
touchedFiles: deltas.touchedFiles,
|
|
146
|
+
contractChanges: deltas.contractChanges,
|
|
147
|
+
constraints: deltas.constraints,
|
|
148
|
+
notes,
|
|
149
|
+
reviewRounds: reviews.length,
|
|
150
|
+
reviewRejections,
|
|
151
|
+
planApproved,
|
|
152
|
+
taskOutcomes: { total: tasks.length, byStatus },
|
|
153
|
+
counts: {
|
|
154
|
+
learnings: learnings.length,
|
|
155
|
+
deltas: deltas.deltas.length,
|
|
156
|
+
notes: notes.length,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Render the digest as the compact markdown brief handed to the retro agent (rides `appendPrompt`,
|
|
162
|
+
* concatenated after the base `{{retro}}` prompt — so it owns its own leading separator). */
|
|
163
|
+
export function renderRetroBrief(d: RetroDigest): string {
|
|
164
|
+
const lines: string[] = [
|
|
165
|
+
"",
|
|
166
|
+
"",
|
|
167
|
+
"---",
|
|
168
|
+
"",
|
|
169
|
+
`## Retro input — epic ${d.planKey}`,
|
|
170
|
+
"",
|
|
171
|
+
`Target repo: **${d.repo}**${d.issueUrl ? ` · issue: ${d.issueUrl}` : ""}`,
|
|
172
|
+
d.title ? `Epic: ${d.title}` : "",
|
|
173
|
+
"",
|
|
174
|
+
`### Plan review — ${d.reviewRounds} round(s), ${d.planApproved ? "approved" : "not approved"}`,
|
|
175
|
+
];
|
|
176
|
+
if (d.reviewRejections.length === 0) {
|
|
177
|
+
lines.push(
|
|
178
|
+
d.reviewRounds === 0
|
|
179
|
+
? "_(no review rounds recorded)_"
|
|
180
|
+
: "_(approved with no recorded rejections)_",
|
|
181
|
+
);
|
|
182
|
+
} else {
|
|
183
|
+
lines.push(`The plan was revised after ${d.reviewRejections.length} rejected round(s):`);
|
|
184
|
+
for (const r of d.reviewRejections) lines.push(`- **round ${r.round}**: ${r.findings}`);
|
|
185
|
+
}
|
|
186
|
+
if (d.taskOutcomes.total > 0) {
|
|
187
|
+
const shape = Object.entries(d.taskOutcomes.byStatus)
|
|
188
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
189
|
+
.map(([s, n]) => `${s}: ${n}`)
|
|
190
|
+
.join(", ");
|
|
191
|
+
lines.push("", `### Task outcomes (${d.taskOutcomes.total} task(s))`, shape);
|
|
192
|
+
}
|
|
193
|
+
lines.push(
|
|
194
|
+
"",
|
|
195
|
+
`### Learnings agents posted while implementing (${d.learnings.length})`,
|
|
196
|
+
);
|
|
197
|
+
if (d.learnings.length === 0) {
|
|
198
|
+
lines.push("_(none — agents posted no `learning` entries for this epic)_");
|
|
199
|
+
} else {
|
|
200
|
+
for (const l of d.learnings) lines.push(`- **[${l.author_task}]** ${l.body}`);
|
|
201
|
+
}
|
|
202
|
+
if (d.constraints.length > 0) {
|
|
203
|
+
lines.push("", `### Constraints discovered (${d.constraints.length})`);
|
|
204
|
+
for (const c of d.constraints) lines.push(`- **[${c.taskId}]** ${c.constraint}`);
|
|
205
|
+
}
|
|
206
|
+
if (d.contractChanges.length > 0) {
|
|
207
|
+
lines.push("", `### Contract changes (${d.contractChanges.length})`);
|
|
208
|
+
for (const c of d.contractChanges) lines.push(`- **[${c.taskId}]** ${c.change}`);
|
|
209
|
+
}
|
|
210
|
+
if (d.touchedFiles.length > 0) {
|
|
211
|
+
lines.push("", `### Files touched beyond original slices`, d.touchedFiles.map((f) => `\`${f}\``).join(", "));
|
|
212
|
+
}
|
|
213
|
+
if (d.notes.length > 0) {
|
|
214
|
+
lines.push("", `### Other blackboard notes (${d.notes.length})`);
|
|
215
|
+
for (const n of d.notes) lines.push(`- **[${n.author_task}]** _${n.kind}_: ${n.body}`);
|
|
216
|
+
}
|
|
217
|
+
return lines.join("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** True when a digest carries nothing worth an agent run. A plan is worth retrospecting when
|
|
221
|
+
* implementers shared material (learnings/deltas/notes) OR the plan itself needed revision — a
|
|
222
|
+
* rejected review round's findings are reflection material in their own right, even when no
|
|
223
|
+
* learning was posted. (Task-outcome shape alone is NOT: a cleanly-approved plan whose tasks all
|
|
224
|
+
* ran has nothing to teach.) */
|
|
225
|
+
export function isDigestEmpty(d: RetroDigest): boolean {
|
|
226
|
+
return d.counts.learnings === 0 && d.counts.deltas === 0 && d.counts.notes === 0 &&
|
|
227
|
+
d.reviewRejections.length === 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The persisted retro shape (written by pr.retro-record). */
|
|
231
|
+
export interface RetroInput {
|
|
232
|
+
status: string; // filed | skipped | blocked
|
|
233
|
+
prKey?: string | null;
|
|
234
|
+
learnings?: number;
|
|
235
|
+
summary?: string | null;
|
|
236
|
+
report?: string | null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const retrosTbl = (data: DataLayer) =>
|
|
240
|
+
data.table<{ plan_key: string } & Record<string, unknown>>("plan_retros", "plan_key");
|
|
241
|
+
|
|
242
|
+
async function claimRetroStart(data: DataLayer, planKey: string): Promise<boolean> {
|
|
243
|
+
try {
|
|
244
|
+
await retroStartsTbl(data).insert({ plan_key: planKey, started_at: now() });
|
|
245
|
+
return true;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
// Only a UNIQUE/PK collision means "another starter already elected itself" — a benign
|
|
248
|
+
// duplicate the fire-once guard exists to detect. Any other constraint (e.g. a FOREIGN KEY
|
|
249
|
+
// failure from a missing plan row) is real corruption and must propagate, not be swallowed.
|
|
250
|
+
if (isUniqueViolation(err)) return false;
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Upsert a plan's retro row (idempotent on plan_key, so a job retry overwrites in place).
|
|
256
|
+
*
|
|
257
|
+
* Insert-first, then fall back to update only on a verified unique/PK violation: a get-then-insert
|
|
258
|
+
* can race (two concurrent retries both see no row, then one insert wins and the other throws), so
|
|
259
|
+
* we treat "row already exists" as the update path rather than an error. */
|
|
260
|
+
export async function recordRetro(
|
|
261
|
+
data: DataLayer,
|
|
262
|
+
planKey: string,
|
|
263
|
+
input: RetroInput,
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
const ts = now();
|
|
266
|
+
const fields = {
|
|
267
|
+
status: input.status,
|
|
268
|
+
pr_key: input.prKey ?? null,
|
|
269
|
+
learnings: input.learnings ?? 0,
|
|
270
|
+
summary: input.summary ?? null,
|
|
271
|
+
report: input.report ?? null,
|
|
272
|
+
updated_at: ts,
|
|
273
|
+
};
|
|
274
|
+
try {
|
|
275
|
+
await retrosTbl(data).insert({ plan_key: planKey, created_at: ts, ...fields });
|
|
276
|
+
} catch (err) {
|
|
277
|
+
// Fall back to update only on a verified UNIQUE/PK violation (the get-then-insert race, or a
|
|
278
|
+
// job retry). Restrict to unique/duplicate/primary-key: a FOREIGN KEY (or other) constraint
|
|
279
|
+
// failure would otherwise be swallowed here, making the write look successful while doing
|
|
280
|
+
// nothing — so rethrow anything that isn't a duplicate-row collision.
|
|
281
|
+
if (!isUniqueViolation(err)) throw err;
|
|
282
|
+
await retrosTbl(data).update(planKey, fields);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Called from a PR's terminal point (mark-merged / finalize review-only). If that PR was the last
|
|
287
|
+
* of its plan to land AND there is anything to reflect on, start the `retro` process exactly once.
|
|
288
|
+
*
|
|
289
|
+
* Best-effort and non-blocking: any failure here must never fail the terminal job that called it —
|
|
290
|
+
* the retro is advisory. The fire-once guard is `plan_retro_starts`: a PRIMARY KEY insert
|
|
291
|
+
* atomically elects one starter across app processes before we stamp `plans.retro_started_at`
|
|
292
|
+
* and start the instance. */
|
|
293
|
+
export async function maybeStartRetro(
|
|
294
|
+
data: DataLayer,
|
|
295
|
+
engine: EngineClient,
|
|
296
|
+
prKey: string,
|
|
297
|
+
log?: (level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>) => void,
|
|
298
|
+
): Promise<{ started: boolean; planKey?: string; reason?: string }> {
|
|
299
|
+
if (!autoRetroEnabled()) return { started: false, reason: "disabled" };
|
|
300
|
+
try {
|
|
301
|
+
const planKey = await planKeyForPr(data, prKey);
|
|
302
|
+
if (!planKey) return { started: false, reason: "no-plan" };
|
|
303
|
+
|
|
304
|
+
const plan = await plansTbl(data).get(planKey);
|
|
305
|
+
if (!plan) return { started: false, reason: "no-plan" };
|
|
306
|
+
if (plan.retro_started_at) return { started: false, planKey, reason: "already-started" };
|
|
307
|
+
|
|
308
|
+
if (!(await isPlanComplete(data, planKey))) return { started: false, planKey, reason: "incomplete" };
|
|
309
|
+
|
|
310
|
+
const digest = await gatherRetro(data, planKey);
|
|
311
|
+
if (isDigestEmpty(digest)) {
|
|
312
|
+
if (!(await claimRetroStart(data, planKey))) return { started: false, planKey, reason: "already-started" };
|
|
313
|
+
// Nothing to reflect on — stamp anyway so we don't re-check on every future terminal PR of a
|
|
314
|
+
// (now settled) plan, and record a skipped retro for visibility.
|
|
315
|
+
await plansTbl(data).update(planKey, { retro_started_at: now(), updated_at: now() });
|
|
316
|
+
await recordRetro(data, planKey, { status: "skipped", summary: "No learnings, deltas, or notes to retrospect." });
|
|
317
|
+
return { started: false, planKey, reason: "nothing-to-retro" };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!(await claimRetroStart(data, planKey))) return { started: false, planKey, reason: "already-started" };
|
|
321
|
+
|
|
322
|
+
// Stamp before starting so restarts/retries can take the cheap already-started path.
|
|
323
|
+
await plansTbl(data).update(planKey, { retro_started_at: now(), updated_at: now() });
|
|
324
|
+
|
|
325
|
+
let processInstanceKey: string | number | undefined;
|
|
326
|
+
try {
|
|
327
|
+
({ processInstanceKey } = await engine.createInstance({
|
|
328
|
+
processDefinitionId: RETRO_PROCESS_ID,
|
|
329
|
+
variables: {
|
|
330
|
+
planKey,
|
|
331
|
+
repo: digest.repo,
|
|
332
|
+
issueUrl: digest.issueUrl,
|
|
333
|
+
},
|
|
334
|
+
}));
|
|
335
|
+
} catch (err) {
|
|
336
|
+
// The fire-once guard is already consumed (retro_started_at stamped, plan_retro_starts
|
|
337
|
+
// claimed), so this plan will never re-enter the start path. If we returned here with no
|
|
338
|
+
// record, the epic surface would show a plan that "started a retro" with nothing to show and
|
|
339
|
+
// no way to retry. Persist a `blocked` retro instead so the failure stays visible and the
|
|
340
|
+
// system state is consistent. Recording must not mask the original error in the log.
|
|
341
|
+
log?.("error", `retro: could not start process for epic ${planKey}`, { err: String(err) });
|
|
342
|
+
// Persisting the blocked record is best-effort: recordRetro rethrows non-unique DB errors, and
|
|
343
|
+
// if that escaped here it would fall through to the outer catch and return `error` instead of
|
|
344
|
+
// `start-failed` — reintroducing the very silent-gap failure this path guards against (guard
|
|
345
|
+
// consumed, no durable record). Swallow a secondary persistence failure and log it separately
|
|
346
|
+
// so the createInstance failure path always reports `start-failed`.
|
|
347
|
+
try {
|
|
348
|
+
await recordRetro(data, planKey, {
|
|
349
|
+
status: "blocked",
|
|
350
|
+
summary: `Retro process could not be started: ${String(err)}`,
|
|
351
|
+
});
|
|
352
|
+
} catch (persistErr) {
|
|
353
|
+
log?.("error", `retro: could not persist blocked retro for epic ${planKey}`, {
|
|
354
|
+
err: String(persistErr),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return { started: false, planKey, reason: "start-failed" };
|
|
358
|
+
}
|
|
359
|
+
log?.("info", `retro: started for epic ${planKey}`, { processInstanceKey, learnings: digest.counts.learnings });
|
|
360
|
+
return { started: true, planKey };
|
|
361
|
+
} catch (err) {
|
|
362
|
+
log?.("error", `retro: could not start for PR ${prKey}`, { err: String(err) });
|
|
363
|
+
return { started: false, reason: "error" };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -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.31.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/epic.page.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"type": "text",
|
|
24
24
|
"id": "subtitle",
|
|
25
25
|
"props": {
|
|
26
|
-
"text": "Read-only observability over each plan's wave state, merge-exclusion graph, coordination notes, and trial-merge gate results.",
|
|
26
|
+
"text": "Read-only observability over each plan's review trace, wave state, merge-exclusion graph, coordination notes, and trial-merge gate results.",
|
|
27
27
|
"variant": "sub"
|
|
28
28
|
}
|
|
29
29
|
},
|
|
@@ -70,6 +70,27 @@
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
|
+
{
|
|
74
|
+
"type": "dataGrid",
|
|
75
|
+
"id": "plan-reviews",
|
|
76
|
+
"props": {
|
|
77
|
+
"title": "Plan review trace",
|
|
78
|
+
"refreshMs": 5000,
|
|
79
|
+
"data": {
|
|
80
|
+
"kind": "datasource",
|
|
81
|
+
"source": "app",
|
|
82
|
+
"table": "plan_reviews",
|
|
83
|
+
"orderBy": { "field": "round", "dir": "asc" }
|
|
84
|
+
},
|
|
85
|
+
"columns": [
|
|
86
|
+
{ "field": "plan_key", "header": "Plan" },
|
|
87
|
+
{ "field": "round", "header": "Round" },
|
|
88
|
+
{ "field": "approved", "header": "Approved? (1/0)" },
|
|
89
|
+
{ "field": "findings", "header": "Reviewer findings" },
|
|
90
|
+
{ "field": "created_at", "header": "Recorded" }
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
},
|
|
73
94
|
{
|
|
74
95
|
"type": "dataGrid",
|
|
75
96
|
"id": "wave-state",
|
package/pages/home.page.json
CHANGED
|
@@ -228,6 +228,20 @@
|
|
|
228
228
|
{ "field": "summary", "header": "Summary" }
|
|
229
229
|
]
|
|
230
230
|
},
|
|
231
|
+
{
|
|
232
|
+
"title": "Plan reviews",
|
|
233
|
+
"source": "app",
|
|
234
|
+
"table": "plan_reviews",
|
|
235
|
+
"parentField": "plan_key",
|
|
236
|
+
"childField": "plan_key",
|
|
237
|
+
"orderBy": { "field": "round", "dir": "asc" },
|
|
238
|
+
"columns": [
|
|
239
|
+
{ "field": "round", "header": "Round" },
|
|
240
|
+
{ "field": "approved", "header": "Approved? (1/0)" },
|
|
241
|
+
{ "field": "findings", "header": "Reviewer findings" },
|
|
242
|
+
{ "field": "created_at", "header": "Recorded" }
|
|
243
|
+
]
|
|
244
|
+
},
|
|
231
245
|
{
|
|
232
246
|
"title": "Escalations",
|
|
233
247
|
"source": "app",
|
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.
|
|
@@ -216,7 +216,7 @@
|
|
|
216
216
|
<bpmn:sequenceFlow id="f_toRecordPlanReview" sourceRef="review-plan" targetRef="record-plan-review" />
|
|
217
217
|
<bpmn:sequenceFlow id="f_toGwPlanReview" sourceRef="record-plan-review" targetRef="gw-plan-review" />
|
|
218
218
|
<bpmn:sequenceFlow id="f_plan_proceed" name="approved" sourceRef="gw-plan-review" targetRef="select-wave">
|
|
219
|
-
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=planApproved
|
|
219
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=planApproved</bpmn:conditionExpression>
|
|
220
220
|
</bpmn:sequenceFlow>
|
|
221
221
|
<bpmn:sequenceFlow id="f_plan_revise" name="revise" sourceRef="gw-plan-review" targetRef="plan" />
|
|
222
222
|
<bpmn:sequenceFlow id="f_toImplement" sourceRef="select-wave" targetRef="implement" />
|