@nanobpm/nano-workforce 0.104.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.
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
 
@@ -6,11 +6,11 @@
6
6
  // loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
7
7
  // GitHub transport forced off so it is hermetic.
8
8
  import { test } from "node:test";
9
- import { assertEquals } from "#test-assert";
9
+ import { assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
10
10
  import { memDataFor } from "../test/worldDb.ts";
11
11
  import { DurableResumeRegistry } from "./durableResume.ts";
12
12
  import { WorldStore } from "./world/index.ts";
13
- import { parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
13
+ import { abandonClosedPr, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
14
14
 
15
15
  function memTable(rows: any[], key: string) {
16
16
  return {
@@ -755,7 +755,195 @@ test("pollWaveGatesImpl never releases the barrier on an unverifiable subscripti
755
755
  });
756
756
  });
757
757
 
758
- // ── pollCapabilityGatesImpl the host half of the cross-repo capability edge (issue #289) ──────────
758
+ // #352: a wave WEDGES forever at `wait-wave-merged` when one member PR is closed on GitHub WITHOUT
759
+ // merging (abandoned / superseded / perpetually conflicting). The old gate released only when EVERY
760
+ // wave-target PR reached `merged`, so a closed-unmerged member kept `allMerged = false` forever and
761
+ // the epic could never advance. The fix classifies each target against live GitHub state and, for a
762
+ // closed-unmerged one, (a) treats it as NON-blocking so the wave completes on its surviving merged
763
+ // members and (b) reconciles it terminal — flipping BOTH the `pull_requests` row and its
764
+ // `plan_tasks` row to `abandoned` so it drops out of `waveMergeTargets` and the epic read model.
765
+ //
766
+ // Forces token transport WITH a token and stubs `fetch` to answer both the single-PR GET (the closed
767
+ // member reports state="closed", merged:false) and `/message-subscriptions/search` (barrier open).
768
+ function closedMemberFetch(open: Set<string>, closedNumbers: Set<number>) {
769
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
770
+ const u = typeof url === "string" ? url : url.toString();
771
+ const pullMatch = u.match(/\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)$/);
772
+ if (pullMatch) {
773
+ const n = Number(pullMatch[1]);
774
+ const body = closedNumbers.has(n)
775
+ ? { merged: false, state: "closed", mergeable_state: "dirty" }
776
+ : { merged: true, state: "closed", mergeable_state: "clean" };
777
+ return Promise.resolve(
778
+ new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }),
779
+ );
780
+ }
781
+ if (u.endsWith("/message-subscriptions/search")) {
782
+ const pik = (JSON.parse(String(init?.body ?? "{}")) as { filter?: { processInstanceKey?: string } })
783
+ .filter?.processInstanceKey ?? "";
784
+ const items = open.has(pik)
785
+ ? [{ messageName: "wave-merged", correlationKey: "owner/repo#67", messageSubscriptionState: "CREATED" }]
786
+ : [];
787
+ return Promise.resolve(
788
+ new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
789
+ );
790
+ }
791
+ throw new Error(`unexpected fetch: ${u}`);
792
+ };
793
+ }
794
+
795
+ test("pollWaveGatesImpl releases the wave when a member PR is closed-unmerged and reconciles it terminal (#352)", async () => {
796
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
797
+ const prevTok = process.env["GITHUB_TOKEN"];
798
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
799
+ process.env["GITHUB_TOKEN"] = "test-token"; // token present → fetchPrState hits the stubbed REST GET
800
+ try {
801
+ const PLAN_KEY = "owner/repo#67";
802
+ const PI = "PI-13794";
803
+ const plan = { plan_key: PLAN_KEY, process_key: PI, gate_wave: 2 as number | null, updated_at: "t0" };
804
+ const stores: Record<string, { rows: any[]; key: string }> = {
805
+ plans: { rows: [plan], key: "plan_key" },
806
+ plan_tasks: {
807
+ rows: [
808
+ // A surviving MERGED member (tracked row → no network) and a member whose PR was CLOSED
809
+ // on GitHub without merging while its task was still `opened` (never reached merge stage).
810
+ { id: "owner/repo#67:a", plan_key: PLAN_KEY, wave: 2, status: "opened", pr_key: "owner/repo#68" },
811
+ { id: "owner/repo#67:b", plan_key: PLAN_KEY, wave: 2, status: "opened", pr_key: "owner/repo#70" },
812
+ ],
813
+ key: "id",
814
+ },
815
+ pull_requests: {
816
+ rows: [
817
+ { pr_key: "owner/repo#68", status: "merged" },
818
+ { pr_key: "owner/repo#70", status: "converging" }, // not merged in the DB → falls to live GitHub read
819
+ ],
820
+ key: "pr_key",
821
+ },
822
+ merges: { rows: [], key: "id" },
823
+ };
824
+ const data = {
825
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
826
+ } as any;
827
+
828
+ const published: { name: string; correlationKey?: string }[] = [];
829
+ const engine = {
830
+ publishMessage: (input: { name: string; correlationKey?: string }) => {
831
+ published.push(input);
832
+ return Promise.resolve();
833
+ },
834
+ } as any;
835
+ const headers = { "content-type": "application/json" };
836
+ const prevFetch = globalThis.fetch;
837
+
838
+ globalThis.fetch = closedMemberFetch(new Set([PI]), new Set([70])) as typeof fetch;
839
+ try {
840
+ await pollWaveGatesImpl(data, engine, "test-token", "http://engine/v2", headers);
841
+ } finally {
842
+ globalThis.fetch = prevFetch;
843
+ }
844
+
845
+ // The barrier RELEASES — the closed-unmerged member no longer wedges the wave.
846
+ assertEquals(published.length, 1, "must publish wave-merged once the closed member is treated non-blocking");
847
+ assertEquals(published[0]?.name, "wave-merged");
848
+ assertEquals(published[0]?.correlationKey, PLAN_KEY);
849
+
850
+ // The closed member is reconciled terminal: PR row + its plan_tasks row flip to `abandoned`,
851
+ // and a terminal `merges` audit row is recorded (the canonical abandon writer).
852
+ const prRow = stores.pull_requests.rows.find((r) => r.pr_key === "owner/repo#70");
853
+ assertEquals(prRow?.status, "abandoned");
854
+ const taskRow = stores.plan_tasks.rows.find((r) => r.id === "owner/repo#67:b");
855
+ assertEquals(taskRow?.status, "abandoned");
856
+ assertEquals(stores.merges.rows.length, 1);
857
+ assertEquals((stores.merges.rows[0] as any).outcome, "abandoned");
858
+ assertEquals((stores.merges.rows[0] as any).method, "pr-closed");
859
+ // The surviving merged member is untouched.
860
+ assertEquals(stores.plan_tasks.rows.find((r) => r.id === "owner/repo#67:a")?.status, "opened");
861
+ } finally {
862
+ if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
863
+ else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
864
+ if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
865
+ else delete process.env["GITHUB_TOKEN"];
866
+ }
867
+ });
868
+
869
+ // #352 review: `abandonClosedPr` must be genuinely idempotent. It is reached from BOTH observers of a
870
+ // closed-unmerged member (merge worker + wave gate) and can be retried by the poller, so an
871
+ // unconditional `merges` insert would spam the audit with duplicate `outcome:"abandoned"/method:
872
+ // "pr-closed"` rows and skew reporting. Re-running it re-stamps the terminal status but writes the
873
+ // audit row only once.
874
+ test("abandonClosedPr is idempotent — the terminal merges audit row is written at most once (#352)", async () => {
875
+ const stores: Record<string, { rows: any[]; key: string }> = {
876
+ pull_requests: { rows: [{ pr_key: "owner/repo#70", status: "converging" }], key: "pr_key" },
877
+ plan_tasks: { rows: [{ id: "owner/repo#67:b", plan_key: "owner/repo#67", wave: 2, status: "opened", pr_key: "owner/repo#70" }], key: "id" },
878
+ merges: { rows: [], key: "id" },
879
+ };
880
+ const data = {
881
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
882
+ } as any;
883
+
884
+ await abandonClosedPr(data, "owner/repo#70", "closed without merging");
885
+ await abandonClosedPr(data, "owner/repo#70", "closed without merging"); // retry / second observer
886
+
887
+ // Terminal status re-stamped, but exactly one audit row despite two calls.
888
+ assertEquals(stores.pull_requests.rows.find((r) => r.pr_key === "owner/repo#70")?.status, "abandoned");
889
+ assertEquals(stores.plan_tasks.rows.find((r) => r.id === "owner/repo#67:b")?.status, "abandoned");
890
+ assertEquals(stores.merges.rows.length, 1, "no duplicate abandoned/pr-closed audit rows on retry");
891
+ assertEquals((stores.merges.rows[0] as any).method, "pr-closed");
892
+ });
893
+
894
+ // #352 review (suppressed advisory app/service.ts:863): `abandonClosedPr` writes the terminal
895
+ // `merges` audit row — an FK child of `pull_requests.pr_key` — and must not assume the parent row
896
+ // exists. The merge-worker caller pre-heals with `ensurePr`, but the wave-gate self-heal path
897
+ // (`pollWaveGatesImpl`) does NOT, so in an engine/app.db desync the canonical writer could observe a
898
+ // closed member whose `pull_requests` row is missing and hit a `FOREIGN KEY constraint failed`,
899
+ // wedging the poller pass. The canonical writer must self-heal the parent (idempotent `ensurePr`)
900
+ // before the FK-child insert, symmetrically for BOTH callers. Read-model witness: with a missing
901
+ // parent row the old code's `prs.update` was a silent no-op, so the PR was never reconciled terminal.
902
+ test("abandonClosedPr self-heals a missing pull_requests parent row before the FK-child audit insert (#352)", async () => {
903
+ const stores: Record<string, { rows: any[]; key: string }> = {
904
+ pull_requests: { rows: [], key: "pr_key" }, // desync: parent row is MISSING
905
+ plan_tasks: { rows: [{ id: "owner/repo#67:c", plan_key: "owner/repo#67", wave: 3, status: "opened", pr_key: "owner/repo#71" }], key: "id" },
906
+ merges: { rows: [], key: "id" },
907
+ };
908
+ const data = {
909
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
910
+ } as any;
911
+
912
+ await abandonClosedPr(data, "owner/repo#71", "closed without merging");
913
+
914
+ // The parent row is reconstructed AND flipped terminal, so the FK-child audit row has a parent.
915
+ const parent = stores.pull_requests.rows.find((r) => r.pr_key === "owner/repo#71");
916
+ assertEquals(parent?.status, "abandoned", "missing parent pull_requests row is self-healed and reconciled terminal");
917
+ assertEquals(parent?.repo, "owner/repo", "reconstructed parent carries the parsed repo");
918
+ assertEquals(parent?.number, 71, "reconstructed parent carries the parsed number");
919
+ assertEquals(stores.plan_tasks.rows.find((r) => r.id === "owner/repo#67:c")?.status, "abandoned");
920
+ assertEquals(stores.merges.rows.length, 1, "terminal audit row written once");
921
+ });
922
+
923
+ // #352 review (suppressed advisory app/service.ts:861): the self-heal only runs when `parsePr(prKey)`
924
+ // succeeds, so a MALFORMED prKey (engine/app.db desync, a process-variable regression) would skip
925
+ // the heal yet still reach `merges.insert` — which, with a missing parent, fails with an opaque
926
+ // `FOREIGN KEY constraint failed`, the exact incident this helper exists to prevent. The canonical
927
+ // writer must fail closed with a clear, actionable error naming the bad key, not leak an FK incident.
928
+ test("abandonClosedPr rejects a malformed prKey with a clear error before any FK-child insert (#352)", async () => {
929
+ const stores: Record<string, { rows: any[]; key: string }> = {
930
+ pull_requests: { rows: [], key: "pr_key" },
931
+ plan_tasks: { rows: [], key: "id" },
932
+ merges: { rows: [], key: "id" },
933
+ };
934
+ const data = {
935
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
936
+ } as any;
937
+
938
+ const err = await assertRejects(() => abandonClosedPr(data, "not-a-valid-pr-key", "closed without merging"));
939
+ assertStringIncludes((err as Error).message, "malformed prKey");
940
+ assertStringIncludes((err as Error).message, "not-a-valid-pr-key");
941
+ // No audit row and no terminal side effects leaked before the guard fired.
942
+ assertEquals(stores.merges.rows.length, 0, "no FK-child audit insert attempted on a malformed prKey");
943
+ assertEquals(stores.pull_requests.rows.length, 0, "no parent row written on a malformed prKey");
944
+ });
945
+
946
+
759
947
  //
