@nanobpm/nano-workforce 0.189.3 → 0.191.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.
@@ -13,8 +13,10 @@ import { join, resolve } from "node:path";
13
13
  import { after, describe, test } from "node:test";
14
14
  import assert from "node:assert/strict";
15
15
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
16
+ import { redactFreeText } from "../app/deliveryGraphCompiler.ts";
16
17
  import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
17
18
  import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
19
+ import { isEquivalentReStage, stableProposalRunKey } from "./dispatchDeliveryGraph.ts";
18
20
 
19
21
  const APP_ROOT = resolve(import.meta.dirname, "..");
20
22
  const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
@@ -32,6 +34,74 @@ const SIDE_EFFECTING = {
32
34
  edges: [{ from: "open-b", to: "publish" }],
33
35
  };
34
36
 
37
+ test("stableProposalRunKey: digest identity + secret disambiguation (issue #778 review)", () => {
38
+ // The run key is derived from the proposal's semantic `digest` (which the compiler already normalises
39
+ // — collapsing omitted-vs-default fields and top-level node/edge REORDER) PLUS a fingerprint of the
40
+ // digest-invisible raw values. So a re-stage of the SAME logical graph in ANY encoding sharing the
41
+ // digest short-circuits, while a credential-differing graph (same digest, different secret) diverges.
42
+ const D1 = "sha-aaa";
43
+ const D2 = "sha-bbb";
44
+ const credA = {
45
+ name: "g",
46
+ nodes: [{ id: "n", kind: "connector", connector: { target: "//user:pass@host" } }],
47
+ edges: [],
48
+ };
49
+ // Same digest + identical graph → same key (a double-click / re-dispatch short-circuits).
50
+ assert.equal(stableProposalRunKey(D1, credA), stableProposalRunKey(D1, credA));
51
+ // Same digest but a DIFFERENT redacted-away credential — both redact to the same `//***@host` display
52
+ // so they SHARE the digest — must yield a DIFFERENT key, else the second dispatch collapses onto the
53
+ // first's still-running instance.
54
+ const credB = { name: "g", nodes: [{ id: "n", kind: "connector", connector: { target: "//other:secret@host" } }], edges: [] };
55
+ assert.notEqual(stableProposalRunKey(D1, credA), stableProposalRunKey(D1, credB));
56
+ // A genuinely different semantic graph carries a DIFFERENT digest → a different key even with no secrets.
57
+ const plain = { name: "g", nodes: [{ id: "n", kind: "human", human: { prompt: "x" } }], edges: [] };
58
+ assert.notEqual(stableProposalRunKey(D1, plain), stableProposalRunKey(D2, plain));
59
+ // A graph with NO digest-invisible content has an EMPTY fingerprint, so its key is a pure function of
60
+ // the digest: a re-stage in a DIFFERENT object-key encoding (same digest) short-circuits.
61
+ const plainReordered = { edges: [], name: "g", nodes: [{ human: { prompt: "x" }, kind: "human", id: "n" }] };
62
+ assert.equal(stableProposalRunKey(D1, plain), stableProposalRunKey(D1, plainReordered));
63
+ assert.ok(stableProposalRunKey(D1, credA).startsWith("staged-"), "the key is self-describing");
64
+ });
65
+
66
+ test("stableProposalRunKey: node-ORDER of digest-invisible content does not fork the key (issue #778 review)", () => {
67
+ // The compiler SORTS nodes before emitting `semanticBpmn`, so two graphs differing ONLY in top-level
68
+ // node order share one digest. Their digest-invisible raw values (`digestInvisibleRawValues`) must
69
+ // therefore fingerprint identically too — else a re-stage of the same logical graph in a different
70
+ // node encoding forks a DISTINCT run-key and double-launches instead of short-circuiting. The
71
+ // fingerprint is code-unit sorted at its source, making it reorder-invariant like the digest.
72
+ const D = "sha-order";
73
+ const ab = {
74
+ name: "g",
75
+ nodes: [
76
+ { id: "a", kind: "connector", connector: { target: "//user:pass@host" } },
77
+ { id: "b", kind: "connector", connector: { target: "//other:secret@host" } },
78
+ ],
79
+ edges: [],
80
+ };
81
+ const ba = {
82
+ name: "g",
83
+ nodes: [
84
+ { id: "b", kind: "connector", connector: { target: "//other:secret@host" } },
85
+ { id: "a", kind: "connector", connector: { target: "//user:pass@host" } },
86
+ ],
87
+ edges: [],
88
+ };
89
+ assert.equal(stableProposalRunKey(D, ab), stableProposalRunKey(D, ba), "a top-level node reorder must not fork the run-key");
90
+ });
91
+
92
+ test("isEquivalentReStage: only a live staged row whose RAW graph JSON is byte-identical to the launched graph is an equivalent re-stage — a credential-different re-stage (same digest) is NOT, and a retired/consumed row (null) is NOT (issue #778 review — thread dispatchDeliveryGraph.ts:320)", () => {
93
+ const launched = JSON.stringify({ name: "g", nodes: [{ id: "n", kind: "connector", connector: { target: "//user:pass@host" } }], edges: [] });
94
+ // A byte-identical re-stage IS equivalent — the door consumes the duplicate at its bumped revision.
95
+ assert.equal(isEquivalentReStage({ graph: launched }, launched), true);
96
+ // A credential-different re-stage shares the redacted digest but its RAW graph differs, so it is a
97
+ // newer, never-launched revision the door must leave `staged`.
98
+ const credDifferent = JSON.stringify({ name: "g", nodes: [{ id: "n", kind: "connector", connector: { target: "//other:secret@host" } }], edges: [] });
99
+ assert.equal(isEquivalentReStage({ graph: credDifferent }, launched), false);
100
+ // No live staged row (consumed/dismissed/superseded/expired) → not equivalent, nothing to consume.
101
+ assert.equal(isEquivalentReStage(null, launched), false);
102
+ assert.equal(isEquivalentReStage(undefined, launched), false);
103
+ });
104
+
35
105
  describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest", () => {
