@nanobpm/nano-workforce 0.190.0 → 0.191.1

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.
Files changed (34) hide show
  1. package/.github/workflows/mirror-install-dispatch.yml +6 -6
  2. package/CHANGELOG.md +12 -0
  3. package/app/deliveryGraph.test.ts +447 -0
  4. package/app/deliveryGraph.ts +356 -1
  5. package/app/deliveryGraphCompiler.test.ts +1465 -3
  6. package/app/deliveryGraphCompiler.ts +833 -36
  7. package/app/deliveryGraphDispatch.test.ts +118 -2
  8. package/app/deliveryGraphDispatch.ts +138 -10
  9. package/app/deliveryGraphProposals.test.ts +243 -0
  10. package/app/deliveryGraphProposals.ts +130 -56
  11. package/app/deliveryGraphRun.test.ts +67 -4
  12. package/app/deliveryGraphRun.ts +62 -10
  13. package/app/deliveryHuman.test.ts +79 -2
  14. package/app/deliveryHuman.ts +92 -0
  15. package/app/deliveryRunner.test.ts +21 -0
  16. package/app/deliveryRunner.ts +9 -2
  17. package/app/mergeProtocol.ts +1 -1
  18. package/app/pollUserTasks.test.ts +39 -0
  19. package/app/readiness.test.ts +72 -0
  20. package/app/readiness.ts +234 -8
  21. package/app/service.ts +8 -3
  22. package/db/migrations/113_delivery_graph_run_identity.sql +37 -0
  23. package/db/migrations/114_delivery_graph_proposals_stage_seq.sql +21 -0
  24. package/docs/adr/0001-cross-repo-epics-and-artifact-wait-gates.md +1 -1
  25. package/docs/adr/0002-escalations-are-user-tasks-and-forms.md +1 -1
  26. package/docs/adr/0003-epic-base-branch-admission.md +1 -1
  27. package/docs/adr/0004-shared-contract-coordination.md +1 -1
  28. package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
  29. package/docs/adr/0006-delivery-units-one-representation.md +1 -1
  30. package/operations/dispatchDeliveryGraph.test.ts +255 -0
  31. package/operations/dispatchDeliveryGraph.ts +156 -5
  32. package/package.json +1 -1
  33. package/workers/merge/worker.test.ts +2 -2
  34. package/workers/merge/worker.ts +1 -1
@@ -62,6 +62,15 @@ export interface DeliveryGraphProposal {
62
62
  created_at: string;
63
63
  updated_at: string;
64
64
  expires_at: string;
65
+ /** A strictly-monotonic per-write stage revision (migration 114), reassigned to `MAX(stage_seq)+1`
66
+ * on every stage write (insert AND re-stage). It is the supersede reconcile's same-millisecond
67
+ * tie-break, AND the version token the dispatch door threads through so `markProposalDispatched`
68
+ * can guard its terminal flip against a concurrent re-stage that overwrote this same-digest row with
69
+ * a newer (e.g. credential-differing) graph after this dispatch snapshotted it (issue #778 review —
70
+ * thread deliveryGraphProposals.ts:243). Persisted rows carry a value ≥ 1; a pre-migration row reads
71
+ * `0` until its next stage re-sequences it. Assigned atomically in the `stageProposal` SQL, never
72
+ * from a row object — a freshly-`buildProposalRow`'d row's `0` here is a never-persisted placeholder. */
73
+ stage_seq: number;
65
74
  }
66
75
 
67
76
  /** The rendered preview a staged proposal carries — the operator-facing view of WHAT the graph does
@@ -150,6 +159,9 @@ export function buildProposalRow(input: {
150
159
  created_at: createdAt,
151
160
  updated_at: at,
152
161
  expires_at: proposalExpiry(createdAt),
162
+ // Placeholder only: the persisted `stage_seq` is assigned atomically as `MAX(stage_seq)+1` inside
163
+ // the `stageProposal` INSERT/UPSERT (never bound from this object), so this `0` is never written.
164
+ stage_seq: 0,
153
165
  };
154
166
  }
155
167
 
@@ -190,55 +202,77 @@ export interface StageOutcome {
190
202
  * retire. */
