@nanobpm/nano-workforce 0.101.0 → 0.102.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.102.0](https://github.com/nanobpm/nano-workforce/compare/v0.101.1...v0.102.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * **console:** merged-per-day burn-down/throughput chart ([#345](https://github.com/nanobpm/nano-workforce/issues/345)) ([d4b27a9](https://github.com/nanobpm/nano-workforce/commit/d4b27a9cad36217db7c8ffc49ef26e0f31fb688e)), closes [#344](https://github.com/nanobpm/nano-workforce/issues/344) [#290](https://github.com/nanobpm/nano-workforce/issues/290) [#337](https://github.com/nanobpm/nano-workforce/issues/337) [#339](https://github.com/nanobpm/nano-workforce/issues/339) [#340](https://github.com/nanobpm/nano-workforce/issues/340) [#338](https://github.com/nanobpm/nano-workforce/issues/338)
7
+
8
+ ## [0.101.1](https://github.com/nanobpm/nano-workforce/compare/v0.101.0...v0.101.1) (2026-08-19)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **merge-loop:** abandon a closed-not-merged PR instead of escalating ([#342](https://github.com/nanobpm/nano-workforce/issues/342)) ([#343](https://github.com/nanobpm/nano-workforce/issues/343)) ([10ea6be](https://github.com/nanobpm/nano-workforce/commit/10ea6be0e23c9315dfb71a6c94958826a1795a8c)), closes [#350](https://github.com/nanobpm/nano-workforce/issues/350)
14
+
1
15
  # [0.101.0](https://github.com/nanobpm/nano-workforce/compare/v0.100.0...v0.101.0) (2026-08-19)
2
16
 
3
17
 
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { BaseBranchMustExistError, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead } from "./github.ts";
6
+ import { BaseBranchMustExistError, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead, type PrState } from "./github.ts";
7
7
 
8
8
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
9
9
  // files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
@@ -428,3 +428,38 @@ test("ensurePromotionPr: creates when none exists, then reuses on a re-run (idem
428
428
  assertEquals(second?.number, 500);
429
429
  assertEquals(state.creates.length, 1);
430
430
  });
431
+ // The shared PR-liveness gate (#342) maps live GitHub state onto one of {open, merged, closed,
432
+ // unknown} so neither durable loop (merge/convergence) can escalate against a non-open PR. A closed
433
+ // PR is terminal (abandon), a merged PR completes, and a null read stays conservative (unknown →
434
+ // proceed as before). `merged` wins over `state` because a merged PR also reports state="closed".
435
+ function prState(over: Partial<PrState>): PrState {
436
+ return {
437
+ merged: false,
438
+ state: "open",
439
+ mergeStateStatus: "CLEAN",
440
+ failingChecks: 0,
441
+ failingCheckNames: [],
442
+ presentCheckNames: [],
443
+ totalChecks: 0,
444
+ isDraft: false,
445
+ headRefOid: null,
446
+ ...over,
447
+ };
448
+ }
449
+
450
+ test("classifyPrLiveness: an open PR proceeds", () => {
451
+ assertEquals(classifyPrLiveness(prState({ state: "open" })), "open");
452
+ });
453
+
454
+ test("classifyPrLiveness: a merged PR completes (merged wins over a closed state)", () => {
455
+ assertEquals(classifyPrLiveness(prState({ merged: true, state: "closed" })), "merged");
456
+ assertEquals(classifyPrLiveness(prState({ merged: true, state: "merged" })), "merged");
457
+ });
458
+
459
+ test("classifyPrLiveness: a closed-not-merged PR is terminal (abandon)", () => {
460
+ assertEquals(classifyPrLiveness(prState({ merged: false, state: "closed" })), "closed");
461
+ });
462
+
463
+ test("classifyPrLiveness: a null read (transport hiccup) is unknown — never abandons blind", () => {
464
+ assertEquals(classifyPrLiveness(null), "unknown");
465
+ });
package/app/github.ts CHANGED
@@ -484,6 +484,11 @@ export function coalesceTitle(...candidates: (string | null | undefined)[]): str
484
484
  * failing gates (empty in token mode) so the CI-fix agent knows what to make green. */
