@nanobpm/nano-workforce 0.131.1 → 0.133.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,15 @@
1
+ ## [0.133.0](https://github.com/nanobpm/nano-workforce/compare/v0.132.0...v0.133.0) (2026-08-24)
2
+
3
+ ### Features
4
+
5
+ * **delivery-graph:** connector `converge`/`converge-merge` target enrolls a PR via submitPr (retire the manual land gate) ([#501](https://github.com/nanobpm/nano-workforce/issues/501)) ([a24562e](https://github.com/nanobpm/nano-workforce/commit/a24562eb12db8e6f81e6f382e94886a537f16378)), closes [#500](https://github.com/nanobpm/nano-workforce/issues/500)
6
+
7
+ ## [0.132.0](https://github.com/nanobpm/nano-workforce/compare/v0.131.1...v0.132.0) (2026-08-24)
8
+
9
+ ### Features
10
+
11
+ * **lineage:** surface delivery-graph runs as fan-in parent threads ([#504](https://github.com/nanobpm/nano-workforce/issues/504)) ([c62b925](https://github.com/nanobpm/nano-workforce/commit/c62b92556dd8ec21f42bc70ca4eba5455e448343)), closes [#498](https://github.com/nanobpm/nano-workforce/issues/498) [#498](https://github.com/nanobpm/nano-workforce/issues/498)
12
+
1
13
  ## [0.131.1](https://github.com/nanobpm/nano-workforce/compare/v0.131.0...v0.131.1) (2026-08-24)
2
14
 
3
15
  ### Bug Fixes
@@ -15,10 +15,14 @@ import { join, resolve } from "node:path";
15
15
  import type { DataLayer } from "@nanobpm/urban";
16
16
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
17
17
  import {
18
+ CONVERGE_MERGE_TARGET,
19
+ CONVERGE_TARGET,
18
20
  connectorDedupeKey,
21
+ convergeOnlyForTarget,
19
22
  type DeliveryConnectorDispatchRow,
20
23
  deliveryConnectorDispatches,
21
24
  dispatchConnector,
25
+ isConvergeTarget,
22
26
  } from "./deliveryConnector.ts";
23
27
 
24
28
  const APP_ROOT = resolve(import.meta.dirname, "..");
@@ -84,6 +88,21 @@ test("connectorDedupeKey: author key wins; else derives <processInstanceKey>:<el
84
88
  assertEquals(connectorDedupeKey({ processInstanceKey: 12345, elementId: "n3" }), "12345:n3");
85
89
  });
86
90
 
91
+ test("converge targets: `converge`/`converge-merge` are the enrollment targets; `converge` is review-only", () => {
92
+ assertEquals(CONVERGE_TARGET, "converge");
93
+ assertEquals(CONVERGE_MERGE_TARGET, "converge-merge");
94
+ // Only the two converge literals route into `submitPr`; any other target stays a stub dispatch.
95
+ assert(isConvergeTarget("converge"));
96
+ assert(isConvergeTarget("converge-merge"));
97
+ assert(!isConvergeTarget("slack"));
98
+ assert(!isConvergeTarget("Converge"));
99
+ assert(!isConvergeTarget(""));
100
+ // `convergeOnly` default maps onto `submitPr`'s arg: `converge` stops at converged (true),
101
+ // `converge-merge` drives the merge loop (false) — mirroring converge-feature's autoMerge inversion.
102
+ assertEquals(convergeOnlyForTarget("converge"), true);
103
+ assertEquals(convergeOnlyForTarget("converge-merge"), false);
104
+ });
105
+
87
106
  test("first dispatch delivers exactly once; a redelivery on the same key dedupes and never re-acts", async () => {
88
107
  await withApp(async (app) => {
89
108
  const at = "2025-01-01T00:00:00.000Z";
@@ -33,6 +33,33 @@ export const DELIVERY_CONNECTOR_TASK_TYPE = "pr.delivery-connector";
33
33
  export const OUTCOME_CLAIMED = "claimed";
34
34
  export const OUTCOME_DELIVERED = "delivered";
35
35
 
36
+ /** The two connector `target`s that enroll an agent-opened PR into the app's SHARED convergence /
37
+ * merge doors via `submitPr` (issue #500) — the delivery-graph side of the exact seam the feature
38
+ * cell reuses (`workers/converge-feature`), no duplicated machinery. `converge-merge` drives review
39
+ * convergence AND the merge loop; `converge` stops at `converged` (converge-only). This is the "real
40
+ * target dispatch" ADR 0005 deferred as a later slice for the connector I/O surface: a `converge`/
41
+ * `converge-merge` connector IS the "automated, side-effecting outbound action" a connector is
42
+ * defined to be. Named constants so the worker's dispatch branch and the docs/preview can never drift
43
+ * on the literal. */
44
+ export const CONVERGE_TARGET = "converge";
45
+ export const CONVERGE_MERGE_TARGET = "converge-merge";
46
+
47
+ /** Is `target` one of the converge-enrollment targets (`converge` / `converge-merge`)? The single
48
+ * predicate the worker branches on to route a dispatch into `submitPr` instead of the forward-declared
49
+ * stub. */
50
+ export function isConvergeTarget(target: string): boolean {
51
+ return target === CONVERGE_TARGET || target === CONVERGE_MERGE_TARGET;
52
+ }
53
+
54
+ /** The DEFAULT `convergeOnly` for a converge target: `converge` is review-only (`true` — stop at
55
+ * `converged`), `converge-merge` drives the merge loop too (`false`). Maps directly onto `submitPr`'s
56
+ * `convergeOnly` argument (mirroring how `converge-feature` inverts `autoMerge`). An author may still
57
+ * override it per-dispatch via the connector payload's `convergeOnly`. Only ever consulted behind
58
+ * `isConvergeTarget`, so a non-converge target's `false` is unreachable. */
59
+ export function convergeOnlyForTarget(target: string): boolean {
60
+ return target === CONVERGE_TARGET;
61
+ }
62
+
36
63
  /** One durable dispatch-claim row — the at-most-once ledger entry a connector writes before it acts. */
37
64
  export interface DeliveryConnectorDispatchRow extends Record<string, unknown> {
38
65
  id?: number;
@@ -77,16 +104,27 @@ export function connectorDedupeKey(input: {
77
104
  return null;
78
105
  }
79
106
 
80
- /** The forward-declared connector I/O surface (ADR non-goal the concrete scheme is deferred). A STUB
81
- * that "performs" the action by returning a deterministic acknowledgement; a later slice replaces the
82
- * body with the real transport without touching the idempotency envelope around it. */
83
- function performConnectorAction(_input: {
107
+ /** The side effect a connector dispatch performs EXACTLY ONCE per dedupe key. It runs only on the claim
108
+ * winner (or a resumed crashed claim), never on a `deduped` settled redelivery, so a real, non-idempotent
109
+ * side effect (e.g. `submitPr`, which deliberately re-opens a TERMINAL PR) is fenced by the ledger and
110
+ * can never double-fire — the reason the enrollment lives HERE rather than unconditionally around the
111
+ * dispatch. Returns the `detail` recorded on the ledger row. May be async (the real converge enrollment
112
+ * awaits `submitPr`). Must be idempotent so a resumed crashed claim can safely re-perform it — including
113
+ * terminal-safe against a NON-idempotent target (the converge action no-ops when its PR already settled,
114
+ * so a resume can never regress a terminal PR by re-opening it). */
115
+ export type ConnectorAction = (input: {
84
116
  target: string;
85
117
  payload: Record<string, unknown> | null;
86
118
  boundFacts: readonly BoundFact[];
87
- }): { detail: string } {
119
+ }) => { detail: string } | Promise<{ detail: string }>;
120
+
121
+ /** The forward-declared connector I/O surface (ADR non-goal — the concrete scheme is deferred). The
122
+ * DEFAULT `ConnectorAction`: a STUB that "performs" the action by returning a deterministic
123
+ * acknowledgement; a caller with a real side effect (the converge worker's `submitPr` enrollment) injects
124
+ * its own action into `dispatchConnector` instead, without touching the idempotency envelope around it. */
125
+ const performConnectorAction: ConnectorAction = (_input) => {
88
126
  return { detail: "connector stub — I/O surface forward-declared (ADR 0005 non-goal)" };
89
- }
127
+ };
90
128
 
91
129
  /** The result of one connector dispatch attempt. `delivered` — the claim was won and the action fired
92
130
  * exactly once; `deduped` — the key was already claimed (an at-least-once redelivery), so the recorded
@@ -107,13 +145,14 @@ async function resumeOrDedupe(
107
145
  ledger: ReturnType<typeof deliveryConnectorDispatches>,
108
146
  row: DeliveryConnectorDispatchRow,
109
147
  input: { dedupeKey: string; target: string; payload?: Record<string, unknown> | null; boundFacts?: readonly BoundFact[] | null },
148
+ perform: ConnectorAction,
110
149
  ): Promise<ConnectorDispatchResult> {
111
150
  if (row.outcome === OUTCOME_DELIVERED) {
112
151
  return { connectorOutcome: "deduped", connectorDedupeKey: input.dedupeKey, connectorDetail: row.detail ?? "" };
113
152
  }
114
153
  // Still `claimed` — a prior attempt (sequential or the concurrent-race winner) claimed the key but
115
154
  // never recorded delivery. Resume on the existing row rather than dedupe forever on an un-acted claim.
116
- const { detail } = performConnectorAction({
155
+ const { detail } = await perform({
117
156
  target: input.target,
118
157
  payload: input.payload ?? null,
119
158
  boundFacts: input.boundFacts ?? [],
@@ -149,6 +188,7 @@ export async function dispatchConnector(
149
188
  data: DataLayer,
150
189
  input: { dedupeKey: string; target: string; payload?: Record<string, unknown> | null; boundFacts?: readonly BoundFact[] | null },
151
190
  at: string,
191
+ perform: ConnectorAction = performConnectorAction,
152
192
  ): Promise<ConnectorDispatchResult> {
153
193
  const ledger = deliveryConnectorDispatches(data);
154
194
  const existing = await ledger.findOne({ dedupe_key: input.dedupeKey });
@@ -167,7 +207,7 @@ export async function dispatchConnector(
167
207
  if (existing) {
168
208
  // A prior attempt recorded (`delivered`) or claimed-but-crashed (`claimed`) this key. Dedupe or
169
209
  // resume it on the existing row — the ONE decision shared with the fence-loser path below.
170
- return resumeOrDedupe(ledger, existing, input);
210
+ return resumeOrDedupe(ledger, existing, input, perform);
171
211
  }
172
212
  let claimId: number | bigint;
173
213
  try {
@@ -196,11 +236,11 @@ export async function dispatchConnector(
196
236
  // as the sequential path. Deduping a still-`claimed` winner here would complete the job on our ack,
197
237
  // so a winner that then crashed would strand the side effect forever (the engine won't redeliver an
198
238
  // acked job); resuming closes that gap and is safe because the action is idempotent.
199
- if (won) return resumeOrDedupe(ledger, won, input);
239
+ if (won) return resumeOrDedupe(ledger, won, input, perform);
200
240
  return { connectorOutcome: "deduped", connectorDedupeKey: input.dedupeKey, connectorDetail: "" };
201
241
  }
202
242
  // We alone won the claim — perform the side effect exactly once and record its outcome on our row.
203
- const { detail } = performConnectorAction({
243
+ const { detail } = await perform({
204
244
  target: input.target,
205
245
  payload: input.payload ?? null,
206
246
  boundFacts: input.boundFacts ?? [],
@@ -280,6 +280,29 @@ test("sideEffects: agent + connector only; connector carries its dedupeKey", asy
280
280
  assert(!r.sideEffects.some((s) => s.nodeId === "publish"));
281
281
  });
282
282
 
283
+ test("converge-merge worked graph: agent → connector[converge-merge] → wait[pr,merged] compiles with NO human node (retires the manual land gate, #500)", async () => {
284
+ const graph = {
285
+ name: "open → converge+merge → wait merged",
286
+ nodes: [
287
+ { id: "open", kind: "agent", agent: { jobType: "senior:feature", prompt: "Implement the change and open a PR." } },
288
+ { id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "acme/repo#123" } } },
289
+ { id: "merged", kind: "wait", wait: { kind: "pr", target: "acme/repo#123", match: { prState: "merged" }, onTimeout: "escalate" } },
290
+ ],
291
+ edges: [
292
+ { from: "open", to: "land" },
293
+ { from: "land", to: "merged" },
294
+ ],
295
+ };
296
+ const r = await compileOk(graph);
297
+ // The canonical shape has NO human land-* gate — convergence is driven by the connector itself.
298
+ assertEquals(r.humanNodes.length, 0, "no human node bridges the PR to convergence");
299
+ // The connector is a side effect, naming its converge-merge target; the wait gate is read-only.
300
+ const connector = r.sideEffects.find((s) => s.nodeId === "land");
301
+ assertEquals(connector?.kind, "connector");
302
+ assert(connector?.description.includes("converge-merge"), "the side-effect names the converge-merge target");
303
+ assert(!r.sideEffects.some((s) => s.nodeId === "merged"), "the wait gate is not a side effect");
304
+ });
305
+
283
306
  test("resolved edges carry the resolved fromNode and the referenced fact", async () => {
284
307
  const r = await compileOk(RELEASE_RUNBOOK);
285
308
  const factEdge = r.resolved.edges.find((e) => e.from === "watch-b.mergedSha");
@@ -131,6 +131,91 @@ test("feature/self-rooted threads carry no epic phase label", () => {
131
131
  assertEquals(self.epicPhaseLabel, null, "a self-rooted PR is not an epic slice");
132
132
  });
133
133
 
134
+ // ── delivery-graph fan-in parent (issue #498) ─────────────────────────────────────────────────
135
+
136
+ test("delivery: a running run with no PR yet is implementing, frontier from its phase", () => {
137
+ const t = deriveLineage(
138
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "running", phase: "Running", processKey: "d1" },
139
+ [],
140
+ );
141
+ assertEquals(t.kind, "delivery");
142
+ assertEquals(t.rootRequestKey, "dg-abc");
143
+ assertEquals(t.stage, "implementing");
144
+ assertEquals(t.stageLabel, "Running", "the frontier reflects the run's derived phase");
145
+ assert(t.active, "a running run is active");
146
+ assertEquals(t.processKey, "d1");
147
+ assertEquals(t.title, "Ship widget");
148
+ assertEquals(t.issueUrl, null, "a delivery run is keyed by run_key, not a GitHub issue");
149
+ assertEquals(t.epicPhaseLabel, null, "a delivery run's member PRs are not epic slices");
150
+ assertEquals(t.prCount, 0);
151
+ });
152
+
153
+ test("delivery: downstream PR convergences nest under the run and temper the frontier to converging", () => {
154
+ const t = deriveLineage(
155
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "running", phase: "Parked on human node: publish", processKey: "d1" },
156
+ [
157
+ pr({ prKey: "a/b#1", status: "merged" }),
158
+ pr({ prKey: "c/d#9", status: "converging", processKey: "c9" }),
159
+ ],
160
+ );
161
+ assertEquals(t.kind, "delivery");
162
+ assertEquals(t.stage, "converging", "a member PR still in flight tempers the frontier to converging");
163
+ assertEquals(t.stageLabel, "Parked on human node: publish", "the label still prefers the run's stamped phase");
164
+ assertEquals(t.processKey, "c9", "frontier prefers the in-flight member PR's instance");
165
+ assertEquals(t.prKeys, ["a/b#1", "c/d#9"], "heterogeneous downstream PRs across repos nest under the run");
166
+ assert(t.active);
167
+ });
168
+
169
+ test("delivery: a done run settles as resolved (no active frontier)", () => {
170
+ const t = deriveLineage(
171
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "done", phase: "Completed", processKey: "d1" },
172
+ [pr({ prKey: "a/b#1", status: "merged" })],
173
+ );
174
+ assertEquals(t.stage, "resolved");
175
+ assertEquals(t.stageLabel, "Completed");
176
+ assert(!t.active, "a completed run has no active frontier");
177
+ });
178
+
179
+ test("delivery: a failed run settles as abandoned", () => {
180
+ const t = deriveLineage(
181
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "failed", phase: "Failed", processKey: "d1" },
182
+ [],
183
+ );
184
+ assertEquals(t.stage, "abandoned");
185
+ assert(!t.active);
186
+ });
187
+
188
+ test("delivery: an abandoned run with no phase is labeled 'Abandoned', not 'Failed'", () => {
189
+ // `deliveryOriginStage` folds both `failed` and `abandoned` statuses onto the `abandoned` stage, so
190
+ // the label must consult the run status: a genuinely abandoned run reads "Abandoned" (only a failed
191
+ // one reads "Failed"). Regress with no stamped phase so the status-derived fallback label is used.
192
+ const t = deriveLineage(
193
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "abandoned", phase: null, processKey: "d1" },
194
+ [],
195
+ );
196
+ assertEquals(t.stage, "abandoned");
197
+ assertEquals(t.stageLabel, "Abandoned", "an abandoned run is not mislabeled as failed");
198
+ assert(!t.active);
199
+ });
200
+
201
+ test("delivery: a failed run with no phase is labeled 'Failed'", () => {
202
+ const t = deriveLineage(
203
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "failed", phase: null, processKey: "d1" },
204
+ [],
205
+ );
206
+ assertEquals(t.stage, "abandoned");
207
+ assertEquals(t.stageLabel, "Failed");
208
+ });
209
+
210
+ test("delivery: with no stamped phase, the frontier falls back to a status-derived label", () => {
211
+ const t = deriveLineage(
212
+ { kind: "delivery", key: "dg-abc", title: "Ship widget", status: "running", phase: null, processKey: "d1" },
213
+ [],
214
+ );
215
+ assertEquals(t.stage, "implementing");
216
+ assertEquals(t.stageLabel, "Running", "null phase falls back to the status-derived frontier label");
217
+ });
218
+
134
219
  // ── self-rooted (human/webhook) PR ───────────────────────────────────────────────────────────
135
220
 
136
221
  test("pr: a human/webhook PR with no origin is its own root", () => {
@@ -237,6 +322,67 @@ test("pollLineage: projects feature, epic, and self-rooted threads onto lineage_
237
322
  assertEquals(after, before, "steady-state pass is a no-op");
238
323
  });
239
324
 
325
+ test("pollLineage: projects a delivery-graph run as a fan-in parent thread with its downstream PRs nested", async () => {
326
+ // Issue #498: a dispatched delivery-graph run appears as its own thread, and the downstream PR
327
+ // convergences threaded to it (root_request_key = run_key) across DIFFERENT repos nest under it
328
+ // rather than appearing as disconnected self-rooted PRs.
329
+ const { data, stores } = memData();
330
+ stores.feature_runs = [];
331
+ stores.plans = [];
332
+ stores.plan_tasks = [];
333
+ stores.delivery_graph_runs = [
334
+ { run_key: "dg-xyz", title: "Ship widget across repos", status: "running", phase: "Running", process_key: "P-dg" },
335
+ ];
336
+ stores.pull_requests = [
337
+ { pr_key: "a/b#1", title: "widget in a/b", url: "x", status: "merged", current_round: 1, process_key: "c1", outcome: null, root_request_key: "dg-xyz" },
338
+ { pr_key: "c/d#9", title: "widget in c/d", url: "x", status: "converging", current_round: 2, process_key: "c2", outcome: null, root_request_key: "dg-xyz" },
339
+ ];
340
+
341
+ await pollLineage(data);
342
+
343
+ const threads: LineageThreadRow[] = stores.lineage_threads;
344
+ assertEquals(threads.length, 1, "one delivery thread, not two disconnected self-rooted PRs");
345
+ const dg = threads.find((t) => t.root_request_key === "dg-xyz");
346
+ assert(dg, "delivery thread present, keyed on run_key");
347
+ // A member PR still converging tempers the frontier to converging; the label prefers the run phase.
348
+ assertEquals(dg?.stage, "converging");
349
+ assertEquals(dg?.stage_label, "Running");
350
+ assertEquals(dg?.active, 1);
351
+ assertEquals(dg?.pr_count, 2);
352
+ assertEquals(JSON.parse(dg?.pr_keys ?? "[]").sort(), ["a/b#1", "c/d#9"]);
353
+ // A delivery run's member PRs are not epic slices, so no epic phase label is projected onto them.
354
+ const prById = (k: string) => stores.pull_requests.find((r: any) => r.pr_key === k);
355
+ assertEquals(prById("a/b#1").epic_phase_label ?? null, null);
356
+ assertEquals(prById("c/d#9").epic_phase_label ?? null, null);
357
+ });
358
+
359
+ test("pollLineage: a delivery run_key colliding with a feature key does not overwrite the feature thread", async () => {
360
+ // The SQL view (migration 079) classifies epic > feature > delivery, so a `delivery_graph_runs.run_key`
361
+ // that equals an existing `feature_key`/`plan_key` must NOT clobber that earlier thread — otherwise the
362
+ // poller would stamp delivery-derived frontier columns onto a row the view still classifies feature/epic,
363
+ // and the two projections drift. The colliding run is skipped; feature precedence is preserved.
364
+ const { data, stores } = memData();
365
+ stores.feature_runs = [
366
+ { feature_key: "o/r#7", title: "Feature seven", issue_url: "u7", status: "converging", process_key: "f7", pr_key: "o/r#700" },
367
+ ];
368
+ stores.plans = [];
369
+ stores.plan_tasks = [];
370
+ stores.delivery_graph_runs = [
371
+ { run_key: "o/r#7", title: "Colliding run", status: "running", phase: "Running", process_key: "P-dup" },
372
+ ];
373
+ stores.pull_requests = [
374
+ { pr_key: "o/r#700", title: "Feat PR", url: "x", status: "converging", current_round: 2, process_key: "c1", outcome: null, root_request_key: "o/r#7" },
375
+ ];
376
+
377
+ await pollLineage(data);
378
+
379
+ const threads: LineageThreadRow[] = stores.lineage_threads;
380
+ assertEquals(threads.length, 1, "the colliding run does not create a second row for the same key");
381
+ const t = threads.find((r) => r.root_request_key === "o/r#7");
382
+ assert(t, "the single thread for the shared key is the feature thread");
383
+ assertEquals(t?.stage_label, "Converging (round 2)", "feature frontier wins; the delivery run did not overwrite it");
384
+ });
385
+
240
386
  test("pollLineage: a self-rooted PR row (root_request_key === pr_key) projects exactly one thread keyed on its pr_key", async () => {
241
387
  // Regression (#245): submitPr now self-roots a human/webhook PR on its own `pr_key` (rather than
242
388
  // NULL) so the Lineage page's `lineage_threads.root_request_key → pull_requests.root_request_key`
package/app/lineage.ts CHANGED
@@ -20,14 +20,15 @@
20
20
  // `pr_key` (kind `pr`), and also tolerates a legacy NULL `root_request_key` the same way.
21
21
  import type { DataLayer } from "@nanobpm/urban";
22
22
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
23
+ import { type DeliveryGraphRun, deliveryGraphRuns } from "./deliveryGraphRun.ts";
23
24
  import { type FeatureRun, featureRuns } from "./feature.ts";
24
25
  import { derivedTrackingTable } from "./instanceTracking.ts";
25
26
  import { type Plan, type PlanTask, plans, planTasks } from "./plan.ts";
26
27
 
27
28
  const now = () => new Date().toISOString();
28
29
 
29
- /** The three origin shapes a lineage arc can spring from. */
30
- export type LineageKind = "feature" | "epic" | "pr";
30
+ /** The origin shapes a lineage arc can spring from. */
31
+ export type LineageKind = "feature" | "epic" | "pr" | "delivery";
31
32
 
32
33
  /** A member PR of a lineage thread — the subset of `pull_requests` the projection reads. */
33
34
  export interface LineagePr {
@@ -66,6 +67,23 @@ export type LineageOrigin =
66
67
  // A human/webhook PR with no originating request: its own root.
67
68
  kind: "pr";
68
69
  key: string;
70
+ }
71
+ | {
72
+ // A delivery-graph run (issue #498): a FAN-IN parent thread. The run is the thread root, and
73
+ // the heterogeneous downstream tasks it spawns — PR convergences across different repos/issue
74
+ // numbers, package publishes, human gates — nest under it (they thread `root_request_key =
75
+ // run_key`, mirroring how `submitPr` threads feature/epic roots). Closer to an epic than a
76
+ // single-PR arc.
77
+ kind: "delivery";
78
+ key: string;
79
+ title: string | null;
80
+ status: string;
81
+ // The run's already-derived display phase (`delivery_graph_runs.phase`, recomputed by
82
+ // `pollDeliveryGraphPhase` from engine truth — generalised from `epic_phase`), e.g. "Running",
83
+ // "Parked on human node: manual OTP publish", "Completed". NULL until the poller stamps one; the
84
+ // thread then falls back to a status-derived frontier label.
85
+ phase: string | null;
86
+ processKey: string | null;
69
87
  };
70
88
 
71
89
  /** One stitched arc: `request → implementation → PR(s) → convergence → merge → outcome`. */
@@ -241,6 +259,18 @@ export function deriveLineage(origin: LineageOrigin, prsIn: readonly LineagePr[]
241
259
  stageLabel = featureStageLabel(stage);
242
260
  processKey = origin.processKey ?? rep?.processKey ?? null;
243
261
  }
262
+ } else if (origin.kind === "delivery") {
263
+ // Fan-in parent (issue #498): the delivery-graph RUN is the thread root; its heterogeneous
264
+ // downstream PR convergences (across different repos/issues) nest under it. Unlike an epic's
265
+ // slice rollup, a run's narrative is "where is the run" — so the frontier reflects the run's OWN
266
+ // derived phase (`delivery_graph_runs.phase`), with member PRs shown as nested children. The
267
+ // machine `stage` is derived from the run status (tempered to `converging` while a member PR is
268
+ // still in flight); the human `stageLabel` prefers the run's stamped phase, else a status label.
269
+ stage = deliveryOriginStage(origin.status, prs);
270
+ stageLabel = origin.phase ?? deliveryStageLabel(stage, origin.status);
271
+ // Active-frontier instance: an in-flight member PR's process, else the run's own.
272
+ const activePr = prs.find((p) => !TERMINAL_STATUSES.includes(p.status));
273
+ processKey = activePr?.processKey ?? origin.processKey ?? rep?.processKey ?? null;
244
274
  } else {
245
275
  // Self-rooted PR (human/webhook): the PR IS the whole arc.
246
276
  stage = rep ? prStage(rep.status) : "converging";
@@ -259,7 +289,9 @@ export function deriveLineage(origin: LineageOrigin, prsIn: readonly LineagePr[]
259
289
  rootRequestKey: origin.key,
260
290
  kind: origin.kind,
261
291
  title: origin.kind === "pr" ? (rep?.title ?? null) : origin.title,
262
- issueUrl: origin.kind === "pr" ? null : origin.issueUrl,
292
+ // Only feature/epic threads root on a GitHub issue; a self-rooted PR and a delivery-graph run
293
+ // (issue #498, keyed by `run_key`) have none.
294
+ issueUrl: origin.kind === "feature" || origin.kind === "epic" ? origin.issueUrl : null,
263
295
  stage,
264
296
  stageLabel,
265
297
  epicPhaseLabel,
@@ -293,6 +325,45 @@ function featureStageLabel(stage: LineageStage): string {
293
325
  }
294
326
  }
295
327
 
328
+ /** Map a delivery-graph run's lifecycle status onto a frontier stage (issue #498). A `running` run
329
+ * with a member PR still in flight reads as `converging` (the fan-in is landing PRs); otherwise it is
330
+ * `implementing`. Terminal run statuses settle: `done` → `resolved` (the run completed), `failed` /
331
+ * `abandoned` → `abandoned`. `awaiting-approval` (reserved, no longer produced) parks at `planning`. */
332
+ function deliveryOriginStage(status: string, prs: readonly LineagePr[]): LineageStage {
333
+ switch (status) {
334
+ case "awaiting-approval":
335
+ return "planning";
336
+ case "running":
337
+ return prs.some((p) => !TERMINAL_STATUSES.includes(p.status)) ? "converging" : "implementing";
338
+ case "done":
339
+ return "resolved";
340
+ case "failed":
341
+ case "abandoned":
342
+ return "abandoned";
343
+ default:
344
+ return "implementing";
345
+ }
346
+ }
347
+
348
+ /** The fallback frontier label for a delivery thread when the run has not stamped a `phase` yet.
349
+ * `deliveryOriginStage` folds both the `failed` and `abandoned` run statuses onto the terminal
350
+ * `abandoned` stage, so the stage alone cannot tell them apart — take the run `status` too and label a
351
+ * genuinely `abandoned` run "Abandoned" (only a `failed` run reads "Failed"). */
352
+ function deliveryStageLabel(stage: LineageStage, status: string): string {
353
+ switch (stage) {
354
+ case "planning":
355
+ return "Awaiting approval";
356
+ case "converging":
357
+ return "Converging";
358
+ case "resolved":
359
+ return "Completed";
360
+ case "abandoned":
361
+ return status === "abandoned" ? "Abandoned" : "Failed";
362
+ default:
363
+ return "Running";
364
+ }
365
+ }
366
+
296
367
  // ── gateway glue ───────────────────────────────────────────────────────────────────────────────
297
368
 
298
369
  /** The subset of `pull_requests` the lineage projection reads. */
@@ -450,6 +521,21 @@ async function collectThreads(
450
521
  threads.set(plan.plan_key, deriveLineage(epicOrigin(plan), prs.map(toLineagePr)));
451
522
  }
452
523
 
524
+ // Delivery-graph runs (issue #498): each run is a fan-in parent thread keyed on its `run_key`,
525
+ // attaching the downstream PRs threaded to it (`pull_requests.root_request_key = run_key`). Mirrors
526
+ // the feature/epic loops — a run with no PR landed yet still projects a thread (its derived phase).
527
+ const deliveryRows = await deliveryGraphRuns(data).all();
528
+ for (const run of deliveryRows) {
529
+ // Feature/epic precedence: the SQL view's CASE classifies epic > feature > delivery, so a
530
+ // `run_key` that collides with an existing `plan_key`/`feature_key` must NOT overwrite that
531
+ // thread — otherwise the poller projection would stamp delivery-derived frontier columns onto a
532
+ // row the view still classifies epic/feature, and the two drift. Skip the colliding run so the
533
+ // earlier feature/epic thread (and the view's precedence) stays intact.
534
+ if (threads.has(run.run_key)) continue;
535
+ const prs = collectRootPrs(run.run_key, null, prsByRoot, prByKey, claimed);
536
+ threads.set(run.run_key, deriveLineage(deliveryGraphOrigin(run), prs.map(toLineagePr)));
537
+ }
538
+
453
539
  // Any PR not claimed by a feature/epic root is its own root: a human/webhook PR, a legacy row
454
540
  // predating migration 037's backfill, or a `root_request_key` whose origin row no longer survives.
455
541
  // Key each such thread by the root STORED on the PR row (`root_request_key`, falling back to
@@ -532,6 +618,17 @@ function epicOrigin(plan: Plan): LineageOrigin {
532
618
  };
533
619
  }
534
620
 
621
+ function deliveryGraphOrigin(run: DeliveryGraphRun): LineageOrigin {
622
+ return {
623
+ kind: "delivery",
624
+ key: run.run_key,
625
+ title: run.title,
626
+ status: run.status,
627
+ phase: run.phase,
628
+ processKey: run.process_key,
629
+ };
630
+ }
631
+
535
632
  /** On-demand: the stitched thread for one origin issue (or self-rooted PR), computed from the live
536
633
  * rows. Returns null when the root is unknown. */
537
634
  export async function getLineage(