@nanobpm/nano-workforce 0.191.1 → 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.
@@ -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
  });
package/app/service.ts CHANGED
@@ -514,9 +514,45 @@ export async function isPrSettled(data: DataLayer, prKey: string): Promise<boole
514
514
  return !!existing && TERMINAL_STATUSES.includes(existing.derived_status);
515
515
  }
516
516
 
517
+ // Per-`prKey` in-process serialization for `submitPr` (Copilot review of #819). `submitPr` reads the
518
+ // PR row, decides `alreadyRunning`, `createInstance`s, and reopens the row across several `await`s.
519
+ // Two `submitPr` calls for the SAME PR (a poll pass racing a delivery-connector redelivery, a
520
+ // record-wave enrollment, …) could otherwise interleave at the `createInstance` await: both read the
521
+ // pre-submit terminal row, both pass the idempotency gate (a terminal row is RESUBMITTABLE), and both
522
+ // create a convergence instance for one PR. Serializing the whole create→reopen critical section per
523
+ // `prKey` makes the second caller observe the row the first already claimed (`converging` + the new
524
+ // `process_key`, derived-active) and short-circuit at the `alreadyRunning` gate instead of minting a
525
+ // duplicate instance. The lock is deliberately IN-PROCESS, never a durable DB claim: every `submitPr`
526
+ // caller — the poller, record-wave, delivery-connector, converge-feature — runs in THIS one Node
527
+ // process (main.ts hosts the poller and the job workers on a single engine client) against one SQLite
528
+ // store, so an in-memory chain fully serializes them; and an in-memory lock evaporates on crash, so a
529
+ // half-done submit recovers through the existing terminal→resubmittable derive path. A durable non-null
530
+ // claim would instead strand a keyed `converging` row `alreadyRunning` FOREVER on a create-instance
531
+ // crash — exactly the #704/#497 phantom the keyless-row guard above exists to avoid. Distinct keys
532
+ // never block each other. This composes with the atomic reopen below (which fences the old-run
533
+ // straggler): the lock removes the duplicate-instance race, the atomic write removes the stale-owner
534
+ // race, and neither reintroduces the other.
535
+ const submitChains = new Map<string, Promise<unknown>>();
536
+ function withPrSubmitLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
537
+ const prev = submitChains.get(key) ?? Promise.resolve();
538
+ // Chain after the previous holder SETTLES (fulfilled OR rejected — both handlers are `fn`), so one
539
+ // failed/throwing submit never wedges the key for the PR's next submit.
540
+ const run = prev.then(fn, fn);
541
+ // The stored tail is error-swallowed so the chain survives a rejection; bound the map by dropping
542
+ // the key once this tail settles and no later caller has replaced it as the chain's end.
543
+ const tail = run.then(() => undefined, () => undefined);
544
+ submitChains.set(key, tail);
545
+ tail.then(() => {
546
+ if (submitChains.get(key) === tail) submitChains.delete(key);
547
+ });
548
+ return run;
549
+ }
550
+
517
551
  /** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
518
552
  * `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
519
- * recorded as the PR's merge-stage dependency set. */
553
+ * recorded as the PR's merge-stage dependency set. Concurrent calls for the SAME PR are serialized
554
+ * (see {@link withPrSubmitLock}) so a poll pass racing a worker redelivery can't mint two convergence
555
+ * instances for one PR; the loser observes the winner's claim and returns `alreadyRunning`. */
520
556
  export async function submitPr(
521
557
  data: DataLayer,
522
558
  engine: EngineClient,
@@ -525,6 +561,20 @@ export async function submitPr(
525
561
  maxRounds: number = MAX_ROUNDS,
526
562
  convergeOnly = false,
527
563
  rootRequestKey: string | null = null,
564
+ ) {
565
+ return withPrSubmitLock(parsed.prKey, () =>
566
+ submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey),
567
+ );
568
+ }
569
+
570
+ async function submitPrCritical(
571
+ data: DataLayer,
572
+ engine: EngineClient,
573
+ parsed: ParsedPr,
574
+ dependsOn: string[] = [],
575
+ maxRounds: number = MAX_ROUNDS,
576
+ convergeOnly = false,
577
+ rootRequestKey: string | null = null,
528
578
  ) {
529
579
  const table = prs(data);
530
580
  const existing = await table.get(parsed.prKey);
@@ -604,44 +654,16 @@ export async function submitPr(
604
654
  // while `process_key` still names the OLD instance leaves a window where a delayed old-instance
605
655
  // answer job still passes the worker's staleness gate and reinserts its adjudication into the fresh
606
656
  // run (Copilot review of #806). Advancing the run identity FIRST, then clearing, fences that job.
607
- // Re-open a previously converged/abandoned/merged PR for a fresh convergence run.
608
- await table.update(parsed.prKey, {
609
- status: "converging",
610
- current_round: 1,
611
- url: parsed.url,
612
- // Coalesce to the key so `pull_requests.title` stays non-blank for the title-led grids
613
- // (issue #248): a fresh fetch wins, else the prior title, else the `owner/repo#N` key.
614
- // A blank/whitespace title counts as missing (matches the 036 backfill), so an empty
615
- // external title never lands as an unlabeled row.
616
- title: coalesceTitle(title, existing.title, parsed.prKey),
617
- waiting_since: null,
618
- last_review_id: null,
619
- last_nudge_at: null,
620
- // Clear the no-progress head baseline: it is scoped to the PRIOR convergence run, and a fresh
621
- // run at round 1 must compare its first addressed round against a clean slate. Leaving a stale
622
- // `last_round_head` lets a resubmission whose branch changed read `currentHead !== previousHead`
623
- // on its first husked round, mis-route it as progress, and bypass the bounded husk retry (#786).
624
- last_round_head: null,
625
- // Clear the attempt watermark too: it is scoped to the prior convergence run's `review-round`
626
- // instances (Copilot #789). A fresh run mints new, higher-keyed instances so a carried-over
627
- // watermark would still be below them, but clearing keeps the per-run husk-correlation state
628
- // unambiguous and self-contained.
629
- last_progress_agent_watermark: null,
630
- // Clear the at-least-once REPLAY stamp too (Copilot PR #789). The idempotency guard replays a
631
- // recorded outcome whenever a redelivered job key matches this row; if the stamp survived a
632
- // re-open, an OLD `pr.progress-check` delivery redelivered after the NEW convergence instance
633
- // starts would still match its job key here and replay a stale escalation/progress effect into
634
- // the fresh run. Clearing it makes the new run treat any such straggler as an unknown key (a
635
- // normal, freshly-computed decision) rather than replaying the prior run's outcome.
636
- last_progress_job_key: null,
637
- last_progress_result: null,
638
- outcome: null,
639
- converged_at: null,
640
- merged_at: null,
641
- abandon_token: abandonToken,
642
- root_request_key: effectiveRoot,
643
- updated_at: ts,
644
- });
657
+ // Re-open a previously converged/abandoned/merged PR for a fresh convergence run. The row-reset
658
+ // that flips the PR to `converging`/round 1 is DEFERRED and applied ATOMICALLY WITH the
659
+ // `process_key` advance below (after `createInstance`), never here. Flipping the row to the fresh
660
+ // run's shape while `process_key` still names the OLD (now-superseded) instance opens a window in
661
+ // which a delayed old-run `pr.progress-check` straggler reads its own key as the CURRENT owner,
662
+ // passes both `isSuperseded` and the late `stillOwns` guard, and fast-forwards the PR head onto
663
+ // its own stale checkpoint under the fresh run's identity (Copilot review of #819). Publishing the
664
+ // new run identity in the SAME write that resets the row eliminates that "converging-but-old-owner"
665
+ // window: the old generation is invalidated the instant the row becomes the new run's, so no
666
+ // straggler is ever accepted as owner of a row that already presents as the fresh run.
645
667
  } else {
646
668
  await table.insert({
647
669
  pr_key: parsed.prKey,
@@ -701,18 +723,67 @@ export async function submitPr(
701
723
  },
702
724
  });
703
725
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
704
- // The `process_key` advance and the adjudication reset below are the two writes that MAKE the new
705
- // run authoritative. If EITHER throws, the newly created instance is already live but the reopen is
706
- // only half-committed — and a retry would short-circuit at the `alreadyRunning` idempotency gate
707
- // (the new instance is ACTIVE, so `derived_status` is non-terminal), never re-running the reset. A
708
- // failed reset would then leave the fresh run replaying STALE adjudication memory indefinitely
709
- // (Copilot review). So roll the just-created run back on failure: terminate it and rethrow, so the
710
- // submission is NOT treated as started. Terminating flips the PR's derived tracking status to a
711
- // terminal edge (`abandoned`) via the `instanceTracking` reconciler, making the PR resubmittable so
712
- // a retry re-creates a fresh instance and re-runs the reset cleanly — no orphaned run auto-applies
713
- // stale decisions in the meantime.
726
+ // The reopen row-reset, the `process_key` advance, and the adjudication reset below are the writes
727
+ // that MAKE the new run authoritative. If ANY throws, the newly created instance is already live but
728
+ // the reopen is only half-committed — and a retry would short-circuit at the `alreadyRunning`
729
+ // idempotency gate (the new instance is ACTIVE, so `derived_status` is non-terminal), never
730
+ // re-running them. A failed reset would then leave the fresh run replaying STALE adjudication memory
731
+ // indefinitely (Copilot review). So roll the just-created run back on failure: terminate it and
732
+ // rethrow, so the submission is NOT treated as started. Terminating flips the PR's derived tracking
733
+ // status to a terminal edge (`abandoned`) via the `instanceTracking` reconciler, making the PR
734
+ // resubmittable so a retry re-creates a fresh instance and re-runs everything cleanly — no orphaned
735
+ // run auto-applies stale decisions, and (crucially) the row stays in its OLD terminal shape until
736
+ // the atomic reopen write lands, so no straggler ever sees a fresh-run row still owned by the old
737
+ // instance.
714
738
  try {
715
- if (processKey != null) {
739
+ if (existing) {
740
+ // ATOMIC REOPEN (Copilot review of #819): reset the row to the fresh run's shape AND publish the
741
+ // new run identity (`process_key`) in a SINGLE write, so the row never presents as `converging`
742
+ // round 1 while still owned by the OLD instance. A delayed old-run straggler is therefore never
743
+ // accepted as the current owner of a row that already belongs to the fresh run.
744
+ await table.update(parsed.prKey, {
745
+ status: "converging",
746
+ current_round: 1,
747
+ url: parsed.url,
748
+ // Coalesce to the key so `pull_requests.title` stays non-blank for the title-led grids
749
+ // (issue #248): a fresh fetch wins, else the prior title, else the `owner/repo#N` key.
750
+ // A blank/whitespace title counts as missing (matches the 036 backfill), so an empty
751
+ // external title never lands as an unlabeled row.
752
+ title: coalesceTitle(title, existing.title, parsed.prKey),
753
+ waiting_since: null,
754
+ last_review_id: null,
755
+ last_nudge_at: null,
756
+ // Clear the no-progress head baseline: it is scoped to the PRIOR convergence run, and a fresh
757
+ // run at round 1 must compare its first addressed round against a clean slate. Leaving a stale
758
+ // `last_round_head` lets a resubmission whose branch changed read `currentHead !== previousHead`
759
+ // on its first husked round, mis-route it as progress, and bypass the bounded husk retry (#786).
760
+ last_round_head: null,
761
+ // Clear the attempt watermark too: it is scoped to the prior convergence run's `review-round`
762
+ // instances (Copilot #789). A fresh run mints new, higher-keyed instances so a carried-over
763
+ // watermark would still be below them, but clearing keeps the per-run husk-correlation state
764
+ // unambiguous and self-contained.
765
+ last_progress_agent_watermark: null,
766
+ // Clear the at-least-once REPLAY stamp too (Copilot PR #789). The idempotency guard replays a
767
+ // recorded outcome whenever a redelivered job key matches this row; if the stamp survived a
768
+ // re-open, an OLD `pr.progress-check` delivery redelivered after the NEW convergence instance
769
+ // starts would still match its job key here and replay a stale escalation/progress effect into
770
+ // the fresh run. Clearing it makes the new run treat any such straggler as an unknown key (a
771
+ // normal, freshly-computed decision) rather than replaying the prior run's outcome.
772
+ last_progress_job_key: null,
773
+ last_progress_result: null,
774
+ outcome: null,
775
+ converged_at: null,
776
+ merged_at: null,
777
+ abandon_token: abandonToken,
778
+ root_request_key: effectiveRoot,
779
+ // Publish the fresh run's identity in the SAME write as the status flip (may be null if the
780
+ // instance could not be created — a phantom row a retry re-enrolls, as on first submit).
781
+ process_key: processKey,
782
+ updated_at: ts,
783
+ });
784
+ } else if (processKey != null) {
785
+ // First submit: the row was INSERTED keyless before `createInstance` (the phantom-row pattern,
786
+ // #704/#497); publish its `process_key` now that the instance exists.
716
787
  await table.update(parsed.prKey, { process_key: processKey });
717
788
  }
718
789
  // Invalidate this PR's durable adjudication memory for the fresh run (issue #806, Copilot review) —
@@ -721,8 +792,7 @@ export async function submitPr(
721
792
  // rejected by the worker's staleness gate (its `processInstanceKey` no longer matches the advanced
722
793
  // `process_key`), so it cannot reinsert a stale adjudication after the reset; and the worker reads
723
794
  // `process_key` as late as possible so it observes this advance. The insert-if-absent record then
724
- // re-learns the operator's new answer for the new run. Runs unconditionally (even if `processKey` is
725
- // null: the memory must still be clean for the fresh run). The wipe is a SINGLE atomic `DELETE`
795
+ // re-learns the operator's new answer for the new run. The wipe is a SINGLE atomic `DELETE`
726
796
  // (`resetAdjudications`), never a row-by-row loop, so a crash mid-reset cannot leave a partially
727
797
  // cleared memory (Copilot review of #806).
728
798
  if (existing) {
@@ -45,6 +45,10 @@ export interface WorldCheckpointInput {
45
45
  /** The round's irreversible effects, in order. Defaults to a single `push` effect keyed by the
46
46
  * commit SHA. */
47
47
  readonly effects?: readonly Effect[];
48
+ /** The convergence RUN (process instance) recording this checkpoint — persisted so the no-advance
49
+ * self-heal can prove the checkpoint belongs to THIS run and never heals onto a prior run's stale
50
+ * checkpoint (issue #819). `null`/omitted when the recording job has no process instance. */
51
+ readonly processKey?: string | null;
48
52
  }
49
53
 
50
54
  /** The result of recording a world checkpoint: the shared offset (mind + world commit at it) and the
@@ -73,6 +77,7 @@ export async function recordWorldCheckpoint(
73
77
  roundNo: input.roundNo,
74
78
  commitSha: input.commitSha,
75
79
  effects,
80
+ processKey: input.processKey ?? null,
76
81
  });
77
82
  // The JOIN: hand the mind the identical checkpoint the world just persisted, at the same boundary.
78
83
  // Advisory — a sink failure must not undo the durable world record (the world is authoritative for