36
106
  const dirs: string[] = [];
37
107
  const apps: TestApp[] = [];
@@ -165,6 +235,138 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
165
235
  assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
166
236
  });
167
237
 
238
+ test("a SAME-payload retry of a secret-bearing graph under the SAME idempotencyKey (a lost response / double-click) → 202 short-circuit, the proposal IS consumed and NO second run launches — the persisted identity fingerprint PROVES the running run is this exact graph, so the idempotency contract holds instead of a spurious 409 (issue #778 review — thread dispatchDeliveryGraph.ts:332)", async () => {
239
+ const app = await boot();
240
+ assert.ok(app.api);
241
+ const api = app.api;
242
+ let agentFired = 0;
243
+ await app.engine.registerWorker("senior:demo", async () => {
244
+ agentFired++;
245
+ return {};
246
+ });
247
+
248
+ // Stage + dispatch a SECRET-BEARING graph (SIDE_EFFECTING carries redacted-away secret material —
249
+ // the same graph the cross-graph 409 case above refuses) under an explicit idempotencyKey. It
250
+ // launches and parks on its human node, so its run stays `running`.
251
+ const first = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
252
+ const dispatched = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
253
+ body: { digest: first.body.digest, idempotencyKey: "retry-key", repoless: true },
254
+ });
255
+ assert.equal(dispatched.status, 202);
256
+ await app.settle();
257
+ assert.equal(agentFired, 1, "the side effect fired exactly once on the first dispatch");
258
+ assert.equal((await deliveryGraphProposals(app.db).get(first.body.digest))?.status, "dispatched");
259
+
260
+ // Re-stage the IDENTICAL graph (byte-for-byte the same → same digest) and re-dispatch under the
261
+ // SAME key — the caller retrying a lost/duplicated request. The running run's persisted fingerprint
262
+ // matches this graph, so the door short-circuits onto it (202, alreadyRunning) and CONSUMES the
263
+ // re-staged proposal — it does NOT 409 as a cross-graph collision would.
264
+ const restaged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
265
+ assert.equal(restaged.body.digest, first.body.digest);
266
+ assert.equal((await deliveryGraphProposals(app.db).get(restaged.body.digest))?.status, "staged");
267
+ const retry = await api.call<{ ok: boolean; status: string; alreadyRunning?: boolean }>("dispatchDeliveryGraph", {
268
+ body: { digest: restaged.body.digest, idempotencyKey: "retry-key", repoless: true },
269
+ });
270
+ assert.equal(retry.status, 202);
271
+ assert.equal(retry.body.ok, true);
272
+ assert.equal(retry.body.alreadyRunning, true);
273
+ await app.settle();
274
+ // The retry short-circuited onto the existing run — the side effect did NOT fire a second time and
275
+ // no second run launched.
276
+ assert.equal(agentFired, 1, "a same-payload retry does not re-fire the side effect");
277
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
278
+ // The re-staged proposal was consumed (marked dispatched), not left lingering.
279
+ assert.equal((await deliveryGraphProposals(app.db).get(restaged.body.digest))?.status, "dispatched");
280
+ });
281
+
282
+ test("an explicit idempotencyKey short-circuit onto a running SECRET-BEARING run, re-dispatched from a graph authored AS the redacted form (graphCarriesRedactedSecrets === false) that SHARES the digest → 409; the proposal is NOT consumed (the refusal must not be gated on the INCOMING graph's redaction side — identity is unprovable whenever !identityConfirmed) (thread dispatchDeliveryGraph.ts:320)", async () => {
283
+ const app = await boot();
284
+ assert.ok(app.api);
285
+ const api = app.api;
286
+
287
+ // Graph 1 CARRIES a redacted-away credential in its human prompt — its content-addressed digest is
288
+ // over the REDACTED display, so it is NOT a faithful identity. It parks on its human node and stays
289
+ // running under an explicit idempotencyKey.
290
+ const secretPrompt = "click done: https://user:pass@host";
291
+ const SECRET = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: secretPrompt } }] };
292
+ // Graph 2 is authored LITERALLY as the redacted form of graph 1's prompt. Redaction is a no-op on it,
293
+ // so `graphCarriesRedactedSecrets` is FALSE, yet it SHARES graph 1's digest (both digest over the same
294
+ // redacted display). It is a DIFFERENT graph from the running secret-bearing run.
295
+ const redactedTwin = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: redactFreeText(secretPrompt) } }] };
296
+
297
+ // Stage + dispatch the SECRET-bearing graph FIRST (before the twin re-stages over its same-digest
298
+ // row) under an explicit key — it launches and parks (running), stamping its LOSSLESS fingerprint.
299
+ const g1 = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET });
300
+ const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
301
+ body: { digest: g1.body.digest, idempotencyKey: "shared-key", repoless: true },
302
+ });
303
+ assert.equal(first.status, 202);
304
+ await app.settle();
305
+ assert.equal((await deliveryGraphProposals(app.db).get(g1.body.digest))?.status, "dispatched");
306
+
307
+ // Now re-stage the redacted-twin at the SAME digest (a DIFFERENT graph re-staged over the consumed
308
+ // row) and dispatch it under the SAME key. It short-circuits onto the still-running SECRET run — but
309
+ // that run is a different graph, so its persisted fingerprint does NOT match (identityConfirmed ===
310
+ // false). The digest COLLISION is the crux: the twin shares graph 1's digest yet carries no redacted
311
+ // secret. The refusal must fire even though the INCOMING graph carries no redacted secret; otherwise
312
+ // the twin's proposal is marked `dispatched` while it never launched.
313
+ const twin = await api.call<{ digest: string }>("compileDeliveryGraph", { body: redactedTwin });
314
+ assert.equal(twin.body.digest, g1.body.digest);
315
+ assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
316
+ const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
317
+ body: { digest: twin.body.digest, idempotencyKey: "shared-key", repoless: true },
318
+ });
319
+ assert.equal(second.status, 409);
320
+ assert.equal(second.body.ok, false);
321
+ await app.settle();
322
+ // The twin proposal is still staged — it was never launched (the running run is the secret graph).
323
+ assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
324
+ // Only the single secret run exists — the twin dispatch launched nothing.
325
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
326
+ });
327
+
328
+ test("a KEYLESS short-circuit onto a running graph whose run key equals the shared digest (an earlier explicit `idempotencyKey === digest`), re-dispatched from a redacted-twin that SHARES the digest → 409; the proposal is NOT consumed. The ambiguous-identity refusal must gate EVERY `alreadyRunning && !identityConfirmed`, not only explicit-key requests — a keyless faithful-digest dispatch collides on the digest run key just the same (thread dispatchDeliveryGraph.ts:325)", async () => {
329
+ const app = await boot();
330
+ assert.ok(app.api);
331
+ const api = app.api;
332
+
333
+ // Graph 1 CARRIES a redacted-away credential — its digest is over the REDACTED display, NOT a faithful
334
+ // identity. Dispatch it under an explicit `idempotencyKey` set to its OWN digest, so the run is keyed
335
+ // by the digest itself and parks running, stamping graph 1's LOSSLESS fingerprint.
336
+ const secretPrompt = "click done: https://user:pass@host";
337
+ const SECRET = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: secretPrompt } }] };
338
+ // Graph 2 is authored LITERALLY as the redacted form — redaction is a no-op on it, so
339
+ // `graphCarriesRedactedSecrets` is FALSE. Because it is faithful, a KEYLESS dispatch leaves the run key
340
+ // undefined and the core falls back to the digest — which it SHARES with graph 1, colliding onto that
341
+ // still-running secret run even though NO explicit key was supplied.
342
+ const redactedTwin = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: redactFreeText(secretPrompt) } }] };
343
+
344
+ const g1 = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET });
345
+ const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
346
+ body: { digest: g1.body.digest, idempotencyKey: g1.body.digest, repoless: true },
347
+ });
348
+ assert.equal(first.status, 202);
349
+ await app.settle();
350
+ assert.equal((await deliveryGraphProposals(app.db).get(g1.body.digest))?.status, "dispatched");
351
+
352
+ // Re-stage the twin at the SAME digest and dispatch it WITHOUT a key. It short-circuits onto the
353
+ // running secret run (same digest run key), but that run is a different graph → identityConfirmed is
354
+ // false. The refusal must fire despite the request being keyless; otherwise the twin is marked
355
+ // `dispatched` while it never launched.
356
+ const twin = await api.call<{ digest: string }>("compileDeliveryGraph", { body: redactedTwin });
357
+ assert.equal(twin.body.digest, g1.body.digest);
358
+ assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
359
+ const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
360
+ body: { digest: twin.body.digest, repoless: true },
361
+ });
362
+ assert.equal(second.status, 409);
363
+ assert.equal(second.body.ok, false);
364
+ await app.settle();
365
+ // The twin proposal is still staged — nothing new launched.
366
+ assert.equal((await deliveryGraphProposals(app.db).get(twin.body.digest))?.status, "staged");
367
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
368
+ });
369
+
168
370
  test("a proposal whose stored graph is corrupt JSON → 400 AND the proposal is retired (expired), never lingering staged", async () => {
169
371
  const app = await boot();
170
372
  assert.ok(app.api);
@@ -251,6 +453,59 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
251
453
  assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
252
454
  });
