@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.
@@ -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
+ });
@@ -0,0 +1,194 @@
1
+ // No-advance self-heal (issue #818, the Layer 2 durable fix for the agent PR-head push-divergence
2
+ // class — jwulf/c8ctl-plugin-nano#270).
3
+ //
4
+ // The convergence loop escalates an `addressed` round whose PR head did NOT advance (see
5
+ // app/roundProgress.ts). But the round's work is often ALREADY pushed and reachable on the
6
+ // remote — just not on the PR head. A producer harness that provisions the review shape wrong cuts a
7
+ // throwaway `nano/agent-work/*` fallback branch and pushes the fix THERE, so the agent's "pushed"
8
+ // claim is true while the PR head never moves (this stranded nanobpm/nano-coder#26/#28, recovered by
9
+ // hand). Layer 1 (jwulf/c8ctl-plugin-nano#270) fixes the harness to push the PR head directly AND to
10
+ // report the pushed SHA as a `worldMarker`, which `pr.persist-round` records as a durable
11
+ // push-checkpoint (`world_checkpoints`). This module closes the loop: BEFORE escalating a no-advance
12
+ // round, reconcile that recorded checkpoint SHA against the PR head — if it is a STRICT
13
+ // fast-forward descendant of the head, advance the head onto it and continue the loop as real
14
+ // progress instead of parking a human.
15
+ //
16
+ // It keys off the recorded SHA (canonical world state), not any branch-naming convention, so it heals
17
+ // the whole class: any producer that leaves the round's work reachable-but-off-the-head. Every step
18
+ // fails SAFE — an absent checkpoint, an unreadable comparison, a non-fast-forward (diverged) tip, a
19
+ // cross-repo fork head, or a refused ref update all yield `{ healed: false }`, so the caller escalates
20
+ // exactly as it did before. It is purely additive to the existing no-advance path.
21
+
22
+ /** The push-checkpoint reader — the newest durable `{ commitSha }` recorded by a SPECIFIC RUN of a PR
23
+ * AT A SPECIFIC ROUND, or `null` when that run/round pushed nothing (so there is nothing to
24
+ * reconcile). Scoped by the convergence RUN's `processKey` AND `roundNo` so a heal is anchored to THIS
25
+ * attempt's own checkpoint and can never fast-forward the head onto a stale checkpoint from a prior
26
+ * run — `round_no` alone does not identify a run, since a reopen resets `current_round` while the old
27
+ * run's checkpoints survive (#819). Backed by {@link ../world/store.ts}'s
28
+ * `WorldStore.lastCheckpointForRun`; injectable for tests. */
29
+ export type CheckpointReader = (
30
+ prKey: string,
31
+ processKey: string | null,
32
+ roundNo: number,
33
+ ) => Promise<{ commitSha: string } | null>;
34
+
35
+ /** The PR-head reader — the head branch name + repo of a PR, so the self-heal knows WHICH ref to
36
+ * advance and whether it lives in THIS repo (a fork head is unpushable from the base token). Backed
37
+ * by `fetchPrHead`; injectable for tests. */
38
+ export type PrHeadReader = (
39
+ repo: string,
40
+ prNumber: number,
41
+ ) => Promise<{ headRef: string | null; headRepo: string | null } | null>;
42
+
43
+ /** The ancestry comparator — proves the checkpoint SHA strictly fast-forward-descends the PR head.
44
+ * Backed by `compareCommits`; injectable for tests. `null` means the comparison was unavailable. */
45
+ export type CommitComparator = (
46
+ repo: string,
47
+ base: string,
48
+ head: string,
49
+ ) => Promise<{ status: "ahead" | "behind" | "identical" | "diverged"; aheadBy: number; behindBy: number } | null>;
50
+
51
+ /** The fast-forward ref mover — advances the PR head branch to the checkpoint SHA. Backed by
52
+ * `updateBranchRef` (a NON-force update, so GitHub itself refuses a non-fast-forward). The 4th
53
+ * argument is the COMPARE-AND-SWAP precondition: the exact head SHA the fast-forward proof was
54
+ * computed against (`currentHead`); the mover refuses (`false`) unless the ref still equals it at
55
+ * mutation time, so a head advanced/replaced by a concurrent (superseded) run after the proof is
56
+ * never PATCHed. Returns `false` when the move was refused (non-fast-forward OR lost CAS). Injectable
57
+ * for tests. */
58
+ export type HeadAdvancer = (repo: string, branch: string, sha: string, expectedSha: string) => Promise<boolean>;
59
+
60
+ export interface SelfHealDeps {
61
+ readonly lastCheckpoint: CheckpointReader;
62
+ readonly prHead: PrHeadReader;
63
+ readonly compare: CommitComparator;
64
+ readonly advanceHead: HeadAdvancer;
65
+ /** A LATE ownership guard, re-checked immediately BEFORE the (irreversible) ref mutation to close
66
+ * the check-to-mutation TOCTOU (Copilot review on #819). The worker's pre-call `isSuperseded()`
67
+ * read is not a fence across the async heal: after it returns, this function still awaits the
68
+ * checkpoint lookup, PR-head read, and compare before `advanceHead`, and `submitPr` can install a
69
+ * new `process_key` (a fresh run) in that window. Returns `true` while this run still owns the PR
70
+ * row; when it returns `false` the heal is abandoned WITHOUT touching the ref, so a superseded
71
+ * straggler can never PATCH the branch. Optional — omitted (or absent) means "assume ownership"
72
+ * (the fail-open convention the worker's `isSuperseded` already uses when the identity is unknown),
73
+ * so a caller with no supersession concept keeps the pure #818 behaviour. */
74
+ readonly stillOwns?: () => Promise<boolean>;
75
+ }
76
+
77
+ /** Why a no-advance round did or did not self-heal — `healed` drives the routing (continue vs.
78
+ * escalate); `sha`/`reason` are for the operator log so a skipped heal is diagnosable. */
79
+ export interface SelfHealResult {
80
+ readonly healed: boolean;
81
+ /** The SHA the PR head was advanced to (only when `healed`). */
82
+ readonly sha?: string;
83
+ /** A short machine-readable reason a heal was skipped (only when NOT `healed`). */
84
+ readonly reason?:
85
+ | "no-checkpoint"
86
+ | "already-at-head"
87
+ | "no-head-ref"
88
+ | "fork-head"
89
+ | "compare-unavailable"
90
+ | "not-fast-forward"
91
+ | "superseded"
92
+ /** The ref move was refused: GitHub rejected the non-fast-forward, OR the compare-and-swap lost
93
+ * (the head no longer equals the `currentHead` the fast-forward proof was validated against — a
94
+ * concurrent run moved it). */
95
+ | "advance-refused";
96
+ }
97
+
98
+ /** Attempt to self-heal a no-advance round by fast-forwarding the PR head onto a recorded
99
+ * push-checkpoint SHA. Returns `{ healed: true, sha }` ONLY when a recorded checkpoint strictly
100
+ * fast-forward-descends the current head AND the head was moved onto it; every other outcome is a
101
+ * safe `{ healed: false, reason }` (→ the caller escalates). Never throws: a rejected promise from
102
+ * any dep is caught and degrades to `{ healed: false }` so a transient GitHub/DB outage can never
103
+ * fabricate a heal (or crash the guard).
104
+ *
105
+ * @param currentHead the PR head SHA the no-advance decision was made against (the compare base). It
106
+ * is non-null at the escalation point — a null/unreadable head fails OPEN upstream (continues) and
107
+ * never reaches here — so the comparison is always anchored on the real head.
108
+ * @param roundNo the round being reconciled; the checkpoint lookup is scoped to it (and to
109
+ * `processKey`) so the heal can only fast-forward onto THIS run's own pushed SHA, never a stale
110
+ * prior-run/later-round one.
111
+ * @param processKey the convergence RUN (process instance) reconciling the round. Scopes the
112
+ * checkpoint lookup to this run so a reopened PR (whose `current_round` reset to 1 while the prior
113
+ * run's checkpoints survive) can never select the old run's checkpoint (#819). */
114
+ export async function attemptNoAdvanceSelfHeal(
115
+ deps: SelfHealDeps,
116
+ repo: string,
117
+ prNumber: number,
118
+ prKey: string,
119
+ currentHead: string,
120
+ roundNo: number,
121
+ processKey: string | null,
122
+ ): Promise<SelfHealResult> {
123
+ try {
124
+ const checkpoint = await deps.lastCheckpoint(prKey, processKey, roundNo);
125
+ if (!checkpoint) return { healed: false, reason: "no-checkpoint" };
126
+ const sha = checkpoint.commitSha;
127
+ // The head is already at the checkpoint — nothing stranded, nothing to move. (A no-advance round
128
+ // reaching here with head === checkpoint would be a benign no-op; treat it as "nothing to heal".)
129
+ if (sha === currentHead) return { healed: false, reason: "already-at-head" };
130
+
131
+ const head = await deps.prHead(repo, prNumber);
132
+ const headRef = head?.headRef;
133
+ if (!headRef) return { healed: false, reason: "no-head-ref" };
134
+ // Require a KNOWN, same-repo head before touching any ref. A fork-based PR's head branch lives in
135
+ // ANOTHER repo, and an UNRESOLVABLE head repo (`headRepo == null`, e.g. a deleted fork) is equally
136
+ // unhealable: `fetchPrHead` reports `null` when it cannot resolve the head's owner/name, yet
137
+ // `headRef` can still be a same-named branch that also exists in the base repo. Treating `null` as
138
+ // "in the base repo" would PATCH that unrelated base-repo branch, so classify any non-match (a real
139
+ // fork OR an unresolved head) as an unhealable `fork-head` and escalate instead of risking a
140
+ // cross-repo / wrong-branch ref write. Compare NORMALIZED (case-insensitive) identities: `parsePr`
141
+ // preserves the caller's `owner/repo` spelling while GitHub returns the repo's canonical
142
+ // `head.repo.full_name`, so a valid same-repo PR submitted as `Owner/Repo#7` would otherwise
143
+ // mismatch the canonical `owner/repo`, be misclassified `fork-head`, and silently skip every
144
+ // self-heal (Copilot review of #819). GitHub owner/repo names are case-insensitive, so lowercasing
145
+ // both sides is the correct identity test while retaining the null rejection.
146
+ if (head.headRepo == null || head.headRepo.toLowerCase() !== repo.toLowerCase()) {
147
+ return { healed: false, reason: "fork-head" };
148
+ }
149
+
150
+ // Prove the checkpoint STRICTLY fast-forward-descends the current head before moving anything: a
151
+ // `behind`/`identical`/`diverged` checkpoint is NOT a safe fast-forward and must escalate. Anchor
152
+ // the compare base on `currentHead` (the SHA the no-advance verdict used), not the branch name,
153
+ // so a mid-check head move can't make us fast-forward onto the wrong baseline.
154
+ const cmp = await deps.compare(repo, currentHead, sha);
155
+ if (!cmp) return { healed: false, reason: "compare-unavailable" };
156
+ // Validate the compare COUNTS before trusting the fast-forward proof: a malformed compare
157
+ // response can coerce `ahead_by`/`behind_by` to `NaN` (`Number("not-a-number")`), and because
158
+ // `NaN <= 0` and `NaN !== 0` are BOTH false, a bare `aheadBy <= 0 || behindBy !== 0` gate would
159
+ // let a `{status:"ahead", behindBy:0, aheadBy:NaN}` response slip through and PATCH the ref onto
160
+ // an unproven SHA. Require finite INTEGER counts (a strict fast-forward is `behindBy === 0` AND
161
+ // `aheadBy > 0`); any non-integer count is a corrupt proof and must escalate, not heal.
162
+ if (
163
+ cmp.status !== "ahead" ||
164
+ !Number.isInteger(cmp.aheadBy) ||
165
+ !Number.isInteger(cmp.behindBy) ||
166
+ cmp.behindBy !== 0 ||
167
+ cmp.aheadBy <= 0
168
+ ) {
169
+ return { healed: false, reason: "not-fast-forward" };
170
+ }
171
+
172
+ // LATE OWNERSHIP FENCE (Copilot review on #819). Everything above only READ GitHub/DB state; the
173
+ // ONLY irreversible act is the ref move below. Two guards bind it to THIS run's validated state:
174
+ // (1) the late `stillOwns()` re-check — right before the move — abandons a run that was superseded
175
+ // DURING the awaits above (a concurrent `submitPr` installed a new `process_key`), instead of
176
+ // moving the head and only then being discarded by the downstream `commit` fence (which guards the
177
+ // DB write, not a ref mutation already made). Absent guard ⇒ assume ownership (the same fail-open
178
+ // the worker's `isSuperseded` uses when identity is unknown). (2) the move itself is a
179
+ // COMPARE-AND-SWAP keyed on `currentHead` — the exact base the fast-forward proof was computed
180
+ // against — so the mutation's precondition is BOUND TO the validated state rather than trusted from
181
+ // the separate `stillOwns()` read: a straggler that races past (1) still PATCHes nothing unless the
182
+ // ref is verifiably still at `currentHead` at mutation time. GitHub exposes no server-side
183
+ // expected-old-value precondition, so the CAS is the tightest fence the platform allows; the
184
+ // non-force fast-forward invariant makes the bounded residual window fail-safe (head only ever
185
+ // moves forward onto this run's own reachable checkpoint, never a rewrite).
186
+ if (deps.stillOwns && !(await deps.stillOwns())) return { healed: false, reason: "superseded" };
187
+
188
+ const advanced = await deps.advanceHead(repo, headRef, sha, currentHead);
189
+ if (!advanced) return { healed: false, reason: "advance-refused" };
190
+ return { healed: true, sha };
191
+ } catch {
192
+ return { healed: false };
193
+ }
194
+ }
@@ -33,6 +33,55 @@ test("lastCheckpoint returns the newest push-checkpoint (max offset), or null wh
33
33
  assertEquals(last, { offset: 1, commitSha: "sha-b", roundNo: 2 }, "the newest checkpoint wins");
34
34
  });
