@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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.105.0](https://github.com/nanobpm/nano-workforce/compare/v0.104.0...v0.105.0) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* examine epic implementation against spec in retro (conformance) ([#355](https://github.com/nanobpm/nano-workforce/issues/355)) ([c52d059](https://github.com/nanobpm/nano-workforce/commit/c52d0594d767c551d31ed967d1ef79cb30e82ceb)), closes [#217](https://github.com/nanobpm/nano-workforce/issues/217) [#216](https://github.com/nanobpm/nano-workforce/issues/216) [#217](https://github.com/nanobpm/nano-workforce/issues/217)
|
|
7
|
+
|
|
1
8
|
# [0.104.0](https://github.com/nanobpm/nano-workforce/compare/v0.103.0...v0.104.0) (2026-08-19)
|
|
2
9
|
|
|
3
10
|
|
package/README.md
CHANGED
|
@@ -153,6 +153,7 @@ capability):
|
|
|
153
153
|
| `fix-ci` | `senior:fix-ci` | `merge-loop` | Green a `blocked` PR's failing checks |
|
|
154
154
|
| `rebase` | `senior:rebase` | `merge-loop` | Rebase a conflicting PR up to date with its base |
|
|
155
155
|
| `retro` | `senior:retro` | `retro` | Synthesize a finished epic's learnings and promote the recurring ones |
|
|
156
|
+
| `conformance` | `senior:conformance` | `retro` | Examine a finished epic's implementation against its spec; report met/deviations on the issue |
|
|
156
157
|
|
|
157
158
|
- `--command 'copilot -p - --allow-all-tools'` starts the Copilot CLI reading its
|
|
158
159
|
prompt from **stdin** (`-p -`). The harness pipes the whole job JSON (prompt +
|
package/app/abandon.ts
CHANGED
|
@@ -20,11 +20,20 @@
|
|
|
20
20
|
import type { DataLayer } from "@nanobpm/urban";
|
|
21
21
|
import { publicBaseUrl } from "./blackboard.ts";
|
|
22
22
|
|
|
23
|
-
/** The one app-row status
|
|
24
|
-
*
|
|
23
|
+
/** The one app-row status meaning a PR is terminally abandoned — the run must not be worked on
|
|
24
|
+
* further. Two disjoint producers flip a row here, and both are non-completion terminals that must
|
|
25
|
+
* stop a servicing agent:
|
|
26
|
+
* 1. an explicit **cancel** of a live convergence/merge run (Urban's cancel primitive, via the
|
|
27
|
+
* `instanceTracking` `onTerminated.set` patch), and
|
|
28
|
+
* 2. **`abandonClosedPr`** reconciling a wave-member PR that was **closed on GitHub without
|
|
29
|
+
* merging** (#352) — for both `pull_requests` and its `plan_tasks`.
|
|
30
|
+
* Convergence/merge terminal states `converged`/`merged` are NOT abandonment. In either abandoned
|
|
31
|
+
* case a servicing agent should stop, so the abandon-check endpoint treating both as `abandoned:
|
|
32
|
+
* true` is correct. */
|
|
25
33
|
export const ABANDONED_STATUS = "abandoned";
|
|
26
34
|
|
|
27
|
-
/** True when a PR's app-row status
|
|
35
|
+
/** True when a PR's app-row status is terminally abandoned (run cancelled, or PR closed-unmerged)
|
|
36
|
+
* and the agent must not act. */
|
|
28
37
|
export function isAbandoned(status: string | null | undefined): boolean {
|
|
29
38
|
return status === ABANDONED_STATUS;
|
|
30
39
|
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Unit tests for the spec-conformance review stage (app/conformance.ts, 052_plan_conformance.sql).
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
4
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
5
|
+
import { memBlackboardSource } from "../test/blackboardDb.ts";
|
|
6
|
+
import { appendEntry } from "./blackboard.ts";
|
|
7
|
+
import {
|
|
8
|
+
gatherConformance,
|
|
9
|
+
hasDeliveredImplementation,
|
|
10
|
+
hasDeliveredImplementationForPlan,
|
|
11
|
+
recordConformance,
|
|
12
|
+
renderConformanceBrief,
|
|
13
|
+
} from "./conformance.ts";
|
|
14
|
+
|
|
15
|
+
// In-memory record gateway matching the Table<T> subset conformance.ts uses.
|
|
16
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
17
|
+
const stores: Record<string, any[]> = {};
|
|
18
|
+
const seq: Record<string, number> = {};
|
|
19
|
+
function tbl(name: string, pk = "id") {
|
|
20
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
21
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
22
|
+
return {
|
|
23
|
+
async insert(row: any) {
|
|
24
|
+
if (pk !== "id" && rows.some((r) => r[pk] === row[pk])) {
|
|
25
|
+
throw new Error(`UNIQUE constraint failed: ${name}.${pk}`);
|
|
26
|
+
}
|
|
27
|
+
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
28
|
+
rows.push(pk === "id" ? { id, ...row } : { ...row });
|
|
29
|
+
return pk === "id" ? id : row[pk];
|
|
30
|
+
},
|
|
31
|
+
async find(where: any = {}) {
|
|
32
|
+
return rows.filter((r) => match(r, where));
|
|
33
|
+
},
|
|
34
|
+
async findOne(where: any = {}) {
|
|
35
|
+
return rows.find((r) => match(r, where));
|
|
36
|
+
},
|
|
37
|
+
async get(id: any) {
|
|
38
|
+
return rows.find((row) => row[pk] === id);
|
|
39
|
+
},
|
|
40
|
+
async update(id: any, patch: any) {
|
|
41
|
+
const r = rows.find((row) => row[pk] === id);
|
|
42
|
+
if (r) Object.assign(r, patch);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
|
|
47
|
+
return { data, stores };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PLAN = "acme/widgets#7";
|
|
51
|
+
|
|
52
|
+
function seedPlan(stores: Record<string, any[]>) {
|
|
53
|
+
stores["plans"] = [{
|
|
54
|
+
plan_key: PLAN,
|
|
55
|
+
repo: "acme/widgets",
|
|
56
|
+
issue_url: "https://github.com/acme/widgets/issues/7",
|
|
57
|
+
title: "Widgets epic",
|
|
58
|
+
status: "done",
|
|
59
|
+
}];
|
|
60
|
+
}
|
|
61
|
+
function seedTask(stores: Record<string, any[]>, task: Record<string, unknown>) {
|
|
62
|
+
(stores["plan_tasks"] ??= []).push({ plan_key: PLAN, ...task });
|
|
63
|
+
}
|
|
64
|
+
function seedPr(stores: Record<string, any[]>, pr_key: string, status: string) {
|
|
65
|
+
(stores["pull_requests"] ??= []).push({ pr_key, status });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test("gatherConformance: collects the spec, only LANDED PRs, and raised scope-changes", async () => {
|
|
69
|
+
const { data, stores } = memData();
|
|
70
|
+
seedPlan(stores);
|
|
71
|
+
seedTask(stores, { id: 1, task_index: 0, task_id: "t1", title: "Auth", prompt: "add JWT auth", status: "opened", pr_key: "acme/widgets#10" });
|
|
72
|
+
seedTask(stores, { id: 2, task_index: 1, task_id: "t2", title: "Rate limit", prompt: "add rate limiting", status: "opened", pr_key: "acme/widgets#11" });
|
|
73
|
+
seedTask(stores, { id: 3, task_index: 2, task_id: "t3", title: "Webhook", prompt: "retry webhooks", status: "opened", pr_key: "acme/widgets#12" });
|
|
74
|
+
seedTask(stores, { id: 4, task_index: 3, task_id: "t4", title: "Docs", prompt: "write docs", status: "skipped", pr_key: null });
|
|
75
|
+
seedPr(stores, "acme/widgets#10", "merged"); // landed
|
|
76
|
+
seedPr(stores, "acme/widgets#11", "converged"); // landed (review-only)
|
|
77
|
+
seedPr(stores, "acme/widgets#12", "abandoned"); // NOT landed
|
|
78
|
+
await appendEntry(data, PLAN, { author_task: "t2", kind: "scope-change", body: "narrowed rate limit to per-IP only" });
|
|
79
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "not a scope change" });
|
|
80
|
+
|
|
81
|
+
const d = await gatherConformance(data, PLAN);
|
|
82
|
+
assertEquals(d.repo, "acme/widgets");
|
|
83
|
+
assertEquals(d.issueUrl, "https://github.com/acme/widgets/issues/7");
|
|
84
|
+
assertEquals(d.slices.length, 4);
|
|
85
|
+
// Only merged/converged PRs are "delivered"; abandoned and task-less slices are excluded.
|
|
86
|
+
assertEquals(d.deliveredPrs, ["acme/widgets#10", "acme/widgets#11"]);
|
|
87
|
+
assertEquals(d.slices.find((s) => s.taskId === "t3")?.landed, false);
|
|
88
|
+
assertEquals(d.slices.find((s) => s.taskId === "t4")?.landed, false);
|
|
89
|
+
// Only scope-change entries surface as raised deviations — learnings are ignored.
|
|
90
|
+
assertEquals(d.scopeChanges.length, 1);
|
|
91
|
+
assertEquals(d.scopeChanges[0].author_task, "t2");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("gatherConformance: sorts slices by task_index", async () => {
|
|
95
|
+
const { data, stores } = memData();
|
|
96
|
+
seedPlan(stores);
|
|
97
|
+
seedTask(stores, { id: 1, task_index: 2, task_id: "t3", status: "skipped", pr_key: null });
|
|
98
|
+
seedTask(stores, { id: 2, task_index: 0, task_id: "t1", status: "skipped", pr_key: null });
|
|
99
|
+
seedTask(stores, { id: 3, task_index: 1, task_id: "t2", status: "skipped", pr_key: null });
|
|
100
|
+
const d = await gatherConformance(data, PLAN);
|
|
101
|
+
assertEquals(d.slices.map((s) => s.taskId), ["t1", "t2", "t3"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("gatherConformance: uses pre-fetched blackboard entries instead of re-scanning", async () => {
|
|
105
|
+
const { data, stores } = memData();
|
|
106
|
+
seedPlan(stores);
|
|
107
|
+
seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "skipped", pr_key: null });
|
|
108
|
+
// A scope-change lives in the store, but the caller passes an EMPTY pre-fetched snapshot — the
|
|
109
|
+
// function must honour what it was handed and not re-read the store.
|
|
110
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "scope-change", body: "should be ignored" });
|
|
111
|
+
const d = await gatherConformance(data, PLAN, []);
|
|
112
|
+
assertEquals(d.scopeChanges.length, 0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("hasDeliveredImplementation: true iff at least one PR landed", async () => {
|
|
116
|
+
const { data, stores } = memData();
|
|
117
|
+
seedPlan(stores);
|
|
118
|
+
seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
119
|
+
seedPr(stores, "acme/widgets#10", "abandoned");
|
|
120
|
+
assert(!hasDeliveredImplementation(await gatherConformance(data, PLAN)));
|
|
121
|
+
stores["pull_requests"] = [{ pr_key: "acme/widgets#10", status: "merged" }];
|
|
122
|
+
assert(hasDeliveredImplementation(await gatherConformance(data, PLAN)));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("hasDeliveredImplementationForPlan: matches the digest without a blackboard scan", async () => {
|
|
126
|
+
const { data, stores } = memData();
|
|
127
|
+
seedPlan(stores);
|
|
128
|
+
seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
129
|
+
seedTask(stores, { id: 2, task_index: 1, task_id: "t2", status: "skipped", pr_key: null });
|
|
130
|
+
seedPr(stores, "acme/widgets#10", "abandoned");
|
|
131
|
+
// No landed PR yet — agrees with the full-digest helper.
|
|
132
|
+
assertEquals(await hasDeliveredImplementationForPlan(data, PLAN), false);
|
|
133
|
+
assertEquals(hasDeliveredImplementation(await gatherConformance(data, PLAN)), false);
|
|
134
|
+
// A landed PR flips both to true.
|
|
135
|
+
stores["pull_requests"] = [{ pr_key: "acme/widgets#10", status: "converged" }];
|
|
136
|
+
assertEquals(await hasDeliveredImplementationForPlan(data, PLAN), true);
|
|
137
|
+
assertEquals(hasDeliveredImplementation(await gatherConformance(data, PLAN)), true);
|
|
138
|
+
// The cheap check must not touch the blackboard.
|
|
139
|
+
const before = (stores["blackboard"] ?? []).length;
|
|
140
|
+
await hasDeliveredImplementationForPlan(data, PLAN);
|
|
141
|
+
assertEquals((stores["blackboard"] ?? []).length, before);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("renderConformanceBrief: lists PRs to examine, the spec, and raised deviations", () => {
|
|
145
|
+
const brief = renderConformanceBrief({
|
|
146
|
+
planKey: PLAN,
|
|
147
|
+
repo: "acme/widgets",
|
|
148
|
+
issueUrl: "https://x/7",
|
|
149
|
+
title: "Epic",
|
|
150
|
+
slices: [
|
|
151
|
+
{ taskId: "t1", title: "Auth", prompt: "add JWT auth", status: "opened", prKey: "acme/widgets#10", landed: true },
|
|
152
|
+
{ taskId: "t2", title: "Docs", prompt: null, status: "skipped", prKey: null, landed: false },
|
|
153
|
+
],
|
|
154
|
+
deliveredPrs: ["acme/widgets#10"],
|
|
155
|
+
scopeChanges: [{ author_task: "t1", body: "narrowed to per-IP", created_at: "now" }],
|
|
156
|
+
});
|
|
157
|
+
assertStringIncludes(brief, "gh pr diff");
|
|
158
|
+
assertStringIncludes(brief, "acme/widgets#10");
|
|
159
|
+
assertStringIncludes(brief, "add JWT auth");
|
|
160
|
+
assertStringIncludes(brief, "narrowed to per-IP");
|
|
161
|
+
assertStringIncludes(brief, "RAISED during implementation");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("renderConformanceBrief: states 'none' for no delivered PRs and no scope-changes", () => {
|
|
165
|
+
const brief = renderConformanceBrief({
|
|
166
|
+
planKey: PLAN, repo: "acme/widgets", issueUrl: "", title: null,
|
|
167
|
+
slices: [], deliveredPrs: [], scopeChanges: [],
|
|
168
|
+
});
|
|
169
|
+
assertStringIncludes(brief, "no implementation to verify");
|
|
170
|
+
assertStringIncludes(brief, "treat any deviation you find as UNRAISED");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("recordConformance: inserts then updates the same plan_key row in place", async () => {
|
|
174
|
+
const { data, stores } = memData();
|
|
175
|
+
await recordConformance(data, PLAN, {
|
|
176
|
+
status: "filed",
|
|
177
|
+
commentUrl: "https://x/7#issuecomment-1",
|
|
178
|
+
slicesMet: 4,
|
|
179
|
+
slicesReduced: 1,
|
|
180
|
+
slicesNotVerified: 1,
|
|
181
|
+
deviationsRaised: 2,
|
|
182
|
+
deviationsUnraised: 1,
|
|
183
|
+
hasDeviations: true,
|
|
184
|
+
summary: "6 items, 4 met",
|
|
185
|
+
report: "full report",
|
|
186
|
+
});
|
|
187
|
+
assertEquals(stores["plan_conformance"].length, 1);
|
|
188
|
+
const row = stores["plan_conformance"][0];
|
|
189
|
+
assertEquals(row.status, "filed");
|
|
190
|
+
assertEquals(row.comment_url, "https://x/7#issuecomment-1");
|
|
191
|
+
assertEquals(row.slices_met, 4);
|
|
192
|
+
assertEquals(row.has_deviations, 1);
|
|
193
|
+
|
|
194
|
+
await recordConformance(data, PLAN, { status: "skipped", summary: "nothing shipped" });
|
|
195
|
+
assertEquals(stores["plan_conformance"].length, 1, "same plan_key must not duplicate");
|
|
196
|
+
assertEquals(stores["plan_conformance"][0].status, "skipped");
|
|
197
|
+
assertEquals(stores["plan_conformance"][0].has_deviations, 0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("recordConformance: rethrows a non-unique (FOREIGN KEY) constraint error instead of swallowing it", async () => {
|
|
201
|
+
let updated = false;
|
|
202
|
+
const table = {
|
|
203
|
+
async insert() {
|
|
204
|
+
throw new Error("FOREIGN KEY constraint failed");
|
|
205
|
+
},
|
|
206
|
+
async update() {
|
|
207
|
+
updated = true;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
const data = { table: () => table } as any as DataLayer;
|
|
211
|
+
let threw = false;
|
|
212
|
+
try {
|
|
213
|
+
await recordConformance(data, PLAN, { status: "filed" });
|
|
214
|
+
} catch (err) {
|
|
215
|
+
threw = true;
|
|
216
|
+
assertStringIncludes(String(err), "FOREIGN KEY");
|
|
217
|
+
}
|
|
218
|
+
assert(threw, "the FK error must propagate");
|
|
219
|
+
assertEquals(updated, false, "must not silently fall back to update on a non-unique error");
|
|
220
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Spec-conformance review — "did we build what the spec asked for?", examined against the ACTUAL
|
|
2
|
+
// implementation.
|
|
3
|
+
//
|
|
4
|
+
// It rides the existing `retro` process (app/retro.ts): when an epic's last PR lands, a
|
|
5
|
+
// `senior:conformance` agent runs BEFORE the lessons agent. Unlike retro — which reflects on what
|
|
6
|
+
// implementers *claimed* via `learning` blackboard entries and task deltas — conformance is
|
|
7
|
+
// deliberately grounded in the code: the digest it builds hands the agent the spec (the epic issue
|
|
8
|
+
// + every slice's `prompt`) and the set of PRs that actually LANDED, so the agent reads the real
|
|
9
|
+
// diffs/code/tests (`gh pr diff`, `git`) and verifies delivery rather than trusting the transcript.
|
|
10
|
+
//
|
|
11
|
+
// It surfaces two classes of deviation: those RAISED during implementation (`scope-change`
|
|
12
|
+
// blackboard entries — quoted here so the agent can reconcile them) and those it finds itself that
|
|
13
|
+
// were NEVER raised. The result is persisted to `plan_conformance` (052_plan_conformance.sql).
|
|
14
|
+
//
|
|
15
|
+
// Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
|
|
16
|
+
// app/retro.ts, app/plan.ts, and app/blackboard.ts.
|
|
17
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
18
|
+
import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
19
|
+
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
20
|
+
import { planTasks } from "./plan.ts";
|
|
21
|
+
|
|
22
|
+
const now = () => new Date().toISOString();
|
|
23
|
+
|
|
24
|
+
/** A slice PR "landed" — its implementation is really in the tree and worth examining — when its
|
|
25
|
+
* PR reached a terminal state that isn't `abandoned`. In auto-merge mode that terminal is `merged`;
|
|
26
|
+
* in review-only mode it is `converged`. Derived from app/delivery.ts TERMINAL_STATUSES (the single
|
|
27
|
+
* source of truth for PR-terminal states) minus `abandoned`, so conformance and retro can't drift
|
|
28
|
+
* about what counts as landed. */
|
|
29
|
+
const LANDED_PR_STATUSES = new Set(TERMINAL_STATUSES.filter((s) => s !== "abandoned"));
|
|
30
|
+
|
|
31
|
+
interface PlanRow extends Record<string, unknown> {
|
|
32
|
+
plan_key: string;
|
|
33
|
+
repo: string;
|
|
34
|
+
issue_url: string;
|
|
35
|
+
title: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
|
|
39
|
+
const prsTbl = (data: DataLayer) =>
|
|
40
|
+
data.table<{ pr_key: string; status: string }>("pull_requests", "pr_key");
|
|
41
|
+
|
|
42
|
+
/** A slice's PR "landed" iff it exists and reached a non-abandoned terminal status. The single
|
|
43
|
+
* predicate both {@link gatherConformance} and {@link hasDeliveredImplementationForPlan} apply, so
|
|
44
|
+
* the full digest and the cheap trigger check can't disagree about what counts as landed. */
|
|
45
|
+
async function isLanded(data: DataLayer, prKey: string | null | undefined): Promise<boolean> {
|
|
46
|
+
if (!prKey) return false;
|
|
47
|
+
const pr = await prsTbl(data).get(prKey);
|
|
48
|
+
return !!pr && LANDED_PR_STATUSES.has(pr.status);
|
|
49
|
+
}
|
|
50
|
+
const conformanceTbl = (data: DataLayer) =>
|
|
51
|
+
data.table<{ plan_key: string } & Record<string, unknown>>("plan_conformance", "plan_key");
|
|
52
|
+
|
|
53
|
+
/** One item of the spec the agent must verify against the code: the slice's planner-supplied
|
|
54
|
+
* `prompt` (its acceptance brief), where it landed, and whether it landed at all. */
|
|
55
|
+
export interface ConformanceSlice {
|
|
56
|
+
taskId: string;
|
|
57
|
+
title: string | null;
|
|
58
|
+
prompt: string | null;
|
|
59
|
+
status: string;
|
|
60
|
+
prKey: string | null;
|
|
61
|
+
landed: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The material a conformance review examines. Unlike {@link RetroDigest}, this is spec + delivery
|
|
65
|
+
* pointers (not distilled claims) — the agent turns `deliveredPrs` into real diffs to inspect. */
|
|
66
|
+
export interface ConformanceDigest {
|
|
67
|
+
planKey: string;
|
|
68
|
+
repo: string;
|
|
69
|
+
issueUrl: string;
|
|
70
|
+
title: string | null;
|
|
71
|
+
slices: ConformanceSlice[];
|
|
72
|
+
/** The landed PR keys ("<owner>/<repo>#<n>") the agent must open and read the diff of. */
|
|
73
|
+
deliveredPrs: string[];
|
|
74
|
+
/** Deviations agents RAISED during implementation (`scope-change` blackboard entries). */
|
|
75
|
+
scopeChanges: { author_task: string; body: string; created_at: string }[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Assemble the conformance material for a plan: the spec (issue + each slice's `prompt`), the set
|
|
79
|
+
* of PRs that actually landed (so the agent examines the real implementation), and the scope
|
|
80
|
+
* deviations raised during implementation. Reads only — no writes.
|
|
81
|
+
*
|
|
82
|
+
* `entries` lets a caller that has already scanned the blackboard for this plan (e.g.
|
|
83
|
+
* `pr.retro-gather`, which also runs {@link gatherRetro}) pass those entries in so the plan is
|
|
84
|
+
* scanned once, not once per gatherer — see workers/retro-gather. Omitted, it reads them itself. */
|
|
85
|
+
export async function gatherConformance(
|
|
86
|
+
data: DataLayer,
|
|
87
|
+
planKey: string,
|
|
88
|
+
entries?: BlackboardEntry[],
|
|
89
|
+
): Promise<ConformanceDigest> {
|
|
90
|
+
const plan = await plansTbl(data).get(planKey);
|
|
91
|
+
const tasks = (await planTasks(data).find({ plan_key: planKey }))
|
|
92
|
+
.slice()
|
|
93
|
+
.sort((a, b) => (a.task_index ?? 0) - (b.task_index ?? 0));
|
|
94
|
+
|
|
95
|
+
const slices: ConformanceSlice[] = [];
|
|
96
|
+
const deliveredPrs: string[] = [];
|
|
97
|
+
for (const t of tasks) {
|
|
98
|
+
const landed = await isLanded(data, t.pr_key);
|
|
99
|
+
if (landed && t.pr_key) deliveredPrs.push(t.pr_key);
|
|
100
|
+
slices.push({
|
|
101
|
+
taskId: t.task_id,
|
|
102
|
+
title: t.title ?? null,
|
|
103
|
+
prompt: t.prompt ?? null,
|
|
104
|
+
status: t.status,
|
|
105
|
+
prKey: t.pr_key ?? null,
|
|
106
|
+
landed,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const scopeChanges = (entries ?? (await readBlackboard(data, planKey)))
|
|
111
|
+
.filter((e) => e.kind === "scope-change")
|
|
112
|
+
.map((e) => ({ author_task: e.author_task, body: e.body, created_at: e.created_at }));
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
planKey,
|
|
116
|
+
repo: plan?.repo ?? planKey.split("#")[0] ?? "",
|
|
117
|
+
issueUrl: plan?.issue_url ?? "",
|
|
118
|
+
title: plan?.title ?? null,
|
|
119
|
+
slices,
|
|
120
|
+
deliveredPrs,
|
|
121
|
+
scopeChanges,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** True when there is real, landed implementation to examine. A plan whose slices were all
|
|
126
|
+
* skipped/blocked/abandoned shipped nothing, so there is nothing to check for conformance — the
|
|
127
|
+
* retro trigger uses this to decide whether the conformance run is worthwhile even when the retro
|
|
128
|
+
* digest itself is empty. */
|
|
129
|
+
export function hasDeliveredImplementation(d: ConformanceDigest): boolean {
|
|
130
|
+
return d.deliveredPrs.length > 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Cheap trigger check for the retro gate: does this plan have ANY landed implementation to examine?
|
|
134
|
+
* Inspects only plan_tasks + pull_requests (short-circuiting on the first landed PR) and — unlike
|
|
135
|
+
* {@link gatherConformance} — performs no blackboard scan, so the empty-digest trigger in
|
|
136
|
+
* app/retro.ts doesn't pay to compute `scopeChanges` it would discard. Shares {@link isLanded} with
|
|
137
|
+
* the full digest so the two can't drift on what "landed" means. */
|
|
138
|
+
export async function hasDeliveredImplementationForPlan(
|
|
139
|
+
data: DataLayer,
|
|
140
|
+
planKey: string,
|
|
141
|
+
): Promise<boolean> {
|
|
142
|
+
const tasks = await planTasks(data).find({ plan_key: planKey });
|
|
143
|
+
for (const t of tasks) {
|
|
144
|
+
if (await isLanded(data, t.pr_key)) return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Render the digest as the compact markdown brief handed to the conformance agent (rides
|
|
150
|
+
* `appendPrompt`, concatenated after the base `conformance.md` linked-resource prompt — so it owns
|
|
151
|
+
* its own leading separator). It deliberately gives POINTERS (the spec text + the PRs to open), not
|
|
152
|
+
* conclusions: the agent must reach the verdicts by reading the code. */
|
|
153
|
+
export function renderConformanceBrief(d: ConformanceDigest): string {
|
|
154
|
+
const lines: string[] = [
|
|
155
|
+
"",
|
|
156
|
+
"",
|
|
157
|
+
"---",
|
|
158
|
+
"",
|
|
159
|
+
`## Conformance input — epic ${d.planKey}`,
|
|
160
|
+
"",
|
|
161
|
+
`Target repo: **${d.repo}**${d.issueUrl ? ` · issue (the spec): ${d.issueUrl}` : ""}`,
|
|
162
|
+
d.title ? `Epic: ${d.title}` : "",
|
|
163
|
+
"",
|
|
164
|
+
"### Delivered PRs to examine",
|
|
165
|
+
];
|
|
166
|
+
if (d.deliveredPrs.length === 0) {
|
|
167
|
+
lines.push("_(none landed — no implementation to verify)_");
|
|
168
|
+
} else {
|
|
169
|
+
lines.push(
|
|
170
|
+
`Read the actual diff of each with \`gh pr diff <n> --repo ${d.repo}\` (and the code/tests it touches):`,
|
|
171
|
+
);
|
|
172
|
+
for (const pr of d.deliveredPrs) lines.push(`- ${pr}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
lines.push("", `### Spec — the ${d.slices.length} slice(s) planned`);
|
|
176
|
+
if (d.slices.length === 0) {
|
|
177
|
+
lines.push("_(no slices recorded — verify the epic issue body directly)_");
|
|
178
|
+
} else {
|
|
179
|
+
for (const s of d.slices) {
|
|
180
|
+
const where = s.landed && s.prKey ? `landed as ${s.prKey}` : `status: ${s.status}`;
|
|
181
|
+
lines.push("", `#### ${s.taskId}${s.title ? ` — ${s.title}` : ""} (${where})`);
|
|
182
|
+
lines.push(s.prompt ? s.prompt : "_(no per-slice prompt; verify against the epic issue body)_");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
lines.push("", `### Deviations RAISED during implementation (${d.scopeChanges.length})`);
|
|
187
|
+
if (d.scopeChanges.length === 0) {
|
|
188
|
+
lines.push("_(none — no `scope-change` entries were posted; treat any deviation you find as UNRAISED)_");
|
|
189
|
+
} else {
|
|
190
|
+
lines.push("Reconcile each against the delivered code — a raised deviation is still a deviation:");
|
|
191
|
+
for (const c of d.scopeChanges) lines.push(`- **[${c.author_task}]** ${c.body}`);
|
|
192
|
+
}
|
|
193
|
+
return lines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The persisted conformance shape (written by pr.conformance-record from the agent's result). */
|
|
197
|
+
export interface ConformanceInput {
|
|
198
|
+
status: string; // filed | skipped | blocked
|
|
199
|
+
commentUrl?: string | null;
|
|
200
|
+
slicesMet?: number;
|
|
201
|
+
slicesReduced?: number;
|
|
202
|
+
slicesNotVerified?: number;
|
|
203
|
+
deviationsRaised?: number;
|
|
204
|
+
deviationsUnraised?: number;
|
|
205
|
+
hasDeviations?: boolean;
|
|
206
|
+
summary?: string | null;
|
|
207
|
+
report?: string | null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Upsert a plan's conformance row (idempotent on plan_key, so a job retry overwrites in place).
|
|
211
|
+
*
|
|
212
|
+
* Insert-first, then fall back to update only on a verified unique/PK violation — mirrors
|
|
213
|
+
* {@link recordRetro}: a get-then-insert can race, so "row already exists" is the update path, but
|
|
214
|
+
* any non-duplicate constraint failure must propagate rather than be silently swallowed. */
|
|
215
|
+
export async function recordConformance(
|
|
216
|
+
data: DataLayer,
|
|
217
|
+
planKey: string,
|
|
218
|
+
input: ConformanceInput,
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
const ts = now();
|
|
221
|
+
const fields = {
|
|
222
|
+
status: input.status,
|
|
223
|
+
comment_url: input.commentUrl ?? null,
|
|
224
|
+
slices_met: input.slicesMet ?? 0,
|
|
225
|
+
slices_reduced: input.slicesReduced ?? 0,
|
|
226
|
+
slices_not_verified: input.slicesNotVerified ?? 0,
|
|
227
|
+
deviations_raised: input.deviationsRaised ?? 0,
|
|
228
|
+
deviations_unraised: input.deviationsUnraised ?? 0,
|
|
229
|
+
has_deviations: input.hasDeviations ? 1 : 0,
|
|
230
|
+
summary: input.summary ?? null,
|
|
231
|
+
report: input.report ?? null,
|
|
232
|
+
updated_at: ts,
|
|
233
|
+
};
|
|
234
|
+
try {
|
|
235
|
+
await conformanceTbl(data).insert({ plan_key: planKey, created_at: ts, ...fields });
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (!isUniqueViolation(err)) throw err;
|
|
238
|
+
await conformanceTbl(data).update(planKey, fields);
|
|
239
|
+
}
|
|
240
|
+
}
|
package/app/dbFence.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// nano-workforce — the ONE canonical classifier for a SQLite UNIQUE-constraint fence collision.
|
|
2
|
+
//
|
|
3
|
+
// A durable fence is a DB-level UNIQUE constraint that a check-then-insert races against: two
|
|
4
|
+
// concurrent/duplicate writers both observe "no row" and both attempt the insert, so the loser hits
|
|
5
|
+
// `UNIQUE constraint failed`. Turning that collision into the SAME intended idempotent outcome
|
|
6
|
+
// (instead of a spurious job failure) is a recurring pattern across the app — the world store's
|
|
7
|
+
// checkpoint/effect ledger (`db/migrations/049_world_checkpoint.sql`) and the merges-audit abandon
|
|
8
|
+
// guard (`abandonClosedPr`, `db/migrations/053_merges_abandon_dedupe.sql`) both rely on it.
|
|
9
|
+
//
|
|
10
|
+
// This is the ONE place that classifies the collision so every catch site shares a single
|
|
11
|
+
// implementation rather than re-encoding the driver's error shape (AGENTS.md: "no drift surfaces").
|
|
12
|
+
// Matched on the message substring the RAD `Table` surface propagates verbatim — the same one the
|
|
13
|
+
// schema/migration tests assert on — because that surface hides the concrete driver error type.
|
|
14
|
+
|
|
15
|
+
/** True when `err` is a SQLite `UNIQUE constraint failed` — the durable fence firing. */
|
|
16
|
+
export function isUniqueConstraintFence(err: unknown): boolean {
|
|
17
|
+
return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
|
|
18
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Regression guard for migration 053 (#352, PR #354 review — suppressed advisory on app/service.ts:867):
|
|
2
|
+
// the DB-level fence that makes `abandonClosedPr`'s terminal audit write TRULY idempotent under a
|
|
3
|
+
// concurrent race, not merely best-effort. The partial `UNIQUE INDEX ux_merges_abandon_pr_closed ON
|
|
4
|
+
// merges(pr_key) WHERE outcome='abandoned' AND method='pr-closed'` IS the fence: the merge worker and
|
|
5
|
+
// the wave-gate self-heal path can both observe "no row" between the guard's `find` and its `insert`
|
|
6
|
+
// and both attempt the write, and this index is what turns the loser's insert into a catchable
|
|
7
|
+
// `UNIQUE constraint failed` instead of a duplicate audit row.
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
13
|
+
|
|
14
|
+
// The `merges` audit table as created by 004_merge.sql, minus the `pull_requests` FK parent (this
|
|
15
|
+
// test proves the index behaviour in isolation, exactly as migration049.test.ts hosts the world
|
|
16
|
+
// tables FK-free). Then apply 053 on top.
|
|
17
|
+
function migratedDb(): DatabaseSync {
|
|
18
|
+
const db = new DatabaseSync(":memory:");
|
|
19
|
+
db.exec(`CREATE TABLE merges (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
pr_key TEXT NOT NULL,
|
|
22
|
+
outcome TEXT NOT NULL,
|
|
23
|
+
method TEXT,
|
|
24
|
+
detail TEXT,
|
|
25
|
+
at TEXT NOT NULL
|
|
26
|
+
);`);
|
|
27
|
+
const sql = readFileSync(fileURLToPath(new URL("../db/migrations/053_merges_abandon_dedupe.sql", import.meta.url)), "utf8");
|
|
28
|
+
db.exec(sql);
|
|
29
|
+
return db;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const insertAbandon = (db: DatabaseSync, prKey: string) =>
|
|
33
|
+
db
|
|
34
|
+
.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES (?, 'abandoned', 'pr-closed', 'd', 't')")
|
|
35
|
+
.run(prKey);
|
|
36
|
+
|
|
37
|
+
test("migration 053 applies cleanly and enforces one abandoned/pr-closed row per pr_key", () => {
|
|
38
|
+
const db = migratedDb();
|
|
39
|
+
insertAbandon(db, "o/r#1");
|
|
40
|
+
// The race: a second observer inserting the SAME abandoned/pr-closed row now hits the fence.
|
|
41
|
+
assertThrows(() => insertAbandon(db, "o/r#1"), undefined, "UNIQUE constraint failed");
|
|
42
|
+
assertEquals(
|
|
43
|
+
Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE pr_key='o/r#1'").get() as { c: number }).c),
|
|
44
|
+
1,
|
|
45
|
+
"the loser's duplicate was rejected — one terminal audit row survives",
|
|
46
|
+
);
|
|
47
|
+
// A DIFFERENT PR's abandon is independent — the index is per pr_key, not global.
|
|
48
|
+
insertAbandon(db, "o/r#2");
|
|
49
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges").get() as { c: number }).c), 2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("migration 053 only fences abandoned/pr-closed rows — merged/queued/blocked still repeat freely", () => {
|
|
53
|
+
const db = migratedDb();
|
|
54
|
+
const insertMerged = () =>
|
|
55
|
+
db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','merged','squash','d','t')").run();
|
|
56
|
+
// A PR can carry several `merged` audit rows (retry / already-merged short-circuit) — the partial
|
|
57
|
+
// index must NOT constrain them (mergesPerDay dedupes with COUNT(DISTINCT pr_key)).
|
|
58
|
+
insertMerged();
|
|
59
|
+
insertMerged();
|
|
60
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE outcome='merged'").get() as { c: number }).c), 2);
|
|
61
|
+
// An abandoned row with a DIFFERENT method is also outside the partial predicate.
|
|
62
|
+
db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','abandoned','other','d','t')").run();
|
|
63
|
+
db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','abandoned','other','d','t')").run();
|
|
64
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE method='other'").get() as { c: number }).c), 2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("migration 053 collapses pre-existing duplicate abandoned/pr-closed rows, keeping the earliest", () => {
|
|
68
|
+
// Simulate a database where the pre-fence race already wrote duplicates, then apply the migration.
|
|
69
|
+
const db = new DatabaseSync(":memory:");
|
|
70
|
+
db.exec(`CREATE TABLE merges (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, pr_key TEXT NOT NULL, outcome TEXT NOT NULL,
|
|
72
|
+
method TEXT, detail TEXT, at TEXT NOT NULL);`);
|
|
73
|
+
db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#9','abandoned','pr-closed','first','t')").run();
|
|
74
|
+
db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#9','abandoned','pr-closed','dup','t')").run();
|
|
75
|
+
db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#8','abandoned','pr-closed','solo','t')").run();
|
|
76
|
+
const sql = readFileSync(fileURLToPath(new URL("../db/migrations/053_merges_abandon_dedupe.sql", import.meta.url)), "utf8");
|
|
77
|
+
db.exec(sql); // dedupe + create index; must not throw despite the pre-existing duplicate
|
|
78
|
+
const rows = db.prepare("SELECT pr_key, detail FROM merges ORDER BY pr_key").all() as { pr_key: string; detail: string }[];
|
|
79
|
+
assertEquals(rows.length, 2, "the duplicate for o/r#9 was collapsed");
|
|
80
|
+
assert(
|
|
81
|
+
rows.some((r) => r.pr_key === "o/r#9" && r.detail === "first"),
|
|
82
|
+
"the EARLIEST (MIN(id)) row survived the collapse",
|
|
83
|
+
);
|
|
84
|
+
});
|
package/app/plan.ts
CHANGED
|
@@ -181,6 +181,12 @@ export const PLAN_TASK_STATUSES = [
|
|
|
181
181
|
"skipped",
|
|
182
182
|
"escalated",
|
|
183
183
|
"waiting-for-lane",
|
|
184
|
+
// Terminal: the task's PR was closed on GitHub without merging (abandoned / superseded /
|
|
185
|
+
// perpetually conflicting). Set by the canonical abandon writer (`abandonClosedPr`, app/service.ts)
|
|
186
|
+
// reached from BOTH the merge stage and the wave-merge gate. An `abandoned` task drops out of
|
|
187
|
+
// `waveMergeTargets` (so a dead member never wedges the wave barrier — #352) and stops
|
|
188
|
+
// `isPlanComplete`/the Epics table counting a phantom open task.
|
|
189
|
+
"abandoned",
|
|
184
190
|
] as const;
|
|
185
191
|
export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
|
|
186
192
|
|