253
455
 
456
+ test("a SECRET-BEARING staged graph dispatches through the cockpit UI WITHOUT an idempotencyKey — the door supplies a stable server-side key (#778)", async () => {
457
+ // Regression: Option C's dispatch gate refuses a keyless dispatch of a graph whose digest is lossy,
458
+ // but the cockpit staged-proposals UI posts no idempotency-key field. The door must therefore derive
459
+ // a stable server-side key from the stored proposal so these proposals remain launchable.
460
+ const app = await boot();
461
+ assert.ok(app.api);
462
+ const api = app.api;
463
+ const SECRET = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:pass@host.example/repo" } }] };
464
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET });
465
+ // No idempotencyKey in the body — exactly what the staged UI posts.
466
+ const res = await api.call<{ ok: boolean; status: string; runKey: string }>("dispatchDeliveryGraph", {
467
+ body: { digest: staged.body.digest, repoless: true },
468
+ });
469
+ assert.equal(res.status, 202);
470
+ assert.equal(res.body.ok, true);
471
+ assert.equal(res.body.status, "running");
472
+ assert.ok(res.body.runKey.startsWith("staged-"), `expected a server-side staged- key, got ${res.body.runKey}`);
473
+ await app.settle();
474
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
475
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
476
+ });
477
+
478
+ test("an explicit idempotencyKey re-used across two SECRET-differing graphs that SHARE a digest is refused at short-circuit → 409; the re-staged proposal is NOT consumed and nothing new launches (#778 review — thread dispatchDeliveryGraph.ts:282)", async () => {
479
+ const app = await boot();
480
+ assert.ok(app.api);
481
+ const api = app.api;
482
+ // Two graphs differing ONLY in a redacted-away credential redact to the SAME `semanticBpmn`, so they
483
+ // share ONE content digest and therefore ONE digest-keyed proposal row.
484
+ const SECRET_A = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:AAAsecret@host.example/repo" } }] };
485
+ const SECRET_B = { name: "deploy", nodes: [{ id: "d", kind: "agent", agent: { jobType: "senior:demo", prompt: "push to https://user:BBBsecret@host.example/repo" } }] };
486
+ const a = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET_A });
487
+ // Dispatch A under an explicit shared key — it launches and parks (running).
488
+ const first = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
489
+ body: { digest: a.body.digest, idempotencyKey: "shared-key", repoless: true },
490
+ });
491
+ assert.equal(first.status, 202);
492
+ await app.settle();
493
+ // B re-stages the SAME digest-keyed proposal row (its credential differs, but the digest is identical).
494
+ const b = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SECRET_B });
495
+ assert.equal(b.body.digest, a.body.digest, "the two secret-differing graphs must share one redacted digest");
496
+ // Dispatch B under the SAME shared key — it short-circuits onto A's still-running run. Because the
497
+ // digest cannot prove the running run is THIS (secret-bearing) graph, B must NOT be consumed: 409.
498
+ const second = await api.call<{ ok: boolean; error?: string }>("dispatchDeliveryGraph", {
499
+ body: { digest: b.body.digest, idempotencyKey: "shared-key", repoless: true },
500
+ });
501
+ assert.equal(second.status, 409);
502
+ assert.equal(second.body.ok, false);
503
+ await app.settle();
504
+ // The proposal is still staged (never falsely consumed) and only A's single run exists.
505
+ assert.equal((await deliveryGraphProposals(app.db).get(b.body.digest))?.status, "staged");
506
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
507
+ });
508
+
254
509
  test("a malformed `repository` is rejected at submit → 400, nothing launched (#684/#686)", async () => {
255
510
  const app = await boot();
256
511
  assert.ok(app.api);
@@ -10,8 +10,10 @@
10
10
  // re-dispatch of an already-running run short-circuits with `alreadyRunning`. An unknown / expired /
11
11
  // superseded / already-dispatched digest is a clean 400.
12
12
 
13
+ import { createHash } from "node:crypto";
13
14
  import { isPlausibleBranchName } from "../app/baseBranch.ts";
14
- import { validateDeliveryGraph } from "../app/deliveryGraph.ts";
15
+ import { canonicalJson, validateDeliveryGraph } from "../app/deliveryGraph.ts";
16
+ import { digestInvisibleRawValues, graphCarriesRedactedSecrets } from "../app/deliveryGraphCompiler.ts";
15
17
  import { dispatchDeliveryGraphRun } from "../app/deliveryGraphDispatch.ts";
16
18
  import { getStagedProposal, markProposalDispatched, markProposalExpired } from "../app/deliveryGraphProposals.ts";
17
19
  import { unresolvedAgentRepoNodes } from "../app/deliveryRunner.ts";
@@ -28,6 +30,46 @@ function truncateForEcho(value: string): string {
28
30
  return value.length > MAX_ECHO_LEN ? `${value.slice(0, MAX_ECHO_LEN)}… (${value.length} chars)` : value;
29
31
  }
30
32
 
33
+ /** A STABLE, server-side dispatch run key for a secret-bearing staged proposal (issue #778, Option C).
34
+ * The graph's identity is content-addressed over the REDACTED `semanticBpmn` digest, so two graphs
35
+ * differing ONLY in a redacted-away credential (or other {@link digestInvisibleRawValues} content) share
36
+ * one digest — a keyless dispatch would collapse the second onto the first's still-running instance. The
37
+ * dispatch core therefore REQUIRES an explicit key for such a graph, but the cockpit's staged-proposals
38
+ * UI posts no idempotency-key field. The operator clicking Dispatch on THIS specific stored proposal is
39
+ * unambiguous, so we derive a key from TWO parts:
40
+ * • the proposal's semantic `digest` — which the compiler already normalises: it collapses every
41
+ * omitted-vs-default field (`edges`/`emits` → `[]`, a `false` boolean vs an absent one, …) and any
42
+ * top-level node/edge REORDER, so a re-stage of the same logical graph in a different encoding — which
43
+ * shares the digest and OVERWRITES `proposal.graph` — yields the SAME key and short-circuits as
44
+ * `alreadyRunning` instead of double-launching (issue #778 review, thread on omitted-vs-empty), and
45
+ * • a fingerprint of {@link digestInvisibleRawValues} — the exact raw content the digest CANNOT see —
46
+ * so a credential-differing proposal (identical digest) gets a DISTINCT key. That traversal now emits
47
+ * its entries in a canonical (code-unit sorted) order, so it too collapses a top-level node reorder:
48
+ * the fingerprint is reorder-invariant, matching the digest, so the whole key short-circuits a
49
+ * re-stage of the same logical graph regardless of node encoding order (issue #778 review).
50
+ * Deriving both parts from the digest + the shared invisible-values traversal means the key needs NO
51
+ * per-field enumeration of the compiler's defaults (which would drift), while still disambiguating on
52
+ * every secret the digest drops. Prefixed so it is self-describing in run listings. */
53
+ export function stableProposalRunKey(digest: string, graph: DeliveryGraph): string {
54
+ const fingerprint = canonicalJson(digestInvisibleRawValues(graph));
55
+ return `staged-${createHash("sha256").update(`${digest}\u0000${fingerprint}`).digest("hex").slice(0, 16)}`;
56
+ }
57
+
58
+ /** After the terminal `markProposalDispatched` no-oped — the digest row left the `stage_seq` this dispatch
59
+ * snapshotted between its `getStagedProposal` load and its mark — decide whether the still-`staged` row is
60
+ * an EQUIVALENT re-stage of the graph we just launched: a BYTE-IDENTICAL graph re-staged into the same
61
+ * digest row in that window (which bumps `stage_seq`). It is equivalent iff a live staged row is still
62
+ * present AND its raw graph JSON is byte-identical to the launched one. We compare the LOSSLESS raw
63
+ * `proposal.graph` (not the lossy semantic `digest`, which collapses credential-different graphs onto one
64
+ * key), so a genuinely different (e.g. credential-different) re-stage is NOT mistaken for equivalent. When
65
+ * true the door consumes the duplicate at its current revision — otherwise it would linger as an
66
+ * undismissable `staged` row every future dispatch short-circuits onto the already-running run without
67
+ * consuming; when false the row is a newer, never-launched revision the door must leave `staged` (issue
68
+ * #778 review — thread dispatchDeliveryGraph.ts:320). */
69
+ export function isEquivalentReStage(reloaded: { graph: string } | null | undefined, launchedGraphJson: string): boolean {
70
+ return !!reloaded && reloaded.graph === launchedGraphJson;
71
+ }
72
+
31
73
  /** Door-level cap on a duration override, mirroring the `maxLength: 64` on these fields in `openapi.yaml`.
32
74
  * Re-enforced here so a syntactically-valid-but-oversized duration is still rejected when the edge
33
75
  * validator is bypassed (internal calls/tests), keeping seeded process variables and error/log output bounded. */
@@ -155,14 +197,23 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
155
197
  }
156
198
 
157
199
  // The stored graph was validated at stage time; dispatch re-compiles it to derive the run-row shape.
200
+ // Snapshot the proposal's stage revision NOW so the terminal `markProposalDispatched` can guard its
201
+ // flip against a concurrent re-stage that overwrites this same-digest row with a newer graph after we
202
+ // read it (issue #778 review — thread dispatchDeliveryGraph.ts:243).
203
+ const stageSeq = proposal.stage_seq;
158
204
  let graph: unknown;
159
205
  try {
160
206
  graph = JSON.parse(proposal.graph);
161
207
  } catch (err) {
162
208
  app.log.error("dispatch-delivery-graph: stored graph is corrupt", { digest });
163
209
  // Fail closed: a corrupt graph can never launch, so retire the proposal (→ `expired`) instead of
164
- // leaving an undismissable `staged` row that fails every dispatch attempt the same way.
165
- await markProposalExpired(app.data, digest);
210
+ // leaving an undismissable `staged` row that fails every dispatch attempt the same way. Guard the
211
+ // expiry on the SAME `stage_seq` we read the corrupt graph at — a concurrent re-stage that overwrites
212
+ // this same-digest row with a fresh (valid) graph in the window between `getStagedProposal` and this
213
+ // update bumps `stage_seq`, so the guard makes the expiry a no-op rather than retiring a revision we
214
+ // never inspected (issue #778 review — thread dispatchDeliveryGraph.ts:188, same revision guard as the
215
+ // terminal `markProposalDispatched` below).
216
+ await markProposalExpired(app.data, digest, stageSeq);
166
217
  return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
167
218
  }
168
219
 
@@ -196,7 +247,27 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
196
247
  }
