@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.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -0
- package/app/abandon.ts +12 -3
- package/app/conformance.test.ts +220 -0
- package/app/conformance.ts +240 -0
- package/app/contracts.ts +8 -0
- package/app/dbFence.ts +18 -0
- package/app/durableResume.test.ts +89 -0
- package/app/durableResume.ts +141 -0
- package/app/migration052.test.ts +66 -0
- package/app/migration053.test.ts +84 -0
- package/app/plan.ts +6 -0
- package/app/retro.test.ts +32 -2
- package/app/retro.ts +26 -10
- package/app/service.test.ts +223 -3
- package/app/service.ts +146 -7
- package/app/waves.test.ts +12 -0
- package/app/world/store.ts +6 -6
- package/db/migrations/004_planning.sql +1 -1
- package/db/migrations/052_plan_conformance.sql +28 -0
- package/db/migrations/052_worker_durable_resume.sql +35 -0
- package/db/migrations/053_merges_abandon_dedupe.sql +29 -0
- package/nano.app.json +6 -1
- package/openapi.yaml +17 -0
- package/operations/enrolAgenticWorker.test.ts +64 -0
- package/operations/enrolAgenticWorker.ts +30 -1
- package/package.json +1 -1
- package/resources/processes/retro.bpmn +59 -8
- package/resources/prompts/conformance.md +105 -0
- package/resources/prompts/retro.md +5 -0
- package/test/worldDb.ts +16 -4
- package/workers/conformance-record/worker.test.ts +199 -0
- package/workers/conformance-record/worker.ts +95 -0
- package/workers/merge/worker.test.ts +4 -0
- package/workers/merge/worker.ts +13 -18
- package/workers/retro-gather/worker.test.ts +6 -0
- package/workers/retro-gather/worker.ts +17 -5
package/app/service.test.ts
CHANGED
|
@@ -6,8 +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";
|
|
10
|
-
import {
|
|
9
|
+
import { assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
|
|
10
|
+
import { memDataFor } from "../test/worldDb.ts";
|
|
11
|
+
import { DurableResumeRegistry } from "./durableResume.ts";
|
|
12
|
+
import { WorldStore } from "./world/index.ts";
|
|
13
|
+
import { abandonClosedPr, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
11
14
|
|
|
12
15
|
function memTable(rows: any[], key: string) {
|
|
13
16
|
return {
|
|
@@ -519,6 +522,35 @@ test("repoEnvelopeVars emits commitSha only for a well-formed 40-hex SHA (world-
|
|
|
519
522
|
assertEquals("commitSha" in none, false);
|
|
520
523
|
});
|
|
521
524
|
|
|
525
|
+
// Durable-resume enrolment gate (issue #325, ADR 0062 Slice 5/5): `worldRestoreSha` — the seam
|
|
526
|
+
// `submitPr`/`startMerge` thread into `repoEnvelopeVars` — hands the harness the last push-checkpoint
|
|
527
|
+
// ONLY when the enrolled fleet advertises `durable-resume`. With no participant it degrades to null,
|
|
528
|
+
// so the round redrives from scratch (exactly as today). Proven against a REAL in-memory SQLite db
|
|
529
|
+
// with the world (049) + enrolment (052) schemas applied.
|
|
530
|
+
test("worldRestoreSha is gated on the durable-resume enrolment: participant → SHA, none → null", async () => {
|
|
531
|
+
const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql"]);
|
|
532
|
+
const PR = "owner/repo#7";
|
|
533
|
+
const sha = "77ee0993cc6ad4493da0f7551212ef16722135db";
|
|
534
|
+
await new WorldStore(data).recordCheckpoint({ prKey: PR, roundNo: 1, commitSha: sha });
|
|
535
|
+
|
|
536
|
+
// No participant enrolled yet — graceful degradation: no resume marker even though a checkpoint exists.
|
|
537
|
+
assertEquals(await worldRestoreSha(data, PR), null, "no participant → redrive from scratch");
|
|
538
|
+
|
|
539
|
+
// A non-participant enrolment still does not open the gate (a fleet of only non-participants).
|
|
540
|
+
await new DurableResumeRegistry(data).recordEnrolment("legacy-1", false);
|
|
541
|
+
assertEquals(await worldRestoreSha(data, PR), null, "only non-participants → still scratch");
|
|
542
|
+
|
|
543
|
+
// One participant makes the mixed fleet resume-capable: the checkpoint SHA is now emitted.
|
|
544
|
+
await new DurableResumeRegistry(data).recordEnrolment("modern-1", true);
|
|
545
|
+
assertEquals(await worldRestoreSha(data, PR), sha, "a participant → resume at the checkpoint SHA");
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("worldRestoreSha is null when a participant is enrolled but the PR has no checkpoint yet", async () => {
|
|
549
|
+
const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql"]);
|
|
550
|
+
await new DurableResumeRegistry(data).recordEnrolment("modern-1", true);
|
|
551
|
+
assertEquals(await worldRestoreSha(data, "owner/repo#8"), null, "nothing to reconstruct on a first activation");
|
|
552
|
+
});
|
|
553
|
+
|
|
522
554
|
// `parsePr` is total on any input: it is called unguarded from several workers (progress-check,
|
|
523
555
|
// persist-round, persist-escalation, record-dependency) with a process variable that a regression
|
|
524
556
|
// — or an older in-flight instance — could carry as a non-string. `.trim()` on a non-string throws,
|
|
@@ -723,7 +755,195 @@ test("pollWaveGatesImpl never releases the barrier on an unverifiable subscripti
|
|
|
723
755
|
});
|
|
724
756
|
});
|
|
725
757
|
|
|
726
|
-
//
|
|
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
|
+
|
|
727
947
|
//
|
|
728
948
|
// plan-fanout parks a task with capability `needs` at the `wait-caps-resolved` message barrier. This
|
|
729
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,7 +21,9 @@ 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";
|
|
26
|
+
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
25
27
|
import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
|
|
26
28
|
import {
|
|
27
29
|
classifyMergeability,
|
|
@@ -441,6 +443,19 @@ async function lastPushedSha(data: DataLayer, prKey: string): Promise<string | n
|
|
|
441
443
|
}
|
|
442
444
|
}
|
|
443
445
|
|
|
446
|
+
/** The world-restore SHA to emit into the repo-provisioning envelope for a PR, GATED on the
|
|
447
|
+
* `durable-resume` enrolment (issue #325, ADR 0062 Slice 5/5). Only when the enrolled fleet includes a
|
|
448
|
+
* durable-resume participant (`fleetSupportsDurableResume`) do we hand the harness the last
|
|
449
|
+
* push-checkpoint so a replacement activation RESUMES by reconstructing the exact pushed tree
|
|
450
|
+
* (inverting `git push` → `git fetch && git checkout <sha>`). With no participant the marker is
|
|
451
|
+
* omitted (`null`), so the round redrives from scratch — graceful degradation, exactly as today.
|
|
452
|
+
* Resume is purely additive: gating on the enrolment attribute, not a sequence flow, keeps the
|
|
453
|
+
* engine/C8 job protocol untouched (ADR 0056 boundary). */
|
|
454
|
+
export async function worldRestoreSha(data: DataLayer, prKey: string): Promise<string | null> {
|
|
455
|
+
if (!(await fleetSupportsDurableResume(data))) return null;
|
|
456
|
+
return lastPushedSha(data, prKey);
|
|
457
|
+
}
|
|
458
|
+
|
|
444
459
|
/** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
|
|
445
460
|
* `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
|
|
446
461
|
* recorded as the PR's merge-stage dependency set. */
|
|
@@ -547,9 +562,11 @@ export async function submitPr(
|
|
|
547
562
|
const abUrl = abandonUrl(abandonToken);
|
|
548
563
|
// World-restore (issue #324, ADR 0062 Slice 4/5): a re-run of convergence for a PR that already
|
|
549
564
|
// pushed is a resume — carry its last durable push-checkpoint so a replacement activation on a
|
|
550
|
-
// fresh worktree reconstructs the tree to the EXACT pushed SHA.
|
|
551
|
-
//
|
|
552
|
-
|
|
565
|
+
// fresh worktree reconstructs the tree to the EXACT pushed SHA. GATED (issue #325, Slice 5/5) on the
|
|
566
|
+
// fleet advertising `durable-resume`: with no participant it stays null, so the round redrives from
|
|
567
|
+
// scratch (graceful degradation). Absent (null) on a first submit, which leaves the envelope
|
|
568
|
+
// unchanged.
|
|
569
|
+
const worldSha = await worldRestoreSha(data, parsed.prKey);
|
|
553
570
|
const { processInstanceKey } = await engine.createInstance({
|
|
554
571
|
processDefinitionId: PROCESS_ID,
|
|
555
572
|
variables: {
|
|
@@ -623,8 +640,10 @@ export async function startMerge(
|
|
|
623
640
|
console.warn(`[startMerge] ${pr.prKey} head branch unresolved — merge-agent workspace won't be provisioned`);
|
|
624
641
|
}
|
|
625
642
|
// World-restore (issue #324): the merge stage runs on the same durable working tree; carry the
|
|
626
|
-
// last push-checkpoint so a replacement fix-ci/rebase activation reconstructs the exact SHA.
|
|
627
|
-
|
|
643
|
+
// last push-checkpoint so a replacement fix-ci/rebase activation reconstructs the exact SHA. GATED
|
|
644
|
+
// (issue #325, Slice 5/5) on the fleet advertising `durable-resume` — otherwise null, so the merge
|
|
645
|
+
// agents redrive from scratch (graceful degradation).
|
|
646
|
+
const worldSha = await worldRestoreSha(data, pr.prKey);
|
|
628
647
|
const { processInstanceKey } = await engine.createInstance({
|
|
629
648
|
processDefinitionId: MERGE_PROCESS_ID,
|
|
630
649
|
variables: {
|
|
@@ -826,6 +845,110 @@ async function isDepMerged(data: DataLayer, depKey: string, token: string): Prom
|
|
|
826
845
|
}
|
|
827
846
|
}
|
|
828
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
|
+
|
|
829
952
|
/** Flip a PR into the transient `merging` status and publish the correlating message, reverting
|
|
830
953
|
* to `prevStatus` if the publish fails. `merging` is deliberately a status no poll branch scans
|
|
831
954
|
* (so a slow pass can't double-signal), which means a publish failure *after* the flip would
|
|
@@ -1391,7 +1514,23 @@ export async function pollWaveGatesImpl(
|
|
|
1391
1514
|
let allMerged = true;
|
|
1392
1515
|
const tasks = await planTasks(data).find({ plan_key: planKey });
|
|
1393
1516
|
for (const prKey of waveMergeTargets(tasks, gateWave)) {
|
|
1394
|
-
|
|
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") {
|
|
1395
1534
|
allMerged = false;
|
|
1396
1535
|
break;
|
|
1397
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
|
+
});
|
package/app/world/store.ts
CHANGED
|
@@ -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.
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
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,35 @@
|
|
|
1
|
+
-- 052_worker_durable_resume.sql — issue #325 (ADR 0062, Slice 5/5): the ENROLMENT GATE for durable
|
|
2
|
+
-- agent-session resume. Slices 1–4 built the mind (harness conversation) and world (git tree + effect
|
|
3
|
+
-- ledger) halves; this slice wires them into the running orchestration behind a `durable-resume`
|
|
4
|
+
-- enrolment gate so a re-leased `senior:pr-review` round RESUMES at the last push-checkpoint on a
|
|
5
|
+
-- participating harness, and gracefully DEGRADES (redriven from scratch, exactly as today) on one
|
|
6
|
+
-- that does not advertise it.
|
|
7
|
+
--
|
|
8
|
+
-- `durable-resume` is a WORKER ATTRIBUTE declared at enrolment (ADR 0056 §7 — capability gates
|
|
9
|
+
-- enrolment, it is NEVER in the routing token `network.role#seat`). The registry records, per worker
|
|
10
|
+
-- instance, whether that worker's harness advertises durable-resume (the probe result from Slice
|
|
11
|
+
-- 2/3). The world-restore `commitSha` is emitted into the repo-provisioning envelope ONLY when the
|
|
12
|
+
-- fleet includes a participant; a fleet with no participant emits no resume marker and clones the
|
|
13
|
+
-- head branch tip — the pre-#324 behaviour. Resume is purely additive, never a new sequence-flow
|
|
14
|
+
-- gate (ADR 0056 boundary): the engine/C8 job protocol is untouched.
|
|
15
|
+
--
|
|
16
|
+
-- One FK-free table keyed by the worker instance (`register.instance` / the enrol `instance`). It is
|
|
17
|
+
-- FK-free by design — enrolment is per-worker and connection-agnostic, with no `pull_requests`/`plans`
|
|
18
|
+
-- parent to reference. EXPAND (additive) phase: one new table + its index; nothing is dropped or
|
|
19
|
+
-- renamed. Numbered after the current highest prefix on origin/main (051). The runner wraps each file
|
|
20
|
+
-- in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS worker_durable_resume (
|
|
23
|
+
instance TEXT PRIMARY KEY, -- the worker instance id (enrol `instance` / register.instance)
|
|
24
|
+
durable_resume INTEGER NOT NULL DEFAULT 0, -- 1 when the worker's harness advertises durable-resume, else 0
|
|
25
|
+
updated_at TEXT NOT NULL,
|
|
26
|
+
-- `durable_resume` is a strict boolean domain — the gate reads it as "does this worker participate?",
|
|
27
|
+
-- so a stray value (a future writer bug, a corrupt row on this externalised enrolment boundary) would
|
|
28
|
+
-- make the gate mis-decide whether to emit the resume marker. Pin it to {0,1} at the schema.
|
|
29
|
+
CHECK (durable_resume IN (0, 1))
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
-- The gate asks "does the fleet include a durable-resume participant?" — an existence probe over the
|
|
33
|
+
-- participants. Index the flag so that lookup is a covered scan, not a table walk.
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_worker_durable_resume_flag
|
|
35
|
+
ON worker_durable_resume(durable_resume);
|
|
@@ -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": {
|