@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
@@ -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,12 @@ import {
21
21
  renderResolvedDepsBrief,
22
22
  UnresolvableCapabilityRefError,
23
23
  } from "./capabilityNeed.ts";
24
+ import {
25
+ activeConformanceReviews,
26
+ CONFORMANCE_ESCALATION_ELEMENT,
27
+ conformanceEscalationQuestion,
28
+ } from "./conformance.ts";
29
+ import { isUniqueConstraintFence } from "./dbFence.ts";
24
30
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
25
31
  import { fleetSupportsDurableResume } from "./durableResume.ts";
26
32
  import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
@@ -844,6 +850,110 @@ async function isDepMerged(data: DataLayer, depKey: string, token: string): Prom
844
850
  }
845
851
  }
846
852
 
853
+ /** Canonically retire a **closed-unmerged** PR's read model. Writes the terminal `merges` audit row
854
+ * and flips BOTH the `pull_requests` row and every `plan_tasks` row keyed to the PR to `abandoned`.
855
+ *
856
+ * This is the ONE canonical abandon implementation, reached from BOTH entry points that observe a
857
+ * wave/merge member closed on GitHub without merging (#352):
858
+ * • the merge stage — `workers/merge` `attempt-merge` closed short-circuit (#342), and
859
+ * • the wave-merge gate — `pollWaveGatesImpl`'s self-heal for a PR closed out-of-band while its
860
+ * task was still `opened` (never reached the merge stage, so `pollMerges` never saw it).
861
+ *
862
+ * Flipping the **task** row (not only the PR row) is essential: `waveMergeTargets` keys on
863
+ * `plan_tasks.status`, so a still-`opened` task keeps a dead PR in the blocking set and wedges the
864
+ * wave barrier forever; an `abandoned` task drops out (the wave completes on its surviving merged
865
+ * members) and stops `isPlanComplete`/the Epics table counting a phantom open task. Idempotent for a
866
+ * poller retry (or the two observers — merge worker + wave gate — racing the same closed PR):
867
+ * re-running just re-stamps the same terminal status, and the terminal `merges` audit row is written
868
+ * only once: the fast-path guard skips the insert when an `abandoned`/`pr-closed` row already exists,
869
+ * and — because that check-then-insert is racy under the two observers (merge worker + wave gate)
870
+ * racing the same closed PR — a DB-level partial UNIQUE fence (`ux_merges_abandon_pr_closed`,
871
+ * migration 053) rejects a concurrent duplicate, which we swallow as the same idempotent outcome. So
872
+ * retries (sequential OR concurrent) can't spam the audit with duplicate rows and skew reporting.
873
+ *
874
+ * Self-heals the FK parent first: the `merges` audit row is an FK child of `pull_requests.pr_key`, so
875
+ * before writing it we ensure the parent row exists via the idempotent {@link ensurePr}. The merge
876
+ * worker already pre-heals, but the wave-gate self-heal path does not — and in an engine/app.db
877
+ * desync (the exact class `ensurePr` heals) it can observe a closed member whose `pull_requests` row
878
+ * is missing. Healing inside the ONE canonical writer covers BOTH callers symmetrically, so the
879
+ * FK-child insert can never hit `FOREIGN KEY constraint failed` and wedge the poller pass. */
880
+ export async function abandonClosedPr(data: DataLayer, prKey: string, detail: string): Promise<void> {
881
+ const ts = now();
882
+ const parsed = parsePr(prKey);
883
+ // Self-heal the FK parent, but ONLY when `prKey` is well-formed. A malformed key can't be healed
884
+ // (there's no repo/number to `ensurePr`), and silently skipping the heal would let the downstream
885
+ // `merges.insert` fail with an opaque `FOREIGN KEY constraint failed` — the exact incident this
886
+ // helper exists to prevent. Fail closed with a clear, actionable error instead.
887
+ if (!parsed) {
888
+ throw new Error(`abandonClosedPr: malformed prKey ${JSON.stringify(prKey)} — cannot self-heal the pull_requests FK parent`);
889
+ }
890
+ await ensurePr(data, { prKey, repo: parsed.repo, number: parsed.number });
891
+ const merges = data.table("merges", "id");
892
+ const alreadyAudited =
893
+ (await merges.findOne({ pr_key: prKey, outcome: "abandoned", method: "pr-closed" })) !== null;
894
+ if (!alreadyAudited) {
895
+ try {
896
+ await merges.insert({
897
+ pr_key: prKey,
898
+ outcome: "abandoned",
899
+ method: "pr-closed",
900
+ detail,
901
+ at: ts,
902
+ });
903
+ } catch (err) {
904
+ // The `find`-then-`insert` guard above is racy: the merge worker and the wave-gate self-heal
905
+ // path can both observe "no row" and both insert. The partial UNIQUE fence
906
+ // (`ux_merges_abandon_pr_closed`, migration 053) rejects the loser — tolerate that collision as
907
+ // the SAME idempotent outcome the guard intends, and only that. Any other error rethrows.
908
+ if (!isUniqueConstraintFence(err)) throw err;
909
+ }
910
+ }
911
+ await prs(data).update(prKey, { status: ABANDONED_STATUS, updated_at: ts });
912
+ const tasks = planTasks(data);
913
+ for (const t of await tasks.find({ pr_key: prKey })) {
914
+ await tasks.update(t.id, { status: ABANDONED_STATUS, updated_at: ts });
915
+ }
916
+ }
917
+
918
+ /** Classify a wave-merge target PR against GitHub ground truth for the wave gate (#352):
919
+ * • `cleared` — merged (tracked `merged` row, an out-of-band `merged` live state, or an
920
+ * already-`abandoned` tracked row / non-PR ref that can never block) → non-blocking.
921
+ * • `closed` — live GitHub state is closed WITHOUT merging → non-blocking, and the caller must
922
+ * reconcile it terminal via {@link abandonClosedPr} so it leaves the target set.
923
+ * • `pending` — still open, or read successfully but in an ambiguous (`unknown`) live state → still
924
+ * blocks the wave. A *thrown* transport error (network/5xx) is NOT swallowed here: it rethrows so
925
+ * the poller pass logs and retries — behaviourally identical for the barrier (it stays armed and
926
+ * re-checks next pass), and canonical with {@link isDepMerged}, which rethrows transient failures
927
+ * the same way. Only a `not-a-pull-request` error is caught (→ `cleared`).
928
+ *
929
+ * Mirrors {@link isDepMerged} (tracked-row fast path, then a live read, `not-a-pull-request` treated
930
+ * as cleared, transient errors rethrown) but adds the closed-unmerged branch the wave barrier lacked
931
+ * — the direct analogue of the merge stage's `classifyPrLiveness === "closed"` abandon. Conservative:
932
+ * an unreadable PR never resolves to a terminal `cleared`/`closed`, so a false negative only costs a
933
+ * retry while a false positive that would drop a live member is impossible. */
934
+ async function classifyWaveTarget(
935
+ data: DataLayer,
936
+ prKey: string,
937
+ token: string,
938
+ ): Promise<"cleared" | "closed" | "pending"> {
939
+ const tracked = await prs(data).get(prKey);
940
+ if (tracked && tracked.status === "merged") return "cleared";
941
+ if (tracked && tracked.status === ABANDONED_STATUS) return "cleared"; // already reconciled terminal → non-blocking, no re-reconcile needed
942
+ const parsed = parsePr(prKey);
943
+ if (!parsed) return "cleared"; // unparseable ref can't be checked → never wedge the barrier
944
+ let st: Awaited<ReturnType<typeof fetchPrState>>;
945
+ try {
946
+ st = await fetchPrState(parsed.repo, parsed.number, token);
947
+ } catch (err) {
948
+ if (isNotAPullRequestError(err)) return "cleared"; // an issue/missing number can never merge
949
+ throw err;
950
+ }
951
+ const liveness = classifyPrLiveness(st);
952
+ if (liveness === "merged") return "cleared";
953
+ if (liveness === "closed") return "closed";
954
+ return "pending"; // read OK but open or ambiguous (`unknown`) live state → stay conservative and keep blocking
955
+ }
956
+
847
957
  /** Flip a PR into the transient `merging` status and publish the correlating message, reverting
848
958
  * to `prevStatus` if the publish fails. `merging` is deliberately a status no poll branch scans
849
959
  * (so a slow pass can't double-signal), which means a publish failure *after* the flip would
@@ -1409,7 +1519,23 @@ export async function pollWaveGatesImpl(
1409
1519
  let allMerged = true;
1410
1520
  const tasks = await planTasks(data).find({ plan_key: planKey });
1411
1521
  for (const prKey of waveMergeTargets(tasks, gateWave)) {
1412
- if (!(await isDepMerged(data, prKey, token))) {
1522
+ const state = await classifyWaveTarget(data, prKey, token);
1523
+ if (state === "closed") {
1524
+ // Self-heal (#352): a wave-target PR closed on GitHub WITHOUT merging (abandoned /
1525
+ // superseded / perpetually conflicting) can never reach `merged`, so it must NOT keep the
1526
+ // barrier armed forever. Retire it through the canonical abandon writer — flipping the
1527
+ // `plan_tasks` row terminal so it drops out of `waveMergeTargets` and the epic read model —
1528
+ // and treat it as non-blocking: the wave completes on its surviving merged members. This is
1529
+ // the wave-gate reach of the SAME abandon path the merge stage uses for a closed member.
1530
+ await abandonClosedPr(
1531
+ data,
1532
+ prKey,
1533
+ "wave-target PR was closed on GitHub without merging — reconciling terminal so the wave gate can advance",
1534
+ );
1535
+ console.log(`[poller] wave-target closed without merging -> ${prKey}`);
1536
+ continue;
1537
+ }
1538
+ if (state === "pending") {
1413
1539
  allMerged = false;
1414
1540
  break;
1415
1541
  }
@@ -2078,6 +2204,41 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
2078
2204
  }
2079
2205
  }
2080
2206
 
2207
+ // Conformance-review acks (`conformance-escalation`) — the advisory `retro` process parks on a
2208
+ // human ack when the spec-conformance audit finds the epic did NOT cleanly meet its spec (issue
2209
+ // #216). retro is not one of the delivery aggregates above, so its instance is tracked on
2210
+ // `plan_conformance` (migration 054): scan each row still `reviewing`, read its open ack task, and
2211
+ // project it keyed to the epic (plan) subject, sourcing the question from the audit's `summary`.
2212
+ for (const review of await activeConformanceReviews(data)) {
2213
+ if (!review.process_key) continue;
2214
+ let tasks: { userTaskKey: string; elementId?: string }[];
2215
+ try {
2216
+ tasks = await engine.openUserTasks({ processInstanceKey: review.process_key });
2217
+ } catch (err) {
2218
+ console.error(`[poller] user tasks (conformance ${review.plan_key}): ${err}`);
2219
+ continue;
2220
+ }
2221
+ const plan = await plans(data).get(review.plan_key);
2222
+ for (const t of tasks) {
2223
+ if (t.elementId !== CONFORMANCE_ESCALATION_ELEMENT) continue;
2224
+ push(
2225
+ buildUserTaskRow(
2226
+ {
2227
+ userTaskKey: t.userTaskKey,
2228
+ elementId: CONFORMANCE_ESCALATION_ELEMENT,
2229
+ subjectType: "plan",
2230
+ subjectKey: review.plan_key,
2231
+ subjectTitle: plan?.title ?? null,
2232
+ subjectUrl: plan?.issue_url ?? null,
2233
+ question: conformanceEscalationQuestion(review),
2234
+ processKey: review.process_key,
2235
+ },
2236
+ at,
2237
+ ),
2238
+ );
2239
+ }
2240
+ }
2241
+
2081
2242
  const persisted = await userTasks(data).all();
2082
2243
  const { inserts, updates, deletes } = reconcileUserTasks(persisted, desired);
2083
2244
  for (const row of inserts) await userTasks(data).insert(row);
@@ -5,6 +5,7 @@
5
5
  // These are the pure source of truth the poller projects.
6
6
  import { test } from "node:test";
7
7
  import { assert, assertEquals } from "#test-assert";
8
+ import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
8
9
  import type { PlanReview } from "./plan.ts";
9
10
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
10
11
  import {
@@ -63,6 +64,25 @@ test("buildUserTaskRow: a blank question / missing url normalises to null", () =
63
64
  assertEquals(row?.kind_label, "Trial merge");
64
65
  });
65
66
 
67
+ test("buildUserTaskRow: a conformance-escalation projects the 'Conformance review' label (issue #216)", () => {
68
+ const row = buildUserTaskRow(
69
+ {
70
+ userTaskKey: "ut-c",
71
+ elementId: CONFORMANCE_ESCALATION_ELEMENT,
72
+ subjectType: "plan",
73
+ subjectKey: "o/r#7",
74
+ subjectTitle: "Ship the cache",
75
+ question: "slice 2 reduced",
76
+ },
77
+ AT,
78
+ );
79
+ assert(row !== null);
80
+ assertEquals(row?.element_id, "conformance-escalation");
81
+ assertEquals(row?.kind_label, "Conformance review");
82
+ assertEquals(row?.subject_type, "plan");
83
+ assertEquals(row?.question, "slice 2 reduced");
84
+ });
85
+
66
86
  test("buildUserTaskRow: an unknown (non-escalation) element yields null — no arbitrary user task leaks", () => {
67
87
  const row = buildUserTaskRow(
68
88
  { userTaskKey: "ut-3", elementId: "some-internal-task", subjectType: "plan", subjectKey: "o/r#3" },
package/app/userTasks.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  // tasks visible; a completed task's row is removed on the next pass when the engine no longer reports
19
19
  // it open.
20
20
  import type { DataLayer } from "@nanobpm/urban";
21
+ import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
21
22
  import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureEscalationRow } from "./feature.ts";
22
23
  import type { PlanReview } from "./plan.ts";
23
24
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
@@ -74,6 +75,7 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
74
75
  [TRIAL_MERGE_ELEMENT]: "Trial merge",
75
76
  [PR_WAIT_ANSWER_ELEMENT]: "PR review",
76
77
  [PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
78
+ [CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
77
79
  };
78
80
 
79
81
  /** The denormalised context the poller has resolved for an open escalation user task. */
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';
@@ -0,0 +1,27 @@
1
+ -- Conformance review — escalation instance tracking (issue #216).
2
+ --
3
+ -- When conformance finds the epic did NOT cleanly meet its spec (a reduced / not-verified item, or a
4
+ -- deviation nobody raised), it escalates to the Tasks inbox as a NON-BLOCKING follow-up: the `retro`
5
+ -- process parks on a native `conformance-escalation` user task until an operator acknowledges it.
6
+ --
7
+ -- The unified inbox (`user_tasks`, 034) is reconciled by `pollUserTasks`, which scans the open user
8
+ -- tasks of each *instance-tracked* aggregate (feature_runs / plans / pull_requests). The retro process
9
+ -- had no such tracking, so its user task was invisible to the inbox. These two columns make
10
+ -- `plan_conformance` the retro run's tracking row: `process_key` is the retro process instance, and
11
+ -- `review_status` is its escalation lifecycle — `reviewing` while the ack task is open (the poller
12
+ -- scans these), `reviewed` once the run settles (set by `record-conformance` when there is nothing to
13
+ -- escalate, by the `pr.conformance-ack` worker (`acknowledgeConformance`) when an operator
14
+ -- acknowledges the escalation and the retro instance COMPLETES normally, and — as a crash/cancel
15
+ -- safety net — by `instanceTracking.onTerminated` when the retro instance is TERMINATED rather than
16
+ -- completing).
17
+ --
18
+ -- Forward-only, additive (expand): two nullable/defaulted columns on the table 052 just added; the
19
+ -- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
20
+ ALTER TABLE plan_conformance ADD COLUMN process_key TEXT;
21
+ ALTER TABLE plan_conformance ADD COLUMN review_status TEXT NOT NULL DEFAULT 'reviewed';
22
+
23
+ -- `pollUserTasks` scans open retro escalations by `review_status = 'reviewing'` every poll pass
24
+ -- (app/conformance.ts `activeConformanceReviews`). Index it so the common-case status scan stays a
25
+ -- cheap lookup instead of a full table scan as conformance rows accumulate — mirrors the
26
+ -- `idx_feature_runs_status` precedent (028) for the equivalent status-scanned aggregate.
27
+ CREATE INDEX IF NOT EXISTS idx_plan_conformance_review_status ON plan_conformance(review_status);
package/nano.app.json CHANGED
@@ -67,6 +67,20 @@
67
67
  }