197
248
  }
198
249
 
199
- const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, repository, baseBranch, repoless, ...timeouts });
250
+ // Derive the dispatch run key (issue #778, Option C). Honour an explicit operator-supplied
251
+ // `idempotencyKey` verbatim — a BLANK/whitespace-only key is already normalised to `undefined` at the
252
+ // request edge (`idemRaw`/`idempotencyKey` above), so `=== undefined` here covers both "omitted" and
253
+ // "blank" (issue #778 review — thread dispatchDeliveryGraph.ts:243, already handled upstream). When the
254
+ // staged graph carries credential-bearing values that redaction strips from the content-addressed
255
+ // digest, that digest is NOT a faithful identity — two credential-differing graphs share it — so a
256
+ // keyless dispatch is refused by the dispatch core. The cockpit UI posts no idempotency-key field, so
257
+ // supply a STABLE server-side key derived from THIS proposal's parsed graph in canonical form
258
+ // (`stableProposalRunKey`); a faithful-digest graph keeps its digest identity (key left undefined). A
259
+ // malformed graph falls through to the core's own validation.
260
+ let dispatchRunKey: string | undefined = idempotencyKey;
261
+ if (dispatchRunKey === undefined) {
262
+ const graphErrors = validateDeliveryGraph(graph);
263
+ if (graphErrors.length === 0) {
264
+ // biome-ignore lint/plugin: validated staged graph narrowed to its contract after validateDeliveryGraph
265
+ const typedGraph = graph as DeliveryGraph;
266
+ if (graphCarriesRedactedSecrets(typedGraph)) dispatchRunKey = stableProposalRunKey(digest, typedGraph);
267
+ }
268
+ }
269
+
270
+ const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: dispatchRunKey, title: proposal.title, repository, baseBranch, repoless, expectedDigest: digest, ...timeouts });
200
271
  if (!dispatched.ok) {
201
272
  app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
202
273
  const outBody: DeliveryGraphTextResult = {
@@ -226,7 +297,87 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
226
297
  },
227
298
  };
