@nanobpm/nano-workforce 0.191.1 → 0.192.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.192.0](https://github.com/nanobpm/nano-workforce/compare/v0.191.1...v0.192.0) (2026-09-28)
2
+
3
+ ### Features
4
+
5
+ * **world:** self-heal a no-advance round onto a reachable push-checkpoint ([#819](https://github.com/nanobpm/nano-workforce/issues/819)) ([891add2](https://github.com/nanobpm/nano-workforce/commit/891add2a217042dd2abaa37c8f12b1f1877c89fc)), closes [26/#28](https://github.com/26/nano-workforce/issues/28) [#818](https://github.com/nanobpm/nano-workforce/issues/818) [#818](https://github.com/nanobpm/nano-workforce/issues/818) [#818](https://github.com/nanobpm/nano-workforce/issues/818) [704/#497](https://github.com/704/nano-workforce/issues/497)
6
+
1
7
  ## [0.191.1](https://github.com/nanobpm/nano-workforce/compare/v0.191.0...v0.191.1) (2026-09-26)
2
8
 
3
9
  ### Bug Fixes
@@ -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
- return makeHandler(readAgentWork ? { readHead, readAgentWork } : { readHead });
227
+ // Default the self-heal OFF (always `{ healed: false }`) so the existing escalation tests keep
228
+ // escalating without touching the real WorldStore/GitHub deps; the #818 tests inject a healing stub.
229
+ const heal = selfHeal ?? (async () => ({ healed: false }));
230
+ // biome-ignore lint/suspicious/noExplicitAny: test double for the injectable worker deps.
231
+ return makeHandler({ readHead, ...(readAgentWork ? { readAgentWork } : {}), selfHeal: heal as any });
217
232
  }
218
233
 
219
234
  test("progress-check: a non-addressed round records the baseline and consumes the attempt watermark but never escalates", async () => {
@@ -312,6 +327,98 @@ test("progress-check: an unchanged head with a terminal agent-instance escalates
312
327
  assertStringIncludes(String(out.noProgressQuestion), "PR head did not advance");
313
328
  });
314
329
 
330
+ test("progress-check: a no-advance round self-heals to a reachable push-checkpoint and continues instead of escalating (#818)", async () => {
331
+ // The head did NOT advance and the agent-instance corroborates a real no-advance (terminal
332
+ // instance) — normally an immediate escalation. But a recorded push-checkpoint fast-forward-
333
+ // descends the head (a producer harness pushed the fix to a fallback branch, off the PR head), so
334
+ // the self-heal advances the head onto it and the loop continues as real progress.
335
+ let healArgs: unknown[] = [];
336
+ let healStillOwns: (() => Promise<boolean>) | null = null;
337
+ const handler = await makeUnderTest(
338
+ async () => "sha-1",
339
+ async () => true,
340
+ async (_app, repo, prNumber, prKey, currentHead, roundNo, processKey, stillOwns) => {
341
+ healArgs = [repo, prNumber, prKey, currentHead, roundNo, processKey];
342
+ healStillOwns = stillOwns;
343
+ return { healed: true, sha: "healed-sha" };
344
+ },
345
+ );
346
+ const { app, updates } = fakeApp({ last_round_head: "sha-1", process_key: "pik" });
347
+ const out = await handler(
348
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 3, huskRetries: 0 } } as any,
349
+ app as any,
350
+ );
351
+ assertEquals(out, { progressed: true, huskRetries: 0 }, "a healed round continues as real progress");
352
+ assertEquals(
353
+ healArgs,
354
+ ["o/r", 1, "o/r#1", "sha-1", 3, "pik"],
355
+ "self-heal is called with the repo/number/prKey, the current head as compare base, the round, and THIS run's process key (#819)",
356
+ );
357
+ assertEquals(typeof healStillOwns, "function", "a late ownership guard is threaded into the heal (#819)");
358
+ assertEquals(await (healStillOwns as unknown as () => Promise<boolean>)(), true, "the owning run's guard reports ownership");
359
+ // The healed head becomes the new baseline and the round parks at wait-review (one atomic write).
360
+ assertEquals(updates.length, 1, "the healed head is rebaselined and parked in one atomic write");
361
+ assertEquals(updates[0]!.patch.last_round_head, "healed-sha", "the baseline advances to the healed head");
362
+ assertEquals(updates[0]!.patch.status, "waiting_review", "a healed round parks for the next review");
363
+ });
364
+
365
+ test("progress-check: self-heal is NOT attempted on a progressed round (only before an escalation)", async () => {
366
+ let healCalls = 0;
367
+ const handler = await makeUnderTest(
368
+ async () => "sha-2", // head advanced → progressed, no escalation
369
+ undefined,
370
+ async () => (healCalls++, { healed: true, sha: "x" }),
371
+ );
372
+ const { app } = fakeApp({ last_round_head: "sha-1" });
373
+ const out = await handler(
374
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 2 } } as any,
375
+ app as any,
376
+ );
377
+ assertEquals(out.progressed, true);
378
+ assertEquals(healCalls, 0, "a genuinely-progressing round never consults the self-heal");
379
+ });
380
+
381
+ test("progress-check: a no-advance round with no recoverable checkpoint still escalates (#818)", async () => {
382
+ // The self-heal declines (no reachable fast-forward checkpoint) → the round escalates exactly as
383
+ // before the self-heal existed.
384
+ const handler = await makeUnderTest(
385
+ async () => "sha-1",
386
+ async () => true,
387
+ async () => ({ healed: false, reason: "no-checkpoint" }),
388
+ );
389
+ const { app } = fakeApp({ last_round_head: "sha-1" });
390
+ const out = await handler(
391
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 3, huskRetries: 0 } } as any,
392
+ app as any,
393
+ );
394
+ assertEquals(out.progressed, false, "an unrecoverable no-advance still escalates");
395
+ assertEquals(out.noProgressReason, "no-advance");
396
+ assertStringIncludes(String(out.noProgressQuestion), "PR head did not advance");
397
+ });
398
+
399
+ test("progress-check: a SUPERSEDED straggler never invokes the self-heal — no stale ref mutation on a reopened PR (#818 fence)", async () => {
400
+ // A delayed progress-check from an OLD convergence instance can reach the no-advance branch after
401
+ // `submitPr` reopened the PR under a NEW `process_key`. The commit fence already drops its DB write,
402
+ // but the self-heal PATCHes the GitHub head — an irreversible side effect. So the worker must
403
+ // re-check ownership BEFORE the ref mutation and skip the heal entirely when superseded, else a
404
+ // straggler's stale checkpoint resurrects old work on the reopened PR.
405
+ let healCalls = 0;
406
+ const handler = await makeUnderTest(
407
+ async () => "sha-1",
408
+ async () => true,
409
+ async () => (healCalls++, { healed: true, sha: "stale-sha" }),
410
+ );
411
+ // The row is owned by a DIFFERENT (newer) instance than this straggler job's processInstanceKey.
412
+ const { app, updates } = fakeApp({ last_round_head: "sha-1", process_key: "new-pik" });
413
+ const out = await handler(
414
+ { processInstanceKey: "old-pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 3, huskRetries: 0 } } as any,
415
+ app as any,
416
+ );
417
+ assertEquals(healCalls, 0, "a superseded straggler must NOT mutate the GitHub head via self-heal");
418
+ assertEquals(updates.length, 0, "and the commit fence drops its DB write too (ack without persisting)");
419
+ assertEquals(out.progressed, false, "the straggler still returns its escalation verdict to ack the job");
420
+ });
421
+
315
422
  test("progress-check: an addressed round whose head advanced reports progressed:true and rebaselines", async () => {
316
423
  const handler = await makeUnderTest(async () => "sha-2");
317
424
  const { app, updates } = fakeApp({ last_round_head: "sha-1" });