@nanobpm/nano-workforce 0.104.0 → 0.106.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -0
  3. package/app/abandon.ts +12 -3
  4. package/app/agentCompletion.test.ts +28 -0
  5. package/app/agentCompletion.ts +14 -2
  6. package/app/conformance.test.ts +313 -0
  7. package/app/conformance.ts +329 -0
  8. package/app/dbFence.ts +18 -0
  9. package/app/instance-tracking.test.ts +24 -0
  10. package/app/migration053.test.ts +84 -0
  11. package/app/plan.ts +6 -0
  12. package/app/pollUserTasks.test.ts +37 -0
  13. package/app/retro.test.ts +32 -2
  14. package/app/retro.ts +26 -10
  15. package/app/service.test.ts +191 -3
  16. package/app/service.ts +163 -2
  17. package/app/userTasks.test.ts +20 -0
  18. package/app/userTasks.ts +2 -0
  19. package/app/waves.test.ts +12 -0
  20. package/app/world/store.ts +6 -6
  21. package/db/migrations/004_planning.sql +1 -1
  22. package/db/migrations/052_plan_conformance.sql +28 -0
  23. package/db/migrations/053_merges_abandon_dedupe.sql +29 -0
  24. package/db/migrations/054_conformance_review_tracking.sql +27 -0
  25. package/nano.app.json +24 -1
  26. package/package.json +1 -1
  27. package/pages/tasks.page.json +84 -0
  28. package/resources/forms/conformance-escalation.form +17 -0
  29. package/resources/processes/retro.bpmn +123 -11
  30. package/resources/prompts/conformance.md +105 -0
  31. package/resources/prompts/retro.md +5 -0
  32. package/workers/conformance-ack/worker.test.ts +50 -0
  33. package/workers/conformance-ack/worker.ts +31 -0
  34. package/workers/conformance-record/worker.test.ts +241 -0
  35. package/workers/conformance-record/worker.ts +127 -0
  36. package/workers/merge/worker.test.ts +4 -0
  37. package/workers/merge/worker.ts +13 -18
  38. package/workers/retro-gather/worker.test.ts +6 -0
  39. package/workers/retro-gather/worker.ts +17 -5