760
948
  // plan-fanout parks a task with capability `needs` at the `wait-caps-resolved` message barrier. This
761
949
  // reconciler, on every pass, (a) starts the durable `readiness-gate` once per need, (b) does a single
package/app/service.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
10
10
  import { readFileSync } from "node:fs";
11
11
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
- import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
12
+ import { ABANDONED_STATUS, abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
13
13
  import { agentSlaTimeout } from "./agentSla.ts";
14
14
  import {
15
15
  CAPS_RESOLVED_MESSAGE,
@@ -21,6 +21,7 @@ import {
21
21
  renderResolvedDepsBrief,
22
22
  UnresolvableCapabilityRefError,
23
23
  } from "./capabilityNeed.ts";
24
+ import { isUniqueConstraintFence } from "./dbFence.ts";
24
25
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
25
26
  import { fleetSupportsDurableResume } from "./durableResume.ts";
26
27
  import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
@@ -844,6 +845,110 @@ async function isDepMerged(data: DataLayer, depKey: string, token: string): Prom
844
845
  }
845
846
  }
846
847
 
848
+ /** Canonically retire a **closed-unmerged** PR's read model. Writes the terminal `merges` audit row
849
+ * and flips BOTH the `pull_requests` row and every `plan_tasks` row keyed to the PR to `abandoned`.
850
+ *
851
+ * This is the ONE canonical abandon implementation, reached from BOTH entry points that observe a
852
+ * wave/merge member closed on GitHub without merging (#352):
853
+ * • the merge stage — `workers/merge` `attempt-merge` closed short-circuit (#342), and
854
+ * • the wave-merge gate — `pollWaveGatesImpl`'s self-heal for a PR closed out-of-band while its
855
+ * task was still `opened` (never reached the merge stage, so `pollMerges` never saw it).
856
+ *
857
+ * Flipping the **task** row (not only the PR row) is essential: `waveMergeTargets` keys on
858
+ * `plan_tasks.status`, so a still-`opened` task keeps a dead PR in the blocking set and wedges the
859
+ * wave barrier forever; an `abandoned` task drops out (the wave completes on its surviving merged
860
+ * members) and stops `isPlanComplete`/the Epics table counting a phantom open task. Idempotent for a
861
+ * poller retry (or the two observers — merge worker + wave gate — racing the same closed PR):
862
+ * re-running just re-stamps the same terminal status, and the terminal `merges` audit row is written
863
+ * only once: the fast-path guard skips the insert when an `abandoned`/`pr-closed` row already exists,
864
+ * and — because that check-then-insert is racy under the two observers (merge worker + wave gate)
865
+ * racing the same closed PR — a DB-level partial UNIQUE fence (`ux_merges_abandon_pr_closed`,
866
+ * migration 053) rejects a concurrent duplicate, which we swallow as the same idempotent outcome. So
867
+ * retries (sequential OR concurrent) can't spam the audit with duplicate rows and skew reporting.
868
+ *
869
+ * Self-heals the FK parent first: the `merges` audit row is an FK child of `pull_requests.pr_key`, so
870
+ * before writing it we ensure the parent row exists via the idempotent {@link ensurePr}. The merge
871
+ * worker already pre-heals, but the wave-gate self-heal path does not — and in an engine/app.db
872
+ * desync (the exact class `ensurePr` heals) it can observe a closed member whose `pull_requests` row
873
+ * is missing. Healing inside the ONE canonical writer covers BOTH callers symmetrically, so the
874
+ * FK-child insert can never hit `FOREIGN KEY constraint failed` and wedge the poller pass. */
875
+ export async function abandonClosedPr(data: DataLayer, prKey: string, detail: string): Promise<void> {
876
+ const ts = now();
877
+ const parsed = parsePr(prKey);
878
+ // Self-heal the FK parent, but ONLY when `prKey` is well-formed. A malformed key can't be healed
879
+ // (there's no repo/number to `ensurePr`), and silently skipping the heal would let the downstream
880
+ // `merges.insert` fail with an opaque `FOREIGN KEY constraint failed` — the exact incident this
881
+ // helper exists to prevent. Fail closed with a clear, actionable error instead.
882
+ if (!parsed) {
883
+ throw new Error(`abandonClosedPr: malformed prKey ${JSON.stringify(prKey)} — cannot self-heal the pull_requests FK parent`);
884
+ }
885
+ await ensurePr(data, { prKey, repo: parsed.repo, number: parsed.number });
886
+ const merges = data.table("merges", "id");
887
+ const alreadyAudited =
888
+ (await merges.findOne({ pr_key: prKey, outcome: "abandoned", method: "pr-closed" })) !== null;
889
+ if (!alreadyAudited) {
890
+ try {
891
+ await merges.insert({
892
+ pr_key: prKey,
893
+ outcome: "abandoned",
894
+ method: "pr-closed",
895
+ detail,
896
+ at: ts,
897
+ });
898
+ } catch (err) {
899
+ // The `find`-then-`insert` guard above is racy: the merge worker and the wave-gate self-heal
900
+ // path can both observe "no row" and both insert. The partial UNIQUE fence
901
+ // (`ux_merges_abandon_pr_closed`, migration 053) rejects the loser — tolerate that collision as
902
+ // the SAME idempotent outcome the guard intends, and only that. Any other error rethrows.
903
+ if (!isUniqueConstraintFence(err)) throw err;
904
+ }
905
+ }
906
+ await prs(data).update(prKey, { status: ABANDONED_STATUS, updated_at: ts });
907
+ const tasks = planTasks(data);
908
+ for (const t of await tasks.find({ pr_key: prKey })) {
909
+ await tasks.update(t.id, { status: ABANDONED_STATUS, updated_at: ts });
910
+ }
911
+ }
912
+
913
+ /** Classify a wave-merge target PR against GitHub ground truth for the wave gate (#352):
914
+ * • `cleared` — merged (tracked `merged` row, an out-of-band `merged` live state, or an
915
+ * already-`abandoned` tracked row / non-PR ref that can never block) → non-blocking.
916
+ * • `closed` — live GitHub state is closed WITHOUT merging → non-blocking, and the caller must
917
+ * reconcile it terminal via {@link abandonClosedPr} so it leaves the target set.
918
+ * • `pending` — still open, or read successfully but in an ambiguous (`unknown`) live state → still
919
+ * blocks the wave. A *thrown* transport error (network/5xx) is NOT swallowed here: it rethrows so
920
+ * the poller pass logs and retries — behaviourally identical for the barrier (it stays armed and
921
+ * re-checks next pass), and canonical with {@link isDepMerged}, which rethrows transient failures
922
+ * the same way. Only a `not-a-pull-request` error is caught (→ `cleared`).
923
+ *
924
+ * Mirrors {@link isDepMerged} (tracked-row fast path, then a live read, `not-a-pull-request` treated
925
+ * as cleared, transient errors rethrown) but adds the closed-unmerged branch the wave barrier lacked
926
+ * — the direct analogue of the merge stage's `classifyPrLiveness === "closed"` abandon. Conservative:
927
+ * an unreadable PR never resolves to a terminal `cleared`/`closed`, so a false negative only costs a
928
+ * retry while a false positive that would drop a live member is impossible. */
929
+ async function classifyWaveTarget(
930
+ data: DataLayer,
931
+ prKey: string,
932
+ token: string,
933
+ ): Promise<"cleared" | "closed" | "pending"> {
934
+ const tracked = await prs(data).get(prKey);
935
+ if (tracked && tracked.status === "merged") return "cleared";
936
+ if (tracked && tracked.status === ABANDONED_STATUS) return "cleared"; // already reconciled terminal → non-blocking, no re-reconcile needed
937
+ const parsed = parsePr(prKey);
938
+ if (!parsed) return "cleared"; // unparseable ref can't be checked → never wedge the barrier
939
+ let st: Awaited<ReturnType<typeof fetchPrState>>;
940
+ try {
941
+ st = await fetchPrState(parsed.repo, parsed.number, token);
942
+ } catch (err) {
943
+ if (isNotAPullRequestError(err)) return "cleared"; // an issue/missing number can never merge
944
+ throw err;
945
+ }
946
+ const liveness = classifyPrLiveness(st);
947
+ if (liveness === "merged") return "cleared";
948
+ if (liveness === "closed") return "closed";
949
+ return "pending"; // read OK but open or ambiguous (`unknown`) live state → stay conservative and keep blocking
950
+ }
951
+
847
952
  /** Flip a PR into the transient `merging` status and publish the correlating message, reverting
848
953
  * to `prevStatus` if the publish fails. `merging` is deliberately a status no poll branch scans
849
954
  * (so a slow pass can't double-signal), which means a publish failure *after* the flip would
@@ -1409,7 +1514,23 @@ export async function pollWaveGatesImpl(
1409
1514
  let allMerged = true;
1410
1515
  const tasks = await planTasks(data).find({ plan_key: planKey });
1411
1516
  for (const prKey of waveMergeTargets(tasks, gateWave)) {
1412
- if (!(await isDepMerged(data, prKey, token))) {
1517
+ const state = await classifyWaveTarget(data, prKey, token);
1518
+ if (state === "closed") {
1519
+ // Self-heal (#352): a wave-target PR closed on GitHub WITHOUT merging (abandoned /
1520
+ // superseded / perpetually conflicting) can never reach `merged`, so it must NOT keep the
1521
+ // barrier armed forever. Retire it through the canonical abandon writer — flipping the
1522
+ // `plan_tasks` row terminal so it drops out of `waveMergeTargets` and the epic read model —
1523
+ // and treat it as non-blocking: the wave completes on its surviving merged members. This is
1524
+ // the wave-gate reach of the SAME abandon path the merge stage uses for a closed member.
1525
+ await abandonClosedPr(
1526
+ data,
1527
+ prKey,
1528
+ "wave-target PR was closed on GitHub without merging — reconciling terminal so the wave gate can advance",
1529
+ );
1530
+ console.log(`[poller] wave-target closed without merging -> ${prKey}`);
1531
+ continue;
1532
+ }
1533
+ if (state === "pending") {
1413
1534
  allMerged = false;
1414
1535
  break;
1415
1536
  }
package/app/waves.test.ts CHANGED
@@ -127,3 +127,15 @@ test("waveMergeTargets → a wave with no opened PRs clears vacuously (empty)",
127
127
  ];
128
128
  assertEquals(waveMergeTargets(tasks, 0), []);
129
129
  });
130
+
131
+ // #352: a wave member whose PR was closed-unmerged is reconciled to the terminal `abandoned`
132
+ // status. Such a task must NOT stay in the blocking set — a dead PR can never reach `merged`, so
133
+ // leaving it in would wedge the wave-merge barrier forever. The surviving merged member is the only
134
+ // target the gate waits on.
135
+ test("waveMergeTargets → an abandoned (closed-unmerged) wave member drops out of the blocking set", () => {
136
+ const tasks: WaveGateTask[] = [
137
+ { wave: 0, status: "opened", pr_key: "o/r#1" },
138
+ { wave: 0, status: "abandoned", pr_key: "o/r#2" }, // PR closed without merging → reconciled terminal
139
+ ];
140
+ assertEquals(waveMergeTargets(tasks, 0), ["o/r#1"]);
141
+ });
@@ -10,6 +10,7 @@
10
10
  // duplicated (AGENTS.md "derivation over duplication": the tree is derived from `remote SHA +
11
11
  // effect-tail`, never snapshot into a log).
12
12
  import type { DataLayer, Table } from "@nanobpm/urban";
13
+ import { isUniqueConstraintFence } from "../dbFence.ts";
13
14
  import type { Effect, EffectKind, Fence } from "./effect-ledger.ts";
14
15
 
15
16
  /** A persisted push-checkpoint row (`world_checkpoints`). */
