@nanobpm/nano-workforce 0.191.1 → 0.193.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/CHANGELOG.md +12 -0
- package/app/agentic/families/presence.family.test.ts +315 -11
- package/app/agentic/families/presence.family.ts +189 -8
- package/app/github.test.ts +223 -1
- package/app/github.ts +122 -0
- package/app/roundProgress.test.ts +108 -1
- package/app/service.test.ts +132 -3
- package/app/service.ts +122 -52
- package/app/world/checkpoint.ts +5 -0
- package/app/world/selfHeal.test.ts +296 -0
- package/app/world/selfHeal.ts +194 -0
- package/app/world/store.test.ts +49 -0
- package/app/world/store.ts +37 -1
- package/db/migrations/115_world_checkpoint_process_key.sql +22 -0
- package/package.json +1 -1
- package/test/worldDb.ts +1 -1
- package/workers/persist-round/worker.ts +6 -0
- package/workers/progress-check/worker.ts +117 -18
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
|
705
|
-
// run authoritative. If
|
|
706
|
-
// only half-committed — and a retry would short-circuit at the `alreadyRunning`
|
|
707
|
-
// (the new instance is ACTIVE, so `derived_status` is non-terminal), never
|
|
708
|
-
// failed reset would then leave the fresh run replaying STALE adjudication memory
|
|
709
|
-
// (Copilot review). So roll the just-created run back on failure: terminate it and
|
|
710
|
-
// submission is NOT treated as started. Terminating flips the PR's derived tracking
|
|
711
|
-
// terminal edge (`abandoned`) via the `instanceTracking` reconciler, making the PR
|
|
712
|
-
// a retry re-creates a fresh instance and re-runs
|
|
713
|
-
// stale decisions
|
|
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 (
|
|
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.
|
|
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) {
|
package/app/world/checkpoint.ts
CHANGED
|
@@ -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
|
+
});
|