@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/service.ts CHANGED
@@ -32,7 +32,7 @@ import { isUniqueConstraintFence } from "./dbFence.ts";
32
32
  import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
33
33
  import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
34
34
  import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
35
- import { deliveryHumanContextQuestion, isDeliveryHumanElement } from "./deliveryHuman.ts";
35
+ import { deliveryHumanContextQuestion, deliveryHumanContextUrl, isDeliveryHumanElement } from "./deliveryHuman.ts";
36
36
  import { fleetSupportsDurableResume } from "./durableResume.ts";
37
37
  import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
38
38
  import { deriveFeatureCompletion, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns, featureRunsTracking, foldCompletedFeatureRun } from "./feature.ts";
@@ -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) {
@@ -3041,11 +3111,16 @@ export async function pollUserTasks(
3041
3111
  // A delivery-graph `human` node's user-task id is inlined per node (`delivery-human-task__<node>`
3042
3112
  // and its `…__esc` twin), so it can't be a static `case` above (issue #772). Fill its "Decision
3043
3113
  // context" from the run's stamped `human_labels` — otherwise the panel is blank and, because this
3044
- // surface seeds no form variables, the operator has no idea what the run is waiting on.
3114
+ // surface seeds no form variables, the operator has no idea what the run is waiting on. A URL the
3115
+ // author embedded in the node instruction is lifted onto `subject_url` so the row's "Link" column /
3116
+ // detail link is clickable (issue #813); a delivery run carries no other subject URL (`subj.url` is
3117
+ // null), so this is the only link a delivery human task can offer.
3118
+ let deliverySubjectUrl: string | null = null;
3045
3119
  if (question === null && isDeliveryHumanElement(elementId)) {
3046
3120
  question = deliveryHumanContextQuestion(subj?.humanLabels, elementId);
3121
+ deliverySubjectUrl = deliveryHumanContextUrl(subj?.humanLabels, elementId);
3047
3122
  }
3048
- return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey, formKey: resolvedFormKey };
3123
+ return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? deliverySubjectUrl, question, processKey: processInstanceKey, formKey: resolvedFormKey };
3049
3124
  };
3050
3125
 
3051
3126
  // Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a