@@ -104,13 +105,12 @@ export class WorldStore {
104
105
  * concurrent/duplicate writer inserted the row BETWEEN our `findOne` and our `insert`. Every insert
105
106
  * in this store guards a UNIQUE constraint (`UNIQUE(pr_key, commit_sha)` / `(pr_key, checkpoint_offset)`
106
107
  * on checkpoints, `UNIQUE(pr_key, idempotency_key)` on effects), so a check-then-insert is inherently
107
- * racy under the at-least-once persist-round delivery + a distributed fleet. This is the ONE place the
108
- * store classifies a fence collision, so the three insert sites below turn it into the SAME intended
109
- * idempotent outcome instead of each re-encoding the driver's error shape (a drift surface). Matched on
110
- * the message substring the RAD `Table` surface propagates verbatim — the same one the schema tests
111
- * assert on — because that surface hides the concrete driver error type. */
108
+ * racy under the at-least-once persist-round delivery + a distributed fleet. Delegates to the ONE
109
+ * canonical classifier ({@link isUniqueConstraintFence}) so this store, `abandonClosedPr`, and any
110
+ * future fence site share a single implementation instead of each re-encoding the driver's error
111
+ * shape (AGENTS.md: "no drift surfaces"). */
112
112
  static #isFenceCollision(err: unknown): boolean {
113
- return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
113
+ return isUniqueConstraintFence(err);
114
114
  }
