@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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
8
|
+
# [0.104.0](https://github.com/nanobpm/nano-workforce/compare/v0.103.0...v0.104.0) (2026-08-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **durable-resume:** enrolment gate + re-lease world-restore wiring ([#325](https://github.com/nanobpm/nano-workforce/issues/325)) ([#351](https://github.com/nanobpm/nano-workforce/issues/351)) ([b495dfa](https://github.com/nanobpm/nano-workforce/commit/b495dfabfda3e9e0953c6637342b79ca0c8148cc))
|
|
14
|
+
|
|
1
15
|
# [0.103.0](https://github.com/nanobpm/nano-workforce/compare/v0.102.1...v0.103.0) (2026-08-19)
|
|
2
16
|
|
|
3
17
|
|
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/contracts.ts
CHANGED
|
@@ -402,6 +402,14 @@ export const TYPE_CONTRACTS = {
|
|
|
402
402
|
"The mind/world checkpoint contract shape (issue #324, ADR 0062 Slice 4/5). `{ commitSha, effectLedger }` — the ONE type both the world marker (recorded in `world_checkpoints`/`world_effects`) and the mind checkpoint (Slice 1's `session.checkpoint`) derive from, so a single derivation feeds both halves and they cannot diverge. Its `effectLedger` is `Effect[]` (the fence-keyed irreversible-action ledger). The world half imports it from app/world; when Slice 1's harness-side `@nanobpm/agentic/session` lands it MUST reuse this shape, not re-declare a synonym.",
|
|
403
403
|
module: "app/world/checkpoint.ts",
|
|
404
404
|
},
|
|
405
|
+
DurableResumeRegistry: {
|
|
406
|
+
category: "type",
|
|
407
|
+
name: "DurableResumeRegistry",
|
|
408
|
+
owner: "app/durableResume.ts",
|
|
409
|
+
semantics:
|
|
410
|
+
"The `durable-resume` ENROLMENT GATE (issue #325, ADR 0062 Slice 5/5, the INTEGRATION slice). `durable-resume` is a worker attribute declared at enrolment (ADR 0056 §7 — capability gates enrolment, NEVER the routing token `network.role#seat`), recorded per worker instance in `worker_durable_resume` (migration 052). The enrol door (`operations/enrolAgenticWorker.ts`) records it via `recordEnrolment`; `app/service.ts` consults `fleetSupportsDurableResume` before emitting the world-restore `commitSha` (the `io.nanobpm.agentTask.repository` envelope) so a re-leased `senior:pr-review` round RESUMES only on a participating fleet and gracefully DEGRADES (redriven from scratch) otherwise. Consume this ONE module for the durable-resume gate — do not re-declare a synonym or read the flag off a second store.",
|
|
411
|
+
module: "app/durableResume.ts",
|
|
412
|
+
},
|
|
405
413
|
} as const satisfies Record<string, TypeContract>;
|
|
406
414
|
|
|
407
415
|
export const CAPABILITY_URL_CONTRACTS = {
|
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,89 @@
|
|
|
1
|
+
// Tests for the `durable-resume` enrolment registry (issue #325, ADR 0062 Slice 5/5) against a REAL
|
|
2
|
+
// in-memory SQLite engine with migration 052 applied — so the upsert, the {0,1} flag domain, and the
|
|
3
|
+
// fleet-level participation probe are proven, not mocked.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assert, assertEquals } from "#test-assert";
|
|
6
|
+
import { memDataFor } from "../test/worldDb.ts";
|
|
7
|
+
import { DurableResumeRegistry, DURABLE_RESUME_ATTR, fleetSupportsDurableResume } from "./durableResume.ts";
|
|
8
|
+
|
|
9
|
+
const mem = () => memDataFor(["052_worker_durable_resume.sql"]);
|
|
10
|
+
|
|
11
|
+
test("DURABLE_RESUME_ATTR is the canonical enrolment-attribute name", () => {
|
|
12
|
+
assertEquals(DURABLE_RESUME_ATTR, "durable-resume");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("recordEnrolment persists a participant and isParticipant reads it back", async () => {
|
|
16
|
+
const { data } = mem();
|
|
17
|
+
const reg = new DurableResumeRegistry(data);
|
|
18
|
+
assertEquals(await reg.isParticipant("w1"), false, "unknown instance is a non-participant (safe default)");
|
|
19
|
+
await reg.recordEnrolment("w1", true);
|
|
20
|
+
assertEquals(await reg.isParticipant("w1"), true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("recordEnrolment records an explicit non-participant as false", async () => {
|
|
24
|
+
const { data } = mem();
|
|
25
|
+
const reg = new DurableResumeRegistry(data);
|
|
26
|
+
await reg.recordEnrolment("w1", false);
|
|
27
|
+
assertEquals(await reg.isParticipant("w1"), false);
|
|
28
|
+
assertEquals(await reg.anyParticipant(), false, "a recorded non-participant is not a participant");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("recordEnrolment is an idempotent upsert — a re-enrol overwrites the flag", async () => {
|
|
32
|
+
const { data } = mem();
|
|
33
|
+
const reg = new DurableResumeRegistry(data);
|
|
34
|
+
await reg.recordEnrolment("w1", true);
|
|
35
|
+
assertEquals(await reg.isParticipant("w1"), true);
|
|
36
|
+
// A redeploy that drops durable-resume support flips the flag back — no duplicate row, no stale yes.
|
|
37
|
+
await reg.recordEnrolment("w1", false);
|
|
38
|
+
assertEquals(await reg.isParticipant("w1"), false);
|
|
39
|
+
await reg.recordEnrolment("w1", true);
|
|
40
|
+
assertEquals(await reg.isParticipant("w1"), true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the registry normalises keys and ignores a blank/whitespace instance", async () => {
|
|
44
|
+
const { data } = mem();
|
|
45
|
+
const reg = new DurableResumeRegistry(data);
|
|
46
|
+
// A blank or whitespace-only key cannot key a reachable row and must never open the fleet gate.
|
|
47
|
+
await reg.recordEnrolment("", true);
|
|
48
|
+
await reg.recordEnrolment(" ", true);
|
|
49
|
+
assertEquals(await reg.anyParticipant(), false, "a blank/whitespace enrolment is ignored, gate stays closed");
|
|
50
|
+
assertEquals(await reg.isParticipant(" "), false, "a blank key is never a participant");
|
|
51
|
+
// A padded key is canonicalised (trimmed) so reads and writes agree on one row — no unreachable dup.
|
|
52
|
+
await reg.recordEnrolment(" w1 ", true);
|
|
53
|
+
assertEquals(await reg.isParticipant("w1"), true, "a padded write is readable by the trimmed key");
|
|
54
|
+
assertEquals(await reg.isParticipant(" w1 "), true, "a padded read normalises to the same row");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("anyParticipant is the fleet-level existence probe over participants", async () => {
|
|
58
|
+
const { data } = mem();
|
|
59
|
+
const reg = new DurableResumeRegistry(data);
|
|
60
|
+
assertEquals(await reg.anyParticipant(), false, "no enrolment yet");
|
|
61
|
+
await reg.recordEnrolment("legacy-1", false);
|
|
62
|
+
await reg.recordEnrolment("legacy-2", false);
|
|
63
|
+
assertEquals(await reg.anyParticipant(), false, "a fleet of only non-participants does not support resume");
|
|
64
|
+
await reg.recordEnrolment("modern-1", true);
|
|
65
|
+
assertEquals(await reg.anyParticipant(), true, "one participant makes the mixed fleet resume-capable");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("fleetSupportsDurableResume mirrors anyParticipant, and degrades to false without a data layer", async () => {
|
|
69
|
+
const { data } = mem();
|
|
70
|
+
assertEquals(await fleetSupportsDurableResume(undefined), false, "no data layer → additive-safe false");
|
|
71
|
+
assertEquals(await fleetSupportsDurableResume(data), false, "no participant enrolled");
|
|
72
|
+
await new DurableResumeRegistry(data).recordEnrolment("w1", true);
|
|
73
|
+
assertEquals(await fleetSupportsDurableResume(data), true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("fleetSupportsDurableResume degrades to false on a store read failure (legacy DB predating 052)", async () => {
|
|
77
|
+
// A DataLayer whose table has no `worker_durable_resume` — the read throws; the gate must degrade to
|
|
78
|
+
// false (redrive from scratch) rather than blocking a submit/merge on the enrolment registry.
|
|
79
|
+
const { data } = memDataFor([]);
|
|
80
|
+
assertEquals(await fleetSupportsDurableResume(data), false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the flag domain is pinned to {0,1} — a participant reads as exactly true", async () => {
|
|
84
|
+
const { data, db } = mem();
|
|
85
|
+
await new DurableResumeRegistry(data).recordEnrolment("w1", true);
|
|
86
|
+
const rows = db.prepare("SELECT durable_resume FROM worker_durable_resume WHERE instance = ?").all("w1");
|
|
87
|
+
assertEquals(rows.length, 1);
|
|
88
|
+
assert(rows[0].durable_resume === 1, "true is stored as the integer 1");
|
|
89
|
+
});
|