@@ -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
@@ -0,0 +1,296 @@
1
+ // Tests for the no-advance self-heal (issue #818, Layer 2). The orchestration is proven against
2
+ // injected primitives (checkpoint reader / PR-head reader / commit comparator / head advancer) so no
3
+ // git or network is touched — mirroring the injectable-deps style of the progress-check worker. The
4
+ // invariant under test: heal ONLY when a recorded checkpoint STRICTLY fast-forward-descends the PR
5
+ // head AND the head was moved; every other outcome degrades safely to `{ healed: false }`.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import { attemptNoAdvanceSelfHeal, type SelfHealDeps } from "./selfHeal.ts";
9
+
10
+ const REPO = "o/r";
11
+ const PR_NUM = 7;
12
+ const PR_KEY = "o/r#7";
13
+ const HEAD = "a".repeat(40);
14
+ const AHEAD = "b".repeat(40);
15
+ const ROUND = 3;
16
+ const PROC = "9001"; // the convergence run (process instance) reconciling the round (#819)
17
+
18
+ /** Build deps with per-test overrides; every primitive defaults to the happy-path fast-forward. */
19
+ function deps(over: Partial<SelfHealDeps> = {}): SelfHealDeps {
20
+ return {
21
+ lastCheckpoint: async () => ({ commitSha: AHEAD }),
22
+ prHead: async () => ({ headRef: "feat/x", headRepo: REPO }),
23
+ compare: async () => ({ status: "ahead", aheadBy: 1, behindBy: 0 }),
24
+ advanceHead: async () => true,
25
+ ...over,
26
+ };
27
+ }
28
+
29
+ test("heals: a checkpoint that strictly fast-forwards the head advances it and continues", async () => {
30
+ const calls: Array<[string, string, string, string]> = [];
31
+ const res = await attemptNoAdvanceSelfHeal(
32
+ deps({ advanceHead: async (r, b, s, expected) => (calls.push([r, b, s, expected]), true) }),
33
+ REPO,
34
+ PR_NUM,
35
+ PR_KEY,
36
+ HEAD,
37
+ ROUND,
38
+ PROC,
39
+ );
40
+ assertEquals(res, { healed: true, sha: AHEAD });
41
+ assertEquals(
42
+ calls,
43
+ [[REPO, "feat/x", AHEAD, HEAD]],
44
+ "advances the PR head branch to the checkpoint SHA, compare-and-swapping on the validated head",
45
+ );
46
+ });
47
+
48
+ test("does NOT heal when the PR has no recorded checkpoint (nothing was pushed)", async () => {
49
+ const res = await attemptNoAdvanceSelfHeal(deps({ lastCheckpoint: async () => null }), REPO, PR_NUM, PR_KEY, HEAD, ROUND, PROC);
50
+ assertEquals(res, { healed: false, reason: "no-checkpoint" });
51
+ });
52
+
53
+ test("does NOT heal when the checkpoint already equals the head (nothing stranded)", async () => {
54
+ const res = await attemptNoAdvanceSelfHeal(deps({ lastCheckpoint: async () => ({ commitSha: HEAD }) }), REPO, PR_NUM, PR_KEY, HEAD, ROUND, PROC);
55
+ assertEquals(res, { healed: false, reason: "already-at-head" });
56
+ });
57
+
58
+ test("does NOT heal a diverged checkpoint — a non-fast-forward must escalate, never rewrite history", async () => {
59
+ const res = await attemptNoAdvanceSelfHeal(
60
+ deps({ compare: async () => ({ status: "diverged", aheadBy: 2, behindBy: 3 }) }),
61
+ REPO,
62
+ PR_NUM,
63
+ PR_KEY,
64
+ HEAD,
65
+ ROUND,
66
+ PROC,
67
+ );
68
+ assertEquals(res, { healed: false, reason: "not-fast-forward" });
69
+ });
70
+
71
+ test("does NOT heal a behind/identical checkpoint (aheadBy 0 or behindBy > 0)", async () => {
72
+ const behind = await attemptNoAdvanceSelfHeal(
73
+ deps({ compare: async () => ({ status: "behind", aheadBy: 0, behindBy: 4 }) }),
74
+ REPO,
75
+ PR_NUM,
76
+ PR_KEY,
77
+ HEAD,
78
+ ROUND,
79
+ PROC,
80
+ );
81
+ assertEquals(behind, { healed: false, reason: "not-fast-forward" });
82
+ const identical = await attemptNoAdvanceSelfHeal(
83
+ deps({ compare: async () => ({ status: "identical", aheadBy: 0, behindBy: 0 }) }),
84
+ REPO,
85
+ PR_NUM,
86
+ PR_KEY,
87
+ HEAD,
88
+ ROUND,
89
+ PROC,
90
+ );
91
+ assertEquals(identical, { healed: false, reason: "not-fast-forward" });
92
+ });
93
+
94
+ test("does NOT heal a cross-repo fork head — the base token cannot move a fork's ref", async () => {
95
+ const res = await attemptNoAdvanceSelfHeal(
96
+ deps({ prHead: async () => ({ headRef: "feat/x", headRepo: "someone-else/r" }) }),
97
+ REPO,
98
+ PR_NUM,
99
+ PR_KEY,
100
+ HEAD,
101
+ ROUND,
102
+ PROC,
103
+ );
104
+ assertEquals(res, { healed: false, reason: "fork-head" });
105
+ });
106
+
107
+ test("does NOT heal an UNRESOLVED head repo (null) even with a same-named ref — never PATCH the base repo's branch", async () => {
108
+ // `fetchPrHead` reports `headRepo: null` when it cannot resolve the head's owner/name (e.g. a
109
+ // deleted fork). A still-present `headRef` can coincide with a same-named branch in the base repo,
110
+ // so treating `null` as "in the base repo" would advance an UNRELATED branch. It must classify as
111
+ // an unhealable fork-head and escalate — never move a ref against an unknown head repository.
112
+ const advanceCalls: string[] = [];
113
+ const res = await attemptNoAdvanceSelfHeal(
114
+ deps({
115
+ prHead: async () => ({ headRef: "feat/x", headRepo: null }),
116
+ advanceHead: async (_r, b) => (advanceCalls.push(b), true),
117
+ }),
118
+ REPO,
119
+ PR_NUM,
120
+ PR_KEY,
121
+ HEAD,
122
+ ROUND,
123
+ PROC,
124
+ );
125
+ assertEquals(res, { healed: false, reason: "fork-head" });
126
+ assertEquals(advanceCalls, []);
127
+ });
128
+
129
+ test("does NOT heal when the PR head ref is unknown", async () => {
130
+ const res = await attemptNoAdvanceSelfHeal(deps({ prHead: async () => ({ headRef: null, headRepo: REPO }) }), REPO, PR_NUM, PR_KEY, HEAD, ROUND, PROC);
131
+ assertEquals(res, { healed: false, reason: "no-head-ref" });
132
+ });
133
+
134
+ test("heals a same-repo head whose CASING differs from the caller's spelling (normalized identity, #819)", async () => {
135
+ // `parsePr` preserves the caller's `owner/repo` spelling while GitHub returns the canonical
136
+ // `head.repo.full_name`, so a valid same-repo PR can arrive with a different case (`O/R` vs `o/r`).
137
+ // The identity test is case-insensitive (GitHub owner/repo names are), so this must still heal —
138
+ // not be misclassified `fork-head` and silently skip every self-heal.
139
+ const advanceCalls: string[] = [];
140
+ const res = await attemptNoAdvanceSelfHeal(
141
+ deps({
142
+ prHead: async () => ({ headRef: "feat/x", headRepo: REPO.toUpperCase() }),
143
+ advanceHead: async (_r, b) => (advanceCalls.push(b), true),
144
+ }),
145
+ REPO,
146
+ PR_NUM,
147
+ PR_KEY,
148
+ HEAD,
149
+ ROUND,
150
+ PROC,
151
+ );
152
+ assertEquals(res, { healed: true, sha: AHEAD });
153
+ assertEquals(advanceCalls, ["feat/x"]);
154
+ });
155
+
156
+ test("does NOT heal when the comparison is unavailable (idle transport)", async () => {
157
+ const res = await attemptNoAdvanceSelfHeal(deps({ compare: async () => null }), REPO, PR_NUM, PR_KEY, HEAD, ROUND, PROC);
158
+ assertEquals(res, { healed: false, reason: "compare-unavailable" });
159
+ });
160
+
161
+ test("does NOT heal (and never crashes) when GitHub refuses the fast-forward ref update", async () => {
162
+ const res = await attemptNoAdvanceSelfHeal(deps({ advanceHead: async () => false }), REPO, PR_NUM, PR_KEY, HEAD, ROUND, PROC);
163
+ assertEquals(res, { healed: false, reason: "advance-refused" });
164
+ });
165
+
166
+ test("fails safe (never throws) when a dependency rejects — a transient outage can't fabricate a heal", async () => {
167
+ const res = await attemptNoAdvanceSelfHeal(
168
+ deps({
169
+ compare: async () => {
170
+ throw new Error("boom");
171
+ },
172
+ }),
173
+ REPO,
174
+ PR_NUM,
175
+ PR_KEY,
176
+ HEAD,
177
+ ROUND,
178
+ PROC,
179
+ );
180
+ assertEquals(res, { healed: false });
181
+ });
182
+
183
+ test("does not advance the head when the comparison is not a fast-forward", async () => {
184
+ let advanced = false;
185
+ await attemptNoAdvanceSelfHeal(
186
+ deps({
187
+ compare: async () => ({ status: "diverged", aheadBy: 1, behindBy: 1 }),
188
+ advanceHead: async () => ((advanced = true), true),
189
+ }),
190
+ REPO,
191
+ PR_NUM,
192
+ PR_KEY,
193
+ HEAD,
194
+ ROUND,
195
+ PROC,
196
+ );
197
+ assertEquals(advanced, false, "the ref is never moved unless the checkpoint strictly fast-forwards the head");
198
+ });
199
+
200
+ test("scopes the checkpoint lookup to the CURRENT run AND round — never heals onto a prior-run checkpoint (#819)", async () => {
201
+ // The reader must be asked for THIS run's THIS round's checkpoint, not the newest across all
202
+ // runs/rounds: a reopen resets `current_round` to 1 while the prior run's higher-offset checkpoints
203
+ // survive, so a `round_no`-only lookup would resurrect stale work if it descended the (reset) head.
204
+ // Prove BOTH the run generation (`processKey`) and the `roundNo` are threaded into the reader.
205
+ const seen: Array<[string | null, number]> = [];
206
+ const res = await attemptNoAdvanceSelfHeal(
207
+ deps({
208
+ lastCheckpoint: async (_pk, run, rn) => {
209
+ seen.push([run, rn]);
210
+ return run === PROC && rn === ROUND ? { commitSha: AHEAD } : null;
211
+ },
212
+ }),
213
+ REPO,
214
+ PR_NUM,
215
+ PR_KEY,
216
+ HEAD,
217
+ ROUND,
218
+ PROC,
219
+ );
220
+ assertEquals(seen, [[PROC, ROUND]], "the checkpoint reader is scoped to the run + round being reconciled");
221
+ assertEquals(res, { healed: true, sha: AHEAD });
222
+ });
223
+
224
+ test("does NOT mutate the ref when the run was SUPERSEDED during the heal — the late ownership fence wins (#819)", async () => {
225
+ // The pre-call `isSuperseded()` read is not a fence across the heal's awaits: a concurrent
226
+ // `submitPr` can install a new `process_key` AFTER it, between the checkpoint/compare reads and the
227
+ // ref PATCH. The late `stillOwns` guard, evaluated immediately before `advanceHead`, must abandon
228
+ // the heal WITHOUT touching the ref, so a superseded straggler can never resurrect old work.
229
+ let advanced = false;
230
+ const res = await attemptNoAdvanceSelfHeal(
231
+ deps({
232
+ // Ownership was valid at the outer check but is lost by the time we reach the ref mutation.
233
+ stillOwns: async () => false,
234
+ advanceHead: async () => ((advanced = true), true),
235
+ }),
236
+ REPO,
237
+ PR_NUM,
238
+ PR_KEY,
239
+ HEAD,
240
+ ROUND,
241
+ PROC,
242
+ );
243
+ assertEquals(res, { healed: false, reason: "superseded" });
244
+ assertEquals(advanced, false, "a superseded run never PATCHes the PR head");
245
+ });
246
+
247
+ test("heals normally when the late ownership fence still reports ownership", async () => {
248
+ // The guard is only a fence for a LOST ownership — a run that still owns the row heals as before.
249
+ let advanced = false;
250
+ const res = await attemptNoAdvanceSelfHeal(
251
+ deps({
252
+ stillOwns: async () => true,
253
+ advanceHead: async () => ((advanced = true), true),
254
+ }),
255
+ REPO,
256
+ PR_NUM,
257
+ PR_KEY,
258
+ HEAD,
259
+ ROUND,
260
+ PROC,
261
+ );
262
+ assertEquals(res, { healed: true, sha: AHEAD });
263
+ assertEquals(advanced, true, "an owning run advances the head");
264
+ });
265
+
266
+ test("does NOT heal on a malformed compare — a non-integer aheadBy/behindBy can't bypass the fast-forward gate", async () => {
267
+ // A malformed compare response coerces to NaN (`Number("not-a-number")`); since `NaN <= 0` and
268
+ // `NaN !== 0` are both false, a bare count gate would let `{ahead, behindBy:0, aheadBy:NaN}` PATCH
269
+ // the ref. Reject any non-integer count as a corrupt proof and escalate instead.
270
+ let advanced = false;
271
+ const nanAhead = await attemptNoAdvanceSelfHeal(
272
+ deps({
273
+ compare: async () => ({ status: "ahead", aheadBy: Number("not-a-number"), behindBy: 0 }),
274
+ advanceHead: async () => ((advanced = true), true),
275
+ }),
276
+ REPO,
277
+ PR_NUM,
278
+ PR_KEY,
279
+ HEAD,
280
+ ROUND,
281
+ PROC,
282
+ );
283
+ assertEquals(nanAhead, { healed: false, reason: "not-fast-forward" });
284
+ assertEquals(advanced, false, "a NaN ahead count never reaches the ref mutation");
285
+ // A non-integer (fractional) count is equally a corrupt proof.
286
+ const frac = await attemptNoAdvanceSelfHeal(
287
+ deps({ compare: async () => ({ status: "ahead", aheadBy: 1.5, behindBy: 0 }) }),
288
+ REPO,
289
+ PR_NUM,
290
+ PR_KEY,
291
+ HEAD,
292
+ ROUND,
293
+ PROC,
294
+ );
295
+ assertEquals(frac, { healed: false, reason: "not-fast-forward" });
296
+ });