191
203
  export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal): Promise<StageOutcome> {
192
204
  const table = deliveryGraphProposals(data);
193
- const existing = await table.get(row.digest);
194
205
  // Capture the same-logical-key staged siblings that exist BEFORE this stage writes (excluding our own
195
206
  // digest) — the supersede reconcile below normally flips them all to `superseded`; we re-read them
196
207
  // after to report exactly which digests this stage retired.
197
208
  const priorSameKey = (await table.find({ status: "staged" })).filter(
198
209
  (r) => r.logical_key === row.logical_key && r.digest !== row.digest && isLiveStaged(r),
199
210
  );
200
- const toWrite = existing
201
- ? buildProposalRow({
202
- digest: row.digest,
203
- logicalKey: row.logical_key,
204
- title: row.title,
205
- graphJson: row.graph,
206
- preview: JSON.parse(row.preview),
207
- nodeCount: row.node_count,
208
- humanNodeCount: row.human_node_count,
209
- sideEffectCount: row.side_effect_count,
210
- sideEffecting: row.side_effecting === 1,
211
- // Re-stage: if the existing row is STILL LIVE, preserve its original stage time so the TTL
212
- // stays anchored to the first stage. But if it has already aged out of its TTL (or was
213
- // dispatched/superseded long ago), reusing the stale `created_at` would yield a past
214
- // `expires_at`, leaving the "re-staged" row immediately non-dispatchable (`getStagedProposal`
215
- // rejects it as expired) while the preview claims it is staged. In that case re-anchor the TTL
216
- // to now (omit `createdAt`) so a re-proposed digest is genuinely live again.
217
- createdAt: isProposalExpired(existing.expires_at) ? undefined : existing.created_at,
218
- })
219
- : row;
220
- if (existing) {
221
- const { digest, ...patch } = toWrite;
222
- await table.update(row.digest, patch);
223
- } else {
224
- await table.insert(toWrite);
225
- }
226
- // Reconcile to EXACTLY ONE live proposal per logical graph: supersede every `staged` row for this
227
- // `logical_key` that has a strictly-NEWER staged sibling (by `updated_at`, with a deterministic
228
- // `digest` tie-breaker), leaving only the globally-newest live. This is ORDER-INDEPENDENT — it never
229
- // references the just-written digest, so it converges to a single live row regardless of the
230
- // interleaving of concurrent stages. A supersede pass keyed to "only flip rows older than the one *I*
231
- // just wrote" leaves TWO live proposals when an OLDER stage's pass runs AFTER a newer stage already
232
- // committed (the older pass won't flip the newer row, and the newer pass ran before the older row
233
- // existed) — and an unordered supersede-all leaves ZERO. Anchoring on the newest staged sibling avoids
234
- // both: the newest row is never superseded (no newer sibling), so it stays staged throughout the
235
- // statement and every older row's `EXISTS` is satisfied by it. Idempotent: a no-op once one row remains.
236
- await data
237
- .open()
238
- .exec(
239
- `UPDATE "delivery_graph_proposals" SET "status" = 'superseded', "updated_at" = ? WHERE "logical_key" = ? AND "status" = 'staged' AND EXISTS (SELECT 1 FROM "delivery_graph_proposals" AS "newer" WHERE "newer"."logical_key" = "delivery_graph_proposals"."logical_key" AND "newer"."status" = 'staged' AND ("newer"."updated_at" > "delivery_graph_proposals"."updated_at" OR ("newer"."updated_at" = "delivery_graph_proposals"."updated_at" AND "newer"."digest" > "delivery_graph_proposals"."digest")))`,
211
+ // Persist the write, its strictly-monotonic stage sequence, AND the supersede reconcile as ONE
212
+ // transaction (issue #778 review — threads deliveryGraphProposals.ts:237/:239). Two properties matter:
213
+ // • `stage_seq` is assigned ATOMICALLY inside the write itself — `MAX(stage_seq)+1` in the
214
+ // INSERT/UPDATE, NOT a separate stamp statement — so there is never a "written but unstamped
215
+ // (seq 0)" window in which a rival same-millisecond stage's reconcile could read the half-written
216
+ // sibling at its DEFAULT 0 and supersede it out of order (the inversion the old two-statement
217
+ // stamp+reconcile allowed). Assigning inside the serialized transaction keeps `MAX(...)` atomic, so
218
+ // `+1` is strictly greater than every committed seq and reflects true write order.
219
+ // • wrapping the reconcile in the SAME transaction means no concurrent stage observes an intermediate
220
+ // state between the write and the reconcile.
221
+ // `MAX(stage_seq)` reads the pre-write table via the `stage_seq` index (migration 114), an O(1)
222
+ // reverse-index seek rather than the full-history scan a bare aggregate over the retained terminal rows
223
+ // would cost.
224
+ const at = now();
225
+ const atExpiry = proposalExpiry(at);
226
+ const nextSeq = `(SELECT COALESCE(MAX("stage_seq"), 0) + 1 FROM "delivery_graph_proposals")`;
227
+ await data.open().tx(async (t) => {
228
+ // Single idempotent UPSERT rather than a read-`existing`-then-branch INSERT/UPDATE: reading the row
229
+ // BEFORE the transaction meant two concurrent FIRST stages of the same digest both saw `null` and
230
+ // would both take an INSERT — the second hitting a primary-key conflict that fails the stage and drops
231
+ // its reconciliation. `ON CONFLICT("digest") DO UPDATE` collapses the raced insert into an in-place
232
+ // update instead, so a same-digest stage is idempotent regardless of arrival order (issue #778 review —
233
+ // thread deliveryGraphProposals.ts:245). `stage_seq` is still assigned ATOMICALLY inside the write
234
+ // (`MAX(stage_seq)+1`, materialised once as `excluded."stage_seq"` so the insert and the on-conflict
235
+ // update use the exact same value), preserving the strictly-monotonic ordering the supersede reconcile
236
+ // below tie-breaks on.
237
+ //
238
+ // The TTL anchor (`created_at`) and horizon (`expires_at`) are ALSO derived inside this atomic write,
239
+ // from the row as it exists AT WRITE TIME — NOT from a pre-transaction `table.get` snapshot (issue #778
240
+ // review — thread deliveryGraphProposals.ts:247). A pre-read snapshot is a TOCTOU: a concurrent re-stage
241
+ // can commit a fresh TTL between the read and this write, and the stale caller — committing LAST, so it
242
+ // wins the newest `stage_seq` — would overwrite that fresh horizon with the one it computed from the OLD
243
+ // `created_at`, restoring an already-expired `expires_at` on the newest row and leaving the proposal
244
+ // non-dispatchable. On a fresh INSERT the caller's `created_at`/`expires_at` stand (first stage anchors
245
+ // the TTL). On a CONFLICT the `CASE` re-reads the CURRENT committed row: if it is still LIVE
246
+ // (`expires_at` in the future) the original anchor and horizon are PRESERVED (an idempotent re-stage
247
+ // keeps the TTL pinned to the first stage); once it has aged out (past, blank, or NULL `expires_at`,
248
+ // matching `isProposalExpired`'s fail-closed rule) the TTL is RE-ANCHORED to now so a re-proposed digest
249
+ // is genuinely live again. All timestamps are ISO-8601 UTC, so the lexical `>` comparison is a valid
250
+ // chronological test.
251
+ await t.exec(
252
+ `INSERT INTO "delivery_graph_proposals" ("digest", "logical_key", "title", "graph", "preview", "node_count", "human_node_count", "side_effect_count", "side_effecting", "status", "created_at", "updated_at", "expires_at", "stage_seq") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${nextSeq}) ON CONFLICT("digest") DO UPDATE SET "logical_key" = excluded."logical_key", "title" = excluded."title", "graph" = excluded."graph", "preview" = excluded."preview", "node_count" = excluded."node_count", "human_node_count" = excluded."human_node_count", "side_effect_count" = excluded."side_effect_count", "side_effecting" = excluded."side_effecting", "status" = excluded."status", "created_at" = CASE WHEN "delivery_graph_proposals"."expires_at" > ? THEN "delivery_graph_proposals"."created_at" ELSE ? END, "updated_at" = ?, "expires_at" = CASE WHEN "delivery_graph_proposals"."expires_at" > ? THEN "delivery_graph_proposals"."expires_at" ELSE ? END, "stage_seq" = excluded."stage_seq"`,
253
+ [row.digest, row.logical_key, row.title, row.graph, row.preview, row.node_count, row.human_node_count, row.side_effect_count, row.side_effecting, row.status, row.created_at, at, row.expires_at, at, at, at, at, atExpiry],
254
+ );
255
+ // Reconcile to EXACTLY ONE live proposal per logical graph: supersede every `staged` row for this
256
+ // `logical_key` that has a strictly-NEWER staged sibling (by `updated_at`, with a deterministic
257
+ // `stage_seq` tie-breaker), leaving only the globally-newest live. This is ORDER-INDEPENDENT
258
+ // — it never references the just-written digest, so it converges to a single live row regardless of the
259
+ // interleaving of concurrent stages. A supersede pass keyed to "only flip rows older than the one *I*
260
+ // just wrote" leaves TWO live proposals when an OLDER stage's pass runs AFTER a newer stage already
261
+ // committed (the older pass won't flip the newer row, and the newer pass ran before the older row
262
+ // existed) — and an unordered supersede-all leaves ZERO. Anchoring on the newest staged sibling avoids
263
+ // both: the newest row is never superseded (no newer sibling), so it stays staged throughout the
264
+ // statement and every older row's `EXISTS` is satisfied by it. Idempotent: a no-op once one row remains.
265
+ // The tie-break is the monotonic `stage_seq` (atomically assigned in the write above), NOT the content
266
+ // `digest` NOR the frozen `rowid`: two stages landing in the SAME millisecond tie on `updated_at`, and a
267
+ // `digest`-string tie-break is arbitrary w.r.t. submission order while `rowid` does not advance on a
268
+ // re-stage UPDATE — so either could pick the LATER-submitted graph as the one superseded, inverting
269
+ // "the latest stage wins". `stage_seq` advances on every stage (insert AND re-stage alike), so the
270
+ // most-recently-written row deterministically wins the tie.
271
+ await t.exec(
272
+ `UPDATE "delivery_graph_proposals" SET "status" = 'superseded', "updated_at" = ? WHERE "logical_key" = ? AND "status" = 'staged' AND EXISTS (SELECT 1 FROM "delivery_graph_proposals" AS "newer" WHERE "newer"."logical_key" = "delivery_graph_proposals"."logical_key" AND "newer"."status" = 'staged' AND ("newer"."updated_at" > "delivery_graph_proposals"."updated_at" OR ("newer"."updated_at" = "delivery_graph_proposals"."updated_at" AND "newer"."stage_seq" > "delivery_graph_proposals"."stage_seq")))`,
240
273
  [now(), row.logical_key],
241
274
  );
275
+ });
242
276
 
243
277
  // Report what the stage did to siblings (issue #740). `superseded` = the same-logical-key proposals
244
278
  // this stage retired (re-read post-reconcile so a concurrent newer stage that kept ITS row live —
@@ -259,12 +293,13 @@ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal)
259
293
  }
