@nanobpm/nano-workforce 0.191.0 → 0.192.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/app/github.ts CHANGED
@@ -1658,6 +1658,128 @@ async function branchHeadSha(repo: string, branch: string, token: string): Promi
1658
1658
  return j.object?.sha ?? null;
1659
1659
  }
1660
1660
 
1661
+ /** The ancestry relationship between two commits on `repo`, from GitHub's compare endpoint
1662
+ * (`compare/<base>...<head>`). `status` is GitHub's verdict — `"ahead"` (head strictly descends
1663
+ * base — a fast-forward from base to head is possible), `"behind"`, `"identical"`, or `"diverged"` —
1664
+ * and `aheadBy`/`behindBy` are the commit counts either side of the merge-base. The no-advance
1665
+ * self-heal (#818) uses this to prove a recorded push-checkpoint SHA is a *strict fast-forward*
1666
+ * descendant of the PR head (`status === "ahead"`, `behindBy === 0`, `aheadBy > 0`) before it moves
1667
+ * the head — a diverged/behind checkpoint is NOT safe to fast-forward onto and must escalate instead.
1668
+ * `null` when no transport is usable (idle); throws only on a genuine transport failure. */
1669
+ export interface CommitComparison {
1670
+ readonly status: "ahead" | "behind" | "identical" | "diverged";
1671
+ readonly aheadBy: number;
1672
+ readonly behindBy: number;
1673
+ }
1674
+
1675
+ export async function compareCommits(
1676
+ repo: string,
1677
+ base: string,
1678
+ head: string,
1679
+ token: string,
1680
+ ): Promise<CommitComparison | null> {
1681
+ // Encode each ref/SHA so a branch name containing git-legal-but-URL-significant characters
1682
+ // (`#`, `?`, spaces) can't truncate the path (same hazard branchHeadSha guards). The `...`
1683
+ // three-dot separator between the two encoded refs is the compare API's own syntax, not a ref char.
1684
+ const enc = (r: string) => r.split("/").map(encodeURIComponent).join("/");
1685
+ const apiPath = `repos/${repo}/compare/${enc(base)}...${enc(head)}`;
1686
+ const parse = (body: string): CommitComparison => {
1687
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
1688
+ const j = JSON.parse(body) as { status?: unknown; ahead_by?: unknown; behind_by?: unknown };
1689
+ const status = j.status === "ahead" || j.status === "behind" || j.status === "identical" || j.status === "diverged" ? j.status : "diverged";
1690
+ // Validate the commit counts at THIS transport boundary rather than lean on the downstream
1691
+ // `Number.isInteger` gate (Copilot review of #819). A bare `Number(j.ahead_by ?? 0)` silently
1692
+ // COERCES a malformed value into a spurious ancestry proof: `Number("1")`/`Number(true)` === `1`,
1693
+ // and `Number.isInteger(1e21)` is `true` for an unsafe magnitude — any of which would pass the
1694
+ // self-heal's `Number.isInteger`/`aheadBy > 0` fast-forward check and PATCH the ref WITHOUT a
1695
+ // genuine GitHub ancestry proof. A MISSING count is a legitimate zero (GitHub omits it), but a
1696
+ // PRESENT value must be a real, non-negative SAFE integer NUMBER; anything else (string, boolean,
1697
+ // float, unsafe magnitude) is a corrupt/tampered response we reject by throwing — `compareCommits`
1698
+ // fails, the self-heal's `catch` degrades to `{ healed: false }`, and the round escalates instead
1699
+ // of fast-forwarding on unproven data.
1700
+ const count = (v: unknown, field: string): number => {
1701
+ if (v === undefined || v === null) return 0;
1702
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0) {
1703
+ throw new Error(`github compare returned an invalid ${field}: ${JSON.stringify(v)}`);
1704
+ }
1705
+ return v;
1706
+ };
1707
+ return { status, aheadBy: count(j.ahead_by, "ahead_by"), behindBy: count(j.behind_by, "behind_by") };
1708
+ };
1709
+ if (await useGh()) {
1710
+ const out = await runGh(["api", apiPath, "-H", "Accept: application/vnd.github+json"]);
1711
+ return parse(out);
1712
+ }
1713
+ if (!token) return null;
1714
+ const r = await fetch(`https://api.github.com/${apiPath}`, {
1715
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
1716
+ });
1717
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
1718
+ return parse(await r.text());
1719
+ }
1720
+
1721
+ /** Fast-forward `refs/heads/<branch>` on `repo` to `sha` (a NON-force ref update — GitHub itself
1722
+ * rejects a non-fast-forward with `422`, a hard backstop beneath the caller's own compare check).
1723
+ * Returns `true` when the ref now points at `sha`, `false` when GitHub refused the move as
1724
+ * non-fast-forward (`422`) — or, when `expectedSha` is given, when the ref no longer points at it
1725
+ * (the compare-and-swap below lost). The no-advance self-heal (#818) uses this to advance a PR head
1726
+ * onto a reachable push-checkpoint SHA that the harness pushed but left off the head. `force` is
1727
+ * never set: the whole point is to move the head ONLY when it is a safe fast-forward, never to
1728
+ * rewrite history.
1729
+ *
1730
+ * `expectedSha` (optional) makes the move a COMPARE-AND-SWAP on the ref's prior value: read the ref
1731
+ * immediately before the PATCH and refuse (`false`) unless it still equals `expectedSha`. The
1732
+ * self-heal passes the exact head SHA its fast-forward proof was computed against (`currentHead`), so
1733
+ * the mutation's precondition is BOUND TO the validated state instead of being trusted from a
1734
+ * separate, earlier ownership read (Copilot review on #819): if a concurrent run advanced/replaced
1735
+ * the head after the proof — a superseded straggler that raced past the late `stillOwns()` guard, or
1736
+ * any other writer — the ref no longer equals `expectedSha` and the straggler PATCHes nothing.
1737
+ * GitHub's ref API exposes no server-side expected-old-value precondition, so this read-verify is the
1738
+ * tightest fence the platform allows; combined with the non-force fast-forward invariant (the head
1739
+ * can only ever move FORWARD, never rewrite) the residual window is bounded and fail-safe. */
1740
+ export async function updateBranchRef(
1741
+ repo: string,
1742
+ branch: string,
1743
+ sha: string,
1744
+ token: string,
1745
+ expectedSha?: string,
1746
+ ): Promise<boolean> {
1747
+ const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
1748
+ const apiPath = `repos/${repo}/git/refs/heads/${encodedBranch}`;
1749
+ const isNonFastForward = (msg: string) => /\b422\b|not a fast forward|fast[- ]forward/i.test(msg);
1750
+ // COMPARE-AND-SWAP precondition: refuse the move unless the ref still points at the SHA the caller
1751
+ // validated against. Read as late as possible (right before the PATCH) so the window between the
1752
+ // check and the mutation is minimal; a mismatch (or an unreadable/deleted ref → `null`) means the
1753
+ // validated base is gone, so the move is refused rather than applied against unproven state.
1754
+ if (expectedSha !== undefined) {
1755
+ const current = await branchHeadSha(repo, branch, token);
1756
+ if (current !== expectedSha) return false;
1757
+ }
1758
+ if (await useGh()) {
1759
+ try {
1760
+ await runGh(["api", apiPath, "-X", "PATCH", "-f", `sha=${sha}`, "-F", "force=false"]);
1761
+ } catch (err) {
1762
+ const msg = err instanceof Error ? err.message : String(err);
1763
+ if (isNonFastForward(msg)) return false;
1764
+ throw err;
1765
+ }
1766
+ return true;
1767
+ }
1768
+ if (!token) throw new Error(`no GitHub transport available to update ${apiPath}`);
1769
+ const r = await fetch(`https://api.github.com/${apiPath}`, {
1770
+ method: "PATCH",
1771
+ headers: {
1772
+ authorization: `Bearer ${token}`,
1773
+ accept: "application/vnd.github+json",
1774
+ "content-type": "application/json",
1775
+ },
1776
+ body: JSON.stringify({ sha, force: false }),
1777
+ });
1778
+ if (r.ok) return true;
1779
+ if (r.status === 422) return false; // non-fast-forward — refused, as intended
1780
+ throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
1781
+ }
1782
+
1661
1783
  /** Create `refs/heads/<branch>` pointing at `sha`. Idempotent: a concurrent create / re-plan
1662
1784
  * that already made the ref (GitHub `422 Reference already exists`) is treated as a no-op.
1663
1785
  * Returns `true` when this call actually created the ref, `false` when it lost the race and the
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Merlin's merge stage was repo-blind: it ran a plain `gh pr merge` and, on refusal, escalated to
4
4
  // a human. That breaks on any repo with a frugal-CI + on-demand-queue posture — e.g.
5
- // `Magikcraft/nano-bpm`, where auto-merge is OFF, CI runs only on `opened` (review-fix pushes do
5
+ // `nanobpm/nano-bpm`, where auto-merge is OFF, CI runs only on `opened` (review-fix pushes do
6
6
  // NOT re-run it), and the documented way to land is: produce a fresh head run (`gh pr ready` /
7
7
  // close+reopen) → wait for it to go green → `@mergifyio queue`. See that repo's
8
8
  // `AGENTS.md → ## Merging PRs`.
@@ -695,6 +695,45 @@ test("pollUserTasks: a parked delivery-human node carries its instruction as `qu
695
695
  assertEquals(byKey["20412"].question, "Run the manual OTP publish for @nanobpm/urban");
696
696
  });
697
697
 
698
+ test("pollUserTasks: a delivery-human node lifts an embedded prompt URL onto subject_url (issue #813)", async () => {
699
+ // A delivery run carries no other subject URL, so the ONLY clickable link a delivery human task can
700
+ // offer is a URL the author embedded in the node instruction — `deliveryHumanContextUrl` lifts it
701
+ // onto `subject_url`. Assert the wiring end-to-end (pure-helper tests alone would miss a projection
702
+ // regression): the base node AND its bounded-timeout `…__esc` twin both resolve the same stored
703
+ // label to the same link, while a URL-less instruction leaves `subject_url` null.
704
+ const { data, stores } = memData({
705
+ delivery_graph_runs: [
706
+ {
707
+ run_key: "delivery-graph-403eb22e",
708
+ process_key: "dg-1",
709
+ status: "running",
710
+ title: "release runbook",
711
+ human_labels: JSON.stringify({
712
+ "delivery-human-task__n7": "Review the release PR at https://github.com/nanobpm/nano-workforce/pull/814 before publishing.",
713
+ "delivery-human-task__n8": "Run the manual OTP publish for @nanobpm/urban",
714
+ }),
715
+ },
716
+ ],
717
+ });
718
+ const restore = stubUserTaskSearch([
719
+ { userTaskKey: "20411", elementId: "delivery-human-task__n7", processInstanceKey: "dg-1", state: "CREATED" },
720
+ // the bounded-timeout escalation twin parks on the `…__esc` id but resolves the same base link
721
+ { userTaskKey: "20412", elementId: "delivery-human-task__n7__esc", processInstanceKey: "dg-1", state: "CREATED" },
722
+ // a URL-less instruction leaves the link null (no fabricated link)
723
+ { userTaskKey: "20413", elementId: "delivery-human-task__n8", processInstanceKey: "dg-1", state: "CREATED" },
724
+ ]);
725
+ try {
726
+ await pollUserTasks(data, fakeEngine({}), REST);
727
+ } finally {
728
+ restore();
729
+ }
730
+
731
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
732
+ assertEquals(byKey["20411"].subject_url, "https://github.com/nanobpm/nano-workforce/pull/814");
733
+ assertEquals(byKey["20412"].subject_url, "https://github.com/nanobpm/nano-workforce/pull/814");
734
+ assertEquals(byKey["20413"].subject_url, null);
735
+ });
736
+
698
737
  test("pollUserTasks: a delivery-human node with no stored label still gets a non-blank Decision context (issue #772)", async () => {
699
738
  // An untracked run (or a run whose label wasn't stamped) must not leave the panel blank — a static,
700
739
  // node-NEUTRAL fallback still tells the operator a delivery-graph step is waiting. It must read true
@@ -185,6 +185,7 @@ function fakeApp(
185
185
  // exercises the AVAILABILITY-AWARE default reader unless it injects its own readAgentWork or a
186
186
  // non-empty instance list.
187
187
  engine: { searchAgentInstances },
188
+ log: { info() {}, warn() {}, error() {}, debug() {} },
188
189
  data: {
189
190
  table(_name: string, _key: string) {
190
191
  return {
@@ -211,9 +212,23 @@ async function makeUnderTest(
211
212
  round: number,
212
213
  priorWatermark?: string | null,
213
214
  ) => Promise<boolean | null | { work: boolean | null; consumedKey?: string | null }>,
215
+ selfHeal?: (
216
+ app: unknown,
217
+ repo: string,
218
+ prNumber: number,
219
+ prKey: string,
220
+ currentHead: string,
221
+ roundNo: number,
222
+ processKey: string | null,
223
+ stillOwns: () => Promise<boolean>,
224
+ ) => Promise<{ healed: boolean; sha?: string; reason?: string }>,
214
225
  ) {
215
226
  const { makeHandler } = await import("../workers/progress-check/worker.ts");
216
- return makeHandler(readAgentWork ? { readHead, readAgentWork } : { readHead });
227
+ // Default the self-heal OFF (always `{ healed: false }`) so the existing escalation tests keep
228
+ // escalating without touching the real WorldStore/GitHub deps; the #818 tests inject a healing stub.
229
+ const heal = selfHeal ?? (async () => ({ healed: false }));
230
+ // biome-ignore lint/suspicious/noExplicitAny: test double for the injectable worker deps.
231
+ return makeHandler({ readHead, ...(readAgentWork ? { readAgentWork } : {}), selfHeal: heal as any });
217
232
  }
218
233
 
219
234
  test("progress-check: a non-addressed round records the baseline and consumes the attempt watermark but never escalates", async () => {
@@ -312,6 +327,98 @@ test("progress-check: an unchanged head with a terminal agent-instance escalates
312
327
  assertStringIncludes(String(out.noProgressQuestion), "PR head did not advance");
313
328
  });
314
329
 
330
+ test("progress-check: a no-advance round self-heals to a reachable push-checkpoint and continues instead of escalating (#818)", async () => {
331
+ // The head did NOT advance and the agent-instance corroborates a real no-advance (terminal
332
+ // instance) — normally an immediate escalation. But a recorded push-checkpoint fast-forward-
333
+ // descends the head (a producer harness pushed the fix to a fallback branch, off the PR head), so
334
+ // the self-heal advances the head onto it and the loop continues as real progress.
335
+ let healArgs: unknown[] = [];
336
+ let healStillOwns: (() => Promise<boolean>) | null = null;
337
+ const handler = await makeUnderTest(
338
+ async () => "sha-1",
339
+ async () => true,
340
+ async (_app, repo, prNumber, prKey, currentHead, roundNo, processKey, stillOwns) => {
341
+ healArgs = [repo, prNumber, prKey, currentHead, roundNo, processKey];
342
+ healStillOwns = stillOwns;
343
+ return { healed: true, sha: "healed-sha" };
344
+ },
345
+ );
346
+ const { app, updates } = fakeApp({ last_round_head: "sha-1", process_key: "pik" });
347
+ const out = await handler(
348
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 3, huskRetries: 0 } } as any,
349
+ app as any,
350
+ );
351
+ assertEquals(out, { progressed: true, huskRetries: 0 }, "a healed round continues as real progress");
352
+ assertEquals(
353
+ healArgs,
354
+ ["o/r", 1, "o/r#1", "sha-1", 3, "pik"],
355
+ "self-heal is called with the repo/number/prKey, the current head as compare base, the round, and THIS run's process key (#819)",
356
+ );
357
+ assertEquals(typeof healStillOwns, "function", "a late ownership guard is threaded into the heal (#819)");
358
+ assertEquals(await (healStillOwns as unknown as () => Promise<boolean>)(), true, "the owning run's guard reports ownership");
359
+ // The healed head becomes the new baseline and the round parks at wait-review (one atomic write).
360
+ assertEquals(updates.length, 1, "the healed head is rebaselined and parked in one atomic write");
361
+ assertEquals(updates[0]!.patch.last_round_head, "healed-sha", "the baseline advances to the healed head");
362
+ assertEquals(updates[0]!.patch.status, "waiting_review", "a healed round parks for the next review");
363
+ });
364
+
365
+ test("progress-check: self-heal is NOT attempted on a progressed round (only before an escalation)", async () => {
366
+ let healCalls = 0;
367
+ const handler = await makeUnderTest(
368
+ async () => "sha-2", // head advanced → progressed, no escalation
369
+ undefined,
370
+ async () => (healCalls++, { healed: true, sha: "x" }),
371
+ );
372
+ const { app } = fakeApp({ last_round_head: "sha-1" });
373
+ const out = await handler(
374
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 2 } } as any,
375
+ app as any,
376
+ );
377
+ assertEquals(out.progressed, true);
378
+ assertEquals(healCalls, 0, "a genuinely-progressing round never consults the self-heal");
379
+ });
380
+
381
+ test("progress-check: a no-advance round with no recoverable checkpoint still escalates (#818)", async () => {
382
+ // The self-heal declines (no reachable fast-forward checkpoint) → the round escalates exactly as
383
+ // before the self-heal existed.
384
+ const handler = await makeUnderTest(
385
+ async () => "sha-1",
386
+ async () => true,
387
+ async () => ({ healed: false, reason: "no-checkpoint" }),
388
+ );
389
+ const { app } = fakeApp({ last_round_head: "sha-1" });
390
+ const out = await handler(
391
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 3, huskRetries: 0 } } as any,
392
+ app as any,
393
+ );
394
+ assertEquals(out.progressed, false, "an unrecoverable no-advance still escalates");
395
+ assertEquals(out.noProgressReason, "no-advance");
396
+ assertStringIncludes(String(out.noProgressQuestion), "PR head did not advance");
397
+ });
398
+
399
+ test("progress-check: a SUPERSEDED straggler never invokes the self-heal — no stale ref mutation on a reopened PR (#818 fence)", async () => {
400
+ // A delayed progress-check from an OLD convergence instance can reach the no-advance branch after
401
+ // `submitPr` reopened the PR under a NEW `process_key`. The commit fence already drops its DB write,
402
+ // but the self-heal PATCHes the GitHub head — an irreversible side effect. So the worker must
403
+ // re-check ownership BEFORE the ref mutation and skip the heal entirely when superseded, else a
404
+ // straggler's stale checkpoint resurrects old work on the reopened PR.
405
+ let healCalls = 0;
406
+ const handler = await makeUnderTest(
407
+ async () => "sha-1",
408
+ async () => true,
409
+ async () => (healCalls++, { healed: true, sha: "stale-sha" }),
410
+ );
411
+ // The row is owned by a DIFFERENT (newer) instance than this straggler job's processInstanceKey.
412
+ const { app, updates } = fakeApp({ last_round_head: "sha-1", process_key: "new-pik" });
413
+ const out = await handler(
414
+ { processInstanceKey: "old-pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 3, huskRetries: 0 } } as any,
415
+ app as any,
416
+ );
417
+ assertEquals(healCalls, 0, "a superseded straggler must NOT mutate the GitHub head via self-heal");
418
+ assertEquals(updates.length, 0, "and the commit fence drops its DB write too (ack without persisting)");
419
+ assertEquals(out.progressed, false, "the straggler still returns its escalation verdict to ack the job");
420
+ });
421
+
315
422
  test("progress-check: an addressed round whose head advanced reports progressed:true and rebaselines", async () => {
316
423
  const handler = await makeUnderTest(async () => "sha-2");
317
424
  const { app, updates } = fakeApp({ last_round_head: "sha-1" });
@@ -338,7 +338,136 @@ test("re-submit advances process_key BEFORE resetting adjudications (fence order
338
338
  });
339
339
  });
340
340
 
341
- // Red/green regression (Copilot review): the durable adjudication RESET runs AFTER the new instance is
341
+ // Red/green regression (Copilot review of #819): the reopen must publish the fresh run's identity
342
+ // (`process_key`) in the SAME write that flips the row to `converging`/round 1. If the status flip
343
+ // landed while `process_key` still named the OLD instance, a delayed old-run `pr.progress-check`
344
+ // straggler would read its own key as the current owner, pass `isSuperseded`/`stillOwns`, and
345
+ // fast-forward the PR head onto its stale checkpoint under the fresh run's identity. Asserting the
346
+ // atomic write closes that "converging-but-old-owner" window.
347
+ test("re-submit flips status to converging and advances process_key in the SAME write (no old-owner window, #819)", async () => {
348
+ await withGithubOff(async () => {
349
+ const PR_KEY = "owner/repo#42";
350
+ const patches: Array<Record<string, unknown>> = [];
351
+ const stores: Record<string, { rows: any[]; key: string }> = {
352
+ pull_requests: {
353
+ rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", title: "t", status: "converged", process_key: "PI-OLD" }],
354
+ key: "pr_key",
355
+ },
356
+ escalations: { rows: [], key: "id" },
357
+ pr_adjudications: { rows: [], key: "id" },
358
+ pr_dependencies: { rows: [], key: "pr_key" },
359
+ };
360
+ const wrap = (name: string, key: string) => {
361
+ const t = memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key);
362
+ return {
363
+ ...t,
364
+ update: (k: any, patch: any) => {
365
+ if (name === "pull_requests") patches.push({ ...patch });
366
+ return t.update(k, patch);
367
+ },
368
+ };
369
+ };
370
+ const data = { table: withTrackingViews(wrap), open: () => memOpen(stores) } as any;
371
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-NEW" }) } as any;
372
+
373
+ await submitPr(data, engine, { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY });
374
+
375
+ // No update may set status to "converging" while leaving process_key at (or below) the old key.
376
+ const convergingPatch = patches.find((p) => p.status === "converging");
377
+ assertEquals(convergingPatch !== undefined, true, "the reopen flips the row to converging");
378
+ assertEquals(convergingPatch?.process_key, "PI-NEW", "the SAME write that flips to converging advances the owner to the new run");
379
+ assertEquals(
380
+ patches.some((p) => p.status === "converging" && p.process_key === "PI-OLD"),
381
+ false,
382
+ "the row never presents as the fresh run while still owned by the old instance",
383
+ );
384
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
385
+ assertEquals(pr.status, "converging");
386
+ assertEquals(pr.current_round, 1);
387
+ assertEquals(pr.process_key, "PI-NEW");
388
+ });
389
+ });
390
+
391
+ // Red/green regression (Copilot review of #819, follow-up): deferring the terminal→`converging`
392
+ // transition until AFTER `createInstance()` (the atomic-reopen fix above) left the row in its OLD
393
+ // terminal shape across the create await. A second `submitPr` for the same PR racing that await would
394
+ // read the still-terminal row, pass the RESUBMITTABLE idempotency gate, and create a SECOND
395
+ // convergence instance for one PR. `submitPr` serializes its create→reopen critical section per
396
+ // `prKey`, so the loser observes the winner's claim (`converging` + the new key, derived-active) and
397
+ // returns `alreadyRunning` — exactly one instance is created. Without the lock both concurrent calls
398
+ // enter `createInstance` (calls === 2) and this fails.
399
+ test("concurrent submitPr for one reopened PR mints only ONE convergence instance (#819 follow-up)", async () => {
400
+ await withGithubOff(async () => {
401
+ const PR_KEY = "owner/repo#42";
402
+ const stores: Record<string, { rows: any[]; key: string }> = {
403
+ pull_requests: {
404
+ rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", title: "t", status: "converged", current_round: 3, process_key: "PI-OLD" }],
405
+ key: "pr_key",
406
+ },
407
+ escalations: { rows: [], key: "id" },
408
+ pr_adjudications: { rows: [], key: "id" },
409
+ pr_dependencies: { rows: [], key: "pr_key" },
410
+ };
411
+ const data = { table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)), open: () => memOpen(stores) } as any;
412
+ let calls = 0;
413
+ const engine = {
414
+ createInstance: async () => {
415
+ const id = ++calls;
416
+ // Hold the critical section open long enough for a racing submit to interleave at this await.
417
+ await new Promise((r) => setTimeout(r, 5));
418
+ return { processInstanceKey: `PI-${id}` };
419
+ },
420
+ } as any;
421
+ const parsed = { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY };
422
+
423
+ const results = await Promise.all([submitPr(data, engine, parsed), submitPr(data, engine, parsed)]);
424
+
425
+ assertEquals(calls, 1, "concurrent submits of the same PR create exactly one convergence instance");
426
+ assertEquals(
427
+ results.filter((r: any) => r.alreadyRunning === true).length,
428
+ 1,
429
+ "the second concurrent submit observes the first's claim and short-circuits at the alreadyRunning gate",
430
+ );
431
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
432
+ assertEquals(pr.status, "converging");
433
+ assertEquals(pr.process_key, "PI-1", "the single created instance owns the reopened row");
434
+ });
435
+ });
436
+
437
+ // A distinct key must never block another PR's submit — the per-`prKey` lock serializes only same-PR
438
+ // calls, so two DIFFERENT PRs submitted concurrently both proceed and each mints its own instance.
439
+ test("submitPr serialization is per-prKey — different PRs never block each other (#819 follow-up)", async () => {
440
+ await withGithubOff(async () => {
441
+ const mkStores = (n: number): Record<string, { rows: any[]; key: string }> => ({
442
+ pull_requests: { rows: [{ pr_key: `owner/repo#${n}`, repo: "owner/repo", number: n, url: `https://github.com/owner/repo/pull/${n}`, title: "t", status: "converged", current_round: 1, process_key: `PI-OLD-${n}` }], key: "pr_key" },
443
+ escalations: { rows: [], key: "id" },
444
+ pr_adjudications: { rows: [], key: "id" },
445
+ pr_dependencies: { rows: [], key: "pr_key" },
446
+ });
447
+ const s1 = mkStores(1);
448
+ const s2 = mkStores(2);
449
+ const mkData = (s: Record<string, { rows: any[]; key: string }>) => ({ table: withTrackingViews((name: string, key: string) => memTable(s[name]?.rows ?? [], s[name]?.key ?? key)), open: () => memOpen(s) } as any);
450
+ let calls = 0;
451
+ const engine = {
452
+ createInstance: async () => {
453
+ const id = ++calls;
454
+ await new Promise((r) => setTimeout(r, 5));
455
+ return { processInstanceKey: `PI-${id}` };
456
+ },
457
+ } as any;
458
+
459
+ await Promise.all([
460
+ submitPr(mkData(s1), engine, { repo: "owner/repo", number: 1, url: "https://github.com/owner/repo/pull/1", prKey: "owner/repo#1" }),
461
+ submitPr(mkData(s2), engine, { repo: "owner/repo", number: 2, url: "https://github.com/owner/repo/pull/2", prKey: "owner/repo#2" }),
462
+ ]);
463
+
464
+ assertEquals(calls, 2, "two different PRs each mint their own convergence instance");
465
+ assertEquals(s1.pull_requests.rows[0].status, "converging");
466
+ assertEquals(s2.pull_requests.rows[0].status, "converging");
467
+ });
468
+ });
469
+
470
+
342
471
  // created and `process_key` is advanced. If the reset DELETE fails, the new convergence instance is
343
472
  // already live while the OLD adjudications remain — and because the new instance is ACTIVE the
344
473
  // `alreadyRunning` idempotency gate short-circuits every retry, so the reset is never re-run and the
@@ -1024,7 +1153,7 @@ test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#
1024
1153
  // so the round redrives from scratch (exactly as today). Proven against a REAL in-memory SQLite db
1025
1154
  // with the world (049) + enrolment (052) schemas applied.
1026
1155
  test("worldRestoreSha is gated on the durable-resume enrolment: participant → SHA, none → null", async () => {
1027
- const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql"]);
1156
+ const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql", "115_world_checkpoint_process_key.sql"]);
1028
1157
  const PR = "owner/repo#7";
1029
1158
  const sha = "77ee0993cc6ad4493da0f7551212ef16722135db";
1030
1159
  await new WorldStore(data).recordCheckpoint({ prKey: PR, roundNo: 1, commitSha: sha });
@@ -1042,7 +1171,7 @@ test("worldRestoreSha is gated on the durable-resume enrolment: participant →
1042
1171
  });
1043
1172
 
1044
1173
  test("worldRestoreSha is null when a participant is enrolled but the PR has no checkpoint yet", async () => {
1045
- const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql"]);
1174
+ const { data } = memDataFor(["049_world_checkpoint.sql", "052_worker_durable_resume.sql", "115_world_checkpoint_process_key.sql"]);
1046
1175
  await new DurableResumeRegistry(data).recordEnrolment("modern-1", true);
1047
1176
  assertEquals(await worldRestoreSha(data, "owner/repo#8"), null, "nothing to reconstruct on a first activation");
1048
1177
  });