@nanobpm/nano-workforce 0.58.0 → 0.59.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 CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.59.0](https://github.com/nanobpm/nano-workforce/compare/v0.58.1...v0.59.0) (2026-08-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * derive epic delivery signal (converging vs landed) ([#197](https://github.com/nanobpm/nano-workforce/issues/197)) ([39ab5f1](https://github.com/nanobpm/nano-workforce/commit/39ab5f1b2628aadd350e9043ab0114103a24a1e2)), closes [#171](https://github.com/nanobpm/nano-workforce/issues/171) [#171](https://github.com/nanobpm/nano-workforce/issues/171)
7
+
8
+ ## [0.58.1](https://github.com/nanobpm/nano-workforce/compare/v0.58.0...v0.58.1) (2026-08-13)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * regenerate nano-generated/ before start (prestart: urban gen) ([#195](https://github.com/nanobpm/nano-workforce/issues/195)) ([d45fe22](https://github.com/nanobpm/nano-workforce/commit/d45fe22de58766730721f51cbcb7e97d6f4f06c8)), closes [#192](https://github.com/nanobpm/nano-workforce/issues/192)
14
+
1
15
  # [0.58.0](https://github.com/nanobpm/nano-workforce/compare/v0.57.0...v0.58.0) (2026-08-13)
2
16
 
3
17
 
@@ -0,0 +1,174 @@
1
+ // Read-model derivation test for the epic delivery signal (issue #171). `deriveDelivery` is the
2
+ // single source of truth for the denormalised `plans.delivery` / `plans.delivery_label` columns the
3
+ // poller projects. It must cleanly distinguish an epic whose fan-out is `done` but whose slices are
4
+ // still CONVERGING from one where every slice PR has LANDED, and count abandoned/converged PRs as
5
+ // resolved-not-landed (never `landed`).
6
+ import { test } from "node:test";
7
+ import { assert, assertEquals } from "#test-assert";
8
+ import type { DataLayer } from "@nanobpm/urban";
9
+ import { deriveDelivery, pollDelivery, TERMINAL_STATUSES } from "./service.ts";
10
+
11
+ // A tiny in-memory record gateway (all/find/update/insert), mirroring the fake-app style used
12
+ // across the app tests (see app/taskDelta.test.ts), enough to exercise the `pollDelivery` projection.
13
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
14
+ const stores: Record<string, any[]> = {};
15
+ function tbl(name: string, pk = "id") {
16
+ const rows = (stores[name] ??= [] as any[]);
17
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
18
+ return {
19
+ async all() {
20
+ return rows.slice();
21
+ },
22
+ async get(id: any) {
23
+ return rows.find((r) => r[pk] === id);
24
+ },
25
+ async find(where: any = {}) {
26
+ return rows.filter((r) => match(r, where));
27
+ },
28
+ async insert(row: any) {
29
+ rows.push({ ...row });
30
+ return row[pk];
31
+ },
32
+ async update(id: any, patch: any) {
33
+ const r = rows.find((row) => row[pk] === id);
34
+ if (r) Object.assign(r, patch);
35
+ },
36
+ };
37
+ }
38
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
39
+ return { data, stores };
40
+ }
41
+
42
+ test("all slice PRs merged -> landed", () => {
43
+ const r = deriveDelivery("done", ["merged", "merged", "merged"]);
44
+ assertEquals(r.delivery, "landed");
45
+ assertEquals(r.prsOpened, 3);
46
+ assertEquals(r.prsMerged, 3);
47
+ assertEquals(r.prsInFlight, 0);
48
+ assertEquals(r.label, "3/3 slices merged");
49
+ });
50
+
51
+ test("one slice PR still in flight -> converging", () => {
52
+ const r = deriveDelivery("done", ["merged", "converging", "merged"]);
53
+ assertEquals(r.delivery, "converging");
54
+ assertEquals(r.prsOpened, 3);
55
+ assertEquals(r.prsMerged, 2);
56
+ assertEquals(r.prsInFlight, 1);
57
+ assertEquals(r.label, "2/3 slices merged, 1 converging");
58
+ });
59
+
60
+ test("mixed merged/abandoned (all terminal, not all merged) -> resolved-not-landed (null)", () => {
61
+ const r = deriveDelivery("done", ["merged", "abandoned", "merged"]);
62
+ assertEquals(r.delivery, null);
63
+ assertEquals(r.label, null);
64
+ assertEquals(r.prsOpened, 3);
65
+ assertEquals(r.prsMerged, 2);
66
+ // abandoned is terminal, so it is NOT counted as in flight.
67
+ assertEquals(r.prsInFlight, 0);
68
+ });
69
+
70
+ test("a converged (review-only, unmerged) slice keeps the epic out of landed", () => {
71
+ // `converged` is terminal but not `merged`: resolved-not-landed, like abandoned.
72
+ const r = deriveDelivery("done", ["merged", "converged"]);
73
+ assertEquals(r.delivery, null);
74
+ assertEquals(r.prsInFlight, 0);
75
+ assertEquals(r.prsMerged, 1);
76
+ });
77
+
78
+ test("plan not yet done -> no delivery signal even with slice PRs", () => {
79
+ for (const status of ["planning", "dispatched"]) {
80
+ const r = deriveDelivery(status, ["merged", "converging"]);
81
+ assertEquals(r.delivery, null, `status=${status}`);
82
+ assertEquals(r.label, null, `status=${status}`);
83
+ }
84
+ });
85
+
86
+ test("done but zero slice PRs -> no delivery signal", () => {
87
+ const r = deriveDelivery("done", []);
88
+ assertEquals(r.delivery, null);
89
+ assertEquals(r.prsOpened, 0);
90
+ });
91
+
92
+ test("a single in-flight slice on a done plan is converging, not landed", () => {
93
+ const r = deriveDelivery("done", ["waiting_review"]);
94
+ assertEquals(r.delivery, "converging");
95
+ assertEquals(r.label, "0/1 slices merged, 1 converging");
96
+ });
97
+
98
+ test("every non-terminal status counts as in flight", () => {
99
+ const inFlight = ["converging", "waiting_review", "escalated", "queued", "open", "opened"];
100
+ for (const s of inFlight) {
101
+ assert(!TERMINAL_STATUSES.includes(s), `${s} must not be terminal`);
102
+ const r = deriveDelivery("done", [s]);
103
+ assertEquals(r.delivery, "converging", `status ${s}`);
104
+ assertEquals(r.prsInFlight, 1, `status ${s}`);
105
+ }
106
+ });
107
+
108
+ test("pollDelivery: a dangling pr_key (missing PR row) counts as in-flight, never false-landed", async () => {
109
+ const { data, stores } = memData();
110
+ stores.plans = [
111
+ { plan_key: "epic-1", status: "done", delivery: null, delivery_label: null },
112
+ ];
113
+ stores.plan_tasks = [
114
+ { id: 1, plan_key: "epic-1", pr_key: "o/r#1" },
115
+ { id: 2, plan_key: "epic-1", pr_key: "o/r#2" }, // no matching pull_requests row (DB desync)
116
+ ];
117
+ stores.pull_requests = [{ pr_key: "o/r#1", status: "merged" }];
118
+
119
+ await pollDelivery(data);
120
+
121
+ // Without the dangling PR being treated as in-flight, this would wrongly become `landed` (1/1).
122
+ assertEquals(stores.plans[0].delivery, "converging");
123
+ assertEquals(stores.plans[0].delivery_label, "1/2 slices merged, 1 converging");
124
+ });
125
+
126
+ test("pollDelivery: all slice PR rows present and merged -> landed", async () => {
127
+ const { data, stores } = memData();
128
+ stores.plans = [
129
+ { plan_key: "epic-2", status: "done", delivery: null, delivery_label: null },
130
+ ];
131
+ stores.plan_tasks = [
132
+ { id: 1, plan_key: "epic-2", pr_key: "o/r#10" },
133
+ { id: 2, plan_key: "epic-2", pr_key: "o/r#11" },
134
+ ];
135
+ stores.pull_requests = [
136
+ { pr_key: "o/r#10", status: "merged" },
137
+ { pr_key: "o/r#11", status: "merged" },
138
+ ];
139
+
140
+ await pollDelivery(data);
141
+
142
+ assertEquals(stores.plans[0].delivery, "landed");
143
+ assertEquals(stores.plans[0].delivery_label, "2/2 slices merged");
144
+ });
145
+
146
+ test("pollDelivery: a non-done plan is skipped and any stale projection is cleared", async () => {
147
+ const { data, stores } = memData();
148
+ stores.plans = [
149
+ // Regressed out of `done` while carrying a stale `converging` projection.
150
+ { plan_key: "epic-3", status: "in_progress", delivery: "converging", delivery_label: "1/2 slices merged, 1 converging" },
151
+ ];
152
+ // A task join here would be wasted work for a non-done plan; assert it is never consulted.
153
+ let taskLookups = 0;
154
+ stores.plan_tasks = [{ id: 1, plan_key: "epic-3", pr_key: "o/r#20" }];
155
+ stores.pull_requests = [{ pr_key: "o/r#20", status: "merged" }];
156
+ const origTable = (data as any).table.bind(data);
157
+ (data as any).table = (n: string, pk?: string) => {
158
+ const t = origTable(n, pk);
159
+ if (n === "plan_tasks") {
160
+ const origFind = t.find.bind(t);
161
+ t.find = async (where: any) => {
162
+ taskLookups++;
163
+ return origFind(where);
164
+ };
165
+ }
166
+ return t;
167
+ };
168
+
169
+ await pollDelivery(data);
170
+
171
+ assertEquals(stores.plans[0].delivery, null);
172
+ assertEquals(stores.plans[0].delivery_label, null);
173
+ assertEquals(taskLookups, 0);
174
+ });
package/app/plan.ts CHANGED
@@ -72,6 +72,13 @@ export interface Plan {
72
72
  // admission); the column stays NULLABLE ONLY to grandfather pre-ADR-0003 / in-flight rows that
73
73
  // carry NULL — those must remain readable, so do NOT add a NOT NULL migration.
74
74
  base_branch: string | null;
75
+ // Derived epic delivery signal (029_plan_delivery.sql, #171): separates "fan-out dispatched to
76
+ // convergence" (status=done) from "all slice PRs actually merged". Recomputed idempotently by the
77
+ // poller's `pollDelivery` pass by joining each plan_tasks.pr_key → pull_requests.status — never
78
+ // written by the plan lifecycle. `delivery` is 'converging' | 'landed' | NULL (see deriveDelivery
79
+ // in app/service.ts); `delivery_label` is the human rollup for the epic detail view. Display-only.
80
+ delivery: string | null;
81
+ delivery_label: string | null;
75
82
  created_at: string;
76
83
  updated_at: string;
77
84
  }