115
115
 
116
116
  /** Reconcile an existing ledger row's `applied` flag toward a LATER record's knowledge: flip a
@@ -26,7 +26,7 @@ CREATE TABLE plan_tasks (
26
26
  task_id TEXT NOT NULL, -- planner-supplied slug (or "t<index>")
27
27
  title TEXT,
28
28
  prompt TEXT,
29
- status TEXT NOT NULL, -- pending | opened | blocked | skipped
29
+ status TEXT NOT NULL, -- pending | opened | blocked | skipped | escalated | waiting-for-lane | abandoned
30
30
  pr_key TEXT, -- the PR this slice produced ("<owner>/<repo>#<n>")
31
31
  summary TEXT,
32
32
  created_at TEXT NOT NULL,
@@ -0,0 +1,28 @@
1
+ -- Spec-conformance review — the post-completion "did we build what the spec asked for?" stage.
2
+ --
3
+ -- It rides the same `retro` process that already fires exactly once when an epic's LAST PR reaches
4
+ -- a terminal state (see 016_plan_retro.sql / app/retro.ts). Before the retro agent distils lessons,
5
+ -- a `senior:conformance` agent EXAMINES THE ACTUAL IMPLEMENTATION — it reads the delivered PR diffs,
6
+ -- the code, and the tests (not just what agents claimed) — and checks each item of the spec (the
7
+ -- epic issue + every slice's `prompt`) against what was really shipped. It posts a conformance
8
+ -- report as a comment on the epic issue and emits per-item acceptance verdicts plus the two classes
9
+ -- of deviation: those RAISED during implementation (`scope-change` blackboard entries) and those it
10
+ -- found itself that were NEVER raised. This table records that result.
11
+ --
12
+ -- Advisory, like the retro it accompanies: it gates no delivery control flow (the epic already
13
+ -- merged). One row per epic, keyed on plan_key.
14
+ CREATE TABLE plan_conformance (
15
+ plan_key TEXT PRIMARY KEY REFERENCES plans(plan_key),
16
+ status TEXT NOT NULL, -- filed | skipped | blocked (the agent's result status)
17
+ comment_url TEXT, -- the conformance report comment the agent posted on the epic issue, or NULL
18
+ slices_met INTEGER NOT NULL DEFAULT 0, -- items fully delivered as specified
19
+ slices_reduced INTEGER NOT NULL DEFAULT 0, -- items delivered in a reduced/partial form, incl. met-in-unit-only (unit-tested but not proven live-wired)
20
+ slices_not_verified INTEGER NOT NULL DEFAULT 0, -- items the agent could not confirm from the implementation (e.g. no live wiring / synthetic only)
21
+ deviations_raised INTEGER NOT NULL DEFAULT 0, -- scope deviations agents flagged during implementation (`scope-change` entries)
22
+ deviations_unraised INTEGER NOT NULL DEFAULT 0, -- scope deviations the agent found by examining the code that were NEVER flagged
23
+ has_deviations INTEGER NOT NULL DEFAULT 0, -- 1 when anything is reduced / not-verified / an unraised deviation exists (drives escalation in a later slice)
24
+ summary TEXT, -- the agent's human-readable conformance summary
25
+ report TEXT, -- the full conformance report / transcript (nullable)
26
+ created_at TEXT NOT NULL,
27
+ updated_at TEXT NOT NULL
28
+ );
@@ -0,0 +1,29 @@
1
+ -- Enforce the abandon-audit invariant at the DB level (#352, PR #354 review — suppressed advisory on
2
+ -- app/service.ts:867). `abandonClosedPr`'s "write the terminal `merges` audit row only once" guard was
3
+ -- a NON-ATOMIC find-then-insert: the two observers that reconcile a closed-unmerged member — the merge
4
+ -- worker's `attempt-merge` closed short-circuit and the wave-gate self-heal in `pollWaveGatesImpl` —
5
+ -- can run concurrently, so both observe "no row" between the `find` and the `insert` and both write an
6
+ -- `abandoned`/`pr-closed` row for the same `pr_key`. Sequential-idempotency tests pass, but the guard
7
+ -- is only best-effort under a real concurrent race.
8
+ --
9
+ -- Make the invariant a DB constraint (the canonical durable-fence idiom — cf. the `UNIQUE` fences on
10
+ -- `world_effects`/`world_checkpoints` in 049): AT MOST ONE (outcome='abandoned', method='pr-closed')
11
+ -- `merges` row per `pr_key`. The writer keeps its fast-path `find` guard (so the common retry never
12
+ -- throws) but now tolerates the UNIQUE fence as the SAME idempotent outcome (see `app/dbFence.ts`).
13
+ --
14
+ -- Expand phase (additive, forward-only): first collapse any duplicates a pre-fence race already wrote
15
+ -- — keep the earliest row (`MIN(id)`) per `pr_key` — so the partial UNIQUE index can be created on an
16
+ -- already-populated database, then add the index. Only `abandoned`/`pr-closed` rows are touched;
17
+ -- `merged`/`queued`/`blocked` audit rows are untouched and may still repeat per `pr_key`.
18
+ DELETE FROM merges
19
+ WHERE outcome = 'abandoned'
20
+ AND method = 'pr-closed'
21
+ AND id NOT IN (
22
+ SELECT MIN(id) FROM merges
23
+ WHERE outcome = 'abandoned' AND method = 'pr-closed'
24
+ GROUP BY pr_key
25
+ );
26
+
27
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_merges_abandon_pr_closed
28
+ ON merges (pr_key)
29
+ WHERE outcome = 'abandoned' AND method = 'pr-closed';
package/nano.app.json CHANGED
@@ -162,6 +162,10 @@
162
162
  "taskType": "pr.retro-record",
163
163
  "handler": "workers/retro-record/worker.ts"
164
164
  },
165
+ {
166
+ "taskType": "pr.conformance-record",
167
+ "handler": "workers/conformance-record/worker.ts"
168
+ },
165
169
  {
166
170
  "taskType": "pr.progress-check",
167
171
  "handler": "workers/progress-check/worker.ts"
@@ -183,7 +187,8 @@
183
187
  "senior:plan-review",
184
188
  "senior:feature",
185
189
  "senior:trial-merge",
186
- "senior:retro"
190
+ "senior:retro",
191
+ "senior:conformance"
187
192
  ],
188
193
  "surfaces": {
189
194
  "taskInbox": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.104.0",
3
+ "version": "0.105.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",