@@ -0,0 +1,329 @@
1
+ // Spec-conformance review — "did we build what the spec asked for?", examined against the ACTUAL
2
+ // implementation.
3
+ //
4
+ // It rides the existing `retro` process (app/retro.ts): when an epic's last PR lands, a
5
+ // `senior:conformance` agent runs BEFORE the lessons agent. Unlike retro — which reflects on what
6
+ // implementers *claimed* via `learning` blackboard entries and task deltas — conformance is
7
+ // deliberately grounded in the code: the digest it builds hands the agent the spec (the epic issue
8
+ // + every slice's `prompt`) and the set of PRs that actually LANDED, so the agent reads the real
9
+ // diffs/code/tests (`gh pr diff`, `git`) and verifies delivery rather than trusting the transcript.
10
+ //
11
+ // It surfaces two classes of deviation: those RAISED during implementation (`scope-change`
12
+ // blackboard entries — quoted here so the agent can reconcile them) and those it finds itself that
13
+ // were NEVER raised. The result is persisted to `plan_conformance` (052_plan_conformance.sql).
14
+ //
15
+ // Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
16
+ // app/retro.ts, app/plan.ts, and app/blackboard.ts.
17
+ import type { DataLayer } from "@nanobpm/urban";
18
+ import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
19
+ import { TERMINAL_STATUSES } from "./delivery.ts";
20
+ import { planTasks } from "./plan.ts";
21
+
22
+ const now = () => new Date().toISOString();
23
+
24
+ /** The BPMN `elementId` of the conformance escalation user task (retro.bpmn). The inbox reconciler
25
+ * (`pollUserTasks`) and the human completer (`HUMAN_COMPLETABLE_ELEMENTS`) key off this. */
26
+ export const CONFORMANCE_ESCALATION_ELEMENT = "conformance-escalation";
27
+
28
+ /** The `review_status` a `plan_conformance` row carries while its escalation ack task is OPEN — the
29
+ * only status `pollUserTasks` scans (migration 054). Every settled run is `reviewed`. */
30
+ export const CONFORMANCE_REVIEWING_STATUS = "reviewing";
31
+
32
+ /** A slice PR "landed" — its implementation is really in the tree and worth examining — when its
33
+ * PR reached a terminal state that isn't `abandoned`. In auto-merge mode that terminal is `merged`;
34
+ * in review-only mode it is `converged`. Derived from app/delivery.ts TERMINAL_STATUSES (the single
35
+ * source of truth for PR-terminal states) minus `abandoned`, so conformance and retro can't drift
36
+ * about what counts as landed. */
37
+ const LANDED_PR_STATUSES = new Set(TERMINAL_STATUSES.filter((s) => s !== "abandoned"));
38
+
39
+ interface PlanRow extends Record<string, unknown> {
40
+ plan_key: string;
41
+ repo: string;
42
+ issue_url: string;
43
+ title: string | null;
44
+ }
45
+
46
+ const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
47
+ const prsTbl = (data: DataLayer) =>
48
+ data.table<{ pr_key: string; status: string }>("pull_requests", "pr_key");
49
+
50
+ /** A slice's PR "landed" iff it exists and reached a non-abandoned terminal status. The single
51
+ * predicate both {@link gatherConformance} and {@link hasDeliveredImplementationForPlan} apply, so
52
+ * the full digest and the cheap trigger check can't disagree about what counts as landed. */
53
+ async function isLanded(data: DataLayer, prKey: string | null | undefined): Promise<boolean> {
54
+ if (!prKey) return false;
55
+ const pr = await prsTbl(data).get(prKey);
56
+ return !!pr && LANDED_PR_STATUSES.has(pr.status);
57
+ }
58
+ const conformanceTbl = (data: DataLayer) =>
59
+ data.table<{ plan_key: string } & Record<string, unknown>>("plan_conformance", "plan_key");
60
+
61
+ /** A `plan_conformance` row viewed as a retro-run tracking record, for the inbox reconciler. */
62
+ export interface ConformanceReviewRow extends Record<string, unknown> {
63
+ plan_key: string;
64
+ process_key: string | null;
65
+ review_status: string;
66
+ summary: string | null;
67
+ }
68
+
69
+ const conformanceReviewsTbl = (data: DataLayer) =>
70
+ data.table<ConformanceReviewRow>("plan_conformance", "plan_key");
71
+
72
+ /** The conformance runs whose escalation ack task is still open (`review_status = 'reviewing'`) —
73
+ * the set `pollUserTasks` scans for an open `conformance-escalation` user task. */
74
+ export async function activeConformanceReviews(data: DataLayer): Promise<ConformanceReviewRow[]> {
75
+ return await conformanceReviewsTbl(data).find({ review_status: CONFORMANCE_REVIEWING_STATUS });
76
+ }
77
+
78
+ /** The escalation question shown in the inbox row: the agent's conformance summary (which names the
79
+ * reduced / not-verified items and the unraised deviations). Best-effort — NULL when none recorded. */
80
+ export function conformanceEscalationQuestion(row: { summary?: unknown } | undefined): string | null {
81
+ const s = row?.summary;
82
+ return typeof s === "string" && s.trim() ? s.trim() : null;
83
+ }
84
+
85
+ /** One item of the spec the agent must verify against the code: the slice's planner-supplied
86
+ * `prompt` (its acceptance brief), where it landed, and whether it landed at all. */
87
+ export interface ConformanceSlice {
88
+ taskId: string;
89
+ title: string | null;
90
+ prompt: string | null;
91
+ status: string;
92
+ prKey: string | null;
93
+ landed: boolean;
94
+ }
95
+
96
+ /** The material a conformance review examines. Unlike {@link RetroDigest}, this is spec + delivery
97
+ * pointers (not distilled claims) — the agent turns `deliveredPrs` into real diffs to inspect. */
98
+ export interface ConformanceDigest {
99
+ planKey: string;
100
+ repo: string;
101
+ issueUrl: string;
102
+ title: string | null;
103
+ slices: ConformanceSlice[];
104
+ /** The landed PR keys ("<owner>/<repo>#<n>") the agent must open and read the diff of. */
105
+ deliveredPrs: string[];
106
+ /** Deviations agents RAISED during implementation (`scope-change` blackboard entries). */
107
+ scopeChanges: { author_task: string; body: string; created_at: string }[];
108
+ }
109
+
110
+ /** Assemble the conformance material for a plan: the spec (issue + each slice's `prompt`), the set
111
+ * of PRs that actually landed (so the agent examines the real implementation), and the scope
112
+ * deviations raised during implementation. Reads only — no writes.
113
+ *
114
+ * `entries` lets a caller that has already scanned the blackboard for this plan (e.g.
115
+ * `pr.retro-gather`, which also runs {@link gatherRetro}) pass those entries in so the plan is
116
+ * scanned once, not once per gatherer — see workers/retro-gather. Omitted, it reads them itself. */
117
+ export async function gatherConformance(
118
+ data: DataLayer,
119
+ planKey: string,
120
+ entries?: BlackboardEntry[],
121
+ ): Promise<ConformanceDigest> {
122
+ const plan = await plansTbl(data).get(planKey);
123
+ const tasks = (await planTasks(data).find({ plan_key: planKey }))
124
+ .slice()
125
+ .sort((a, b) => (a.task_index ?? 0) - (b.task_index ?? 0));
126
+
127
+ const slices: ConformanceSlice[] = [];
128
+ const deliveredPrs: string[] = [];
129
+ for (const t of tasks) {
130
+ const landed = await isLanded(data, t.pr_key);
131
+ if (landed && t.pr_key) deliveredPrs.push(t.pr_key);
132
+ slices.push({
133
+ taskId: t.task_id,
134
+ title: t.title ?? null,
135
+ prompt: t.prompt ?? null,
136
+ status: t.status,
137
+ prKey: t.pr_key ?? null,
138
+ landed,
139
+ });
140
+ }
141
+
142
+ const scopeChanges = (entries ?? (await readBlackboard(data, planKey)))
143
+ .filter((e) => e.kind === "scope-change")
144
+ .map((e) => ({ author_task: e.author_task, body: e.body, created_at: e.created_at }));
145
+
146
+ return {
147
+ planKey,
148
+ repo: plan?.repo ?? planKey.split("#")[0] ?? "",
149
+ issueUrl: plan?.issue_url ?? "",
150
+ title: plan?.title ?? null,
151
+ slices,
152
+ deliveredPrs,
153
+ scopeChanges,
154
+ };
155
+ }
156
+
157
+ /** True when there is real, landed implementation to examine. A plan whose slices were all
158
+ * skipped/blocked/abandoned shipped nothing, so there is nothing to check for conformance — the
159
+ * retro trigger uses this to decide whether the conformance run is worthwhile even when the retro
160
+ * digest itself is empty. */
161
+ export function hasDeliveredImplementation(d: ConformanceDigest): boolean {
162
+ return d.deliveredPrs.length > 0;
163
+ }
164
+
165
+ /** Cheap trigger check for the retro gate: does this plan have ANY landed implementation to examine?
166
+ * Inspects only plan_tasks + pull_requests (short-circuiting on the first landed PR) and — unlike
167
+ * {@link gatherConformance} — performs no blackboard scan, so the empty-digest trigger in
168
+ * app/retro.ts doesn't pay to compute `scopeChanges` it would discard. Shares {@link isLanded} with
169
+ * the full digest so the two can't drift on what "landed" means. */
170
+ export async function hasDeliveredImplementationForPlan(
171
+ data: DataLayer,
172
+ planKey: string,
173
+ ): Promise<boolean> {
174
+ const tasks = await planTasks(data).find({ plan_key: planKey });
175
+ for (const t of tasks) {
176
+ if (await isLanded(data, t.pr_key)) return true;
177
+ }
178
+ return false;
179
+ }
180
+
181
+ /** Render the digest as the compact markdown brief handed to the conformance agent (rides
182
+ * `appendPrompt`, concatenated after the base `conformance.md` linked-resource prompt — so it owns
183
+ * its own leading separator). It deliberately gives POINTERS (the spec text + the PRs to open), not
184
+ * conclusions: the agent must reach the verdicts by reading the code. */
185
+ export function renderConformanceBrief(d: ConformanceDigest): string {
186
+ const lines: string[] = [
187
+ "",
188
+ "",
189
+ "---",
190
+ "",
191
+ `## Conformance input — epic ${d.planKey}`,
192
+ "",
193
+ `Target repo: **${d.repo}**${d.issueUrl ? ` · issue (the spec): ${d.issueUrl}` : ""}`,
194
+ d.title ? `Epic: ${d.title}` : "",
195
+ "",
196
+ "### Delivered PRs to examine",
197
+ ];
198
+ if (d.deliveredPrs.length === 0) {
199
+ lines.push("_(none landed — no implementation to verify)_");
200
+ } else {
201
+ lines.push(
202
+ `Read the actual diff of each with \`gh pr diff <n> --repo ${d.repo}\` (and the code/tests it touches):`,
203
+ );
204
+ for (const pr of d.deliveredPrs) lines.push(`- ${pr}`);
205
+ }
206
+
207
+ lines.push("", `### Spec — the ${d.slices.length} slice(s) planned`);
208
+ if (d.slices.length === 0) {
209
+ lines.push("_(no slices recorded — verify the epic issue body directly)_");
210
+ } else {
211
+ for (const s of d.slices) {
212
+ const where = s.landed && s.prKey ? `landed as ${s.prKey}` : `status: ${s.status}`;
213
+ lines.push("", `#### ${s.taskId}${s.title ? ` — ${s.title}` : ""} (${where})`);
214
+ lines.push(s.prompt ? s.prompt : "_(no per-slice prompt; verify against the epic issue body)_");
215
+ }
216
+ }
217
+
218
+ lines.push("", `### Deviations RAISED during implementation (${d.scopeChanges.length})`);
219
+ if (d.scopeChanges.length === 0) {
220
+ lines.push("_(none — no `scope-change` entries were posted; treat any deviation you find as UNRAISED)_");
221
+ } else {
222
+ lines.push("Reconcile each against the delivered code — a raised deviation is still a deviation:");
223
+ for (const c of d.scopeChanges) lines.push(`- **[${c.author_task}]** ${c.body}`);
224
+ }
225
+ return lines.join("\n");
226
+ }
227
+
228
+ /** The persisted conformance shape (written by pr.conformance-record from the agent's result). */
229
+ export interface ConformanceInput {
230
+ status: string; // filed | skipped | blocked
231
+ commentUrl?: string | null;
232
+ slicesMet?: number;
233
+ slicesReduced?: number;
234
+ slicesNotVerified?: number;
235
+ deviationsRaised?: number;
236
+ deviationsUnraised?: number;
237
+ hasDeviations?: boolean;
238
+ summary?: string | null;
239
+ report?: string | null;
240
+ /** The retro process instance this conformance ran in — the tracking key `pollUserTasks` reads to
241
+ * find an open escalation user task (migration 054). */
242
+ processKey?: string | null;
243
+ /** Escalation lifecycle: `reviewing` while the ack task is open (poller scans these), else
244
+ * `reviewed`. Defaults to `reviewed` — only an escalation flips it to `reviewing`. */
245
+ reviewStatus?: "reviewing" | "reviewed";
246
+ }
247
+
248
+ /** Upsert a plan's conformance row (idempotent on plan_key, so a job retry overwrites in place).
249
+ *
250
+ * Insert-first, then fall back to update only on a verified unique/PK violation — mirrors
251
+ * {@link recordRetro}: a get-then-insert can race, so "row already exists" is the update path, but
252
+ * any non-duplicate constraint failure must propagate rather than be silently swallowed. */
253
+ export async function recordConformance(
254
+ data: DataLayer,
255
+ planKey: string,
256
+ input: ConformanceInput,
257
+ ): Promise<void> {
258
+ const ts = now();
259
+ const processKey = input.processKey ?? null;
260
+ const reviewStatus = input.reviewStatus ?? "reviewed";
261
+ // Invariant: a `reviewing` row must be trackable. `pollUserTasks` skips rows without a
262
+ // `process_key` and the `instanceTracking` binding keys off `process_key`, so a `reviewing` row
263
+ // with a null key can never be surfaced to an operator nor cleared — it wedges forever. The
264
+ // conformance-record worker already guards its own call site, but `recordConformance` is a public
265
+ // API: reject the untrackable combination here too so no future caller can encode it.
266
+ if (reviewStatus === CONFORMANCE_REVIEWING_STATUS && processKey == null) {
267
+ throw new Error(
268
+ `recordConformance: ${planKey} would persist review_status='reviewing' with no process_key — ` +
269
+ "refusing to record an untrackable escalation that no poller or onTerminated binding can clear",
270
+ );
271
+ }
272
+ const fields = {
273
+ status: input.status,
274
+ comment_url: input.commentUrl ?? null,
275
+ slices_met: input.slicesMet ?? 0,
276
+ slices_reduced: input.slicesReduced ?? 0,
277
+ slices_not_verified: input.slicesNotVerified ?? 0,
278
+ deviations_raised: input.deviationsRaised ?? 0,
279
+ deviations_unraised: input.deviationsUnraised ?? 0,
280
+ has_deviations: input.hasDeviations ? 1 : 0,
281
+ summary: input.summary ?? null,
282
+ report: input.report ?? null,
283
+ process_key: processKey,
284
+ review_status: reviewStatus,
285
+ updated_at: ts,
286
+ };
287
+ try {
288
+ await conformanceTbl(data).insert({ plan_key: planKey, created_at: ts, ...fields });
289
+ } catch (err) {
290
+ if (!isUniqueViolation(err)) throw err;
291
+ await conformanceTbl(data).update(planKey, fields);
292
+ }
293
+ }
294
+
295
+ /** Settle a conformance run's escalation once the operator acknowledges it: flip `review_status` to
296
+ * `reviewed` so `pollUserTasks` stops scanning it (its inbox row is already gone once the ack task
297
+ * closes) and stamp the disposition note into `summary` for the audit trail. Needed because the
298
+ * `retro` instance COMPLETES normally after the ack — `instanceTracking.onTerminated` only fires on a
299
+ * TERMINATED (crashed) instance, never a completed one, so nothing else would clear `reviewing`. */
300
+ export async function acknowledgeConformance(
301
+ data: DataLayer,
302
+ planKey: string,
303
+ note?: string | null,
304
+ ): Promise<void> {
305
+ const trimmed = typeof note === "string" && note.trim() ? note.trim() : null;
306
+ const existing = await conformanceTbl(data).get(planKey);
307
+ // Invariant: the ack task only fires after the escalation parked this exact `planKey` at
308
+ // `review_status='reviewing'`, so the row must exist. A missing row means a wrong/mismatched
309
+ // `planKey` (or unexpected DB state); silently returning would let the `retro` instance COMPLETE
310
+ // while the real conformance row stays stuck in `reviewing`, so `pollUserTasks` scans it forever.
311
+ // Fail loudly so the job retries/alerts instead of silently encoding the mismatch.
312
+ if (!existing) {
313
+ throw new Error(
314
+ `acknowledgeConformance: no plan_conformance row for ${planKey} — ` +
315
+ "refusing to settle a missing/mismatched escalation that would leave the real row stuck in 'reviewing'",
316
+ );
317
+ }
318
+ // At-least-once worker semantics can retry `pr.conformance-ack` after a successful DB update; the
319
+ // row is already settled at `reviewed`, so short-circuit to keep the operation idempotent (a retry
320
+ // must not re-append a duplicate `Operator ack: …` block to the audit trail).
321
+ if (existing.review_status === "reviewed") return;
322
+ const prior = typeof existing.summary === "string" ? existing.summary : null;
323
+ const summary = trimmed ? (prior ? `${prior}\n\nOperator ack: ${trimmed}` : `Operator ack: ${trimmed}`) : prior;
324
+ await conformanceTbl(data).update(planKey, {
325
+ review_status: "reviewed",
326
+ summary,
327
+ updated_at: now(),
328
+ });
329
+ }
package/app/dbFence.ts ADDED
@@ -0,0 +1,18 @@
1
+ // nano-workforce — the ONE canonical classifier for a SQLite UNIQUE-constraint fence collision.
2
+ //
3
+ // A durable fence is a DB-level UNIQUE constraint that a check-then-insert races against: two
4
+ // concurrent/duplicate writers both observe "no row" and both attempt the insert, so the loser hits
5
+ // `UNIQUE constraint failed`. Turning that collision into the SAME intended idempotent outcome
6
+ // (instead of a spurious job failure) is a recurring pattern across the app — the world store's
7
+ // checkpoint/effect ledger (`db/migrations/049_world_checkpoint.sql`) and the merges-audit abandon
8
+ // guard (`abandonClosedPr`, `db/migrations/053_merges_abandon_dedupe.sql`) both rely on it.
9
+ //
10
+ // This is the ONE place that classifies the collision so every catch site shares a single
11
+ // implementation rather than re-encoding the driver's error shape (AGENTS.md: "no drift surfaces").
12
+ // Matched on the message substring the RAD `Table` surface propagates verbatim — the same one the
13
+ // schema/migration tests assert on — because that surface hides the concrete driver error type.
14
+
15
+ /** True when `err` is a SQLite `UNIQUE constraint failed` — the durable fence firing. */
16
+ export function isUniqueConstraintFence(err: unknown): boolean {
17
+ return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
18
+ }
@@ -11,6 +11,7 @@ import { PR_ACTIVE_STATUSES, PLAN_ACTIVE_STATUSES, FEATURE_ACTIVE_STATUSES } fro
11
11
  import { TERMINAL_STATUSES } from "./delivery.ts";