package/app/service.ts CHANGED
@@ -99,6 +99,70 @@ const now = () => new Date().toISOString();
99
99
  * guard both key off this set. */
100
100
  export const TERMINAL_STATUSES: readonly string[] = ["converged", "merged", "abandoned"];
101
101
 
102
+ /** The derived epic delivery signal (issue #171). Distinct from `plan.status`: `status = done`
103
+ * means "the fan-out finished and ≥1 slice opened a PR, dispatched to convergence" (record-results
104
+ * sets it as soon as one PR opened — other slices may be blocked/skipped), which conflates hand-off
105
+ * with landing. `delivery` reports whether those slice PRs have actually MERGED. */
106
+ export type Delivery = "converging" | "landed";
107
+
108
+ /** Rollup of a plan's slice-PR landing state, derived by joining `plan_tasks.pr_key` →
109
+ * `pull_requests.status`. Pure and read-only — the single source of truth for the denormalised
110
+ * `plans.delivery` / `plans.delivery_label` columns the poller projects. */
111
+ export interface DeliveryRollup {
112
+ delivery: Delivery | null;
113
+ label: string | null;
114
+ prsOpened: number;
115
+ prsMerged: number;
116
+ prsInFlight: number;
117
+ }
118
+
119
+ /** Derive the delivery signal for one plan from its status and the statuses of its slice PRs.
120
+ *
121
+ * - `converging` — the plan is `done` but ≥1 slice PR is still non-terminal (in flight).
122
+ * - `landed` — every slice PR merged: `prsInFlight == 0 && prsMerged == prsOpened && prsOpened > 0`.
123
+ * - `null` — no positive signal yet: the plan isn't `done`, it opened no PRs, or every PR is
124
+ * terminal but not all merged (some `abandoned`/`converged` — resolved-not-landed, per the issue).
125
+ *
126
+ * A slice's PR status is "in flight" iff it is NOT in `TERMINAL_STATUSES`; `abandoned`/`converged`
127
+ * count as resolved-not-landed (terminal but not merged), so they never make an epic `landed`. */
128
+ export function deriveDelivery(
129
+ planStatus: string,
130
+ prStatuses: readonly string[],
131
+ ): DeliveryRollup {
132
+ const prsOpened = prStatuses.length;
133
+ let prsMerged = 0;
134
+ let prsInFlight = 0;
135
+ for (const s of prStatuses) {
136
+ if (s === "merged") prsMerged++;
137
+ else if (!TERMINAL_STATUSES.includes(s)) prsInFlight++;
138
+ }
139
+ // `delivery` is only meaningful once the fan-out has been dispatched (`status = done`) and at
140
+ // least one slice PR exists; otherwise there is nothing to have landed yet.
141
+ if (planStatus !== "done" || prsOpened === 0) {
142
+ return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
143
+ }
144
+ if (prsInFlight > 0) {
145
+ return {
146
+ delivery: "converging",
147
+ label: `${prsMerged}/${prsOpened} slices merged, ${prsInFlight} converging`,
148
+ prsOpened,
149
+ prsMerged,
150
+ prsInFlight,
151
+ };
152
+ }
153
+ if (prsMerged === prsOpened) {
154
+ return {
155
+ delivery: "landed",
156
+ label: `${prsOpened}/${prsOpened} slices merged`,
157
+ prsOpened,
158
+ prsMerged,
159
+ prsInFlight,
160
+ };
161
+ }
162
+ // Every slice PR is terminal but not all merged (some abandoned/converged): resolved, not landed.
163
+ return { delivery: null, label: null, prsOpened, prsMerged, prsInFlight };
164
+ }
165
+
102
166
  interface PullRequest {
103
167
  pr_key: string;
104
168
  repo: string;
@@ -1132,6 +1196,58 @@ async function pollWaveGates(data: DataLayer, engine: EngineClient, token: strin
1132
1196
  }
1133
1197
  }