260
294
  const siblingsStaged = (await listStagedProposals(data)).filter((r) => r.logical_key !== row.logical_key).length;
261
295
 
262
- // Re-read the just-written row AFTER the reconcile so `row` reports the ACTUALLY-persisted state, not
263
- // the pre-reconcile `toWrite`: a concurrent newer stage of a different digest can flip THIS digest to
264
- // `superseded` in the reconcile above, so returning `toWrite` (still `staged`) would report a status
265
- // that doesn't match the DB. Fall back to `toWrite` only if the row somehow vanished (it shouldn't —
266
- // we just wrote it), keeping the return non-null.
267
- const persisted = (await table.get(toWrite.digest)) ?? toWrite;
296
+ // Re-read the just-written row AFTER the reconcile so the returned row reports the ACTUALLY-persisted
297
+ // state: a concurrent newer stage of a different digest can flip THIS digest to `superseded` in the
298
+ // reconcile above, so returning the pre-reconcile input `row` (still `staged`) would report a status
299
+ // that doesn't match the DB — and the persisted `created_at`/`expires_at`/`stage_seq` are derived inside
300
+ // the write, not on `row`. Fall back to the input `row` only if the persisted row somehow vanished (it
301
+ // shouldn't — we just wrote it), keeping the return non-null.
302
+ const persisted = (await table.get(row.digest)) ?? row;
268
303
 
269
304
  return { row: persisted, superseded, siblingsStaged };
270
305
  }
@@ -321,9 +356,36 @@ export async function listStagedProposals(
321
356
  }
322
357
 