68
68
  },
69
69
  "pollMs": 5000
70
+ },
71
+ {
72
+ "table": "plan_conformance",
73
+ "keyField": "process_key",
74
+ "statusField": "review_status",
75
+ "activeStatuses": [
76
+ "reviewing"
77
+ ],
78
+ "onTerminated": {
79
+ "set": {
80
+ "review_status": "reviewed"
81
+ }
82
+ },
83
+ "pollMs": 5000
70
84
  }
71
85
  ],
72
86
  "workers": [
@@ -162,6 +176,14 @@
162
176
  "taskType": "pr.retro-record",
163
177
  "handler": "workers/retro-record/worker.ts"
164
178
  },
179
+ {
180
+ "taskType": "pr.conformance-record",
181
+ "handler": "workers/conformance-record/worker.ts"
182
+ },
183
+ {
184
+ "taskType": "pr.conformance-ack",
185
+ "handler": "workers/conformance-ack/worker.ts"
186
+ },
165
187
  {
166
188
  "taskType": "pr.progress-check",
167
189
  "handler": "workers/progress-check/worker.ts"
@@ -183,7 +205,8 @@
183
205
  "senior:plan-review",
184
206
  "senior:feature",
185
207
  "senior:trial-merge",
186
- "senior:retro"
208
+ "senior:retro",
209
+ "senior:conformance"
187
210
  ],
188
211
  "surfaces": {
189
212
  "taskInbox": {