228
299
  }
229
- await markProposalDispatched(app.data, digest);
300
+
301
+ // Even when `dispatched.digest === digest`, that equality does NOT prove the short-circuited run is
302
+ // THIS graph: the digest is content-addressed over the REDACTED `semanticBpmn`, so two graphs whose
303
+ // AUTHORED values differ only in redacted-away material share it (the very reason a keyless secret-
304
+ // bearing dispatch is refused upstream). An EXPLICIT `idempotencyKey` bypasses the lossless server-side
305
+ // `stableProposalRunKey`, so a caller who re-uses one key across two such digest-colliding graphs (which
306
+ // occupy the SAME digest-keyed proposal row, one re-staged over the other) would short-circuit onto the
307
+ // FIRST graph's still-running run — `dispatched.alreadyRunning` — while the re-staged graph never
308
+ // launched. Consuming the proposal then marks a graph `dispatched` that never ran. The run row now
309
+ // persists a LOSSLESS identity fingerprint (migration 113), and the dispatch core reports
310
+ // `identityConfirmed` — true when the running run's fingerprint MATCHES this graph's, i.e. a legitimate
311
+ // SAME-payload retry (a lost response / operator double-click of the identical proposal).
312
+ //
313
+ // The proof is the fingerprint MATCH, NOT the redaction side of the INCOMING graph: the ambiguity is
314
+ // symmetric. A secret-bearing running run collides in digest with a graph authored LITERALLY as its
315
+ // redacted form (`https://***@host`) — that incoming graph has `graphCarriesRedactedSecrets === false`,
316
+ // yet it is a DIFFERENT graph from the running run, so short-circuiting onto it is just as unprovable.
317
+ // Gating the refusal on the incoming graph's own redaction let that authored-as-redacted twin slip
318
+ // through and consume the running secret run's proposal (issue #778 review — thread
319
+ // dispatchDeliveryGraph.ts:320). So refuse the genuinely ambiguous CROSS-graph short-circuit whenever
320
+ // `!identityConfirmed` (which also covers a NULL pre-migration fingerprint we cannot prove), regardless
321
+ // of which side carries the redacted value; a same-payload retry (`identityConfirmed`) falls through and
322
+ // consumes the proposal, preserving the idempotency contract rather than 409-ing an identical retry
323
+ // (issue #778 review — thread dispatchDeliveryGraph.ts:332).
324
+ //
325
+ // The gate must fire for a KEYLESS request too, not only an explicit `idempotencyKey`. A faithful-digest
326
+ // graph (no redacted secret) dispatched WITHOUT a key leaves `dispatchRunKey` undefined, so the core
327
+ // falls back to the DIGEST as the run key — which a redacted twin SHARES with an earlier run that was
328
+ // itself keyed by that digest (e.g. an explicit `idempotencyKey === digest`). The keyless twin then
329
+ // short-circuits onto that different graph's run with `identityConfirmed === false`, so scoping the
330
+ // refusal to explicit-key requests would mark the twin `dispatched` though it never ran. `identityConfirmed`
331
+ // is derived from the running run's persisted fingerprint, NOT from whether a key was supplied, so a
332
+ // legitimate keyless same-payload retry still confirms and falls through — only a genuinely unprovable
333
+ // short-circuit is refused (issue #778 review — thread dispatchDeliveryGraph.ts:325).
334
+ if (dispatched.alreadyRunning && !dispatched.identityConfirmed) {
335
+ app.log.warn("dispatch-delivery-graph refused: already-running short-circuit cannot prove the running run is this graph", {
336
+ digest,
337
+ runKey: dispatched.runKey,
338
+ });
339
+ return {
340
+ status: 409,
341
+ body: {
342
+ ok: false,
343
+ error:
344
+ `this graph short-circuited onto an already-running delivery graph whose identity fingerprint does not match it, ` +
345
+ `so its content-addressed digest (${digest}) cannot prove the running run is this exact graph; the staged proposal was NOT ` +
346
+ "dispatched — re-dispatch with a distinct idempotencyKey per graph (or none, to use the lossless server-side key)",
347
+ },
348
+ };
349
+ }
350
+ const marked = await markProposalDispatched(app.data, digest, stageSeq);
351
+ if (!marked) {
352
+ // The row left the `staged` revision we snapshotted between our load and this flip. Distinguish the
353
+ // two reasons it moved, because they need OPPOSITE handling:
354
+ // (a) an EQUIVALENT re-stage bumped `stage_seq` on a BYTE-IDENTICAL graph in our window — the run
355
+ // we launched IS that graph, so the still-`staged` row is a duplicate of what we just
356
+ // dispatched. Leaving it `staged` wedges an undismissable proposal that every future dispatch
357
+ // short-circuits onto our already-running run without ever consuming (issue #778 review — thread
358
+ // dispatchDeliveryGraph.ts:320). Consume it at its CURRENT revision.
359
+ // (b) a re-stage overwrote it with a DIFFERENT (credential-different) graph, or a
360
+ // dispatch/dismiss/supersede/expiry retired it — a newer, never-launched revision we must NOT
361
+ // clobber to `dispatched`.
362
+ // Re-load and let `isEquivalentReStage` compare the LOSSLESS raw graph JSON (not the lossy digest,
363
+ // which collapses credential-different graphs): byte-identical ⇒ equivalent re-stage (a), else (b).
364
+ const restaged = await getStagedProposal(app.data, digest);
365
+ if (isEquivalentReStage(restaged, proposal.graph)) {
366
+ const consumed = await markProposalDispatched(app.data, digest, restaged?.stage_seq);
367
+ app.log.warn("dispatch-delivery-graph: equivalent re-stage consumed at bumped revision", {
368
+ digest,
369
+ stageSeq,
370
+ restagedSeq: restaged?.stage_seq,
371
+ consumed,
372
+ });
373
+ } else {
374
+ // A concurrent dispatch consumed it, or a re-stage overwrote it with a newer graph (bumping
375
+ // stage_seq), or a dismiss/supersede/expiry retired it. The run we launched still stands; we
376
+ // intentionally skip the mark so a newer, never-launched revision is not clobbered to `dispatched`
377
+ // (issue #778 review).
378
+ app.log.warn("dispatch-delivery-graph: staged revision changed before consume; mark skipped", { digest, stageSeq });
379
+ }
380
+ }
230
381
 