485
485
  export interface PrState {
486
486
  merged: boolean;
487
+ /** GitHub's high-level PR lifecycle state, normalised to `"open" | "closed" | "merged"`. A PR
488
+ * closed *without* merging reports `"closed"` (GitHub also reports a merged PR as `"closed"` on
489
+ * the REST list, but `merged` disambiguates it). Lets a caller gate on PR liveness — see
490
+ * `classifyPrLiveness` — so neither loop escalates against a non-open PR (#342). */
491
+ state: "open" | "closed" | "merged";
487
492
  mergeStateStatus: string;
488
493
  failingChecks: number;
489
494
  failingCheckNames: string[];
@@ -584,8 +589,10 @@ export async function fetchPrState(
584
589
  };
585
590
  const rollup = j.statusCheckRollup ?? [];
586
591
  const names = failingCheckNames(rollup);
592
+ const merged = j.state === "MERGED" || !!j.mergedAt;
587
593
  return {
588
- merged: j.state === "MERGED" || !!j.mergedAt,
594
+ merged,
595
+ state: merged ? "merged" : (j.state ?? "").toUpperCase() === "CLOSED" ? "closed" : "open",
589
596
  mergeStateStatus: (j.mergeStateStatus || "UNKNOWN").toUpperCase(),
590
597
  failingChecks: names.length,
591
598
  failingCheckNames: names,
@@ -604,14 +611,19 @@ export async function fetchPrState(
604
611
  const j = (await r.json()) as {
605
612
  merged?: boolean;
606
613
  merged_at?: string | null;
614
+ state?: string;
607
615
  mergeable_state?: string;
608
616
  draft?: boolean;
609
617
  head?: { sha?: string | null };
610
618
  };
619
+ const restMerged = !!j.merged || !!j.merged_at;
611
620
  return {
612
621
  // The single-PR GET returns a `merged` boolean (unlike the list endpoint); we also honour
613
622
  // `merged_at` so this mirrors the gh branch's `state === "MERGED" || mergedAt` rule.
614
- merged: !!j.merged || !!j.merged_at,
623
+ merged: restMerged,
624
+ // REST reports a merged PR as `state:"closed"` too, so `merged` disambiguates: a `closed` PR
625
+ // here is genuinely closed WITHOUT merging (e.g. superseded) — the #342 abandon case.
626
+ state: restMerged ? "merged" : (j.state ?? "").toLowerCase() === "closed" ? "closed" : "open",
615
627
  mergeStateStatus: normalizeMergeState(j.mergeable_state ?? "unknown"),
616
628
  failingChecks: -1, // REST here doesn't enumerate checks → classifier treats BLOCKED as "wait"
617
629
  failingCheckNames: [], // …and the CI-fix agent gets no per-check list in token mode
@@ -622,6 +634,27 @@ export async function fetchPrState(
622
634
  };
623
635
  }
624
636
 
637
+ /** Map a PR's live GitHub state to one **liveness** verdict shared by both durable loops (merge +
638
+ * convergence), so neither can ever escalate against a non-open PR (#342):
639
+ *
640
+ * • `open` — proceed with the normal protocol.
641
+ * • `merged` — already landed (out-of-band); complete the loop as merged.
642
+ * • `closed` — closed on GitHub WITHOUT merging (e.g. superseded); the PR can never merge, so
643
+ * the loop must **abandon** (terminate) it — NOT escalate a merge no human can
644
+ * complete. This is terminal state, not a human decision.
645
+ * • `unknown` — a transport hiccup left us without live state (`fetchPrState` returned null);
646
+ * stay conservative and fall through to the normal path rather than abandoning a
647
+ * PR we could not read.
648
+ *
649
+ * Deriving all three from one source keeps a single canonical liveness gate instead of each loop
650
+ * re-implementing `pre?.merged`/closed checks against drifting field names. */
651
+ export function classifyPrLiveness(pre: PrState | null): "open" | "merged" | "closed" | "unknown" {
652
+ if (!pre) return "unknown";
653
+ if (pre.merged) return "merged";
654
+ if (pre.state === "closed") return "closed";
655
+ return "open";
656
+ }
657
+
625
658
  /** The changed file paths of a PR (for the D2 conflict-scan, #58). `gh` returns them directly;
626
659
  * the token transport pages `/pulls/{n}/files` (100/page, capped). Returns `null` when no
627
660
  * transport is usable (idle), an empty array for a PR with no files. */
@@ -0,0 +1,173 @@
1
+ // Read-model derivation + projection test for the merged-per-day throughput chart (issue #344).
2
+ //
3
+ // `deriveMergesPerDay` is the single source of truth behind the denormalised `merges_per_day` table
4
+ // the Velocity page reads. It must: count DISTINCT PRs per calendar day (a PR with several `merged`
5
+ // audit rows on one day — an `already-merged` short-circuit or a retry — counts once); ignore
6
+ // `queued`/`blocked` attempts entirely; order days ascending; carry a running burn-up `cumulative`;
7
+ // and scale each day's `bar` against the busiest day. `pollMergesPerDay` must project that onto the
8
+ // read table idempotently — a steady-state re-run writes nothing, and a day dropped from the audit is
9
+ // pruned.
10
+ import { test } from "node:test";
11
+ import { assert, assertEquals } from "#test-assert";
12
+ import type { DataLayer } from "@nanobpm/urban";
13
+ import { deriveMergesPerDay, type MergeAuditRow, pollMergesPerDay } from "./mergesPerDay.ts";
14
+
15
+ // A tiny in-memory record gateway (all/find/insert/update/delete), mirroring the fake-app style used
16
+ // across the app tests (see app/delivery.test.ts), enough to exercise the `pollMergesPerDay`
17
+ // projection.
18
+ function memData(): { data: DataLayer; stores: Record<string, any[]>; writes: () => number } {
19
+ const stores: Record<string, any[]> = {};
20
+ let writes = 0;
21
+ function tbl(name: string, pk = "id") {
22
+ const rows = (stores[name] ??= [] as any[]);
23
+ return {
24
+ async all() {
25
+ return rows.slice();
26
+ },
27
+ async get(id: any) {
28
+ return rows.find((r) => r[pk] === id);
29
+ },
30
+ async find(where: any = {}) {
31
+ return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
32
+ },
33
+ async insert(row: any) {
34
+ writes++;
35
+ rows.push({ ...row });
36
+ return row[pk];
37
+ },
38
+ async update(id: any, patch: any) {
39
+ writes++;
40
+ const r = rows.find((row) => row[pk] === id);
41
+ if (r) Object.assign(r, patch);
42
+ return 1;
43
+ },
44
+ async delete(id: any) {
45
+ const i = rows.findIndex((row) => row[pk] === id);
46
+ if (i >= 0) {
47
+ writes++;
48
+ rows.splice(i, 1);
49
+ }
50
+ return 1;
51
+ },
52
+ };
53
+ }
54
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
55
+ return { data, stores, writes: () => writes };
56
+ }
57
+
58
+ const merged = (pr_key: string, at: string): MergeAuditRow => ({ pr_key, outcome: "merged", at });
59
+
60
+ test("counts DISTINCT merged PRs per calendar day", () => {
61
+ const days = deriveMergesPerDay([
62
+ merged("o/r#1", "2026-01-01T09:00:00Z"),
63
+ merged("o/r#2", "2026-01-01T18:30:00Z"),
64
+ merged("o/r#3", "2026-01-02T10:00:00Z"),
65
+ ]);
66
+ assertEquals(days.map((d) => [d.day, d.merged]), [
67
+ ["2026-01-01", 2],
68
+ ["2026-01-02", 1],
69
+ ]);
70
+ });
71
+
72
+ test("dedupes duplicate merged rows for the same PR on the same day (COUNT DISTINCT pr_key)", () => {
73
+ const days = deriveMergesPerDay([
74
+ merged("o/r#1", "2026-01-01T09:00:00Z"),
75
+ merged("o/r#1", "2026-01-01T09:00:05Z"), // retry / already-merged short-circuit
76
+ merged("o/r#1", "2026-01-01T23:59:00Z"),
77
+ ]);
78
+ assertEquals(days.length, 1);
79
+ assertEquals(days[0].merged, 1);
80
+ });
81
+
82
+ test("the same PR merged on two different days counts once per day", () => {
83
+ // A defensive case: distinctness is per-day, not global.
84
+ const days = deriveMergesPerDay([
85
+ merged("o/r#1", "2026-01-01T09:00:00Z"),
86
+ merged("o/r#1", "2026-01-02T09:00:00Z"),
87
+ ]);
88
+ assertEquals(days.map((d) => [d.day, d.merged]), [
89
+ ["2026-01-01", 1],
90
+ ["2026-01-02", 1],
91
+ ]);
92
+ });
93
+
94
+ test("ignores queued and blocked attempts", () => {
95
+ const days = deriveMergesPerDay([
96
+ merged("o/r#1", "2026-01-01T09:00:00Z"),
97
+ { pr_key: "o/r#2", outcome: "queued", at: "2026-01-01T09:10:00Z" },
98
+ { pr_key: "o/r#3", outcome: "blocked", at: "2026-01-01T09:20:00Z" },
99
+ ]);
100
+ assertEquals(days.length, 1);
101
+ assertEquals(days[0].merged, 1);
102
+ });
103
+
104
+ test("orders days ascending and carries a running burn-up cumulative", () => {
105
+ const days = deriveMergesPerDay([
106
+ merged("o/r#5", "2026-01-03T10:00:00Z"),
107
+ merged("o/r#1", "2026-01-01T10:00:00Z"),
108
+ merged("o/r#2", "2026-01-01T11:00:00Z"),
109
+ merged("o/r#4", "2026-01-02T10:00:00Z"),
110
+ ]);
111
+ assertEquals(days.map((d) => d.day), ["2026-01-01", "2026-01-02", "2026-01-03"]);
112
+ assertEquals(days.map((d) => d.merged), [2, 1, 1]);
113
+ assertEquals(days.map((d) => d.cumulative), [2, 3, 4]);
114
+ });
115
+
116
+ test("bar scales against the busiest day: full for the max, non-empty for a lone merge, empty for zero", () => {
117
+ const days = deriveMergesPerDay([
118
+ // day A: 4 merges (the max) → widest bar
119
+ merged("o/r#1", "2026-01-01T01:00:00Z"),
120
+ merged("o/r#2", "2026-01-01T02:00:00Z"),
121
+ merged("o/r#3", "2026-01-01T03:00:00Z"),
122
+ merged("o/r#4", "2026-01-01T04:00:00Z"),
123
+ // day B: 1 merge → short but visible bar
124
+ merged("o/r#5", "2026-01-02T01:00:00Z"),
125
+ ]);
126
+ const [a, b] = days;
127
+ assert(a.bar.length > b.bar.length, "the busier day must draw a longer bar");
128
+ assert(b.bar.length >= 1, "a day with any merge must draw at least one glyph");
129
+ assert(a.bar.length <= 30, "the busiest bar must not exceed the configured width");
130
+ });
131
+
132
+ test("empty audit yields no days", () => {
133
+ assertEquals(deriveMergesPerDay([]), []);
134
+ });
135
+
136
+ test("pollMergesPerDay projects the aggregate onto merges_per_day", async () => {
137
+ const { data, stores } = memData();
138
+ stores.merges = [
139
+ { id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" },
140
+ { id: 2, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:05:00Z" }, // dup same day
141
+ { id: 3, pr_key: "o/r#2", outcome: "merged", at: "2026-01-02T09:00:00Z" },
142
+ { id: 4, pr_key: "o/r#3", outcome: "queued", at: "2026-01-02T09:10:00Z" }, // ignored
143
+ ];
144
+ await pollMergesPerDay(data);
145
+ const rows = (stores.merges_per_day ?? []).slice().sort((x, y) => x.day.localeCompare(y.day));
146
+ assertEquals(rows.map((r) => [r.day, r.merged, r.cumulative]), [
147
+ ["2026-01-01", 1, 1],
148
+ ["2026-01-02", 1, 2],
149
+ ]);
150
+ for (const r of rows) assert(typeof r.updated_at === "string" && r.updated_at.length > 0);
151
+ });
152
+
153
+ test("pollMergesPerDay is idempotent — a steady-state re-run writes nothing", async () => {
154
+ const { data, stores, writes } = memData();
155
+ stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
156
+ await pollMergesPerDay(data);
157
+ const afterFirst = writes();
158
+ assert(afterFirst > 0, "the first pass must project at least one row");
159
+ await pollMergesPerDay(data);
160
+ assertEquals(writes(), afterFirst, "a steady-state re-run must not write");
161
+ });
162
+
163
+ test("pollMergesPerDay prunes a day that no longer derives from the audit", async () => {
164
+ const { data, stores } = memData();
165
+ stores.merges_per_day = [
166
+ { day: "2025-12-31", merged: 3, cumulative: 3, bar: "███", updated_at: "old" },
167
+ ];
168
+ stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
169
+ await pollMergesPerDay(data);
170
+ const days = (stores.merges_per_day ?? []).map((r: any) => r.day);
171
+ assert(!days.includes("2025-12-31"), "a stale day must be pruned");
172
+ assert(days.includes("2026-01-01"), "the derived day must be present");
173
+ });
@@ -0,0 +1,150 @@
1
+ // Merged-per-day throughput / burn-up read model (issue #344).
2
+ //
3
+ // A simple time-series behind the console's "Velocity" page: how many PRs the fleet landed each
4
+ // calendar day (throughput), plus a running `cumulative` burn-up total. Every land is already
5
+ // audited in the `merges` table (004_merge.sql) — one row per merge attempt, whose `outcome`
6
+ // includes `merged`, `queued`, `blocked` and `retry` (see the merge classifier in github.ts; the
7
+ // set is not exhaustive), `at` = ISO timestamp. Only `outcome = 'merged'` rows feed this aggregate,
8
+ // so merged-per-day is
9
+ // fully DERIVABLE from that audit trail with NO new write-path bookkeeping (AGENTS.md: "Derivation
10
+ // over duplication"). The canonical aggregate is the one in the issue:
11
+ //
12
+ // SELECT date(at) AS day, COUNT(DISTINCT pr_key) AS merged
13
+ // FROM merges WHERE outcome = 'merged' GROUP BY date(at);
14
+ //
15
+ // Two halves, mirroring the `deriveDelivery`/`pollDelivery` and `deriveLineage`/`pollLineage`
16
+ // convention:
17
+ // • `deriveMergesPerDay` — a PURE function: merge audit rows → one ordered `MergeDay` per calendar
18
+ // day (merged count, burn-up cumulative, a proportional bar string). No I/O, fully tested. Counts
19
+ // `COUNT(DISTINCT pr_key)` — a PR with several `merged` rows on one day (an `already-merged`
20
+ // short-circuit or retry) counts once — and ignores `queued`/`blocked` rows entirely.
21
+ // • `pollMergesPerDay` — the gateway glue: read the `merges` rows and project them onto the
22
+ // `merges_per_day` read table (051_merges_per_day.sql) the schema-driven Velocity page binds. A
23
+ // denormalised flat table because Urban's datasource cannot read a SQL VIEW (gateway.ts
24
+ // `schema()` whitelists `type='table'` only — same reason `lineage_threads`/`plans.delivery` are
25
+ // flat tables). Writes only when a day's projection actually changes, so a steady-state pass is a
26
+ // no-op.
27
+ import type { DataLayer } from "@nanobpm/urban";
28
+
29
+ const now = () => new Date().toISOString();
30
+
31
+ /** The subset of a `merges` audit row (004_merge.sql) the projection reads. */
32
+ export interface MergeAuditRow {
33
+ pr_key: string;
34
+ outcome: string;
35
+ at: string;
36
+ }
37
+
38
+ /** One projected calendar day of merge throughput. */
39
+ export interface MergeDay {
40
+ /** Calendar day, ISO `YYYY-MM-DD` (SQLite `date(at)`). */
41
+ day: string;
42
+ /** Distinct PRs merged that day (`COUNT(DISTINCT pr_key)`). */
43
+ merged: number;
44
+ /** Running total of merged PRs up to and including this day — the burn-up line. */
45
+ cumulative: number;
46
+ /** Proportional block-character bar (length scaled to the busiest day), for the prose chart. */
47
+ bar: string;
48
+ }
49
+
50
+ /** Widest bar (in block glyphs) the busiest day draws; every other day scales against it. */
51
+ const BAR_WIDTH = 30;
52
+ const BAR_FULL = "█";
53
+
54
+ /** The calendar day of an ISO timestamp — the JS twin of SQLite `date(at)`. A well-formed `merges.at`
55
+ * is an ISO string, so the first 10 chars are `YYYY-MM-DD`; fall back to the whole trimmed value for
56
+ * any non-ISO shape so a malformed row still groups deterministically rather than throwing. */
57
+ function dayOf(at: string): string {
58
+ const s = String(at).trim();
59
+ return /^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : s;
60
+ }
61
+
62
+ /** Render a proportional bar: `merged` glyphs scaled against the busiest day's `max`, min one glyph
63
+ * for any non-zero day so a lone merge is still visible. Zero renders as an empty bar. */
64
+ function barFor(merged: number, max: number): string {
65
+ if (merged <= 0 || max <= 0) return "";
66
+ const n = Math.max(1, Math.round((merged / max) * BAR_WIDTH));
67
+ return BAR_FULL.repeat(n);
68
+ }
69
+
70
+ /** PURE aggregate: merge audit rows → one ordered `MergeDay` per calendar day (ascending).
71
+ *
72
+ * Only `outcome === "merged"` rows count; `queued`/`blocked` attempts are ignored. Within a day a
73
+ * `pr_key` is counted once (`COUNT(DISTINCT pr_key)`), so duplicate `merged` audit rows — an
74
+ * `already-merged` short-circuit or a retry — do not double-count. `cumulative` is the running total
75
+ * across days (burn-up); `bar` is scaled against the busiest day so the chart is comparable. */
76
+ export function deriveMergesPerDay(rows: readonly MergeAuditRow[]): MergeDay[] {
77
+ // day -> set of distinct merged pr_keys that day.
78
+ const prKeysByDay = new Map<string, Set<string>>();
79
+ for (const r of rows) {
80
+ if (r.outcome !== "merged") continue;
81
+ if (r.pr_key == null || r.at == null) continue;
82
+ const day = dayOf(r.at);
83
+ let set = prKeysByDay.get(day);
84
+ if (!set) {
85
+ set = new Set<string>();
86
+ prKeysByDay.set(day, set);
87
+ }
88
+ set.add(r.pr_key);
89
+ }
90
+
91
+ const counts = [...prKeysByDay.entries()]
92
+ .map(([day, set]) => ({ day, merged: set.size }))
93
+ .sort((a, b) => a.day.localeCompare(b.day));
94
+ const max = counts.reduce((m, c) => Math.max(m, c.merged), 0);
95
+
96
+ let cumulative = 0;
97
+ const out: MergeDay[] = [];
98
+ for (const { day, merged } of counts) {
99
+ cumulative += merged;
100
+ out.push({ day, merged, cumulative, bar: barFor(merged, max) });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /** The denormalised read-table row `pollMergesPerDay` projects, one per calendar day. */
106
+ interface MergesPerDayRow extends MergeDay {
107
+ updated_at: string;
108
+ }
109
+
110
+ const mergesPerDay = (data: DataLayer) => data.table<MergesPerDayRow>("merges_per_day", "day");
111
+ const mergesAudit = (data: DataLayer) => data.table<MergeAuditRow>("merges", "id");
112
+
113
+ /** Idempotent read-model pass: recompute merged-per-day from the `merges` audit table and denormalise
114
+ * it onto the `merges_per_day` read table the Velocity page reads. Additive/derived only — never
115
+ * touches `merges`. Upserts a day only when its projection actually changes (so a steady-state pass is
116
+ * a no-op) and prunes any stale day row that no longer derives (defensive — days are append-only in
117
+ * practice, but a purge/rewrite of the audit must not leave a phantom). */
118
+ export async function pollMergesPerDay(data: DataLayer): Promise<void> {
119
+ try {
120
+ // Only `outcome === "merged"` rows contribute to the aggregate, so filter at the read rather than
121
+ // scanning queued/blocked rows as the audit grows (deriveMergesPerDay ignores non-merged rows too).
122
+ const audit = await mergesAudit(data).find({ outcome: "merged" });
123
+ const want = deriveMergesPerDay(audit);
124
+ const wantByDay = new Map(want.map((d) => [d.day, d]));
125
+
126
+ const existing = await mergesPerDay(data).all();
127
+ const existingByDay = new Map(existing.map((r) => [r.day, r]));
128
+
129
+ for (const d of want) {
130
+ const cur = existingByDay.get(d.day);
131
+ if (!cur) {
132
+ await mergesPerDay(data).insert({ ...d, updated_at: now() });
133
+ } else if (cur.merged !== d.merged || cur.cumulative !== d.cumulative || cur.bar !== d.bar) {
134
+ await mergesPerDay(data).update(d.day, {
135
+ merged: d.merged,
136
+ cumulative: d.cumulative,
137
+ bar: d.bar,
138
+ updated_at: now(),
139
+ });
140
+ }
141
+ }
142
+
143
+ // Prune any projected day that no longer derives from the audit trail.
144
+ for (const r of existing) {
145
+ if (!wantByDay.has(r.day)) await mergesPerDay(data).delete(r.day);
146
+ }
147
+ } catch (err) {
148
+ console.error(`[poller] merges-per-day: ${err}`);
149
+ }
150
+ }
@@ -11,6 +11,7 @@ import { queuedVerdict } from "./service.ts";
11
11
  function st(over: Partial<PrState>): PrState {
12
12
  return {
13
13
  merged: false,
14
+ state: "open",
14
15
  mergeStateStatus: "CLEAN",
15
16
  failingChecks: 0,
16
17
  failingCheckNames: [],
@@ -76,7 +76,10 @@ test("the migrated wait stays bounded: an event-based gateway races the signal a
76
76
  });
77
77
 
78
78
  test("the timeout arm escalates to a human (the wait cannot hang forever)", () => {
79
- // timer catch → persist-review-stalled (records the escalation) → wait-answer (native userTask).
79
+ // timer catch → persist-review-stalled (records the escalation) → gw-escalated → wait-answer.
80
+ // Since #340 every escalation arm converges on the shared `gw-escalated` gateway (no per-arm twin
81
+ // edge to the userTask — derivation over duplication), which routes to the native `wait-answer`
82
+ // userTask when an escalation actually opened (`escalated = true`). Assert that canonical path.
80
83
  assert(
81
84
  /sourceRef="wait-review-timeout"[^>]*targetRef="persist-review-stalled"|targetRef="persist-review-stalled"[^>]*sourceRef="wait-review-timeout"/.test(
82
85
  flat,
@@ -84,9 +87,18 @@ test("the timeout arm escalates to a human (the wait cannot hang forever)", () =
84
87
  "a timed-out review wait routes to the stalled-review escalation",
85
88
  );
86
89
  assert(
87
- /sourceRef="persist-review-stalled"[^>]*targetRef="wait-answer"|targetRef="wait-answer"[^>]*sourceRef="persist-review-stalled"/.test(
90
+ /sourceRef="persist-review-stalled"[^>]*targetRef="gw-escalated"|targetRef="gw-escalated"[^>]*sourceRef="persist-review-stalled"/.test(
88
91
  flat,
89
92
  ),
90
- "the stalled-review escalation parks on the human answer userTask",
93
+ "the stalled-review escalation converges on the shared gw-escalated gateway",
94
+ );
95
+ const escWait = flat.match(
96
+ /<bpmn:sequenceFlow\b[^>]*\bsourceRef="gw-escalated"[^>]*\btargetRef="wait-answer"[^>]*>[\s\S]*?<\/bpmn:sequenceFlow>/,
97
+ );
98
+ assert(escWait, "gw-escalated must route to the human answer userTask (wait-answer)");
99
+ assertStringIncludes(
100
+ escWait![0],
101
+ "escalated = true",
102
+ "gw-escalated parks on wait-answer only when an escalation actually opened",
91
103
  );
92
104
  });
package/app/service.ts CHANGED
@@ -25,6 +25,7 @@ import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
25
25
  import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
26
26
  import {
27
27
  classifyMergeability,
28
+ classifyPrLiveness,
28
29
  coalesceTitle,
29
30
  ensureFreshHeadRun,
30
31
  ensurePromotionPr,
@@ -42,6 +43,7 @@ import {
42
43
  import { pollLineage } from "./lineage.ts";
43
44
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
44
45
  import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
46
+ import { pollMergesPerDay } from "./mergesPerDay.ts";
45
47
  import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
46
48
  import {
47
49
  backfillPlanBuckets,
@@ -925,7 +927,8 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
925
927
  try {
926
928
  const st = await fetchPrState(repo, number, token);
927
929
  if (st === null) continue; // no transport → skip this PR (others may still advance)
928
- if (st.merged) {
930
+ const liveness = classifyPrLiveness(st);
931
+ if (liveness === "merged") {
929
932
  // Landed out-of-band (a maintainer clicked Merge, a mergify queue merged it, etc.). The
930
933
  // instance is parked at `wait-mergeable`, which subscribes to `merge-ready` — NOT
931
934
  // `merge-landed` (that catch, `wait-landed`, only exists later, after we enqueue). Publishing
@@ -941,6 +944,21 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
941
944
  console.log(`[poller] already merged -> ${prKey}`);
942
945
  continue;
943
946
  }
947
+ if (liveness === "closed") {
948
+ // Closed on GitHub WITHOUT merging (e.g. superseded by a newer PR — #350). The PR can never
949
+ // land, so it must NOT be classified as blocked/conflict and escalated (that orphans the
950
+ // process on a dead PR, #342). Route it through the same canonical `merge-ready` → `ready` →
951
+ // `attempt-merge` path as the merged case; the merge worker's closed short-circuit records a
952
+ // terminal `abandoned` audit row and drives the loop down its terminate/abandon end event.
953
+ // One canonical abandon implementation lives in the worker — the poller only routes to it.
954
+ await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
955
+ name: "merge-ready",
956
+ correlationKey: prKey,
957
+ variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
958
+ });
959
+ console.log(`[poller] closed without merging -> ${prKey}`);
960
+ continue;
961
+ }
944
962
  const verdict = classifyMergeability(st);
945
963
  if (verdict === "waiting") {
946
964
  // Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
@@ -2099,6 +2117,7 @@ export async function pollOnce(
2099
2117
  await pollPromotion(data, engine, token);
2100
2118
  await pollFeatureDelivery(data);
2101
2119
  await pollLineage(data);
2120
+ await pollMergesPerDay(data);
2102
2121
  await pollUserTasks(data, engine);
2103
2122
  if (engineRest) {
2104
2123
  const base = engineRest.restAddress.replace(/\/+$/, "");
@@ -0,0 +1,33 @@
1
+ -- Merged-per-day throughput / burn-up read model (issue #344).
2
+ --
3
+ -- Every land is already audited in `merges` (004_merge.sql): one row per merge attempt, whose
4
+ -- `outcome` includes `merged`, `queued`, `blocked` and `retry` (the set is not exhaustive) and
5
+ -- `at` = ISO timestamp. Only `outcome = 'merged'` rows count. The per-calendar-day merged count is
6
+ -- therefore fully DERIVABLE from that audit trail — no new write-path bookkeeping (AGENTS.md:
7
+ -- "Derivation over duplication"). The canonical aggregate is:
8
+ --
9
+ -- SELECT date(at) AS day, COUNT(DISTINCT pr_key) AS merged
10
+ -- FROM merges WHERE outcome = 'merged' GROUP BY date(at);
11
+ --
12
+ -- `COUNT(DISTINCT pr_key)` (not `COUNT(*)`) so a PR that produced several audit rows on one day —
13
+ -- e.g. an `already-merged` short-circuit or a retry — counts once per day.
14
+ --
15
+ -- The natural home for that aggregate would be a SQL VIEW, but Urban's page datasource cannot read
16
+ -- one: `gateway.ts schema()` whitelists `sqlite_master.type = 'table'` only, so a page binding to a
17
+ -- VIEW 400s at request time (see the `lineage_threads` / `plans.delivery` precedent, 037_lineage.sql).
18
+ -- So — following the codebase convention for read-model projections — this is a DENORMALISED flat
19
+ -- table the schema-driven Velocity page reads directly, recomputed idempotently each poll pass by
20
+ -- `pollMergesPerDay` (app/mergesPerDay.ts) from the `merges` audit rows. `cumulative` is the burn-up
21
+ -- running total; `bar` is a precomputed proportional block-character bar so the declarative `prose`
22
+ -- renderer can draw a horizontal bar per day with no chart node type.
23
+ --
24
+ -- Forward-only, additive (expand): a new derived read table. The runner wraps each file in its own
25
+ -- transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after the current highest prefix
26
+ -- (050). `day` is the PRIMARY KEY, which SQLite already indexes, so no extra index is needed.
27
+ CREATE TABLE IF NOT EXISTS merges_per_day (
28
+ day TEXT PRIMARY KEY, -- calendar day (date(at), ISO YYYY-MM-DD)
29
+ merged INTEGER NOT NULL DEFAULT 0, -- COUNT(DISTINCT pr_key) merged that day
30
+ cumulative INTEGER NOT NULL DEFAULT 0, -- running total of merged PRs up to and including `day` (burn-up)
31
+ bar TEXT NOT NULL DEFAULT '', -- proportional block-character bar for the prose chart
32
+ updated_at TEXT NOT NULL
33
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.101.0",
3
+ "version": "0.102.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",
package/pages/_nav.json CHANGED
@@ -23,7 +23,8 @@
23
23
  }
24
24
  },
25
25
  { "label": "Cockpit", "page": "cockpit" },
26
- { "label": "Board", "page": "board" }
26
+ { "label": "Board", "page": "board" },
27
+ { "label": "Velocity", "page": "velocity" }
27
28
  ],
28
29
  "sticky": true
29
30
  }
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "label": "Board",
50
50
  "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
51
55
  }
52
56
  ],
53
57
  "sticky": true
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "label": "Board",
50
50
  "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
51
55
  }
52
56
  ],
53
57
  "sticky": true
@@ -48,6 +48,10 @@
48
48
  {
49
49
  "label": "Board",
50
50
  "page": "board"
51
+ },
52
+ {
53
+ "label": "Velocity",
54
+ "page": "velocity"
51
55
  }
52
56
  ],
53
57
  "sticky": true