35
35
 
36
+ test("lastCheckpointForRun scopes to one RUN's round newest checkpoint (never a stale prior-run round, #819)", async () => {
37
+ const { data } = memWorldData();
38
+ const store = new WorldStore(data);
39
+ const RUN_A = "1001";
40
+ const RUN_B = "2002";
41
+ assertEquals(await store.lastCheckpointForRun(PR, RUN_A, 1), null, "no checkpoint for the run/round yet");
42
+ // Prior run A pushes round 1 and round 2 checkpoints.
43
+ await store.recordCheckpoint({ prKey: PR, roundNo: 1, commitSha: "sha-a", processKey: RUN_A });
44
+ await store.recordCheckpoint({ prKey: PR, roundNo: 2, commitSha: "sha-b", processKey: RUN_A });
45
+ // The PR is reopened: a FRESH run B starts at round 1 and re-pushes (a NEW, higher-offset row).
46
+ await store.recordCheckpoint({ prKey: PR, roundNo: 1, commitSha: "sha-c", processKey: RUN_B });
47
+ assertEquals(
48
+ await store.lastCheckpointForRun(PR, RUN_B, 1),
49
+ { offset: 2, commitSha: "sha-c", roundNo: 1 },
50
+ "run B's round-1 checkpoint wins — never run A's same-numbered round-1 checkpoint",
51
+ );
52
+ assertEquals(
53
+ await store.lastCheckpointForRun(PR, RUN_A, 1),
54
+ { offset: 0, commitSha: "sha-a", roundNo: 1 },
55
+ "run A still resolves to its OWN round-1 checkpoint, not run B's",
56
+ );
57
+ assertEquals(
58
+ await store.lastCheckpointForRun(PR, RUN_A, 2),
59
+ { offset: 1, commitSha: "sha-b", roundNo: 2 },
60
+ "run A round 2 resolves to its own checkpoint",
61
+ );
62
+ // A fresh run at round 1 that has pushed NOTHING never inherits a prior run's checkpoint.
63
+ assertEquals(
64
+ await store.lastCheckpointForRun(PR, "3003", 1),
65
+ null,
66
+ "a run with no checkpoint of its own can never heal onto a prior run's stale checkpoint",
67
+ );
68
+ assertEquals(await store.lastCheckpointForRun(PR, RUN_A, 3), null, "a round that pushed nothing has no checkpoint");
69
+ // A null process key (a job with no process instance) is un-attributable — it matches nothing.
70
+ assertEquals(await store.lastCheckpointForRun(PR, null, 1), null, "a null process key can never resolve a checkpoint");
71
+ });
72
+
73
+ test("lastCheckpointForRun never returns a NULL-process_key (pre-#819 / un-attributable) checkpoint", async () => {
74
+ const { data } = memWorldData();
75
+ const store = new WorldStore(data);
76
+ // A checkpoint recorded without a process key (a legacy row, or a job with no process instance).
77
+ await store.recordCheckpoint({ prKey: PR, roundNo: 1, commitSha: "sha-legacy" });
78
+ assertEquals(
79
+ await store.lastCheckpointForRun(PR, "1001", 1),
80
+ null,
81
+ "an un-attributable checkpoint is never healed onto — it degrades to a safe escalation",
82
+ );
83
+ });
84
+
36
85
  test("recordCheckpoint defaults to a single push effect keyed by the commit SHA", async () => {
37
86
  const { data } = memWorldData();
38
87
  const store = new WorldStore(data);
@@ -20,6 +20,11 @@ interface WorldCheckpointRow {
20
20
  round_no: number;
21
21
  checkpoint_offset: number;
22
22
  commit_sha: string;
23
+ /** The convergence RUN (process instance) that recorded this checkpoint — `String(processInstanceKey)`
24
+ * of the `pr.persist-round` job. NULL on pre-#819 rows or a job without a `processInstanceKey`. The
25
+ * run-scoped self-heal lookup ({@link WorldStore.lastCheckpointForRun}) requires an EXACT non-null
26
+ * match, so a NULL-keyed checkpoint is never attributed to a run and can never heal (issue #819). */
27
+ process_key: string | null;
23
28
  created_at: string;
24
29
  }
25
30
 
@@ -58,6 +63,12 @@ export interface RecordCheckpointInput {
58
63
  * a checkpoint records what a round already DID). A pending tail effect (recorded before it is
59
64
  * performed, for crash-precise replay) is recorded with `applied: false`. */
60
65
  readonly applied?: boolean;
66
+ /** The convergence RUN (process instance) recording this checkpoint — `String(processInstanceKey)`
67
+ * of the persist-round job. Persisted so the no-advance self-heal can prove a checkpoint belongs to
68
+ * THIS run and never fast-forwards the head onto a prior run's stale checkpoint (issue #819).
69
+ * Omitted/`null` for a job without a process instance (the checkpoint is then un-attributable and
70
+ * the run-scoped lookup skips it — a safe escalation, not a heal). */
71
+ readonly processKey?: string | null;
61
72
  }
62
73
 
63
74
  /** A durable store over the `world_checkpoints` + `world_effects` tables. */
@@ -143,6 +154,7 @@ export class WorldStore {
143
154
  prKey: string,
144
155
  roundNo: number,
145
156
  commitSha: string,
157
+ processKey: string | null,
146
158
  now: string,
147
159
  ): Promise<number> {
148
160
  const offset = await WorldStore.#nextOffsetOn(checkpoints, prKey);
@@ -152,6 +164,7 @@ export class WorldStore {
152
164
  round_no: roundNo,
153
165
  checkpoint_offset: offset,
154
166
  commit_sha: commitSha,
167
+ process_key: processKey,
155
168
  created_at: now,
156
169
  });
157
170
  return offset;
@@ -188,6 +201,7 @@ export class WorldStore {
188
201
  */
189
202
  async recordCheckpoint(input: RecordCheckpointInput): Promise<number> {
190
203
  const { prKey, roundNo, commitSha } = input;
204
+ const processKey = input.processKey ?? null;
191
205
  const effects: readonly Effect[] = input.effects ?? [{ kind: "push", idempotencyKey: commitSha }];
192
206
  const applied = input.applied ?? true;
193
207
  const now = new Date().toISOString();
@@ -197,7 +211,7 @@ export class WorldStore {
197
211
  const existing = await checkpoints.findOne({ pr_key: prKey, commit_sha: commitSha });
198
212
  const offset = existing
199
213
  ? existing.checkpoint_offset
200
- : await WorldStore.#insertCheckpointFenced(checkpoints, prKey, roundNo, commitSha, now);
214
+ : await WorldStore.#insertCheckpointFenced(checkpoints, prKey, roundNo, commitSha, processKey, now);
201
215
  let seq = await WorldStore.#nextSeqOn(effectsTable, prKey, offset);
202
216
  for (const effect of effects) {
203
217
  await WorldStore.#appendEffect(effectsTable, prKey, offset, seq++, effect, applied, now);
@@ -258,6 +272,28 @@ export class WorldStore {
258
272
  return { offset: newest.checkpoint_offset, commitSha: newest.commit_sha, roundNo: newest.round_no };
259
273
  }
260
274
 
275
+ /** The newest push-checkpoint recorded by a SPECIFIC RUN of a PR at a SPECIFIC ROUND, or `null` when
276
+ * that run/round pushed nothing. Unlike {@link lastCheckpoint} (newest across ALL runs/rounds), this
277
+ * proves the checkpoint belongs to the attempt being reconciled: the no-advance self-heal (#818,
278
+ * #819) must NOT fast-forward the head onto a checkpoint left by a PRIOR run. `round_no` alone does
279
+ * not identify a run — `submitPr` resets a reopened PR's `current_round` to 1 while the prior run's
280
+ * checkpoints survive, so a fresh run at round 1 would select the OLD run's round-1 checkpoint and
281
+ * could resurrect stale work if it descends the (reset) head. Scoping to the convergence RUN's
282
+ * `process_key` (the instance key advanced on every reopen) AND `round_no` anchors the heal to THIS
283
+ * run's own pushed SHA. A `null` `processKey` (a job with no process instance) matches NOTHING and
284
+ * yields `null`, and a checkpoint row with a `NULL` `process_key` (pre-#819, or an un-attributable
285
+ * record) is likewise never returned — both degrade to a safe escalation, never a heal. Still
286
+ * newest-by-offset within the match, so a genuine re-push of the same round wins. */
287
+ async lastCheckpointForRun(prKey: string, processKey: string | null, roundNo: number): Promise<LastCheckpoint | null> {
288
+ if (processKey == null) return null;
289
+ const rows = (await this.#checkpoints().find({ pr_key: prKey })).filter(
290
+ (r) => r.process_key === processKey && r.round_no === roundNo,
291
+ );
292
+ if (rows.length === 0) return null;
293
+ const newest = rows.reduce((a, b) => (b.checkpoint_offset > a.checkpoint_offset ? b : a));
294
+ return { offset: newest.checkpoint_offset, commitSha: newest.commit_sha, roundNo: newest.round_no };
295
+ }
296
+
261
297
  /** The effect tail recorded at a checkpoint offset, in `seq` order — the sequence the restore path
262
298
  * fence-replays after checking the working tree out to that checkpoint's SHA. `seq` is the intended
263
299
  * order, but it is allocated by a racy read-max-plus-one (`#nextSeqOn`) and the schema has no
@@ -0,0 +1,22 @@
1
+ -- 115_world_checkpoint_process_key.sql — issue #819 (Copilot review): bind a push-checkpoint to the
2
+ -- convergence RUN that produced it, so the no-advance self-heal can prove a checkpoint belongs to the
3
+ -- CURRENT run and never fast-forwards the PR head onto a stale checkpoint left by a PRIOR run.
4
+ --
5
+ -- WHY. The no-advance self-heal (app/world/selfHeal.ts, #818) reconciles a recorded push-checkpoint
6
+ -- against the PR head. #819 scoped that lookup to the current `round_no`, but `round_no` alone does
7
+ -- NOT identify a run: `submitPr` (app/service.ts) resets a reopened PR's `current_round` to 1 while
8
+ -- leaving the prior run's `world_checkpoints` rows in place, so a fresh run at round 1 would still
9
+ -- select the previous run's round-1 checkpoint — and if the branch was reset behind that old SHA the
10
+ -- fast-forward compare passes and the heal resurrects stale work. The convergence instance key
11
+ -- (`pull_requests.process_key`, advanced to the new run on every reopen) is the run generation, so
12
+ -- recording it on each checkpoint lets the self-heal scope by `(process_key, round_no)` — proving the
13
+ -- checkpoint is THIS run's own pushed SHA.
14
+ --
15
+ -- EXPAND (additive) phase: one nullable column, no default, nothing dropped or renamed. `NULL` on the
16
+ -- pre-existing rows (and on any checkpoint recorded by a job without a `processInstanceKey`) is the
17
+ -- SAFE value: the run-scoped lookup requires an EXACT non-null match, so a NULL-keyed checkpoint is
18
+ -- never attributed to a run and can never heal — it degrades to escalation, exactly as an absent
19
+ -- checkpoint does. Numbered after the current highest prefix on origin/main (114). The runner wraps
20
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
21
+
22
+ ALTER TABLE world_checkpoints ADD COLUMN process_key TEXT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.191.1",
3
+ "version": "0.192.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",