231
382
  const outBody: DeliveryGraphTextResult = {
232
383
  ok: true,
@@ -40,7 +40,8 @@ test("the guide covers every capability the endpoint promises", async () => {
40
40
  const md = ((await handler(input(), app)) as any).body.instructions as string;
41
41
  // Submit a PR (converge vs. merge), submit an epic, answer escalations…
42
42
  assert(md.includes("start/convergence-loop"), "covers submitting a PR for convergence");
43
- assert(md.includes("convergeOnly"), "documents review-only vs. merge");
43
+ assert(md.includes("autoMerge"), "documents the per-request merge control");
44
+ assert(!md.includes("convergeOnly"), "does not expose the legacy merge control in agent guidance");
44
45
  assert(md.includes("start/plan-fanout"), "covers submitting an epic");
45
46
  assert(md.includes("complete-user-task"), "covers answering escalations via the native user-task door");
46
47
  // …debug the system.
@@ -143,15 +143,57 @@ test("startConvergenceLoop forwards convergeOnly:true to the loop", async () =>
143
143
  });
144
144
  });
145
145
 
146
+ test("startConvergenceLoop maps autoMerge:true to merge-enabled convergence", async () => {
147
+ await withGithubOff(async () => {
148
+ const { app: capApp, get } = captureApp();
149
+ const res = await startConvergenceLoop(input({ pr: "owner/repo#9", autoMerge: true }), capApp);
150
+ assertEquals((res as any).status, 202);
151
+ assertEquals(get(), false);
152
+ });
153
+ });
154
+
155
+ test("startConvergenceLoop maps autoMerge:false to review-only convergence", async () => {
156
+ await withGithubOff(async () => {
157
+ const { app: capApp, get } = captureApp();
158
+ const res = await startConvergenceLoop(input({ pr: "owner/repo#10", autoMerge: false }), capApp);
159
+ assertEquals((res as any).status, 202);
160
+ assertEquals(get(), true);
161
+ });
162
+ });
163
+
164
+ test("startConvergenceLoop lets autoMerge override the legacy convergeOnly alias", async () => {
165
+ await withGithubOff(async () => {
166
+ const merge = captureApp();
167
+ const mergeRes = await startConvergenceLoop(
168
+ input({ pr: "owner/repo#13", autoMerge: true, convergeOnly: true }),
169
+ merge.app,
170
+ );
171
+ assertEquals((mergeRes as any).status, 202);
172
+ assertEquals(merge.get(), false);
173
+
174
+ const review = captureApp();
175
+ const reviewRes = await startConvergenceLoop(
176
+ input({ pr: "owner/repo#14", autoMerge: false, convergeOnly: false }),
177
+ review.app,
178
+ );
179
+ assertEquals((reviewRes as any).status, 202);
180
+ assertEquals(review.get(), true);
181
+ });
182
+ });
183
+
146
184
  test("startConvergenceLoop defaults convergeOnly to false and does not truthy-coerce a non-boolean", async () => {
147
185
  await withGithubOff(async () => {
148
186
  const omitted = captureApp();
149
- await startConvergenceLoop(input({ pr: "owner/repo#9" }), omitted.app);
187
+ await startConvergenceLoop(input({ pr: "owner/repo#11" }), omitted.app);
150
188
  assertEquals(omitted.get(), false);
151
189
 
152
190
  const stringy = captureApp();
153
- await startConvergenceLoop(input({ pr: "owner/repo#10", convergeOnly: "true" }), stringy.app);
191
+ await startConvergenceLoop(input({ pr: "owner/repo#12", convergeOnly: "true" }), stringy.app);
154
192
  assertEquals(stringy.get(), false);
193
+
194
+ const stringyAutoMerge = captureApp();
195
+ await startConvergenceLoop(input({ pr: "owner/repo#15", autoMerge: "true" }), stringyAutoMerge.app);
196
+ assertEquals(stringyAutoMerge.get(), false);
155
197
  });
156
198
  });