323
358
  /** Mark a staged proposal `dispatched` once the operator launches it — it drops out of the cockpit's
324
- * staged list (the run then shows in the in-flight grid). */
325
- export async function markProposalDispatched(data: DataLayer, digest: string): Promise<void> {
326
- await deliveryGraphProposals(data).update(digest, { status: "dispatched", updated_at: now() });
359
+ * staged list (the run then shows in the in-flight grid).
360
+ *
361
+ * Like `markProposalExpired`/`markProposalDismissed`/`sweepExpiredProposals`, this is a GUARDED UPDATE
362
+ * (`... WHERE digest=? AND status='staged'`), not a blind update-by-key. The dispatch door's
363
+ * `getStagedProposal` liveness read, the launch, and this terminal flip are separate statements, so the
364
+ * row can leave `staged` in the window between them (a concurrent dispatch consumed it, or a
365
+ * dismiss/supersede/expiry sweep retired it); a blind `table.update(digest, …)` would clobber that
366
+ * newer terminal status back to `dispatched`, breaking monotonic lifecycle transitions.
367
+ *
368
+ * When `expectedStageSeq` is supplied the guard ALSO pins `stage_seq` to the value the dispatch door
369
+ * snapshotted when it loaded the proposal (issue #778 review — thread deliveryGraphProposals.ts:243). A
370
+ * concurrent RE-STAGE of the SAME digest overwrites the row in place with a newer graph (two
371
+ * credential-differing graphs share a semantic digest) and bumps `stage_seq`; without the seq pin, this
372
+ * flip would retire that newer, never-launched graph — the dispatch launched the STALE snapshot it read
373
+ * yet marks the fresh row consumed. Pinning `stage_seq` makes the flip a no-op in that race, leaving the
374
+ * re-staged graph `staged` for its own dispatch. Omit `expectedStageSeq` (e.g. a pre-migration row whose
375
+ * seq is `0`) to fall back to the `status='staged'`-only guard.
376
+ *
377
+ * Returns whether the guarded UPDATE actually flipped a row (`res.changed > 0`), mirroring
378
+ * `markProposalDismissed`/`sweepExpiredProposals`. A `false` return means the row was no longer the
379
+ * `staged` revision this dispatch snapshotted (a concurrent dispatch/re-stage/retire won) — the launched
380
+ * run still stands, but the mark was intentionally skipped so the newer row is not clobbered. */
381
+ export async function markProposalDispatched(data: DataLayer, digest: string, expectedStageSeq?: number): Promise<boolean> {
382
+ const db = data.open();
383
+ const guardSeq = typeof expectedStageSeq === "number";
384
+ const res = await db.exec(
385
+ `UPDATE "delivery_graph_proposals" SET "status" = 'dispatched', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'${guardSeq ? ` AND "stage_seq" = ?` : ""}`,
386
+ guardSeq ? [now(), digest, expectedStageSeq] : [now(), digest],
387
+ );
388
+ return res.changed > 0;
327
389
  }
328
390
 
329
391
  /** Retire a proposal by flipping it to `expired` — its `graph` payload is unusable (e.g. corrupt JSON
@@ -342,12 +404,18 @@ export async function markProposalDispatched(data: DataLayer, digest: string): P
342
404
  * Returns whether the guarded UPDATE actually flipped a row (`res.changed > 0`), mirroring
343
405
  * `markProposalDismissed`/`sweepExpiredProposals`. A `false` return means the row was no longer `staged`
344
406
  * at write time (a dismiss/supersede/dispatch race won), so the caller must not treat the retirement as
345
- * having happened. */
346
- export async function markProposalExpired(data: DataLayer, digest: string): Promise<boolean> {
407
+ * having happened.
408
+ *
409
+ * When an `expectedStageSeq` is supplied (the dispatch door's corrupt-graph retirement), the flip is
410
+ * ADDITIONALLY guarded on `stage_seq = ?`, exactly like `markProposalDispatched`: a concurrent re-stage
411
+ * of the same digest bumps `stage_seq`, so guarding on the revision we inspected keeps the expiry from
412
+ * retiring a newer graph that landed in the read→write window (issue #778 review). */
413
+ export async function markProposalExpired(data: DataLayer, digest: string, expectedStageSeq?: number): Promise<boolean> {
347
414
  const db = data.open();
415
+ const guardSeq = typeof expectedStageSeq === "number";
348
416
  const res = await db.exec(
349
- `UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'`,
350
- [now(), digest],
417
+ `UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'${guardSeq ? ` AND "stage_seq" = ?` : ""}`,
418
+ guardSeq ? [now(), digest, expectedStageSeq] : [now(), digest],
351
419
  );
352
420
  return res.changed > 0;
353
421
  }
@@ -404,9 +472,15 @@ export async function sweepExpiredProposals(data: DataLayer, at: Date = new Date
404
472
  let swept = 0;
405
473
  for (const row of staged) {
406
474
  if (isProposalExpired(row.expires_at, at)) {
475
+ // Pin `stage_seq` (like `markProposalDispatched`/`markProposalExpired`): the `find` liveness read and
476
+ // this flip are separate statements, so a concurrent RE-STAGE can overwrite the row in place with a
477
+ // newer graph — bumping `stage_seq` and REFRESHING `expires_at` — in the window between them. A blind
478
+ // `digest + status='staged'` update would then expire that fresh, un-expired revision. Guarding on the
479
+ // `stage_seq` we inspected makes the flip a no-op in that race, leaving the re-staged graph `staged`
480
+ // (issue #778 review — thread deliveryGraphProposals.ts:417).
407
481
  const res = await db.exec(
408
- `UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged'`,
409
- [ts, row.digest],
482
+ `UPDATE "delivery_graph_proposals" SET "status" = 'expired', "updated_at" = ? WHERE "digest" = ? AND "status" = 'staged' AND "stage_seq" = ?`,
483
+ [ts, row.digest, row.stage_seq],
410
484
  );
411
485
  swept += res.changed;
412
486
  }
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join, resolve } from "node:path";
10
10
  import { test } from "node:test";
11
- import { assertEquals } from "#test-assert";
11
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
12
12
  import type { DataLayer } from "@nanobpm/urban";
13
13
  import { bootTestApp } from "@nanobpm/urban-testkit";
14
14
  import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
@@ -19,6 +19,7 @@ import {
19
19
  computeRunKey,
20
20
  deriveDeliveryPhase,
21
21
  DELIVERY_PHASE,
22
+ deliveryGraphRunIdentities,
22
23
  deliveryGraphRuns,
23
24
  humanTaskElementId,
24
25
  parseHumanLabels,
@@ -107,6 +108,25 @@ test("claimRunForLaunch: re-running a terminal row clears the PRIOR instance key
107
108
  });
108
109
  });
109
110
 
111
+ test("claimRunForLaunch: winning a re-run claim ATOMICALLY invalidates the prior run's identity side-row — a concurrent loser's short-circuit read sees a MISSING (unprovable) fingerprint, not the stale prior one (issue #778 review — thread deliveryGraphDispatch.ts:180)", async () => {
112
+ await withData(async (data) => {
113
+ const runs = deliveryGraphRuns(data);
114
+ const identities = deliveryGraphRunIdentities(data);
115
+ // A terminal prior run still carrying its stamped lossless identity fingerprint.
116
+ await runs.insert({ ...claimRow("running"), status: "failed" });
117
+ await identities.insert({ run_key: "rk", graph_fingerprint: "PRIOR-FP", created_at: "2020-01-01T00:00:00.000Z" });
118
+ // The winner re-runs (conceptually a DIFFERENT graph that shares the digest-derived run_key but not
119
+ // the credential-sensitive fingerprint). The CAS flips the row to running…
120
+ assertEquals(await claimRunForLaunch(data, true, claimRow("running")), true);
121
+ // …and the stale identity row must be GONE the instant the row is `running`. Otherwise a racing loser
122
+ // reading between this flip and the winner's post-launch `upsertRunIdentity` re-stamp would read
123
+ // "PRIOR-FP" and, if that matched ITS graph, wrongly confirm identity for a credential-different run.
124
+ // Missing → dispatch's identityConfirmed is false (the safe 409) throughout the window.
125
+ const after = await identities.get("rk");
126
+ assert(after == null, `the stale identity row must be gone, got: ${JSON.stringify(after)}`);
127
+ });
128
+ });
129
+
110
130
  // ── pollDeliveryGraphPhase: engine-key coercion ───────────────────────────────
111
131
  test("pollDeliveryGraphPhase: a numeric engine processInstanceKey still matches the string process_key, so a COMPLETED instance reconciles to done", async () => {
112
132
  await withData(async (data) => {
@@ -181,7 +201,7 @@ test("computeRunKey: a non-blank caller key wins; a blank/absent key falls back
181
201
  });
182
202
 
183
203
  // ── buildHumanLabels / parseHumanLabels ───────────────────────────────────────
184
- test("buildHumanLabels: maps each human node's compiled user-task element id → its instruction label", async () => {
204
+ test("buildHumanLabels: maps each human node's compiled user-task element id → its FULL instruction", async () => {
185
205
  const graph = {
186
206
  nodes: [
187
207
  { id: "open-b", kind: "agent", agent: { jobType: "j" } },
@@ -196,16 +216,38 @@ test("buildHumanLabels: maps each human node's compiled user-task element id →
196
216
  const labels = buildHumanLabels(compiled);
197
217
  const publishEl = compiled.resolved.nodes.find((n) => n.id === "publish")?.element ?? "";
198
218
  const ackEl = compiled.resolved.nodes.find((n) => n.id === "ack")?.element ?? "";
199
- assertEquals(labels[humanTaskElementId(publishEl)], "run the manual OTP publish"); // first line only
219
+ // The FULL prompt is stored (issue #813) — not a clamped first line — so the Tasks "Decision
220
+ // context" can show the whole instruction. The phase pill clamps this at display (deriveDeliveryPhase).
221
+ assertEquals(labels[humanTaskElementId(publishEl)], "run the manual OTP publish\nsecond line");
200
222
  assertEquals(labels[humanTaskElementId(ackEl)], "ack"); // fallback to node id
201
223
  });
202
224
 
225
+ test("buildHumanLabels: redacts a credential-bearing human prompt before it lands in human_labels (issue #778 review)", async () => {
226
+ const graph = {
227
+ nodes: [
228
+ { id: "publish", kind: "human", human: { prompt: "deploy via https://user:s3cr3t@registry.example.com then confirm" } },
229
+ ],
230
+ edges: [],
231
+ };
232
+ const compiled = await compileDeliveryGraph(graph);
233
+ assertEquals(compiled.ok, true);
234
+ if (!compiled.ok) return;
235
+ const labels = buildHumanLabels(compiled);
236
+ const publishEl = compiled.resolved.nodes.find((n) => n.id === "publish")?.element ?? "";
237
+ const label = labels[humanTaskElementId(publishEl)];
238
+ assert(!label.includes("s3cr3t") && !label.includes("user:s3cr3t"), "the credential must not survive into the denormalised inbox label");
239
+ assertStringIncludes(label, "//***@");
240
+ });
241
+
203
242
  test("parseHumanLabels: round-trips a stored map and tolerates null/blank/corrupt", () => {
204
- assertEquals(parseHumanLabels(JSON.stringify({ a: "x" })), { a: "x" });
205
243
  assertEquals(parseHumanLabels(null), {});
206
244
  assertEquals(parseHumanLabels(""), {});
207
245
  assertEquals(parseHumanLabels(" "), {});
208
246
  assertEquals(parseHumanLabels("{not json"), {});
247
+ assertEquals(parseHumanLabels(JSON.stringify({ n1: "manual OTP publish", n2: "confirm deploy" })), {
248
+ n1: "manual OTP publish",
249
+ n2: "confirm deploy",
250
+ }); // round-trips a valid string→string map
209
251
  assertEquals(parseHumanLabels(JSON.stringify(["a"])), {}); // non-object
210
252
  assertEquals(parseHumanLabels(JSON.stringify({ a: 1, b: "y" })), { b: "y" }); // drops non-string values
211
253
  });
@@ -224,6 +266,16 @@ test("deriveDeliveryPhase: ACTIVE with an open human task → parked on that nod
224
266
  assertEquals(p.phase_node_id, el);
225
267
  });
226
268
 
269
+ test("deriveDeliveryPhase: clamps a long/multi-line stored instruction to a first-line phase pill (#813)", () => {
270
+ const el = humanTaskElementId("n2");
271
+ const full = `Review the console dependency-adopt PR opened by adopt-console (bumps the ranges).\nSecond line.`;
272
+ const p = deriveDeliveryPhase("ACTIVE", [{ elementId: el }], { [el]: full });
273
+ // Full instruction is stored for the Decision context, but the phase pill shows only the clamped
274
+ // first line (77 chars + …), never the second line.
275
+ assertEquals(p.phase, "Parked on human node: Review the console dependency-adopt PR opened by adopt-console (bumps the ran…");
276
+ assert(!p.phase.includes("Second line"));
277
+ });
278
+
227
279
  test("deriveDeliveryPhase: a parked node with no stored label falls back to the element id", () => {
228
280
  const el = humanTaskElementId("n5");
229
281
  const p = deriveDeliveryPhase("ACTIVE", [{ elementId: el }], {});
@@ -231,6 +283,17 @@ test("deriveDeliveryPhase: a parked node with no stored label falls back to the
231
283
  assertEquals(p.phase_node_id, el);
232
284
  });
233
285
 
286
+ test("deliveryHuman: a parked bounded-timeout twin (`…__esc`) resolves the base node's stored label in the phase pill (#813)", () => {
287
+ // The twin's exact `…__esc` id is never stamped into `human_labels` (only the base id is), so the
288
+ // phase-pill lookup must strip `__esc` — the same fallback the Tasks Decision-context helpers use —
289
+ // else a parked timeout twin shows the raw element id instead of the instruction's first line.
290
+ const base = humanTaskElementId("n2");
291
+ const twin = `${base}__esc`;
292
+ const p = deriveDeliveryPhase("ACTIVE", [{ elementId: twin }], { [base]: "manual OTP publish" });
293
+ assertEquals(p.phase, "Parked on human node: manual OTP publish");
294
+ assertEquals(p.phase_node_id, twin);
295
+ });
296
+
234
297
  test("deriveDeliveryPhase: ACTIVE with only a non-human open task (or none) → a bare Running", () => {
235
298
  assertEquals(deriveDeliveryPhase("ACTIVE", [], {}), { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null });
236
299
  assertEquals(deriveDeliveryPhase("ACTIVE", [{ elementId: "some-service-task" }], {}), {
@@ -26,6 +26,7 @@
26
26
  import type { DataLayer, ProcessInstanceState } from "@nanobpm/urban";
27
27
  import type { CompileDeliveryGraphResult } from "../nano-generated/api-io.d.ts";
28
28
  import { isUniqueConstraintFence } from "./dbFence.ts";
29
+ import { redactFreeText } from "./deliveryGraphCompiler.ts";
29
30
  import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
30
31
 
31
32
  const now = () => new Date().toISOString();
@@ -97,6 +98,24 @@ export const DELIVERY_PHASE = {
97
98
  export const deliveryGraphRuns = (data: DataLayer) =>
98
99
  data.table<DeliveryGraphRun>("delivery_graph_runs", "run_key");
99
100
 
101
+ /** One row of the `delivery_graph_run_identity` SIDE table (migration 113) — the run's LOSSLESS
102
+ * content-identity fingerprint, kept OFF the heavily-projected `delivery_graph_runs` base row so no
103
+ * drift-guarded read-model/compat view has to carry it. Keyed by `run_key`. */
104
+ export interface DeliveryGraphRunIdentity {
105
+ run_key: string;
106
+ /** `sha256(digest \0 canonicalJson(digestInvisibleRawValues(graph)))`, captured at launch — the same
107
+ * lossless identity `stableProposalRunKey` folds into the keyless run key. An explicit-`idempotencyKey`
108
+ * short-circuit compares this to prove the running run is THIS exact graph (a same-payload retry) vs. a
109
+ * credential-different graph re-staged under the same key. */
110
+ graph_fingerprint: string;
111
+ created_at: string;
112
+ }
113
+
114
+ /** The `delivery_graph_run_identity` side-table accessor — the run's lossless identity fingerprint,
115
+ * keyed by `run_key` (issue #778 review — thread dispatchDeliveryGraph.ts:332). */
116
+ export const deliveryGraphRunIdentities = (data: DataLayer) =>
117
+ data.table<DeliveryGraphRunIdentity>("delivery_graph_run_identity", "run_key");
118
+
100
119
  /** Atomically claim a run for LAUNCH — the at-most-once dispatch fence. Returns `true` iff THIS caller
101
120
  * won the claim and must proceed to `runDeliveryGraph`; `false` iff a concurrent submit already claimed
102
121
  * it (the caller must short-circuit as `alreadyRunning` instead of double-launching). Two fences, one
@@ -119,7 +138,9 @@ export const deliveryGraphRuns = (data: DataLayer) =>
119
138
  * the row briefly visible as `running` while still pointing at the OLD instance key, so the
120
139
  * `process_key`-keyed instance-tracking reconciler / poller could act on (and mis-reconcile against)
121
140
  * the stale instance before the winner's follow-up metadata write lands. Clearing them atomically with
122
- * the flip means a claimed `running` row can never be observed with a stale instance key. */
141
+ * the flip means a claimed `running` row can never be observed with a stale instance key. A winning
142
+ * relaunch also DELETES the run's prior `delivery_graph_run_identity` side-row in the same transaction,
143
+ * for the same reason — see the inline note (issue #778 review). */
123
144
  export async function claimRunForLaunch(
124
145
  data: DataLayer,
125
146
  existing: boolean,
@@ -134,9 +155,8 @@ export async function claimRunForLaunch(
134
155
  return false;
135
156
  }
136
157
  }
137
- const res = await data
138
- .open()
139
- .exec(
158
+ const res = await data.open().tx(async (t) => {
159
+ const flip = await t.exec(
140
160
  `UPDATE "delivery_graph_runs" SET "status" = ?, "process_key" = ?, "process_definition_id" = ?, "phase" = ?, "phase_node_id" = ?, "updated_at" = ? WHERE "run_key" = ? AND "status" <> 'running'`,
141
161
  [
142
162
  claim.status,
@@ -148,7 +168,21 @@ export async function claimRunForLaunch(
148
168
  claim.run_key,
149
169
  ],
150
170
  );
151
- return res.changed === 1;
171
+ if (flip.changed !== 1) return false;
172
+ // Invalidate the PRIOR run's lossless identity fingerprint IN THE SAME transaction as the flip, so a
173
+ // claimed `running` row can never be observed alongside the previous run's stale `graph_fingerprint`.
174
+ // dispatch's `identityConfirmed` short-circuit reads this side-row to prove an already-running run is
175
+ // THIS exact graph (a same-payload retry) vs. a credential-different graph re-staged under the same
176
+ // key; between this flip and the winner's post-launch re-stamp (`upsertRunIdentity`) the prior row
177
+ // would otherwise still match a relaunch that shares only the digest, WRONGLY confirming identity for
178
+ // a different-credential run. Deleting it here makes the fingerprint UNPROVABLE (missing →
179
+ // identityConfirmed:false, the safe pre-existing 409) throughout that window, atomically with the
180
+ // status flip — the same "no stale side-state observable on a claimed row" invariant the instance-key
181
+ // clear above enforces (issue #778 review — thread deliveryGraphDispatch.ts:180).
182
+ await t.exec(`DELETE FROM "delivery_graph_run_identity" WHERE "run_key" = ?`, [claim.run_key]);
183
+ return true;
184
+ });
185
+ return res;
152
186
  }
153
187
 
154
188
  /** The idempotency key for a submitted graph: a caller-supplied `idempotencyKey` (trimmed) when
@@ -172,16 +206,28 @@ function firstLine(text: string | undefined | null): string {
172
206
  return line.length > 80 ? `${line.slice(0, 77)}…` : line;
173
207
  }
174
208
 
175
- /** Map each human node's compiled user-task element id → a display label (its instruction's first
176
- * line, else its author node id), from the S1 compile result. Stamped on the run row at dispatch so
177
- * the poller renders the parked-node phase without recompiling the graph. */
209
+ /** Map each human node's compiled user-task element id → its FULL instruction, from the S1 compile
210
+ * result. Stamped on the run row at dispatch so the poller can (a) render the parked-node phase label
211
+ * — clamped at display by {@link deriveDeliveryPhase} — and (b) surface the WHOLE instruction as the
212
+ * Tasks-inbox "Decision context" (issue #813). Storing the full prompt (not a clamped first line) is
213
+ * what lets the operator read the entire instruction, and lets a URL embedded in the prompt reach the
214
+ * task's clickable link. Falls back to the node id when a human node declares no prompt. */
178
215
  export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<string, string> {
179
216
  const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
180
217
  const labels: Record<string, string> = {};
181
218
  for (const stop of compiled.humanNodes) {
182
219
  const element = elementByNodeId.get(stop.nodeId);
183
220
  if (element === undefined) continue;
184
- labels[humanTaskElementId(element)] = firstLine(stop.prompt) || stop.nodeId;
221
+ // Redact the prompt with the SAME display-safe helper `nodeDisplay` uses BEFORE stamping it: this
222
+ // label is denormalised into the run row's `human_labels` and surfaced to the operator as the parked
223
+ // task's Decision context in the Tasks inbox, so a URL credential in a human prompt (`//user:pass@…`)
224
+ // must be stripped here too — else it is persisted and shown unredacted even though the BPMN display
225
+ // path redacts it. The RAW prompt still reaches the runtime user task unmodified (issue #778 review).
226
+ // The FULL redacted prompt is stored (not a clamped first line) so the Tasks "Decision context" can
227
+ // show the whole instruction and surface an embedded link; the phase pill clamps it at display via
228
+ // `firstLine` in `deriveDeliveryPhase` below (issue #813).
229
+ const safePrompt = typeof stop.prompt === "string" ? redactFreeText(stop.prompt).trim() : "";
230
+ labels[humanTaskElementId(element)] = safePrompt || stop.nodeId;
185
231
  }
186
232
  return labels;
187
233
  }
@@ -227,7 +273,13 @@ export function deriveDeliveryPhase(
227
273
  .filter((id): id is string => typeof id === "string" && isDeliveryHumanElement(id))
228
274
  .sort()[0];
229
275
  if (parkedOn !== undefined) {
230
- const label = humanLabels[parkedOn] ?? parkedOn;
276
+ // Clamp the compact phase label to the instruction's first line (the full prompt is stored in
277
+ // `human_labels` for the Tasks "Decision context" — issue #813 — but the phase pill wants a short
278
+ // one-liner). Use the SAME exact-then-`__esc`-stripped lookup as `deliveryHumanContext*` so a
279
+ // parked bounded-timeout twin (`…__esc`, whose exact id is never stamped) resolves to its base
280
+ // node's label instead of showing the raw element id. A missing label falls back to the raw id.
281
+ const stored = humanLabels[parkedOn] ?? humanLabels[parkedOn.replace(/__esc$/, "")];
282
+ const label = firstLine(stored) || parkedOn;
231
283
  return { status: "running", phase: `Parked on human node: ${label}`, phase_node_id: parkedOn };
232
284
  }
233
285
  return { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null };
@@ -11,7 +11,9 @@ import {
11
11
  bindHumanEmits,
12
12
  DELIVERY_HUMAN_ELEMENT,
13
13
  deliveryHumanContextQuestion,
14
+ deliveryHumanContextUrl,
14
15
  deriveHumanCategory,
16
+ firstHttpUrl,
15
17
  GENERIC_HUMAN_FORM,
16
18
  HUMAN_ACK_FORM,
17
19
  HUMAN_PUBLISH_FORM,
@@ -21,6 +23,7 @@ import {
21
23
  renderHumanEmitBrief,
22
24
  resolveHumanForm,
23
25
  } from "./deliveryHuman.ts";
26
+ import { redactFreeText } from "./deliveryGraphCompiler.ts";
24
27
  import { ESCALATION_TASK_ELEMENTS } from "./agentCompletion.ts";
25
28
  import { USER_TASK_KIND_LABELS } from "./userTasks.ts";
26
29
 
@@ -322,8 +325,82 @@ test("deliveryHumanContextQuestion: derives the node instruction from human_labe
322
325
  );
323
326
  });
324
327
 
325
- test("deliveryHumanContextQuestion: falls back to a static message when no label is stored", () => {
326
- // A parked human step must never render a blank Decision context — an untracked/absent label still
328
+ test("firstHttpUrl: extracts the first http(s) URL and trims trailing prose punctuation (#813)", () => {
329
+ assertEquals(firstHttpUrl("see https://github.com/o/r/pull/5 for details"), "https://github.com/o/r/pull/5");
330
+ // Trailing sentence punctuation / closing bracket is stripped so the link resolves.
331
+ assertEquals(firstHttpUrl("Review the PR (https://github.com/o/r/pull/5)."), "https://github.com/o/r/pull/5");
332
+ assertEquals(firstHttpUrl("http://example.test/a,"), "http://example.test/a");
333
+ // A balanced closing bracket is part of the URL and preserved (not truncated into a broken link).
334
+ assertEquals(
335
+ firstHttpUrl("see https://en.wikipedia.org/wiki/Nano_(technology) here"),
336
+ "https://en.wikipedia.org/wiki/Nano_(technology)",
337
+ );
338
+ // A balanced bracket followed by prose punctuation: strip only the punctuation, keep the pair.
339
+ assertEquals(
340
+ firstHttpUrl("ref https://en.wikipedia.org/wiki/Nano_(technology)."),
341
+ "https://en.wikipedia.org/wiki/Nano_(technology)",
342
+ );
343
+ // An UNBALANCED wrapping bracket is prose and still stripped.
344
+ assertEquals(firstHttpUrl("(https://github.com/o/r/pull/9)"), "https://github.com/o/r/pull/9");
345
+ // Multiple/nested UNBALANCED trailing closers + prose punctuation are all peeled off in one linear
346
+ // pass (guards the O(n) trim refactor — #813 review), while an inner balanced pair is preserved.
347
+ assertEquals(
348
+ firstHttpUrl("(see [https://en.wikipedia.org/wiki/Nano_(technology)])."),
349
+ "https://en.wikipedia.org/wiki/Nano_(technology)",
350
+ );
351
+ assertEquals(firstHttpUrl("look: {[(https://example.test/a)]}"), "https://example.test/a");
352
+ // An apostrophe is a valid URL sub-delimiter and preserved WITHIN the URL (not truncated at it)…
353
+ assertEquals(
354
+ firstHttpUrl("see https://en.wikipedia.org/wiki/It's_a_Wonderful_Life here"),
355
+ "https://en.wikipedia.org/wiki/It's_a_Wonderful_Life",
356
+ );
357
+ // …while a TRAILING apostrophe (a prose quote closer) is still trimmed.
358
+ assertEquals(firstHttpUrl("the link 'https://example.test/a' works"), "https://example.test/a");
359
+ // Scheme match is case-insensitive (RFC 3986 schemes are case-insensitive).
360
+ assertEquals(firstHttpUrl("see HTTPS://github.com/o/r/pull/5 now"), "HTTPS://github.com/o/r/pull/5");
361
+ assertEquals(firstHttpUrl("Http://example.test/b"), "Http://example.test/b");
362
+ // First wins when several are present.
363
+ assertEquals(firstHttpUrl("a https://one.test b https://two.test"), "https://one.test");
364
+ // No URL / non-string → null (preserves today's linkless behaviour).
365
+ assertEquals(firstHttpUrl("merge the PR opened by adopt-console"), null);
366
+ assertEquals(firstHttpUrl(undefined), null);
367
+ });
368
+
369
+ test("deliveryHumanContextUrl: lifts a prompt-embedded URL onto the task link (base + __esc twin), else null (#813)", () => {
370
+ const labels = {
371
+ "delivery-human-task__n7": "Review the adopt PR at https://github.com/nanobpm/nano-ide/pull/42 and merge it.",
372
+ "delivery-human-task__n8": "Confirm CI is green, then merge — no link here.",
373
+ };
374
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n7"), "https://github.com/nanobpm/nano-ide/pull/42");
375
+ // The bounded-timeout twin resolves the same node instruction.
376
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n7__esc"), "https://github.com/nanobpm/nano-ide/pull/42");
377
+ // A node whose instruction names no URL yields null (the linkless default).
378
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__n8"), null);
379
+ assertEquals(deliveryHumanContextUrl(undefined, "delivery-human-task__n1"), null);
380
+ });
381
+
382
+ test("deliveryHumanContextUrl: declines to link a redaction-altered URL, links a credential-free one (#814)", () => {
383
+ // The stored `human_labels` are display-REDACTED via the SAME `redactFreeText` `buildHumanLabels` uses,
384
+ // so build the labels through it to mirror production exactly.
385
+ const withQuery = redactFreeText("Review the checks tab at https://example.test/o/r/pull/5?tab=checks now");
386
+ const withCred = redactFreeText("Open https://user:pass@example.test/o/r/pull/5 to review");
387
+ const withFragment = redactFreeText("Jump to https://example.test/o/r/pull/5#files section");
388
+ const clean = redactFreeText("Review the PR at https://example.test/o/r/pull/5 and merge it");
389
+ const labels = {
390
+ "delivery-human-task__q": withQuery,
391
+ "delivery-human-task__c": withCred,
392
+ "delivery-human-task__f": withFragment,
393
+ "delivery-human-task__ok": clean,
394
+ };
395
+ // A redacted query/userinfo/fragment would publish a DIFFERENT, broken link → decline (null).
396
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__q"), null);
397
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__c"), null);
398
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__f"), null);
399
+ // A credential-free URL is untouched by redaction and links faithfully.
400
+ assertEquals(deliveryHumanContextUrl(labels, "delivery-human-task__ok"), "https://example.test/o/r/pull/5");
401
+ });
402
+
403
+ test("deliveryHumanContextQuestion: falls back to a static message when no label is stored", () => { // A parked human step must never render a blank Decision context — an untracked/absent label still
327
404
  // yields actionable guidance rather than null (which would leave the panel empty, issue #772).
328
405
  assertEquals(
329
406
  deliveryHumanContextQuestion({}, "delivery-human-task__n1"),