@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 +6 -0
- 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
|
@@ -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
|
+
}
|
package/app/world/store.test.ts
CHANGED
|
@@ -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);
|
package/app/world/store.ts
CHANGED
|
@@ -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.
|
|
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",
|