157
199
 
@@ -3,13 +3,15 @@
3
3
  // external webhook relay, a CI job, and Swagger all POST here. Parse the PR reference and
4
4
  // register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
5
5
  //
6
- // The request body is FLAT (`{ pr | url, dependsOn?, maxRounds?, convergeOnly? }`), not wrapped in a
7
- // `variables` envelope: this is a purpose-built operation, not a generic engine "start process" call,
8
- // so it does not leak the engine's variable-map concept to callers. The body is a `oneOf` — EXACTLY
9
- // ONE of `pr` or `url` — so the runtime rejects an empty or ambiguous target at the edge (a 400 that
10
- // names the allowed shapes); this delegate no longer coalesces `pr ?? url`, it just narrows the
11
- // validated variant. It keeps the PR-parse guard because the reference FORMAT (owner/repo#123 or a
12
- // URL) is app logic the JSON schema can't express — an unparseable reference is a 400.
6
+ // The request body is FLAT (`{ pr | url, dependsOn?, maxRounds?, autoMerge?, convergeOnly? }`), not
7
+ // wrapped in a `variables` envelope: this is a purpose-built operation, not a generic engine "start
8
+ // process" call, so it does not leak the engine's variable-map concept to callers. `autoMerge` is
9
+ // the positive, preferred form for UI/API callers; `convergeOnly` remains a legacy negative alias.
10
+ // When both are present, `autoMerge` wins. The body is a `oneOf` — EXACTLY ONE of `pr` or `url` — so
11
+ // the runtime rejects an empty or ambiguous target at the edge (a 400 that names the allowed
12
+ // shapes); this delegate no longer coalesces `pr ?? url`, it just narrows the validated variant. It
13
+ // keeps the PR-parse guard because the reference FORMAT (owner/repo#123 or a URL) is app logic the
14
+ // JSON schema can't express — an unparseable reference is a 400.
13
15
 
