@nanobpm/nano-workforce 0.132.0 → 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,9 @@
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
+
1
7
  ## [0.132.0](https://github.com/nanobpm/nano-workforce/compare/v0.131.1...v0.132.0) (2026-08-24)
2
8
 
3
9
  ### Features
@@ -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");
@@ -11,7 +11,9 @@ import { memDataFor } from "../test/worldDb.ts";
11
11
  import { withTrackingViews } from "../test/trackingViews.ts";
12
12
  import { DurableResumeRegistry } from "./durableResume.ts";
13
13
  import { WorldStore } from "./world/index.ts";
14
- import { abandonClosedPr, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
14
+ import { abandonClosedPr, isPrSettled, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
15
+ import { trackingTargetFor } from "./instanceTracking.ts";
16
+ import type { DataLayer } from "@nanobpm/urban";
15
17
 
16
18
  function memTable(rows: any[], key: string) {
17
19
  return {
@@ -49,6 +51,31 @@ function withGithubOff(run: () => Promise<void>): Promise<void> {
49
51
  });
50
52
  }
51
53
 
54
+ test("isPrSettled reads the derived tracking view — an out-of-band-abandoned PR (base row still converging) is settled", async () => {
55
+ // The base `pull_requests` row still reads `converging`, but the ADR-0065 derived tracking VIEW
56
+ // folds the reconciler's out-of-band terminal edge into `derived_status: "abandoned"`. Terminal-edge
57
+ // classification must read `derived_status`, not the stale base `status`, or a crash-window RESUME
58
+ // of the delivery-connector enrollment action re-runs `submitPr` against a PR that has actually
59
+ // already settled (and the ledger detail falsely claims it enrolled).
60
+ const PR_KEY = "owner/repo#7";
61
+ const view = trackingTargetFor("pull_requests").view;
62
+ const base = { pr_key: PR_KEY, status: "converging" };
63
+ function make(derived: string) {
64
+ return {
65
+ table(name: string) {
66
+ if (name === "pull_requests") return { get: async (k: string) => (k === PR_KEY ? { ...base } : null) };
67
+ if (name === view) return { get: async (k: string) => (k === PR_KEY ? { ...base, derived_status: derived } : null) };
68
+ throw new Error(`unexpected table ${name}`);
69
+ },
70
+ } as any as DataLayer;
71
+ }
72
+ assertEquals(await isPrSettled(make("abandoned"), PR_KEY), true, "out-of-band-abandoned PR is settled via derived_status");
73
+ assertEquals(await isPrSettled(make("converging"), PR_KEY), false, "a genuinely live PR is not settled");
74
+ const empty = { table: () => ({ get: async () => null }) } as any as DataLayer;
75
+ assertEquals(await isPrSettled(empty, PR_KEY), false, "an absent PR row is not settled");
76
+ });
77
+
78
+
52
79
  test("re-submit of a cancelled PR marks stale open escalations", async () => {
53
80
  await withGithubOff(async () => {
54
81
  const PR_KEY = "owner/repo#42";
package/app/service.ts CHANGED
@@ -482,6 +482,21 @@ export async function worldRestoreSha(data: DataLayer, prKey: string): Promise<s
482
482
  return lastPushedSha(data, prKey);
483
483
  }
484
484
 
485
+ /** Whether the `pull_requests` row for `prKey` already exists AND is in a TERMINAL state
486
+ * (`converged`/`merged`/`abandoned`). Reads the ADR-0065 derived tracking VIEW's `derived_status`
487
+ * (via `prsTracking`), NOT the base `status`, so an out-of-band-terminated PR — whose base row is
488
+ * still `converging` but whose reconciled edge is `abandoned` — is correctly seen as settled. The
489
+ * delivery-connector's converge-enrollment action guards on this so a crash-window RESUME (a
490
+ * `claimed`-but-not-`delivered` ledger row whose first attempt already enrolled the PR and let it
491
+ * settle) never re-runs `submitPr` against a settled PR — `submitPr` deliberately RE-OPENS a terminal
492
+ * row, so an unconditional re-perform would flip the PR back to `converging`, regressing a settled PR.
493
+ * Reuses the canonical `TERMINAL_STATUSES` so the terminal-safety check can't drift from the one the
494
+ * loop/incident logic uses. */
495
+ export async function isPrSettled(data: DataLayer, prKey: string): Promise<boolean> {
496
+ const existing = await prsTracking(data).get(prKey);
497
+ return !!existing && TERMINAL_STATUSES.includes(existing.derived_status);
498
+ }
499
+
485
500
  /** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
486
501
  * `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
487
502
  * recorded as the PR's merge-stage dependency set. */
@@ -99,6 +99,24 @@ The closed set (extensible only by a deliberate ADR/PR, never by graph authors):
99
99
  - **`human`** — a scheduled user task + form (§4).
100
100
  - **`connector`** — an automated, side-effecting outbound action (the connector I/O surface).
101
101
 
102
+ > **Amendment (issue #500): the connector's first REAL target landed — `converge` / `converge-merge`.**
103
+ > The connector I/O surface shipped in slice S4 with a deliberately forward-declared STUB action
104
+ > (`performConnectorAction`), the real target dispatch deferred to a later slice. That slice is this:
105
+ > a `connector` node whose `target` is **`converge-merge`** (or **`converge`** for converge-only)
106
+ > enrolls its `payload.pr` into the app's shared convergence (+ merge) loop via `submitPr` — the SAME
107
+ > seam the feature cell reuses (`workers/converge-feature`), no duplicated machinery. Enrollment is
108
+ > defined in the worker (it has `app.data`/`app.engine`) but **injected into `dispatchConnector` as the
109
+ > connector's action**, so the existing at-most-once ledger fence wraps the enrollment itself: it fires
110
+ > only on the claim winner (or a resumed crashed claim), and a `deduped` redelivery — a restart / lost
111
+ > ack / graph resume that lands AFTER the PR settled — never re-runs it. That matters because `submitPr`
112
+ > deliberately RE-OPENS a terminal PR; an unfenced re-call would flip a `merged`/`converged`/`abandoned`
113
+ > PR back to `converging`. `submitPr`'s own `prKey` idempotency additionally makes a resumed re-perform
114
+ > double-safe on a still-live row. This retires the manual `land-*` human gate whose only job was "go run convergence
115
+ > yourself" — the canonical shape is now `agent (opens PR) → connector[converge-merge] →
116
+ > wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the MVP
117
+ > sources `pr` as a literal (auto-emitting it from the `agent` node as a typed `pr` fact is a deferred
118
+ > follow-up). Other connector targets remain the forward-declared stub.
119
+
102
120
  Crucially, **execution stays engine-native**: each node kind is a real, already-deployed
103
121
  sub-process / call activity (`readiness-gate`, a user task, the implementation task, a connector
104
122
  invocation). The graph layer owns **scheduling** (which nodes' edges are satisfied → dispatch), not a
@@ -435,7 +435,7 @@ layer schedules, it does not re-implement execution):
435
435
  | `agent` | `agent: { jobType, prompt? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** | yes |
436
436
  | `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`. Read-only. | yes (binds observed facts) |
437
437
  | `human` | `human?: { formKey?, prompt? }` | a scheduled user task + form (the Tasks inbox, §3). Blocks dependents, SLA-bounded, answerable by a human **or** an agent. | yes |
438
- | `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). *(payload is a forward-declared stub.)* | yes |
438
+ | `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Two **real targets** ship today — **`converge`** and **`converge-merge`** (§9.4); other targets are a forward-declared stub. | yes |
439
439
 
440
440
  A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
441
441
  intake uses): `{ kind, target, onTimeout?, match?, poll? }`. The **`pr` kind** watches an
@@ -559,3 +559,49 @@ To swap the manual PR-#303 path for a **capability** edge instead of a raw `pr`
559
559
  the consumer a `wait` node with `kind: "capability"` (resolving *which published
560
560
  `pkg@version` first carries the change*) fed by the same `manual-publish.publishedVersion`
561
561
  fact — the fact-edge syntax is identical.
562
+
563
+ ### 9.4 Connector targets — drive a PR to convergence + merge (`converge` / `converge-merge`)
564
+
565
+ A `connector` node with **`target: "converge-merge"`** (or **`"converge"`**) enrolls an
566
+ agent-opened PR into the app's **shared convergence loop** — the *same* enrollment §1 (a
567
+ standalone submit) and a feature run use (`submitPr`), no duplicated machinery. This replaces
568
+ the old habit of bridging an `agent`-opened PR to review with a **human `land-*` gate** whose
569
+ only job was "go run convergence yourself".
570
+
571
+ - **`converge-merge`** — drive review convergence **and then the merge loop** (the PR merges
572
+ once converged + green). Equivalent to a submit with `convergeOnly: false`.
573
+ - **`converge`** — **converge-only**: drive review convergence and stop at `converged`, never
574
+ handing off to the merge loop (equivalent to `convergeOnly: true`).
575
+
576
+ **Payload:** `{ pr: "owner/repo#123", convergeOnly?: boolean, dependsOn?: string[] }`. `pr` is
577
+ required (a literal `owner/repo#N`, identical to how a `wait: pr` node targets a known PR).
578
+ `convergeOnly` defaults from the target and may be overridden per-node; `dependsOn` is unioned
579
+ into the PR's merge-stage dependency set. The enrollment is idempotent (the connector's
580
+ at-least-once dedupe fence **plus** `submitPr`'s own `prKey` idempotency), so a graph resume /
581
+ redelivery never double-enrolls.
582
+
583
+ **Canonical shape** — the agent opens the PR, the connector enrolls it, and a `wait[pr, merged]`
584
+ gate binds `mergedSha` when it lands, with **no human node**:
585
+
586
+ ```json
587
+ {
588
+ "name": "open → converge+merge → wait merged",
589
+ "nodes": [
590
+ { "id": "open", "kind": "agent",
591
+ "agent": { "jobType": "senior:feature", "prompt": "Implement the change in acme/repo and open a PR." } },
592
+ { "id": "land", "kind": "connector",
593
+ "connector": { "target": "converge-merge", "payload": { "pr": "acme/repo#123" } } },
594
+ { "id": "merged", "kind": "wait",
595
+ "wait": { "kind": "pr", "target": "acme/repo#123", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
596
+ ],
597
+ "edges": [
598
+ { "from": "open", "to": "land" },
599
+ { "from": "land", "to": "merged" }
600
+ ]
601
+ }
602
+ ```
603
+
604
+ > **Follow-up (not shipped):** the MVP sources the connector's `pr` as a **literal**. Auto-emitting
605
+ > the opened PR from the `agent` node as a typed `pr` fact (so the connector/`wait` bind it instead of
606
+ > a literal) is a later slice — not required for the graph above.
607
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.132.0",
3
+ "version": "0.133.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",
@@ -4,7 +4,83 @@
4
4
  // `payload`/`boundFacts` is coerced to null with a surfaced warning rather than passed through.
5
5
  import { test } from "node:test";
6
6
  import { assert, assertEquals, assertThrows } from "#test-assert";
7
- import { readConnectorInput } from "./worker.ts";
7
+ import { PROCESS_ID } from "../../app/service.ts";
8
+ import { withTrackingViews } from "../../test/trackingViews.ts";
9
+ import handler, { readConnectorInput, readConvergeInput, safeStringify } from "./worker.ts";
10
+
11
+ function memTable(rows: Record<string, unknown>[], key: string) {
12
+ return {
13
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
14
+ all: () => Promise.resolve([...rows]),
15
+ find: (q: Record<string, unknown>) =>
16
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
17
+ findOne: (q: Record<string, unknown>) =>
18
+ Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
19
+ insert: (r: Record<string, unknown>) => {
20
+ rows.push(r);
21
+ return Promise.resolve(r);
22
+ },
23
+ update: (k: unknown, patch: Record<string, unknown>) => {
24
+ const r = rows.find((x) => x[key] === k);
25
+ if (r) Object.assign(r, patch);
26
+ return Promise.resolve(r);
27
+ },
28
+ delete: (k: unknown) => {
29
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
30
+ return Promise.resolve();
31
+ },
32
+ };
33
+ }
34
+
35
+ /** A hermetic `app` over in-memory tables + a createInstance-capturing engine, with the GitHub
36
+ * transport forced off so `submitPr`'s best-effort meta fetch is skipped. Returns the created
37
+ * convergence-loop instances so a test can assert the exact enrollment `submitPr` performed. */
38
+ function fakeApp() {
39
+ const stores: Record<string, { rows: Record<string, unknown>[]; key: string }> = {
40
+ pull_requests: { rows: [], key: "pr_key" },
41
+ escalations: { rows: [], key: "id" },
42
+ pr_dependencies: { rows: [], key: "pr_key" },
43
+ delivery_connector_dispatches: { rows: [], key: "id" },
44
+ };
45
+ const created: { processDefinitionId?: string; variables?: Record<string, unknown> }[] = [];
46
+ let nextId = 1;
47
+ const data = {
48
+ table: withTrackingViews((name: string, key: string) => {
49
+ const store = stores[name] ?? { rows: [], key };
50
+ stores[name] ??= store;
51
+ // The ledger PK is auto-assigned on insert (mimics the RAD Table<T> autoincrement).
52
+ if (name === "delivery_connector_dispatches") {
53
+ const base = memTable(store.rows, store.key);
54
+ return { ...base, insert: (r: Record<string, unknown>) => {
55
+ const id = nextId++;
56
+ store.rows.push({ ...r, id });
57
+ return Promise.resolve(id);
58
+ } } as ReturnType<typeof memTable>;
59
+ }
60
+ return memTable(store.rows, store.key);
61
+ }),
62
+ };
63
+ const engine = {
64
+ createInstance: (req: { processDefinitionId?: string; variables?: Record<string, unknown> }) => {
65
+ created.push(req);
66
+ return Promise.resolve({ processInstanceKey: `PI-${created.length}` });
67
+ },
68
+ };
69
+ const app = { data, engine, log: { info() {}, warn() {}, error() {} } };
70
+ return { app: app as unknown as Parameters<typeof handler>[1], stores, created };
71
+ }
72
+
73
+ function withGithubOff(run: () => Promise<void>): Promise<void> {
74
+ const prevMode = process.env.NANO_PR_GITHUB_TRANSPORT;
75
+ const prevTok = process.env.GITHUB_TOKEN;
76
+ process.env.NANO_PR_GITHUB_TRANSPORT = "token"; // no token below -> fetchPrMeta returns null
77
+ delete process.env.GITHUB_TOKEN;
78
+ return run().finally(() => {
79
+ if (prevMode !== undefined) process.env.NANO_PR_GITHUB_TRANSPORT = prevMode;
80
+ else delete process.env.NANO_PR_GITHUB_TRANSPORT;
81
+ if (prevTok !== undefined) process.env.GITHUB_TOKEN = prevTok;
82
+ });
83
+ }
8
84
 
9
85
  test("readConnectorInput: a blank/missing target fails closed (a connector with no destination is meaningless)", () => {
10
86
  for (const target of [undefined, "", " "]) {
@@ -42,3 +118,165 @@ test("readConnectorInput: an array payload is rejected (arrays are not plain obj
42
118
  assertEquals(r.payload, null);
43
119
  assertEquals(r.warnings.length, 1);
44
120
  });
121
+
122
+ // --- converge / converge-merge targets: enroll a PR into the shared convergence loop (issue #500) ---
123
+
124
+ test("readConvergeInput: parses pr; convergeOnly defaults from the target; dependsOn is optional", () => {
125
+ // `converge-merge` drives the merge loop → convergeOnly defaults false.
126
+ const merge = readConvergeInput("converge-merge", { pr: "owner/repo#7" });
127
+ assertEquals(merge.parsed.prKey, "owner/repo#7");
128
+ assertEquals(merge.convergeOnly, false);
129
+ assertEquals(merge.dependsOn, []);
130
+ // `converge` is review-only → convergeOnly defaults true.
131
+ const conv = readConvergeInput("converge", { pr: "owner/repo#7" });
132
+ assertEquals(conv.convergeOnly, true);
133
+ });
134
+
135
+ test("readConvergeInput: an explicit payload.convergeOnly overrides the target default; dependsOn threads through", () => {
136
+ const r = readConvergeInput("converge-merge", { pr: "owner/repo#7", convergeOnly: true, dependsOn: ["owner/repo#5", 42] as unknown as string[] });
137
+ assertEquals(r.convergeOnly, true, "the explicit boolean wins over the target default");
138
+ assertEquals(r.dependsOn, ["owner/repo#5"], "non-string dependsOn entries are dropped");
139
+ });
140
+
141
+ test("readConvergeInput: a missing / unparseable pr fails CLOSED (a converge connector with no target PR is meaningless)", () => {
142
+ assertThrows(() => readConvergeInput("converge-merge", null), Error, "payload.pr");
143
+ assertThrows(() => readConvergeInput("converge-merge", {}), Error, "payload.pr");
144
+ assertThrows(() => readConvergeInput("converge", { pr: "not-a-pr" }), Error, "payload.pr");
145
+ });
146
+
147
+ test("readConvergeInput: an unparseable pr whose value is not JSON-serializable still fails CLOSED with the intended error (not a serializer TypeError)", () => {
148
+ // `p.pr` is user-controlled payload data; a BigInt (or a circular object) makes JSON.stringify
149
+ // throw, which must NOT mask the intended "requires payload.pr" error.
150
+ assertThrows(() => readConvergeInput("converge", { pr: 10n as unknown as string }), Error, "payload.pr");
151
+ const circular: Record<string, unknown> = {};
152
+ circular.self = circular;
153
+ assertThrows(() => readConvergeInput("converge", { pr: circular as unknown as string }), Error, "payload.pr");
154
+ });
155
+
156
+ test("safeStringify: always returns a string, even for values JSON.stringify serializes to undefined (Symbol/undefined/function)", () => {
157
+ // JSON.stringify returns `undefined` (WITHOUT throwing) for a Symbol, a bare undefined, or a
158
+ // function. safeStringify is typed `: string`, so it must fall back to String(value) rather than
159
+ // leak that `undefined` through and violate its own contract.
160
+ assertEquals(typeof safeStringify(Symbol("x")), "string");
161
+ assertEquals(typeof safeStringify(undefined), "string");
162
+ assertEquals(typeof safeStringify(() => 0), "string");
163
+ // A normal serializable value still round-trips through JSON.stringify.
164
+ assertEquals(safeStringify({ a: 1 }), '{"a":1}');
165
+ });
166
+
167
+ test("handler: a `converge-merge` connector enrolls the PR into the convergence loop via submitPr (row + started convergence-loop instance)", async () => {
168
+ await withGithubOff(async () => {
169
+ const { app, stores, created } = fakeApp();
170
+ await handler(
171
+ { variables: { target: "converge-merge", payload: { pr: "owner/repo#7" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never,
172
+ app,
173
+ );
174
+ // The identical row + loop a `converge-feature` enrollment produces.
175
+ assertEquals(stores.pull_requests.rows.length, 1, "exactly one pull_requests row is registered");
176
+ const pr = stores.pull_requests.rows[0];
177
+ assertEquals(pr.pr_key, "owner/repo#7");
178
+ assertEquals(pr.status, "converging");
179
+ assertEquals(created.length, 1, "the convergence-loop instance was started");
180
+ assertEquals(created[0]?.processDefinitionId, PROCESS_ID);
181
+ // converge-merge → not converge-only, so the merge loop is authorised.
182
+ assertEquals(created[0]?.variables?.convergeOnly, false);
183
+ assertEquals(created[0]?.variables?.prKey, "owner/repo#7");
184
+ // Lineage roots on the stable per-node dedupe key (graph-derived here).
185
+ assertEquals(created[0]?.variables?.rootRequestKey, "PI-graph:n2");
186
+ // The connector ledger still recorded the dispatch (the at-most-once fence around the stub).
187
+ assertEquals(stores.delivery_connector_dispatches.rows.length, 1);
188
+ });
189
+ });
190
+
191
+ test("handler: a `converge` connector enrolls converge-ONLY (stops at converged, never hands to the merge loop)", async () => {
192
+ await withGithubOff(async () => {
193
+ const { app, created } = fakeApp();
194
+ await handler(
195
+ { variables: { target: "converge", payload: { pr: "owner/repo#8" } }, processInstanceKey: "PI-g", elementId: "n1" } as never,
196
+ app,
197
+ );
198
+ assertEquals(created.length, 1);
199
+ assertEquals(created[0]?.variables?.convergeOnly, true);
200
+ });
201
+ });
202
+
203
+ test("handler: re-dispatch (at-least-once redelivery) does NOT double-enroll (ledger fence + submitPr prKey idempotency)", async () => {
204
+ await withGithubOff(async () => {
205
+ const { app, stores, created } = fakeApp();
206
+ const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#9" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
207
+ await handler(job, app);
208
+ await handler(job, app); // the graph resumes / the job is redelivered
209
+ assertEquals(stores.pull_requests.rows.length, 1, "still exactly one PR row");
210
+ assertEquals(created.length, 1, "the convergence-loop is started exactly once (submitPr collapses the repeat)");
211
+ assertEquals(stores.delivery_connector_dispatches.rows.length, 1, "one ledger row — the dispatch fence deduped");
212
+ });
213
+ });
214
+
215
+ test("handler: a redelivery AFTER the PR reached a terminal state does NOT re-enroll (the connector's at-most-once fence, not submitPr's short-circuit)", async () => {
216
+ await withGithubOff(async () => {
217
+ const { app, stores, created } = fakeApp();
218
+ const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#11" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
219
+ await handler(job, app);
220
+ assertEquals(created.length, 1, "the first delivery enrolls the PR");
221
+ // The convergence (+ merge) loop ran to completion; the PR row is now TERMINAL.
222
+ stores.pull_requests.rows[0].status = "merged";
223
+ // An at-least-once redelivery (worker restart / lost ack / graph resume) lands AFTER settlement.
224
+ // `submitPr` deliberately RE-OPENS a terminal row, so the connector must not call it again — the
225
+ // node instance already fired exactly once, and the ledger fence must suppress the redelivery.
226
+ await handler(job, app);
227
+ assertEquals(created.length, 1, "the settled PR is NOT re-enrolled — no second convergence-loop instance");
228
+ assertEquals(stores.pull_requests.rows[0].status, "merged", "the terminal PR is never flipped back to converging");
229
+ assertEquals(stores.delivery_connector_dispatches.rows.length, 1, "still one ledger row — the connector fence deduped the redelivery");
230
+ });
231
+ });
232
+
233
+ test("handler: a converge redelivery whose prior claim CRASHED before recording delivery still enrolls (resume, not lost)", async () => {
234
+ await withGithubOff(async () => {
235
+ const { app, stores, created } = fakeApp();
236
+ const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#12" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
237
+ // Simulate a crash BETWEEN claiming the ledger row and recording delivery: a `claimed` row with no
238
+ // enrollment yet. The redelivery must RESUME (perform the enrollment), never dedupe on the un-acted claim.
239
+ stores.delivery_connector_dispatches.rows.push({ id: 1, dedupe_key: "PI-graph:n2", target: "converge-merge", outcome: "claimed", detail: null, dispatched_at: "t0" });
240
+ await handler(job, app);
241
+ assertEquals(created.length, 1, "the crashed claim is resumed — the enrollment fires exactly once now");
242
+ assertEquals(stores.pull_requests.rows.length, 1, "the PR was enrolled on resume");
243
+ assertEquals(stores.delivery_connector_dispatches.rows[0].outcome, "delivered", "the resumed claim is recorded delivered");
244
+ });
245
+ });
246
+
247
+ test("handler: a crash-window RESUME whose PR already SETTLED to terminal does NOT re-open it (the enrollment action is terminal-safe)", async () => {
248
+ await withGithubOff(async () => {
249
+ const { app, stores, created } = fakeApp();
250
+ const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#13" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
251
+ // The first attempt claimed the ledger AND enrolled the PR, which then ran the convergence (+ merge)
252
+ // loop to completion (`merged`) — but the worker crashed BEFORE recording `delivered`, leaving a
253
+ // still-`claimed` row. A redelivery RESUMES that claim (perform-again, since it never recorded done).
254
+ stores.delivery_connector_dispatches.rows.push({ id: 1, dedupe_key: "PI-graph:n2", target: "converge-merge", outcome: "claimed", detail: null, dispatched_at: "t0" });
255
+ stores.pull_requests.rows.push({ pr_key: "owner/repo#13", status: "merged" });
256
+ // `submitPr` deliberately RE-OPENS a terminal PR (it only short-circuits a NON-terminal row), so a
257
+ // resumed re-perform would flip the settled PR back to `converging`. The enrollment action must be
258
+ // terminal-safe: on resume against an already-settled PR it no-ops (no submitPr, no new instance).
259
+ await handler(job, app);
260
+ assertEquals(created.length, 0, "the settled PR is NOT re-enrolled on resume — no new convergence-loop instance");
261
+ assertEquals(stores.pull_requests.rows[0].status, "merged", "the terminal PR stays terminal (never flipped back to converging)");
262
+ assertEquals(stores.delivery_connector_dispatches.rows[0].outcome, "delivered", "the resumed claim is recorded delivered (the dispatch is done, no side effect was needed)");
263
+ });
264
+ });
265
+
266
+ test("handler: a misconfigured converge connector (no parseable pr) fails CLOSED and writes NO ledger row", async () => {
267
+ await withGithubOff(async () => {
268
+ const { app, stores, created } = fakeApp();
269
+ let threw = false;
270
+ try {
271
+ await handler(
272
+ { variables: { target: "converge-merge", payload: { pr: "garbage" } }, processInstanceKey: "PI", elementId: "n1" } as never,
273
+ app,
274
+ );
275
+ } catch {
276
+ threw = true;
277
+ }
278
+ assert(threw, "a converge connector with no target PR fails closed");
279
+ assertEquals(created.length, 0, "no convergence-loop instance is started");
280
+ assertEquals(stores.delivery_connector_dispatches.rows.length, 0, "no junk ledger row is claimed");
281
+ });
282
+ });
@@ -12,8 +12,11 @@ import {
12
12
  type BoundFact,
13
13
  type ConnectorDispatchResult,
14
14
  connectorDedupeKey,
15
+ convergeOnlyForTarget,
15
16
  dispatchConnector,
17
+ isConvergeTarget,
16
18
  } from "../../app/deliveryConnector.ts";
19
+ import { isPrSettled, MAX_ROUNDS, type ParsedPr, parsePr, submitPr } from "../../app/service.ts";
17
20
 
18
21
  // Typed off the compiled `connector` subProcess ioMapping (a RUNTIME-generated definition, so there is
19
22
  // no static data-envelope to derive from): `target`/`dedupeKey`/`payload` are seeded from the node's
@@ -58,8 +61,52 @@ export function readConnectorInput(vars: In): {
58
61
  return { target, payload, boundFacts, warnings };
59
62
  }
60
63
 
64
+ /** JSON-stringify a user-controlled value for an error message, falling back to `String(value)` when
65
+ * the value is not JSON-serializable (e.g. a `BigInt` or a circular object throws, or a `Symbol` /
66
+ * `undefined` / function that `JSON.stringify` serializes to `undefined`) so the intended validation
67
+ * error is never masked by a serializer `TypeError` and the function always honours its `string`
68
+ * return type. */
69
+ export function safeStringify(value: unknown): string {
70
+ try {
71
+ const s = JSON.stringify(value);
72
+ return s === undefined ? String(value) : s;
73
+ } catch {
74
+ return String(value);
75
+ }
76
+ }
77
+
78
+ /** Parse + validate the converge connector's payload (`{ pr, convergeOnly?, dependsOn? }`) for a
79
+ * `converge` / `converge-merge` target. `pr` is REQUIRED and must parse to a canonical `owner/repo#N`
80
+ * (fail CLOSED — a converge connector with no target PR is meaningless and could never enroll).
81
+ * `convergeOnly` DEFAULTS from the target (`converge` → review-only `true`; `converge-merge` → drive
82
+ * the merge loop `false`) and may be overridden per-dispatch by an explicit boolean. `dependsOn` is an
83
+ * optional list of PR refs unioned into the enrolled PR's merge-stage dependency set (only non-string
84
+ * entries are dropped; `submitPr` itself ignores unparseable refs). Exported for unit coverage — the
85
+ * MVP sources `pr` as a literal (identical to how the `wait: pr` node targets a known PR), so no new
86
+ * fact plumbing is needed to ship. */
87
+ export function readConvergeInput(
88
+ target: string,
89
+ payload: Record<string, unknown> | null,
90
+ ): { parsed: ParsedPr; convergeOnly: boolean; dependsOn: string[] } {
91
+ const p = payload ?? {};
92
+ const parsed = parsePr(p.pr);
93
+ if (!parsed) {
94
+ throw new Error(
95
+ `delivery-connector: '${target}' target requires payload.pr as a parseable "owner/repo#N" ` +
96
+ `(got ${safeStringify(p.pr ?? null)})`,
97
+ );
98
+ }
99
+ const convergeOnly = typeof p.convergeOnly === "boolean" ? p.convergeOnly : convergeOnlyForTarget(target);
100
+ const dependsOn = Array.isArray(p.dependsOn) ? p.dependsOn.filter((d): d is string => typeof d === "string") : [];
101
+ return { parsed, convergeOnly, dependsOn };
102
+ }
103
+
61
104
  const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) => {
62
105
  const { target, payload, boundFacts, warnings } = readConnectorInput(job.variables);
106
+ // A `converge`/`converge-merge` target enrolls a PR into the shared convergence (+ merge) loop.
107
+ // Parse + validate its payload BEFORE claiming a ledger row, so a misconfigured converge node (no
108
+ // parseable `pr`) fails CLOSED without writing a junk dispatch row it could never act on.
109
+ const converge = isConvergeTarget(target) ? readConvergeInput(target, payload) : null;
63
110
  const dedupeKey = connectorDedupeKey({
64
111
  dedupeKey: job.variables.dedupeKey ?? null,
65
112
  processInstanceKey: job.processInstanceKey ?? null,
@@ -75,6 +122,46 @@ const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) =>
75
122
  app.data,
76
123
  { dedupeKey, target, payload, boundFacts },
77
124
  new Date().toISOString(),
125
+ // For a `converge`/`converge-merge` target, the connector's REAL side effect is enrolling the PR
126
+ // into the shared convergence (+ merge) loop via `submitPr` — the SAME enrollment
127
+ // `workers/converge-feature` uses, no duplicated machinery. It is injected as the dispatch's
128
+ // action so it lives INSIDE the at-most-once + resume envelope (`dispatchConnector`): it fires only
129
+ // on the claim winner (or a resumed crashed claim), and a `deduped` redelivery — a worker restart /
130
+ // lost ack / graph resume that lands AFTER the PR has settled — NEVER re-runs it. This is what
131
+ // preserves the connector's at-most-once semantics against `submitPr`, which deliberately RE-OPENS a
132
+ // terminal PR (it only short-circuits a non-terminal row); an unconditional call outside the fence
133
+ // would flip a `merged`/`converged`/`abandoned` PR back to `converging` on redelivery.
134
+ //
135
+ // The action is ALSO terminal-safe (idempotent on RESUME). A still-`claimed` crashed claim is
136
+ // re-performed by `dispatchConnector` (it never recorded delivery), and its first attempt may have
137
+ // already enrolled the PR AND let the convergence/merge loop settle it. Because `submitPr` re-opens a
138
+ // terminal row, re-performing blindly would regress that settled PR — so the action first checks
139
+ // `isPrSettled` and NO-OPS when the PR row is already terminal. On a LIVE (non-terminal) row
140
+ // `submitPr`'s own `prKey` short-circuit (`alreadyRunning`) already makes the resume double-safe.
141
+ // `rootRequestKey` is the stable per-node `dedupeKey` (authored, else `<processInstanceKey>:<elementId>`),
142
+ // so the enrolled PR's lineage is deterministic across redeliveries.
143
+ converge
144
+ ? async () => {
145
+ if (await isPrSettled(app.data, converge.parsed.prKey)) {
146
+ // Resume against an already-settled PR: the enrollment already ran its course. Record the
147
+ // dispatch delivered WITHOUT re-opening the terminal PR (never regress a settled PR).
148
+ app.log.info("delivery-connector: enrollment skipped — PR already terminal (resume-safe)", {
149
+ target,
150
+ dedupeKey,
151
+ prKey: converge.parsed.prKey,
152
+ });
153
+ return { detail: `${converge.parsed.prKey} already terminal — enrollment skipped (at-most-once resume-safe)` };
154
+ }
155
+ await submitPr(app.data, app.engine, converge.parsed, converge.dependsOn, MAX_ROUNDS, converge.convergeOnly, dedupeKey);
156
+ app.log.info("delivery-connector: enrolled PR into convergence loop", {
157
+ target,
158
+ dedupeKey,
159
+ prKey: converge.parsed.prKey,
160
+ convergeOnly: converge.convergeOnly,
161
+ });
162
+ return { detail: `enrolled ${converge.parsed.prKey} into convergence loop (convergeOnly=${converge.convergeOnly})` };
163
+ }
164
+ : undefined,
78
165
  );
79
166
  app.log.info("delivery-connector", { target, dedupeKey, outcome: result.connectorOutcome });
80
167
  return result;