@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/CHANGELOG.md +7 -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/dbFence.ts +18 -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 +191 -3
- package/app/service.ts +123 -2
- 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/053_merges_abandon_dedupe.sql +29 -0
- package/nano.app.json +6 -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/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/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
|
|
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", "
|
|
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
|
-
|
|
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
|
|
117
|
-
const learnings =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
package/app/service.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
+
});
|
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,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.
|
|
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",
|