@nanobpm/nano-workforce 0.103.0 → 0.105.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.
@@ -0,0 +1,141 @@
1
+ // nano-workforce — the `durable-resume` ENROLMENT GATE (issue #325, ADR 0062 Slice 5/5).
2
+ //
3
+ // Durable agent-session resume splits into two halves already landed by the earlier slices: the MIND
4
+ // (the harness conversation — Slices 1–3, restored harness-side via `session/load` / native
5
+ // `--resume`) and the WORLD (the git working tree + irreversible effect ledger — Slice 4,
6
+ // `app/world`, restored by inverting the round's `git push` into `git fetch && git checkout <sha>`).
7
+ // This slice is the INTEGRATION that wires both halves into the running orchestration, behind an
8
+ // enrolment gate, so a re-leased `senior:pr-review` round (ADR 0002 lease-expiry redrive) RESUMES at
9
+ // the last push-checkpoint on a participating harness and gracefully DEGRADES — redriven from scratch,
10
+ // exactly as today — on a harness that does not advertise durable-resume.
11
+ //
12
+ // THE GATE (ADR 0056 §7). `durable-resume` is a WORKER ATTRIBUTE declared at ENROLMENT — never a
13
+ // routing token. The routing token `network.role#seat` is unchanged; there is no BPMN change and no
14
+ // job-type change. A worker's harness advertises durable-resume at enrol (the probe result from Slice
15
+ // 2/3); this registry records that per instance so the app can ask, before it emits the world-restore
16
+ // marker, "does the fleet serving this role include a participant?".
17
+ //
18
+ // WHY FLEET-LEVEL. At the moment the app emits the repo-provisioning envelope it does not yet know
19
+ // WHICH worker will lease the `senior:pr-review` job — any worker enrolled for that role may. So the
20
+ // gate is a fleet-level existence probe ({@link DurableResumeRegistry.anyParticipant}). This is
21
+ // well-defined for a MIXED fleet: emitting the world-restore `commitSha` when at least one participant
22
+ // is enrolled lets a participant RESUME, while a non-participant harness simply ignores the marker
23
+ // (the envelope validators are structurally forward-compatible) and clones the head branch tip —
24
+ // redriving from scratch. When NO participant is enrolled the marker is omitted entirely, so nothing
25
+ // regresses: resume is purely additive.
26
+ //
27
+ // Advisory, app-tier only (ADR 0056): this registry NEVER hard-locks or gates a BPMN sequence flow —
28
+ // it only decides whether an OPTIMISATION (world-restore) is offered inside an activation.
29
+ import type { DataLayer } from "@nanobpm/urban";
30
+
31
+ /**
32
+ * The canonical name of the durable-resume enrolment attribute. A worker advertises it at enrol; the
33
+ * registry records it here. It is an ENROLMENT gate (ADR 0056 §7), never a routing token — do not put
34
+ * it in `network.role#seat`.
35
+ */
36
+ export const DURABLE_RESUME_ATTR = "durable-resume";
37
+
38
+ /** A persisted enrolment row (`worker_durable_resume`): one worker instance's durable-resume flag. */
39
+ interface WorkerDurableResumeRow {
40
+ instance: string;
41
+ durable_resume: number;
42
+ updated_at: string;
43
+ }
44
+
45
+ /** True when `err` is the durable PRIMARY KEY fence firing — a SQLite `UNIQUE constraint failed`
46
+ * raised because a concurrent/duplicate enrol inserted the SAME instance BETWEEN our `findOne` and our
47
+ * `insert`. `recordEnrolment` is an upsert, so a collision means "the row now exists" — the same
48
+ * intended outcome as the update branch, not a surfaced error. Matched on the message substring the
49
+ * RAD `Table` surface propagates verbatim (mirrors `WorldStore`), because that surface hides the
50
+ * concrete driver error type. */
51
+ function isFenceCollision(err: unknown): boolean {
52
+ return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
53
+ }
54
+
55
+ /**
56
+ * The durable registry of per-worker durable-resume participation, over the `worker_durable_resume`
57
+ * table (`db/migrations/052_worker_durable_resume.sql`). Backed by the app's SQLite DataLayer through
58
+ * the RAD `Table<T>` surface (`data.table(...)`) — NOT hand-written SQL — mirroring `WorldStore`.
59
+ */
60
+ export class DurableResumeRegistry {
61
+ readonly #data: DataLayer;
62
+
63
+ constructor(data: DataLayer) {
64
+ this.#data = data;
65
+ }
66
+
67
+ #table() {
68
+ return this.#data.table<WorkerDurableResumeRow>("worker_durable_resume", "instance");
69
+ }
70
+
71
+ /** Canonicalise an instance key: trim surrounding whitespace and reject a blank one. The instance is
72
+ * the table PRIMARY KEY and drives the fleet-wide gate via {@link anyParticipant}, so a whitespace or
73
+ * differently-trimmed key would create an unreachable row — or, worse, open the gate on a blank key.
74
+ * Normalising here makes every registry entry point safe by default rather than relying on each call
75
+ * site to pre-trim. Returns the trimmed key, or `undefined` when it is empty/whitespace. */
76
+ static #normaliseInstance(instance: string): string | undefined {
77
+ const trimmed = instance.trim();
78
+ return trimmed.length > 0 ? trimmed : undefined;
79
+ }
80
+
81
+ /**
82
+ * Record a worker's durable-resume participation at enrolment (an idempotent UPSERT keyed by
83
+ * `instance`). A re-enrol overwrites the flag so a harness that gains — or loses — durable-resume
84
+ * support across a redeploy is reflected. The `findOne`-then-insert is racy under a concurrent
85
+ * duplicate enrol, so a PRIMARY KEY fence collision folds into the update path rather than surfacing
86
+ * as an error (the same end-state either way). A blank/whitespace `instance` is ignored (no-op) — it
87
+ * cannot key a reachable row and a blank key would let unrelated workers collide on one registry row.
88
+ */
89
+ async recordEnrolment(instance: string, durableResume: boolean): Promise<void> {
90
+ const key = DurableResumeRegistry.#normaliseInstance(instance);
91
+ if (key === undefined) return;
92
+ const table = this.#table();
93
+ const now = new Date().toISOString();
94
+ const flag = durableResume ? 1 : 0;
95
+ const existing = await table.findOne({ instance: key });
96
+ if (existing) {
97
+ await table.update(key, { durable_resume: flag, updated_at: now });
98
+ return;
99
+ }
100
+ try {
101
+ await table.insert({ instance: key, durable_resume: flag, updated_at: now });
102
+ } catch (err) {
103
+ if (!isFenceCollision(err)) throw err;
104
+ await table.update(key, { durable_resume: flag, updated_at: now });
105
+ }
106
+ }
107
+
108
+ /** Whether a specific worker instance is a durable-resume participant. `false` for an unknown
109
+ * instance (never enrolled) or a blank/whitespace key — the safe default (graceful degradation). */
110
+ async isParticipant(instance: string): Promise<boolean> {
111
+ const key = DurableResumeRegistry.#normaliseInstance(instance);
112
+ if (key === undefined) return false;
113
+ const row = await this.#table().findOne({ instance: key });
114
+ return row?.durable_resume === 1;
115
+ }
116
+
117
+ /** Whether the enrolled fleet includes AT LEAST ONE durable-resume participant — the fleet-level
118
+ * gate the world-restore emission consults. `false` when none is enrolled (nobody advertises
119
+ * durable-resume), so the resume marker is omitted and the round redrives from scratch. */
120
+ async anyParticipant(): Promise<boolean> {
121
+ const row = await this.#table().findOne({ durable_resume: 1 });
122
+ return row != null;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Whether the fleet supports durable resume — the app-tier gate for emitting the world-restore
128
+ * `commitSha` (see `app/service.ts`). Best-effort: any read failure (a legacy DB predating migration
129
+ * 052, an in-flight desync) degrades to `false`, so the round redrives from scratch rather than
130
+ * blocking a submit/merge on the enrolment registry. When no data layer is mounted it is likewise
131
+ * `false` — resume is purely additive, so its absence is always the safe direction.
132
+ */
133
+ export async function fleetSupportsDurableResume(data: DataLayer | undefined): Promise<boolean> {
134
+ if (!data) return false;
135
+ try {
136
+ return await new DurableResumeRegistry(data).anyParticipant();
137
+ } catch (err) {
138
+ console.warn(`[durable-resume] fleet participation read: ${err}`);
139
+ return false;
140
+ }
141
+ }
@@ -0,0 +1,66 @@
1
+ // Regression guard for migration 052 (issue #325, ADR 0062 Slice 5/5): the durable `durable-resume`
2
+ // enrolment registry. The table is FK-free (enrolment is per-worker and connection-agnostic, with no
3
+ // `pull_requests`/`plans` parent), the `instance` PRIMARY KEY makes `recordEnrolment` an idempotent
4
+ // upsert, and `CHECK(durable_resume IN (0,1))` pins the gate's boolean domain.
5
+ import { readFileSync } from "node:fs";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import test from "node:test";
8
+ import { fileURLToPath } from "node:url";
9
+ import { assertEquals, assertThrows } from "#test-assert";
10
+
11
+ function migratedDb(): DatabaseSync {
12
+ const db = new DatabaseSync(":memory:");
13
+ db.exec("PRAGMA foreign_keys = ON;");
14
+ // Deliberately NO parent tables — the enrolment table must apply and accept rows on a bare db.
15
+ const sql = readFileSync(fileURLToPath(new URL("../db/migrations/052_worker_durable_resume.sql", import.meta.url)), "utf8");
16
+ db.exec(sql);
17
+ return db;
18
+ }
19
+
20
+ const upsert = (db: DatabaseSync, instance: string, flag: number) =>
21
+ db
22
+ .prepare(
23
+ `INSERT INTO worker_durable_resume (instance, durable_resume, updated_at) VALUES (?, ?, 't')
24
+ ON CONFLICT(instance) DO UPDATE SET durable_resume = excluded.durable_resume`,
25
+ )
26
+ .run(instance, flag);
27
+
28
+ test("migration 052 applies cleanly with NO parent tables (FK-free) and records an enrolment", () => {
29
+ const db = migratedDb();
30
+ upsert(db, "w1", 1);
31
+ const row = db.prepare("SELECT instance, durable_resume FROM worker_durable_resume WHERE instance = ?").get("w1") as {
32
+ instance: string;
33
+ durable_resume: number;
34
+ };
35
+ assertEquals(row.instance, "w1");
36
+ assertEquals(row.durable_resume, 1);
37
+ });
38
+
39
+ test("instance PRIMARY KEY makes a re-enrol an upsert (one row per worker, latest flag wins)", () => {
40
+ const db = migratedDb();
41
+ upsert(db, "w1", 1);
42
+ upsert(db, "w1", 0);
43
+ const count = Number((db.prepare("SELECT COUNT(*) c FROM worker_durable_resume").get() as { c: number }).c);
44
+ assertEquals(count, 1, "no duplicate row for one instance");
45
+ const row = db.prepare("SELECT durable_resume FROM worker_durable_resume WHERE instance = 'w1'").get() as { durable_resume: number };
46
+ assertEquals(row.durable_resume, 0, "the latest enrolment flag wins");
47
+ });
48
+
49
+ test("durable_resume defaults to 0 (a non-participant) when unset", () => {
50
+ const db = migratedDb();
51
+ db.prepare("INSERT INTO worker_durable_resume (instance, updated_at) VALUES ('w1', 't')").run();
52
+ const row = db.prepare("SELECT durable_resume FROM worker_durable_resume WHERE instance = 'w1'").get() as { durable_resume: number };
53
+ assertEquals(row.durable_resume, 0);
54
+ });
55
+
56
+ test("CHECK(durable_resume IN (0,1)): the gate's boolean domain is pinned at the schema", () => {
57
+ const db = migratedDb();
58
+ assertThrows(
59
+ () => db.prepare("INSERT INTO worker_durable_resume (instance, durable_resume, updated_at) VALUES ('w1', 2, 't')").run(),
60
+ undefined,
61
+ "CHECK constraint failed",
62
+ );
63
+ upsert(db, "yes", 1);
64
+ upsert(db, "no", 0);
65
+ assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM worker_durable_resume").get() as { c: number }).c), 2);
66
+ });
@@ -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
 
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