12
12
  import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
13
13
  import { FEATURE_TERMINAL_STATUSES } from "./feature.ts";
14
+ import { CONFORMANCE_REVIEWING_STATUS } from "./conformance.ts";
14
15
 
15
16
  interface Binding {
16
17
  table: string;
@@ -117,3 +118,26 @@ test("FEATURE_ACTIVE_STATUSES is derived from the manifest binding (no drift)",
117
118
  const b = bindingFor(await bindings(), "feature_runs");
118
119
  assertEquals([...FEATURE_ACTIVE_STATUSES].sort(), [...(b.activeStatuses ?? [])].sort());
119
120
  });
121
+
122
+ // The retro conformance-escalation lifecycle has exactly one in-flight `review_status` — `reviewing`
123
+ // (the only status `pollUserTasks` scans via `activeConformanceReviews`) — and settles to `reviewed`.
124
+ // Tie the manifest binding to the code's single source of truth (`CONFORMANCE_REVIEWING_STATUS`) so
125
+ // the two can't drift: if a future change adds a new in-flight status but forgets the manifest, a
126
+ // terminated retro instance would strand in `review_status='reviewing'` and never clear (issue #96
127
+ // class of drift — the exact gap Copilot flagged on this binding).
128
+ test("instanceTracking: plan_conformance activeStatuses is exactly the reviewing status (no drift)", async () => {
129
+ const b = bindingFor(await bindings(), "plan_conformance");
130
+ assertEquals([...(b.activeStatuses ?? [])].sort(), [CONFORMANCE_REVIEWING_STATUS]);
131
+ });
132
+
133
+ // The settled status the reconciler flips a terminated row to (`onTerminated.set.review_status`)
134
+ // must NOT itself be listed active — otherwise `onTerminated` would leave the row scannable and the
135
+ // reconciler could clobber a settled run (mirrors the "excludes every terminal status" guards above).
136
+ test("instanceTracking: plan_conformance onTerminated status is not active", async () => {
137
+ const b = bindingFor(await bindings(), "plan_conformance");
138
+ const settled = b.onTerminated.set.review_status;
139
+ assert(
140
+ typeof settled === "string" && !b.activeStatuses?.includes(settled),
141
+ `onTerminated review_status "${String(settled)}" must not be listed active`,
142
+ );
143
+ });
@@ -0,0 +1,84 @@
1
+ // Regression guard for migration 053 (#352, PR #354 review — suppressed advisory on app/service.ts:867):
2
+ // the DB-level fence that makes `abandonClosedPr`'s terminal audit write TRULY idempotent under a
3
+ // concurrent race, not merely best-effort. The partial `UNIQUE INDEX ux_merges_abandon_pr_closed ON
4
+ // merges(pr_key) WHERE outcome='abandoned' AND method='pr-closed'` IS the fence: the merge worker and
5
+ // the wave-gate self-heal path can both observe "no row" between the guard's `find` and its `insert`
6
+ // and both attempt the write, and this index is what turns the loser's insert into a catchable
7
+ // `UNIQUE constraint failed` instead of a duplicate audit row.
8
+ import { readFileSync } from "node:fs";
9
+ import { DatabaseSync } from "node:sqlite";
10
+ import test from "node:test";
11
+ import { fileURLToPath } from "node:url";
12
+ import { assert, assertEquals, assertThrows } from "#test-assert";
13
+
14
+ // The `merges` audit table as created by 004_merge.sql, minus the `pull_requests` FK parent (this
15
+ // test proves the index behaviour in isolation, exactly as migration049.test.ts hosts the world
16
+ // tables FK-free). Then apply 053 on top.
17
+ function migratedDb(): DatabaseSync {
18
+ const db = new DatabaseSync(":memory:");
19
+ db.exec(`CREATE TABLE merges (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ pr_key TEXT NOT NULL,
22
+ outcome TEXT NOT NULL,
23
+ method TEXT,
24
+ detail TEXT,
25
+ at TEXT NOT NULL
26
+ );`);
27
+ const sql = readFileSync(fileURLToPath(new URL("../db/migrations/053_merges_abandon_dedupe.sql", import.meta.url)), "utf8");
28
+ db.exec(sql);
29
+ return db;
30
+ }
31
+
32
+ const insertAbandon = (db: DatabaseSync, prKey: string) =>
33
+ db
34
+ .prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES (?, 'abandoned', 'pr-closed', 'd', 't')")
35
+ .run(prKey);
36
+
37
+ test("migration 053 applies cleanly and enforces one abandoned/pr-closed row per pr_key", () => {
38
+ const db = migratedDb();
39
+ insertAbandon(db, "o/r#1");
40
+ // The race: a second observer inserting the SAME abandoned/pr-closed row now hits the fence.
41
+ assertThrows(() => insertAbandon(db, "o/r#1"), undefined, "UNIQUE constraint failed");
42
+ assertEquals(
43
+ Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE pr_key='o/r#1'").get() as { c: number }).c),
44
+ 1,
45
+ "the loser's duplicate was rejected — one terminal audit row survives",
46
+ );
47
+ // A DIFFERENT PR's abandon is independent — the index is per pr_key, not global.
48
+ insertAbandon(db, "o/r#2");
49
+ assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges").get() as { c: number }).c), 2);
50
+ });
51
+
52
+ test("migration 053 only fences abandoned/pr-closed rows — merged/queued/blocked still repeat freely", () => {
53
+ const db = migratedDb();
54
+ const insertMerged = () =>
55
+ db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','merged','squash','d','t')").run();
56
+ // A PR can carry several `merged` audit rows (retry / already-merged short-circuit) — the partial
57
+ // index must NOT constrain them (mergesPerDay dedupes with COUNT(DISTINCT pr_key)).
58
+ insertMerged();
59
+ insertMerged();
60
+ assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE outcome='merged'").get() as { c: number }).c), 2);
61
+ // An abandoned row with a DIFFERENT method is also outside the partial predicate.
62
+ db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','abandoned','other','d','t')").run();
63
+ db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','abandoned','other','d','t')").run();
64
+ assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE method='other'").get() as { c: number }).c), 2);
65
+ });
66
+
67
+ test("migration 053 collapses pre-existing duplicate abandoned/pr-closed rows, keeping the earliest", () => {
68
+ // Simulate a database where the pre-fence race already wrote duplicates, then apply the migration.
69
+ const db = new DatabaseSync(":memory:");
70
+ db.exec(`CREATE TABLE merges (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT, pr_key TEXT NOT NULL, outcome TEXT NOT NULL,
72
+ method TEXT, detail TEXT, at TEXT NOT NULL);`);
73
+ db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#9','abandoned','pr-closed','first','t')").run();
74
+ db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#9','abandoned','pr-closed','dup','t')").run();
75
+ db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#8','abandoned','pr-closed','solo','t')").run();
76
+ const sql = readFileSync(fileURLToPath(new URL("../db/migrations/053_merges_abandon_dedupe.sql", import.meta.url)), "utf8");
77
+ db.exec(sql); // dedupe + create index; must not throw despite the pre-existing duplicate
78
+ const rows = db.prepare("SELECT pr_key, detail FROM merges ORDER BY pr_key").all() as { pr_key: string; detail: string }[];
79
+ assertEquals(rows.length, 2, "the duplicate for o/r#9 was collapsed");
80
+ assert(
81
+ rows.some((r) => r.pr_key === "o/r#9" && r.detail === "first"),
82
+ "the EARLIEST (MIN(id)) row survived the collapse",
83
+ );
84
+ });
package/app/plan.ts CHANGED
@@ -181,6 +181,12 @@ export const PLAN_TASK_STATUSES = [
181
181
  "skipped",
182
182
  "escalated",
183
183
  "waiting-for-lane",
184
+ // Terminal: the task's PR was closed on GitHub without merging (abandoned / superseded /
185
+ // perpetually conflicting). Set by the canonical abandon writer (`abandonClosedPr`, app/service.ts)
186
+ // reached from BOTH the merge stage and the wave-merge gate. An `abandoned` task drops out of
187
+ // `waveMergeTargets` (so a dead member never wedges the wave barrier — #352) and stops
188
+ // `isPlanComplete`/the Epics table counting a phantom open task.
189
+ "abandoned",
184
190
  ] as const;
