@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/github.test.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
6
|
-
import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, type GhReview, type Mergeability, type PrState } from "./github.ts";
|
|
6
|
+
import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, compareCommits, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, updateBranchRef, type GhReview, type Mergeability, type PrState } from "./github.ts";
|
|
7
7
|
import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
|
|
8
8
|
|
|
9
9
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
@@ -956,3 +956,225 @@ test("fetchBranchHead: percent-encodes a special-character branch ref (preservin
|
|
|
956
956
|
);
|
|
957
957
|
assertEquals(sha, "cafef00d");
|
|
958
958
|
});
|
|
959
|
+
|
|
960
|
+
// ── No-advance self-heal transports (issue #818) ────────────────────────────
|
|
961
|
+
// The self-heal's compare (`compareCommits`) and NON-force ref move (`updateBranchRef`) are
|
|
962
|
+
// safety-critical: a regression in the ahead/behind parsing or the `force:false` / 422-refusal
|
|
963
|
+
// handling silently changes whether a recovery mutates a PR head. These pin the token transport of
|
|
964
|
+
// both — a request-capturing fetch stub so the method/body/path and the refusal branch are covered.
|
|
965
|
+
async function withCapturingFetch<T>(
|
|
966
|
+
serve: (req: { path: string; method: string; body: unknown }) => { status: number; statusText?: string; body: unknown },
|
|
967
|
+
fn: () => Promise<T>,
|
|
968
|
+
): Promise<T> {
|
|
969
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
970
|
+
const prevFetch = globalThis.fetch;
|
|
971
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
972
|
+
globalThis.fetch = ((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
973
|
+
const path = new URL(String(url)).pathname.replace(/^\/repos\//, "");
|
|
974
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
975
|
+
const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
|
|
976
|
+
const { status, statusText, body: resp } = serve({ path, method, body });
|
|
977
|
+
return Promise.resolve(new Response(JSON.stringify(resp), { status, statusText }));
|
|
978
|
+
}) as typeof fetch;
|
|
979
|
+
try {
|
|
980
|
+
return await fn();
|
|
981
|
+
} finally {
|
|
982
|
+
globalThis.fetch = prevFetch;
|
|
983
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
984
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
test("compareCommits: parses an 'ahead' fast-forward response and encodes the compare refs", async () => {
|
|
989
|
+
const cmp = await withCapturingFetch(
|
|
990
|
+
({ path, method }) => {
|
|
991
|
+
assertEquals(method, "GET");
|
|
992
|
+
// Each ref segment is percent-encoded (`#` → %23) around the compare API's own `...` separator.
|
|
993
|
+
assertEquals(path, "o/r/compare/feat/x%23123...deadbeef");
|
|
994
|
+
return { status: 200, body: { status: "ahead", ahead_by: 3, behind_by: 0 } };
|
|
995
|
+
},
|
|
996
|
+
() => compareCommits("o/r", "feat/x#123", "deadbeef", "tok"),
|
|
997
|
+
);
|
|
998
|
+
assertEquals(cmp, { status: "ahead", aheadBy: 3, behindBy: 0 });
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test("compareCommits: coerces an unknown status to 'diverged' and defaults missing counts to 0", async () => {
|
|
1002
|
+
const cmp = await withCapturingFetch(
|
|
1003
|
+
() => ({ status: 200, body: { status: "wat" } }),
|
|
1004
|
+
() => compareCommits("o/r", "base", "head", "tok"),
|
|
1005
|
+
);
|
|
1006
|
+
assertEquals(cmp, { status: "diverged", aheadBy: 0, behindBy: 0 });
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// Reject COERCED/UNSAFE compare counts at the transport boundary (Copilot review of #819). A bare
|
|
1010
|
+
// `Number(...)` would turn a malformed/tampered response into a spurious ancestry proof — a string
|
|
1011
|
+
// `"1"` or boolean `true` coerces to `1`, and an unsafe magnitude passes `Number.isInteger` — either
|
|
1012
|
+
// of which could fast-forward the ref WITHOUT a genuine GitHub proof. A present count must be a real,
|
|
1013
|
+
// non-negative SAFE integer number; anything else throws so the self-heal escalates rather than heals.
|
|
1014
|
+
test("compareCommits: rejects a string-coerced count (throws, never a spurious integer proof)", async () => {
|
|
1015
|
+
await assertRejects(
|
|
1016
|
+
() =>
|
|
1017
|
+
withCapturingFetch(
|
|
1018
|
+
() => ({ status: 200, body: { status: "ahead", ahead_by: "1", behind_by: 0 } }),
|
|
1019
|
+
() => compareCommits("o/r", "base", "head", "tok"),
|
|
1020
|
+
),
|
|
1021
|
+
Error,
|
|
1022
|
+
"invalid ahead_by",
|
|
1023
|
+
);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
test("compareCommits: rejects a boolean-coerced count", async () => {
|
|
1027
|
+
await assertRejects(
|
|
1028
|
+
() =>
|
|
1029
|
+
withCapturingFetch(
|
|
1030
|
+
() => ({ status: 200, body: { status: "ahead", ahead_by: 1, behind_by: true } }),
|
|
1031
|
+
() => compareCommits("o/r", "base", "head", "tok"),
|
|
1032
|
+
),
|
|
1033
|
+
Error,
|
|
1034
|
+
"invalid behind_by",
|
|
1035
|
+
);
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("compareCommits: rejects an unsafe-magnitude integer count (passes Number.isInteger but is not a safe integer)", async () => {
|
|
1039
|
+
await assertRejects(
|
|
1040
|
+
() =>
|
|
1041
|
+
withCapturingFetch(
|
|
1042
|
+
() => ({ status: 200, body: { status: "ahead", ahead_by: 1e21, behind_by: 0 } }),
|
|
1043
|
+
() => compareCommits("o/r", "base", "head", "tok"),
|
|
1044
|
+
),
|
|
1045
|
+
Error,
|
|
1046
|
+
"invalid ahead_by",
|
|
1047
|
+
);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
test("compareCommits: rejects a negative count", async () => {
|
|
1051
|
+
await assertRejects(
|
|
1052
|
+
() =>
|
|
1053
|
+
withCapturingFetch(
|
|
1054
|
+
() => ({ status: 200, body: { status: "ahead", ahead_by: -3, behind_by: 0 } }),
|
|
1055
|
+
() => compareCommits("o/r", "base", "head", "tok"),
|
|
1056
|
+
),
|
|
1057
|
+
Error,
|
|
1058
|
+
"invalid ahead_by",
|
|
1059
|
+
);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test("compareCommits: no token → null (idle transport, never a throw)", async () => {
|
|
1063
|
+
const cmp = await withCapturingFetch(
|
|
1064
|
+
() => {
|
|
1065
|
+
throw new Error("fetch must not be called without a token");
|
|
1066
|
+
},
|
|
1067
|
+
() => compareCommits("o/r", "base", "head", ""),
|
|
1068
|
+
);
|
|
1069
|
+
assertEquals(cmp, null);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("compareCommits: a non-2xx transport response throws (an outage is not a silent 'diverged')", async () => {
|
|
1073
|
+
await assertRejects(
|
|
1074
|
+
() =>
|
|
1075
|
+
withCapturingFetch(
|
|
1076
|
+
() => ({ status: 500, statusText: "Server Error", body: {} }),
|
|
1077
|
+
() => compareCommits("o/r", "base", "head", "tok"),
|
|
1078
|
+
),
|
|
1079
|
+
Error,
|
|
1080
|
+
"500",
|
|
1081
|
+
);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test("updateBranchRef: a successful fast-forward PATCHes force:false and returns true", async () => {
|
|
1085
|
+
let seen: { path: string; method: string; body: unknown } | undefined;
|
|
1086
|
+
const moved = await withCapturingFetch(
|
|
1087
|
+
(req) => {
|
|
1088
|
+
seen = req;
|
|
1089
|
+
return { status: 200, body: { ref: "refs/heads/feat/x", object: { sha: "deadbeef" } } };
|
|
1090
|
+
},
|
|
1091
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", "tok"),
|
|
1092
|
+
);
|
|
1093
|
+
assertEquals(moved, true);
|
|
1094
|
+
assertEquals(seen?.method, "PATCH", "the ref move is a PATCH");
|
|
1095
|
+
assertEquals(seen?.path, "o/r/git/refs/heads/feat/x", "targeting the head ref");
|
|
1096
|
+
// The whole point of the self-heal transport: it NEVER force-updates — GitHub must be free to
|
|
1097
|
+
// refuse a non-fast-forward. A regression flipping this to force:true would rewrite history.
|
|
1098
|
+
assertEquals(seen?.body, { sha: "deadbeef", force: false });
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
test("updateBranchRef: GitHub's 422 non-fast-forward refusal returns false, never throws", async () => {
|
|
1102
|
+
const moved = await withCapturingFetch(
|
|
1103
|
+
() => ({ status: 422, statusText: "Unprocessable Entity", body: { message: "Update is not a fast forward" } }),
|
|
1104
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", "tok"),
|
|
1105
|
+
);
|
|
1106
|
+
assertEquals(moved, false, "a refused non-fast-forward is a safe false (→ the caller escalates)");
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
test("updateBranchRef: a non-422 transport error throws (a real outage is not a silent refusal)", async () => {
|
|
1110
|
+
await assertRejects(
|
|
1111
|
+
() =>
|
|
1112
|
+
withCapturingFetch(
|
|
1113
|
+
() => ({ status: 500, statusText: "Server Error", body: { message: "boom" } }),
|
|
1114
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", "tok"),
|
|
1115
|
+
),
|
|
1116
|
+
Error,
|
|
1117
|
+
"500",
|
|
1118
|
+
);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
test("updateBranchRef: no token in token mode throws (no usable transport to move the ref)", async () => {
|
|
1122
|
+
await assertRejects(
|
|
1123
|
+
() =>
|
|
1124
|
+
withCapturingFetch(
|
|
1125
|
+
() => {
|
|
1126
|
+
throw new Error("fetch must not be called without a token");
|
|
1127
|
+
},
|
|
1128
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", ""),
|
|
1129
|
+
),
|
|
1130
|
+
Error,
|
|
1131
|
+
"no GitHub transport available",
|
|
1132
|
+
);
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
// ── updateBranchRef compare-and-swap (Copilot review on #819) ────────────────
|
|
1136
|
+
// When `expectedSha` is given the move is a CAS on the ref's prior value: read the ref right before
|
|
1137
|
+
// the PATCH and refuse unless it still equals the validated base, so a head advanced/replaced by a
|
|
1138
|
+
// concurrent (superseded) run after the caller's fast-forward proof is never PATCHed.
|
|
1139
|
+
test("updateBranchRef: CAS advances when the ref still equals expectedSha (reads then PATCHes)", async () => {
|
|
1140
|
+
const seen: string[] = [];
|
|
1141
|
+
const moved = await withCapturingFetch(
|
|
1142
|
+
(req) => {
|
|
1143
|
+
seen.push(`${req.method} ${req.path}`);
|
|
1144
|
+
if (req.method === "GET") return { status: 200, body: { object: { sha: "basebase" } } };
|
|
1145
|
+
return { status: 200, body: { ref: "refs/heads/feat/x", object: { sha: "deadbeef" } } };
|
|
1146
|
+
},
|
|
1147
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", "tok", "basebase"),
|
|
1148
|
+
);
|
|
1149
|
+
assertEquals(moved, true, "the ref still pointed at the validated base, so the fast-forward applied");
|
|
1150
|
+
assertEquals(
|
|
1151
|
+
seen,
|
|
1152
|
+
["GET o/r/git/ref/heads/feat/x", "PATCH o/r/git/refs/heads/feat/x"],
|
|
1153
|
+
"the CAS reads the ref before mutating it",
|
|
1154
|
+
);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test("updateBranchRef: CAS refuses (false, no PATCH) when the ref has moved off expectedSha", async () => {
|
|
1158
|
+
const methods: string[] = [];
|
|
1159
|
+
const moved = await withCapturingFetch(
|
|
1160
|
+
(req) => {
|
|
1161
|
+
methods.push(req.method);
|
|
1162
|
+
if (req.method === "GET") return { status: 200, body: { object: { sha: "movedmoved" } } };
|
|
1163
|
+
throw new Error("must not PATCH once the compare-and-swap has lost");
|
|
1164
|
+
},
|
|
1165
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", "tok", "basebase"),
|
|
1166
|
+
);
|
|
1167
|
+
assertEquals(moved, false, "a ref moved off the validated base refuses the move (→ the caller escalates)");
|
|
1168
|
+
assertEquals(methods, ["GET"], "a lost CAS never issues the PATCH");
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
test("updateBranchRef: CAS refuses (false) when the ref is absent/unreadable (null)", async () => {
|
|
1172
|
+
const moved = await withCapturingFetch(
|
|
1173
|
+
(req) => {
|
|
1174
|
+
if (req.method === "GET") return { status: 404, statusText: "Not Found", body: { message: "Not Found" } };
|
|
1175
|
+
throw new Error("must not PATCH when the base ref is gone");
|
|
1176
|
+
},
|
|
1177
|
+
() => updateBranchRef("o/r", "feat/x", "deadbeef", "tok", "basebase"),
|
|
1178
|
+
);
|
|
1179
|
+
assertEquals(moved, false, "a deleted/unreadable base ref is not the validated state, so the move is refused");
|
|
1180
|
+
});
|
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
|
|
@@ -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
|
-
|
|
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" });
|
package/app/service.test.ts
CHANGED
|
@@ -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
|
|
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
|
});
|