1134
1198
 
1199
+ /** Idempotent read-model pass: recompute each plan's derived `delivery` signal (issue #171) by
1200
+ * joining its slice tasks' `pr_key` → `pull_requests.status`, and denormalise it onto the `plans`
1201
+ * row so the epics overview / detail views can read it as a flat column (Urban's datasource can't
1202
+ * read a SQL VIEW). Never touches `plan.status` — additive/derived only. Writes only when the
1203
+ * projection actually changes, so a steady-state pass is a no-op. */
1204
+ /** Sentinel status fed to `deriveDelivery` for a `plan_tasks.pr_key` whose `pull_requests` row is
1205
+ * missing (DB desync). It is deliberately non-terminal and not `merged`, so a dangling PR counts as
1206
+ * in-flight — never a false-positive `landed` from a silently-dropped slice. */
1207
+ const MISSING_PR_STATUS = "missing";
1208
+
1209
+ export async function pollDelivery(data: DataLayer) {
1210
+ // Preload every PR status once per pass into a pr_key→status map (avoids the prior N+1
1211
+ // `prs(data).get` per task; mirrors how `activePrs` reads `prs(data).all()` once).
1212
+ const statusByPrKey = new Map<string, string>();
1213
+ for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
1214
+ for (const plan of await plans(data).all()) {
1215
+ try {
1216
+ // `deriveDelivery` always yields `{null, null}` for a non-`done` plan, so skip the per-plan
1217
+ // task join for those — but still clear any stale projection defensively (e.g. a plan that
1218
+ // regressed out of `done`) so the read model never keeps a phantom `converging`/`landed`.
1219
+ if (plan.status !== "done") {
1220
+ if (plan.delivery !== null || plan.delivery_label !== null) {
1221
+ await plans(data).update(plan.plan_key, {
1222
+ delivery: null,
1223
+ delivery_label: null,
1224
+ updated_at: now(),
1225
+ });
1226
+ }
1227
+ continue;
1228
+ }
1229
+ const tasks = await planTasks(data).find({ plan_key: plan.plan_key });
1230
+ const prStatuses: string[] = [];
1231
+ for (const t of tasks) {
1232
+ if (!t.pr_key) continue;
1233
+ // A dangling pr_key (row missing) is treated as in-flight, not dropped, so a DB desync
1234
+ // can never wrongly promote an epic to `landed`.
1235
+ prStatuses.push(statusByPrKey.get(t.pr_key) ?? MISSING_PR_STATUS);
1236
+ }
1237
+ const { delivery, label } = deriveDelivery(plan.status, prStatuses);
1238
+ if (plan.delivery !== delivery || plan.delivery_label !== label) {
1239
+ await plans(data).update(plan.plan_key, {
1240
+ delivery,
1241
+ delivery_label: label,
1242
+ updated_at: now(),
1243
+ });
1244
+ }
1245
+ } catch (err) {
1246
+ console.error(`[poller] delivery ${plan.plan_key}: ${err}`);
1247
+ }
1248
+ }
1249
+ }
1250
+
1135
1251
  /** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
1136
1252
  * (when the engine REST endpoint is supplied) the job-activation visibility pass and the
1137
1253
  * technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
@@ -1144,6 +1260,7 @@ export async function pollOnce(
1144
1260
  await pollReviews(data, engine, token);
1145
1261
  await pollMerges(data, engine, token);
1146
1262
  await pollWaveGates(data, engine, token);
1263
+ await pollDelivery(data);
1147
1264
  if (engineRest) {
1148
1265
  await pollJobActivation(data, engineRest.restAddress, engineRest.token);
1149
1266
  await pollIncidents(data, engineRest.restAddress, engineRest.token);
@@ -0,0 +1,29 @@
1
+ -- Derived epic delivery signal: separate "fan-out dispatched to convergence" from "all slice
2
+ -- PRs actually merged" (issue #171).
3
+ --
4
+ -- `plans.status = done` means "the fan-out finished and ≥1 slice opened a PR and was handed off to
5
+ -- convergence" (record-results marks it as soon as one PR opened — other slices may be
6
+ -- blocked/skipped), NOT "every PR merged". Convergence + merge
7
+ -- then run as separate async per-PR processes, so a `done` epic can still have slice PRs in
8
+ -- flight. That conflation is misleading on the epics overview.
9
+ --
10
+ -- We leave `plans.status` untouched (automation depends on `done` == dispatched) and add a
11
+ -- DERIVED delivery signal, computed by joining each `plan_tasks.pr_key` → `pull_requests.status`.
12
+ -- Urban's datasource cannot read a SQL VIEW (gateway.ts schema() whitelists only type='table'),
13
+ -- so — following the codebase convention for read-model projections onto `plans` (wave_label,
14
+ -- gate_wave, …) — the poller denormalises two flat columns, recomputed idempotently each pass:
15
+ --
16
+ -- • delivery — the derived signal, one of:
17
+ -- 'converging' — plan `done` but ≥1 slice PR still non-terminal.
18
+ -- 'landed' — every slice PR merged (prs_in_flight == 0 &&
19
+ -- prs_merged == prs_opened && prs_opened > 0).
20
+ -- NULL when there is no positive signal yet: the plan is not `done`, it
21
+ -- opened no PRs, or every PR is terminal but not all merged (some
22
+ -- abandoned/converged — resolved-not-landed). `landed` is the honest
23
+ -- "epic delivered" state and the precondition for ready-to-promote (#160).
24
+ -- • delivery_label — the human rollup for the epic detail view, e.g.
25
+ -- "4/5 slices merged, 1 converging". NULL when delivery is NULL.
26
+ --
27
+ -- Additive/derived only: no new writes to the plan lifecycle (`status`).
28
+ ALTER TABLE plans ADD COLUMN delivery TEXT;
29
+ ALTER TABLE plans ADD COLUMN delivery_label TEXT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.58.0",
3
+ "version": "0.59.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",
@@ -28,6 +28,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "scripts": {
31
+ "prestart": "urban gen",
31
32
  "start": "node --experimental-strip-types main.ts",
32
33
  "purge": "node --experimental-strip-types scripts/purge-db.ts",
33
34
  "upgrade": "node --experimental-strip-types scripts/upgrade-from-pack.ts",
@@ -55,6 +55,7 @@
55
55
  "columns": [
56
56
  { "field": "plan_key", "header": "Issue", "linkField": "issue_url" },
57
57
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
58
+ { "field": "delivery", "header": "Delivery" },
58
59
  { "field": "wave_label", "header": "Wave" },
59
60
  { "field": "task_count", "header": "Tasks" },
60
61
  { "field": "updated_at", "header": "Updated" }
@@ -65,6 +66,7 @@
65
66
  { "field": "repo", "label": "Repository" },
66
67
  { "field": "issue_number", "label": "Issue number" },
67
68
  { "field": "base_branch", "label": "Base branch (blank = repo default)" },
69
+ { "field": "delivery_label", "label": "Delivery rollup (slices merged / converging)" },
68
70
  { "field": "outcome", "label": "Outcome" }
69
71
  ]
70
72
  }
@@ -73,6 +73,7 @@
73
73
  "columns": [
74
74
  { "field": "plan_key", "header": "Epic", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
75
75
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
76
+ { "field": "delivery", "header": "Delivery" },
76
77
  { "field": "base_branch", "header": "Base branch" },
77
78
  { "field": "wave_label", "header": "Wave" },
78
79
  { "field": "task_count", "header": "Tasks" },
@@ -142,3 +142,12 @@ inside its slice. Use:
142
142
  - `constraint` — a constraint you discovered that changes another task's direction.
143
143
 
144
144
  This is advisory context, not an escalation — it never blocks you or anyone else.
145
+
146
+ If the repo has an **append-ordered namespace** — files chosen by "the next"
147
+ monotonic value (DB migration prefixes, ADR numbers, changelog fragments,
148
+ ordered fixtures) — `file-claim` your intended slot on the coordination
149
+ blackboard *before* authoring, and read existing claims first. Parallel siblings
150
+ otherwise pick the same value and collide silently (names don't textually
151
+ conflict, so git merges both). This is advisory best-effort; the repo's own CI
152
+ gate, if any, remains the guarantee. Check the repo's AGENTS.md for which
153
+ namespaces are ordered.