185
191
  export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
186
192
 
@@ -209,6 +209,43 @@ test("pollUserTasks: projects a blocked feature run (feature-blocked) with the d
209
209
  assertEquals(byKey["ut-blocked"].question, "agent gave up: no PR");
210
210
  });
211
211
 
212
+ test("pollUserTasks: projects a conformance-escalation ack (issue #216) keyed to the epic, question from summary", async () => {
213
+ // The advisory `retro` process parks on a native `conformance-escalation` user task when the
214
+ // spec-conformance audit found the epic did not cleanly meet its spec. retro is not a delivery
215
+ // aggregate, so its instance is tracked on `plan_conformance` (review_status = 'reviewing'); the
216
+ // poller scans those rows, reads the open ack task from the engine, and projects it under the epic
217
+ // (plan) subject with the audit `summary` as its question. A settled ('reviewed') row is skipped.
218
+ const { data, stores } = memData({
219
+ plan_conformance: [
220
+ {
221
+ plan_key: "o/r#70",
222
+ process_key: "cp-70",
223
+ review_status: "reviewing",
224
+ summary: "slice 2 reduced; auth cache never verified",
225
+ },
226
+ { plan_key: "o/r#71", process_key: "cp-71", review_status: "reviewed", summary: "all clean" },
227
+ ],
228
+ plans: [
229
+ { plan_key: "o/r#70", status: "done", issue_url: "https://github.com/o/r/issues/70", title: "Ship the cache" },
230
+ ],
231
+ });
232
+ const engine = fakeEngine({
233
+ "cp-70": [{ userTaskKey: "ut-conf", elementId: "conformance-escalation" }],
234
+ "cp-71": [{ userTaskKey: "ut-conf-settled", elementId: "conformance-escalation" }],
235
+ });
236
+
237
+ await pollUserTasks(data, engine);
238
+
239
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
240
+ assertEquals(Object.keys(byKey), ["ut-conf"]);
241
+ assertEquals(byKey["ut-conf"].element_id, "conformance-escalation");
242
+ assertEquals(byKey["ut-conf"].kind_label, "Conformance review");
243
+ assertEquals(byKey["ut-conf"].subject_type, "plan");
244
+ assertEquals(byKey["ut-conf"].subject_key, "o/r#70");
245
+ assertEquals(byKey["ut-conf"].subject_title, "Ship the cache");
246
+ assertEquals(byKey["ut-conf"].question, "slice 2 reduced; auth cache never verified");
247
+ });
248
+
212
249
  test("pollUserTasks: removes a row once its task is no longer open (completed / out-of-band)", async () => {
213
250
  const { data, stores } = memData({
214
251
  user_tasks: [
package/app/retro.test.ts CHANGED
@@ -166,6 +166,17 @@ test("gatherRetro: separates learnings from notes and folds in deltas", async ()
166
166
  assertEquals(d.repo, "acme/widgets");
167
167
  });
168
168
 
169
+ test("gatherRetro: uses pre-fetched blackboard entries instead of re-scanning", async () => {
170
+ const { data, stores } = memData();
171
+ seedPlan(stores);
172
+ // A learning lives in the store, but the caller passes an EMPTY pre-fetched snapshot — gatherRetro
173
+ // must honour what it was handed and not re-read the store.
174
+ await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "should be ignored" });
175
+ const d = await gatherRetro(data, PLAN, []);
176
+ assertEquals(d.counts.learnings, 0);
177
+ assertEquals(d.notes.length, 0);
178
+ });
179
+
169
180
  test("gatherRetro: folds in the plan-review trace and task-outcome shape", async () => {
170
181
  const { data, stores } = memData();
171
182
  seedPlan(stores);
@@ -382,11 +393,13 @@ test("maybeStartRetro: bails while the plan is incomplete", async () => {
382
393
  assertEquals(stores["plans"][0].retro_started_at, null, "must not stamp an incomplete plan");
383
394
  });
384
395
 
385
- test("maybeStartRetro: complete but empty → records a skipped retro, does not start the process", async () => {
396
+ test("maybeStartRetro: complete but nothing landed (PR abandoned) → records a skipped retro, does not start", async () => {
386
397
  const { data, stores } = memData();
387
398
  seedPlan(stores);
399
+ // The only task's PR was abandoned: the plan is complete (abandoned is terminal) but shipped no
400
+ // code, so there is neither reflection material nor an implementation to audit.
388
401
  seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
389
- seedPr(stores, "acme/widgets#10", "merged");
402
+ seedPr(stores, "acme/widgets#10", "abandoned");
390
403
  const { engine, started } = fakeEngine();
391
404
 
392
405
  const r = await maybeStartRetro(data, engine, "acme/widgets#10");
@@ -397,6 +410,23 @@ test("maybeStartRetro: complete but empty → records a skipped retro, does not
397
410
  assertEquals(stores["plan_retros"][0].status, "skipped");
398
411
  });
399
412
 
413
+ test("maybeStartRetro: complete with landed code but no learnings → still starts (conformance has something to verify)", async () => {
414
+ const { data, stores } = memData();
415
+ seedPlan(stores);
416
+ // A merged PR but zero learnings/deltas/notes: the retro digest is empty, but there IS delivered
417
+ // implementation to audit for conformance — so the process must still start.
418
+ seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
419
+ seedPr(stores, "acme/widgets#10", "merged");
420
+ const { engine, started } = fakeEngine();
421
+
422
+ const r = await maybeStartRetro(data, engine, "acme/widgets#10");
423
+ assertEquals(r.started, true);
424
+ assertEquals(r.planKey, PLAN);
425
+ assertEquals(started.length, 1);
426
+ assertEquals(started[0].processDefinitionId, "retro");
427
+ assert(stores["plans"][0].retro_started_at, "retro_started_at must be stamped");
428
+ });
429
+
400
430
  test("maybeStartRetro: a rejected review round alone is enough to fire the retro", async () => {
401
431
  const { data, stores } = memData();
402
432
  seedPlan(stores);
package/app/retro.ts CHANGED
@@ -14,7 +14,8 @@
14
14
  // Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
15
15
  // app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
16
16
  import type { DataLayer, EngineClient, Logger } from "@nanobpm/urban";
17
- import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
17
+ import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
18
+ import { hasDeliveredImplementationForPlan } from "./conformance.ts";
18
19
  import { TERMINAL_STATUSES } from "./delivery.ts";
19
20
  import { planReviews, planTasks } from "./plan.ts";
20
21
  import { aggregateEpicDeltas } from "./taskDelta.ts";
@@ -110,14 +111,22 @@ export interface RetroDigest {
110
111
  /** Gather a plan's reflection material: the `learning` blackboard entries (the headline), plus the
111
112
  * task-delta rollup (contract changes, discovered constraints, cross-slice file touches), the
112
113
  * plan-review trace (rounds + rejection findings), the task-outcome shape, and any other
113
- * non-learning blackboard notes for colour. Reads only — no writes. */
114
- export async function gatherRetro(data: DataLayer, planKey: string): Promise<RetroDigest> {
114
+ * non-learning blackboard notes for colour. Reads only — no writes.
115
+ *
116
+ * `entries` lets a caller that has already scanned the blackboard for this plan (e.g.
117
+ * `pr.retro-gather`, which also runs {@link gatherConformance}) pass those entries in so the plan is
118
+ * scanned once, not once per gatherer — see workers/retro-gather. Omitted, it reads them itself. */
119
+ export async function gatherRetro(
120
+ data: DataLayer,
121
+ planKey: string,
122
+ entries?: BlackboardEntry[],
123
+ ): Promise<RetroDigest> {
115
124
  const plan = await plansTbl(data).get(planKey);
116
- const entries = await readBlackboard(data, planKey);
117
- const learnings = entries
125
+ const bbEntries = entries ?? (await readBlackboard(data, planKey));
126
+ const learnings = bbEntries
118
127
  .filter((e) => e.kind === "learning")
119
128
  .map((e) => ({ author_task: e.author_task, body: e.body, created_at: e.created_at }));
120
- const notes = entries
129
+ const notes = bbEntries
121
130
  .filter((e) => e.kind !== "learning")
122
131
  .map((e) => ({ author_task: e.author_task, kind: e.kind, body: e.body }));
123
132
  const deltas = await aggregateEpicDeltas(data, planKey);
@@ -310,12 +319,19 @@ export async function maybeStartRetro(
310
319
  if (!(await isPlanComplete(data, planKey))) return { started: false, planKey, reason: "incomplete" };
311
320
 
312
321
  const digest = await gatherRetro(data, planKey);
313
- if (isDigestEmpty(digest)) {
322
+ // The retro digest can be empty (no learnings/deltas/notes, cleanly-approved plan) yet the epic
323
+ // still shipped real code — in which case conformance has something to verify even though the
324
+ // lessons agent has nothing to distil. So run whenever there is EITHER reflection material OR
325
+ // landed implementation to audit; only truly skip when there is neither. The landed-implementation
326
+ // probe is gathered lazily (only when the digest is empty) and via the lightweight
327
+ // hasDeliveredImplementationForPlan — which inspects only plan_tasks + PR status, with no
328
+ // blackboard scan — so we avoid discarded DB work on every terminal-PR event.
329
+ if (isDigestEmpty(digest) && !(await hasDeliveredImplementationForPlan(data, planKey))) {
314
330
  if (!(await claimRetroStart(data, planKey))) return { started: false, planKey, reason: "already-started" };
315
- // Nothing to reflect on — stamp anyway so we don't re-check on every future terminal PR of a
316
- // (now settled) plan, and record a skipped retro for visibility.
331
+ // Nothing to reflect on and nothing shipped to verify — stamp anyway so we don't re-check on
332
+ // every future terminal PR of a (now settled) plan, and record a skipped retro for visibility.
317
333
  await plansTbl(data).update(planKey, { retro_started_at: now(), updated_at: now() });
318
- await recordRetro(data, planKey, { status: "skipped", summary: "No learnings, deltas, or notes to retrospect." });
334
+ await recordRetro(data, planKey, { status: "skipped", summary: "No learnings, deltas, notes, or landed implementation to retrospect." });
319
335
  return { started: false, planKey, reason: "nothing-to-retro" };
320
336
  }
321
337