14
16
  import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
15
17
  import { defineOperation } from "../nano-generated/operations.ts";
@@ -29,9 +31,10 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
29
31
  }
30
32
  const dependsOn = body.dependsOn ?? [];
31
33
  const maxRounds = clampRounds(body.maxRounds, MAX_ROUNDS);
32
- // Per-request review-only override: when true the PR stops at `converged` and is never
33
- // handed to the merge-loop, regardless of the global NANO_PR_AUTO_MERGE default.
34
- const convergeOnly = body.convergeOnly === true;
34
+ // Prefer the positive autoMerge form so an unchecked UI box is explicitly review-only. Legacy
35
+ // callers that omit autoMerge retain the existing convergeOnly/global-default behavior.
36
+ const hasAutoMerge = typeof body.autoMerge === "boolean";
37
+ const convergeOnly = hasAutoMerge ? body.autoMerge !== true : body.convergeOnly === true;
35
38
  const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, convergeOnly);
36
39
  app.log.info("convergence loop started", {
37
40
  prKey: parsed.prKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.189.3",
3
+ "version": "0.191.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",
@@ -105,8 +105,8 @@
105
105
  "type": "number"
106
106
  },
107
107
  {
108
- "key": "convergeOnly",
109
- "label": "Converge only \u2014 stop at review consensus, do not merge",
108
+ "key": "autoMerge",
109
+ "label": "Auto-merge \u2014 after convergence, drive the merge-loop",
110
110
  "type": "checkbox"
111
111
  }
112
112
  ]