@nanobpm/nano-workforce 0.187.3 → 0.187.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/SPEC.md +71 -9
- package/app/contracts.ts +1 -0
- package/app/convergenceEscalationGuard.test.ts +3 -2
- package/app/github.test.ts +125 -1
- package/app/github.ts +43 -8
- package/app/persist-escalation.test.ts +8 -6
- package/app/persist-round.test.ts +178 -11
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/reviewWait.test.ts +7 -0
- package/app/reviewWait.ts +1 -1
- package/app/roundProgress.test.ts +755 -33
- package/app/roundProgress.ts +144 -0
- package/app/roundResultDefault.test.ts +10 -8
- package/app/service.test.ts +14 -0
- package/app/service.ts +35 -0
- package/db/migrations/102_rounds_process_instance_key.sql +28 -0
- package/db/migrations/103_pr_progress_idempotency.sql +29 -0
- package/db/migrations/104_pull_requests_read_model_progress_idempotency.sql +55 -0
- package/docs/agent-guide.md +1 -1
- package/e2e/convergence-escalation.e2e.ts +5 -4
- package/e2e/feature-run.e2e.ts +6 -1
- package/e2e/plan-fanout-sla.e2e.ts +5 -2
- package/e2e/plan-fanout.e2e.ts +6 -2
- package/e2e/support/time.ts +34 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +211 -142
- package/test/derivation-parity/README.md +3 -3
- package/test/derivation-parity/derivation-parity.test.ts +9 -3
- package/test/derivation-parity/flows.ts +4 -4
- package/workers/capture-head/worker.test.ts +77 -0
- package/workers/capture-head/worker.ts +64 -0
- package/workers/persist-escalation/worker.ts +4 -0
- package/workers/persist-round/worker.ts +75 -9
- package/workers/progress-check/worker.ts +394 -35
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { test } from "node:test";
|
|
12
12
|
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
13
|
-
import { routeProgress } from "./roundProgress.ts";
|
|
13
|
+
import { decideProgress, MAX_HUSK_RETRIES, noProgressQuestion, routeProgress } from "./roundProgress.ts";
|
|
14
14
|
|
|
15
15
|
// ── The canonical router ────────────────────────────────────────────────────
|
|
16
16
|
|
|
@@ -68,13 +68,123 @@ test("routeProgress: fails OPEN when either head is unknown (no baseline / unrea
|
|
|
68
68
|
}
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
// ── Husk classification & bounded self-heal (issue #786) ─────────────────────
|
|
72
|
+
|
|
73
|
+
test("decideProgress: a progressing round continues and RESETS the husk counter", () => {
|
|
74
|
+
// head advanced -> progressed, huskRetries reset to 0 regardless of the carried count.
|
|
75
|
+
const d = decideProgress("addressed", "sha-1", "sha-2", 3, null, 2);
|
|
76
|
+
assertEquals(d.progressed, true);
|
|
77
|
+
assertEquals(d.huskRetries, 0);
|
|
78
|
+
assertEquals(d.huskRetry, undefined);
|
|
79
|
+
assertEquals(d.reason, undefined);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("decideProgress: a husk (successful empty agent-work read) under the cap auto-retries the same round", () => {
|
|
83
|
+
// no head advance + a SUCCESSFUL empty agent-instance read (false) => husk. Under the cap it
|
|
84
|
+
// re-runs the same round on a healthy worker and bumps the counter.
|
|
85
|
+
const d = decideProgress("addressed", "sha-1", "sha-1", 5, false, 0);
|
|
86
|
+
assertEquals(d.progressed, false);
|
|
87
|
+
assertEquals(d.huskRetry, true, "a corroborated-empty husk must auto-retry");
|
|
88
|
+
assertEquals(d.huskRetries, 1, "the husk bumps the counter");
|
|
89
|
+
assertEquals(d.reason, "husk");
|
|
90
|
+
assertEquals(d.question, undefined, "an auto-retry opens no escalation question");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("decideProgress: an UNKNOWN agent-work read (null/undefined) escalates as no-advance — never auto-retries", () => {
|
|
94
|
+
// An engine read that was unavailable/threw (null) — or was never attempted (undefined) — is NOT a
|
|
95
|
+
// corroborated husk. Auto-retrying it could duplicate agent work that actually ran, so it fails
|
|
96
|
+
// safe to an immediate no-advance escalation, exactly as the pre-#786 loop did.
|
|
97
|
+
for (const observed of [null, undefined] as const) {
|
|
98
|
+
const d = decideProgress("addressed", "sha-1", "sha-1", 5, observed, 0);
|
|
99
|
+
assertEquals(d.progressed, false, `observed=${observed}`);
|
|
100
|
+
assertEquals(d.huskRetry, false, `observed=${observed} must NOT auto-retry an unknown read`);
|
|
101
|
+
assertEquals(d.huskRetries, 0, `observed=${observed} does not bump the husk counter`);
|
|
102
|
+
assertEquals(d.reason, "no-advance");
|
|
103
|
+
assert(d.question, `observed=${observed} escalates with a question`);
|
|
104
|
+
assertStringIncludes(d.question ?? "", "PR head did not advance");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("decideProgress: a husk at the retry cap escalates with a husk-specific question and resets", () => {
|
|
109
|
+
const d = decideProgress("addressed", "sha-1", "sha-1", 5, false, MAX_HUSK_RETRIES);
|
|
110
|
+
assertEquals(d.progressed, false);
|
|
111
|
+
assertEquals(d.huskRetry, false, "the cap is reached — no more auto-retries");
|
|
112
|
+
assertEquals(d.huskRetries, 0, "the counter resets so a human-answered resume gets fresh retries");
|
|
113
|
+
assertEquals(d.reason, "husk");
|
|
114
|
+
assert(d.question, "an escalating husk carries a question");
|
|
115
|
+
assertStringIncludes(d.question ?? "", "no durable work");
|
|
116
|
+
assertStringIncludes(d.question ?? "", "did not help");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("decideProgress: a no-advance round (agent DID run) escalates immediately — never auto-retries", () => {
|
|
120
|
+
// A terminal agent-instance exists (agentWorkObserved=true): the agent ran but pushed nothing.
|
|
121
|
+
// Re-running would loop on identical reasoning, so escalate straight away even with 0 retries used.
|
|
122
|
+
const d = decideProgress("addressed", "sha-1", "sha-1", 4, true, 0);
|
|
123
|
+
assertEquals(d.progressed, false);
|
|
124
|
+
assertEquals(d.huskRetry, false, "a no-advance round is never auto-retried");
|
|
125
|
+
assertEquals(d.reason, "no-advance");
|
|
126
|
+
assert(d.question, "a no-advance round carries a question");
|
|
127
|
+
assertStringIncludes(d.question ?? "", "PR head did not advance");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("decideProgress: a legitimately non-addressed / unreadable-head round continues without a husk verdict", () => {
|
|
131
|
+
for (const args of [
|
|
132
|
+
["waiting", "sha-1", "sha-1", 1, null, 0],
|
|
133
|
+
["addressed", null, "sha-1", 1, null, 0],
|
|
134
|
+
["addressed", "sha-1", null, 1, null, 0],
|
|
135
|
+
] as const) {
|
|
136
|
+
const d = decideProgress(...args);
|
|
137
|
+
assertEquals(d.progressed, true, `${JSON.stringify(args)} must continue`);
|
|
138
|
+
assertEquals(d.reason, undefined);
|
|
139
|
+
assertEquals(d.huskRetries, 0);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("decideProgress: a NO-BASELINE addressed round ALWAYS fails open — the no-baseline husk special-case is gone (closed structurally by pr.capture-head)", () => {
|
|
144
|
+
// The no-baseline case is now handled UPSTREAM: `pr.capture-head` records the round's entry head
|
|
145
|
+
// into `roundEntryHead` before `review-round` runs, so within a round there is always a baseline
|
|
146
|
+
// and a first-addressed-round husk leaves `currentHead === roundEntryHead` (→ the escalate/husk
|
|
147
|
+
// split). Consequently `decideProgress` itself no longer special-cases a null baseline: with no
|
|
148
|
+
// baseline the head-diff cannot see a no-advance, so it fails OPEN for EVERY agent-work verdict —
|
|
149
|
+
// including a corroborated non-terminal read that the old code retried. This removes the opposing
|
|
150
|
+
// risk of mis-escalating a straggler push the old special-case carried.
|
|
151
|
+
for (const agentWork of [true, false, null, undefined] as const) {
|
|
152
|
+
const d = decideProgress("addressed", null, "sha-1", 1, agentWork, 0);
|
|
153
|
+
assertEquals(d.progressed, true, `no baseline + agentWork=${String(agentWork)} fails open`);
|
|
154
|
+
assertEquals(d.huskRetry, undefined, "no baseline never auto-retries");
|
|
155
|
+
assertEquals(d.reason, undefined, "a no-baseline round carries no husk verdict");
|
|
156
|
+
assertEquals(d.huskRetries, 0, "the husk counter resets on a fail-open round");
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("decideProgress: a garbage carried husk-retry counter is coerced to 0", () => {
|
|
161
|
+
for (const bad of [Number.NaN, -3, undefined, null] as const) {
|
|
162
|
+
const d = decideProgress("addressed", "sha-1", "sha-1", 5, false, bad);
|
|
163
|
+
assertEquals(d.huskRetry, true, `counter ${String(bad)} coerces to 0 -> under the cap`);
|
|
164
|
+
assertEquals(d.huskRetries, 1);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("noProgressQuestion: husk vs no-advance render distinct, human-actionable reasons", () => {
|
|
169
|
+
assertStringIncludes(noProgressQuestion(5, "husk", false), "no durable work");
|
|
170
|
+
assertStringIncludes(noProgressQuestion(5, "no-advance", false), "PR head did not advance");
|
|
171
|
+
});
|
|
172
|
+
|
|
71
173
|
// ── The worker (with an injected head reader — never touches git/network) ────
|
|
72
174
|
|
|
73
|
-
function fakeApp(
|
|
175
|
+
function fakeApp(
|
|
176
|
+
row?: ({ last_round_head: string | null } & Record<string, unknown>) | undefined,
|
|
177
|
+
searchAgentInstances: (arg: unknown) => Promise<unknown[]> = async () => [],
|
|
178
|
+
) {
|
|
74
179
|
const updates: { key: string; patch: Record<string, unknown> }[] = [];
|
|
75
|
-
const store = new Map<string, unknown
|
|
180
|
+
const store = new Map<string, Record<string, unknown>>();
|
|
76
181
|
if (row) store.set("o/r#1", { pr_key: "o/r#1", ...row });
|
|
77
182
|
const app = {
|
|
183
|
+
// The default agent-work reader consults app.engine.searchAgentInstances; the fake returns an
|
|
184
|
+
// empty list (read-as-absence → availability probe → null → no-advance) by default so a test
|
|
185
|
+
// exercises the AVAILABILITY-AWARE default reader unless it injects its own readAgentWork or a
|
|
186
|
+
// non-empty instance list.
|
|
187
|
+
engine: { searchAgentInstances },
|
|
78
188
|
data: {
|
|
79
189
|
table(_name: string, _key: string) {
|
|
80
190
|
return {
|
|
@@ -83,6 +193,9 @@ function fakeApp(row?: { last_round_head: string | null }) {
|
|
|
83
193
|
},
|
|
84
194
|
async update(key: string, patch: Record<string, unknown>) {
|
|
85
195
|
updates.push({ key, patch });
|
|
196
|
+
// Apply the patch so a redelivery in the SAME test observes the committed baseline +
|
|
197
|
+
// idempotency stamp — the faithful double for the at-least-once replay guard (#789).
|
|
198
|
+
store.set(key, { ...(store.get(key) ?? { pr_key: key }), ...patch });
|
|
86
199
|
},
|
|
87
200
|
};
|
|
88
201
|
},
|
|
@@ -91,22 +204,57 @@ function fakeApp(row?: { last_round_head: string | null }) {
|
|
|
91
204
|
return { app, updates };
|
|
92
205
|
}
|
|
93
206
|
|
|
94
|
-
async function makeUnderTest(
|
|
207
|
+
async function makeUnderTest(
|
|
208
|
+
readHead: (repo: string, n: number) => Promise<string | null>,
|
|
209
|
+
readAgentWork?: (
|
|
210
|
+
pik: string | null | undefined,
|
|
211
|
+
round: number,
|
|
212
|
+
priorWatermark?: string | null,
|
|
213
|
+
) => Promise<boolean | null | { work: boolean | null; consumedKey?: string | null }>,
|
|
214
|
+
) {
|
|
95
215
|
const { makeHandler } = await import("../workers/progress-check/worker.ts");
|
|
96
|
-
return makeHandler({ readHead });
|
|
216
|
+
return makeHandler(readAgentWork ? { readHead, readAgentWork } : { readHead });
|
|
97
217
|
}
|
|
98
218
|
|
|
99
|
-
test("progress-check:
|
|
219
|
+
test("progress-check: a non-addressed round records the baseline and consumes the attempt watermark but never escalates", async () => {
|
|
220
|
+
// Post-#786 the head read + baseline write happen for EVERY round (so the first `addressed` round
|
|
221
|
+
// has a baseline to compare against). Post-#789 (worker.ts:329) a non-addressed `waiting` round
|
|
222
|
+
// ALSO consults the agent-work channel — its own review runs BEFORE this progress-check, so it must
|
|
223
|
+
// CONSUME that review-round instance into the attempt watermark, else a following addressed
|
|
224
|
+
// pre-registration husk would see the stale terminal instance as "newer than the watermark" and
|
|
225
|
+
// bypass the bounded husk retry. It still short-circuits to progressed:true and never escalates.
|
|
100
226
|
let called = false;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
227
|
+
let agentReads = 0;
|
|
228
|
+
const handler = await makeUnderTest(
|
|
229
|
+
async () => {
|
|
230
|
+
called = true;
|
|
231
|
+
return "sha-2";
|
|
232
|
+
},
|
|
233
|
+
async () => {
|
|
234
|
+
agentReads++;
|
|
235
|
+
// The waiting round's own review-round left a terminal instance keyed "9"; the reader reports
|
|
236
|
+
// it consumed (its verdict is irrelevant on the non-addressed path).
|
|
237
|
+
return { work: true, consumedKey: "9" };
|
|
238
|
+
},
|
|
239
|
+
);
|
|
105
240
|
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
106
|
-
const out = await handler({ variables: { prKey: "o/r#1", status: "waiting", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
107
|
-
assertEquals(out, { progressed: true });
|
|
108
|
-
assertEquals(called,
|
|
109
|
-
assertEquals(
|
|
241
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "waiting", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
242
|
+
assertEquals(out, { progressed: true, huskRetries: 0 });
|
|
243
|
+
assertEquals(called, true, "a waiting round reads the head to seed the baseline");
|
|
244
|
+
assertEquals(agentReads, 1, "and consults the agent-work channel to consume its own review instance");
|
|
245
|
+
// ONE atomic write now (Copilot #789): the baseline head advance, the review-wait PARK, and the
|
|
246
|
+
// attempt-watermark advance are folded into a single row update so a redelivery can't observe a
|
|
247
|
+
// half-state. persist-round no longer parks — pr.progress-check is the single writer of
|
|
248
|
+
// `waiting_review` (#786).
|
|
249
|
+
assertEquals(updates.length, 1, "records the baseline and parks for review in one atomic write");
|
|
250
|
+
assertEquals(updates[0]!.patch.last_round_head, "sha-2", "the observed head is the new baseline");
|
|
251
|
+
assertEquals(updates[0]!.patch.status, "waiting_review", "the round parks for review");
|
|
252
|
+
assertEquals(typeof updates[0]!.patch.waiting_since, "string", "and stamps the review-wait start");
|
|
253
|
+
assertEquals(
|
|
254
|
+
updates[0]!.patch.last_progress_agent_watermark,
|
|
255
|
+
"9",
|
|
256
|
+
"the waiting round consumes its own review instance into the watermark (Copilot #789 worker.ts:329)",
|
|
257
|
+
);
|
|
110
258
|
});
|
|
111
259
|
|
|
112
260
|
test("progress-check: a blank/unknown status is treated as addressed — reads the head and can report no progress", async () => {
|
|
@@ -116,41 +264,180 @@ test("progress-check: a blank/unknown status is treated as addressed — reads t
|
|
|
116
264
|
return "sha-1";
|
|
117
265
|
});
|
|
118
266
|
const { app } = fakeApp({ last_round_head: "sha-1" });
|
|
119
|
-
const out = await handler({ variables: { prKey: "o/r#1", status: "", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
120
|
-
assertEquals(out,
|
|
267
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "", repo: "o/r", prNumber: 1, round: 5, huskRetries: MAX_HUSK_RETRIES } } as any, app as any);
|
|
268
|
+
assertEquals(out.progressed, false, "a blank-status no-progress round is caught, not waved through");
|
|
121
269
|
assertEquals(called, true, "a blank status (the safe-default addressed trap) still reads the head");
|
|
122
270
|
});
|
|
123
271
|
|
|
124
|
-
test("progress-check: an addressed round whose head is unchanged
|
|
125
|
-
|
|
272
|
+
test("progress-check: an addressed round whose head is unchanged with no agent work AUTO-RETRIES (husk) under the cap", async () => {
|
|
273
|
+
// No terminal review-round agent-instance (injected false) => husk. Under the cap the worker
|
|
274
|
+
// re-runs the same round instead of parking a human.
|
|
275
|
+
const handler = await makeUnderTest(async () => "sha-1", async () => false);
|
|
276
|
+
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
277
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 5, huskRetries: 0 } } as any, app as any);
|
|
278
|
+
assertEquals(out.progressed, false);
|
|
279
|
+
assertEquals(out.huskRetry, true, "the husk is auto-retried");
|
|
280
|
+
assertEquals(out.huskRetries, 1);
|
|
281
|
+
assertEquals(out.noProgressReason, "husk");
|
|
282
|
+
// Poller non-interference (#786): the retry keeps the PR on a running `converging` status (never a
|
|
283
|
+
// transient `waiting_review`) so pollReviews can't solicit a spurious review while the retried
|
|
284
|
+
// round re-enters review-round. persist-round no longer parks, so this is the ONLY status write.
|
|
285
|
+
const parked = updates.find((u) => u.patch.status === "waiting_review");
|
|
286
|
+
assertEquals(parked, undefined, "a husk-retry round NEVER transits waiting_review");
|
|
287
|
+
const statusUpdate = updates.find((u) => u.patch.status !== undefined);
|
|
288
|
+
assert(statusUpdate, "a husk retry must update the PR status");
|
|
289
|
+
assertEquals(statusUpdate!.patch.status, "converging", "the retry flips the PR to a running status");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("progress-check: an unchanged head with an UNKNOWN agent-work read (null) escalates as no-advance, never auto-retries", async () => {
|
|
293
|
+
// A transient/unavailable AgentInstance read (injected null) must not be treated as a husk — it
|
|
294
|
+
// fails safe to an immediate no-advance escalation so a read outage can never auto-retry (and
|
|
295
|
+
// possibly duplicate) genuinely-completed agent work.
|
|
296
|
+
const handler = await makeUnderTest(async () => "sha-1", async () => null);
|
|
126
297
|
const { app } = fakeApp({ last_round_head: "sha-1" });
|
|
127
|
-
const out = await handler({ variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
128
|
-
assertEquals(out,
|
|
298
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 5, huskRetries: 0 } } as any, app as any);
|
|
299
|
+
assertEquals(out.progressed, false);
|
|
300
|
+
assertEquals(out.huskRetry, false, "an unknown read is never auto-retried");
|
|
301
|
+
assertEquals(out.noProgressReason, "no-advance");
|
|
302
|
+
assertStringIncludes(String(out.noProgressQuestion), "PR head did not advance");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("progress-check: an unchanged head with a terminal agent-instance escalates as no-advance (never auto-retry)", async () => {
|
|
306
|
+
const handler = await makeUnderTest(async () => "sha-1", async () => true);
|
|
307
|
+
const { app } = fakeApp({ last_round_head: "sha-1" });
|
|
308
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 3, huskRetries: 0 } } as any, app as any);
|
|
309
|
+
assertEquals(out.progressed, false);
|
|
310
|
+
assertEquals(out.huskRetry, false, "a corroborated no-advance round is never auto-retried");
|
|
311
|
+
assertEquals(out.noProgressReason, "no-advance");
|
|
312
|
+
assertStringIncludes(String(out.noProgressQuestion), "PR head did not advance");
|
|
129
313
|
});
|
|
130
314
|
|
|
131
315
|
test("progress-check: an addressed round whose head advanced reports progressed:true and rebaselines", async () => {
|
|
132
316
|
const handler = await makeUnderTest(async () => "sha-2");
|
|
133
317
|
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
134
|
-
const out = await handler({ variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
135
|
-
assertEquals(out,
|
|
136
|
-
assertEquals(
|
|
318
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 2 } } as any, app as any);
|
|
319
|
+
assertEquals(out.progressed, true);
|
|
320
|
+
assertEquals(out.huskRetries, 0, "a progressing round resets the husk counter");
|
|
321
|
+
// ONE atomic write (Copilot #789): the rebaseline and the review-wait PARK are a single row
|
|
322
|
+
// update so a redelivery can't see the advanced baseline without the park. progress-check owns
|
|
323
|
+
// `waiting_review` now (#786).
|
|
324
|
+
assertEquals(updates.length, 1, "the observed head is rebaselined and parked in one atomic write");
|
|
137
325
|
assertEquals(updates[0]!.patch.last_round_head, "sha-2");
|
|
326
|
+
assertEquals(updates[0]!.patch.status, "waiting_review", "a progressed round parks for review");
|
|
327
|
+
assertEquals(typeof updates[0]!.patch.waiting_since, "string", "and stamps the review-wait start");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("progress-check: a lost completion-ack REDELIVERS the same job — the recorded outcome is replayed, not recomputed against the advanced baseline (idempotent)", async () => {
|
|
331
|
+
// Copilot #789 (engine at-least-once delivery): a progressed round advances `last_round_head` to
|
|
332
|
+
// the new head. If its completion-ack is LOST the engine redelivers the SAME job key. Without the
|
|
333
|
+
// idempotency guard the redelivery reads the just-advanced baseline as `previousHead`, sees the
|
|
334
|
+
// (still-unchanged) head as "no advance", and mis-ESCALATES an already-progressed round. The guard
|
|
335
|
+
// must recognize its own job key and REPLAY the recorded progressed outcome, making no new write
|
|
336
|
+
// and never consulting the agent-work channel.
|
|
337
|
+
let agentReads = 0;
|
|
338
|
+
const readAgentWork = async () => {
|
|
339
|
+
agentReads++;
|
|
340
|
+
return null; // would drive a no-advance ESCALATION if the guard let it recompute
|
|
341
|
+
};
|
|
342
|
+
// Head reader returns the SAME advanced head on both deliveries (no new push between them).
|
|
343
|
+
const handler = await makeUnderTest(async () => "sha-2", readAgentWork);
|
|
344
|
+
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
345
|
+
|
|
346
|
+
// First delivery: an addressed round whose head advanced → progressed:true, stamps the baseline +
|
|
347
|
+
// idempotency record atomically.
|
|
348
|
+
const first = await handler(
|
|
349
|
+
{ jobKey: "job-1", processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 2, huskRetries: 0 } } as any,
|
|
350
|
+
app as any,
|
|
351
|
+
);
|
|
352
|
+
assertEquals(first.progressed, true, "the first delivery sees the advance and progresses");
|
|
353
|
+
const writesAfterFirst = updates.length;
|
|
354
|
+
assertEquals(agentReads, 1, "a progressing round reads agent-work once to maintain the attempt watermark (#789)");
|
|
355
|
+
|
|
356
|
+
// Second delivery: the SAME job key redelivered after a lost ack. The baseline is now "sha-2" and
|
|
357
|
+
// the head is unchanged, so a RECOMPUTE would read "no advance" and escalate. The guard must
|
|
358
|
+
// replay the recorded progressed outcome instead.
|
|
359
|
+
const replay = await handler(
|
|
360
|
+
{ jobKey: "job-1", processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 2, huskRetries: 0 } } as any,
|
|
361
|
+
app as any,
|
|
362
|
+
);
|
|
363
|
+
assertEquals(replay.progressed, true, "the redelivery REPLAYS progressed:true, never mis-escalates");
|
|
364
|
+
assertEquals(replay.huskRetry, undefined, "the replay is not a no-advance/husk escalation");
|
|
365
|
+
assertEquals(agentReads, 1, "the replay short-circuits before recomputing, so it adds no further read");
|
|
366
|
+
assertEquals(updates.length, writesAfterFirst, "the replay makes no new write (pure replay)");
|
|
138
367
|
});
|
|
139
368
|
|
|
140
369
|
test("progress-check: the first observed round (no baseline) continues and records the baseline", async () => {
|
|
141
370
|
const handler = await makeUnderTest(async () => "sha-1");
|
|
142
371
|
const { app, updates } = fakeApp(); // no row yet -> previousHead null
|
|
143
|
-
const out = await handler({ variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
144
|
-
assertEquals(out,
|
|
372
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 1 } } as any, app as any);
|
|
373
|
+
assertEquals(out.progressed, true, "no baseline yet fails open");
|
|
145
374
|
assertEquals(updates[0]!.patch.last_round_head, "sha-1");
|
|
146
375
|
});
|
|
147
376
|
|
|
377
|
+
test("progress-check: capture-head's roundEntryHead is the within-round baseline — a first-round husk that pushed nothing (head == roundEntryHead, non-terminal instance) is now CAUGHT and retried (#786/#789 categorical fix)", async () => {
|
|
378
|
+
// Copilot #789 "close both sides": `pr.capture-head` records the round-entry head into
|
|
379
|
+
// `roundEntryHead` BEFORE `review-round` runs, so even the FIRST addressed round has a baseline. A
|
|
380
|
+
// husk that pushed nothing leaves `currentHead === roundEntryHead`, so the head-diff now sees a
|
|
381
|
+
// no-advance, and the non-terminal completing instance (agentWork=false) splits it into a husk that
|
|
382
|
+
// auto-retries — no longer waved through as progress for want of a baseline.
|
|
383
|
+
const handler = await makeUnderTest(async () => "sha-1", async () => false);
|
|
384
|
+
const { app } = fakeApp(); // no persisted last_round_head — the baseline comes from roundEntryHead
|
|
385
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 1, huskRetries: 0, roundEntryHead: "sha-1" } } as any, app as any);
|
|
386
|
+
assertEquals(out.progressed, false, "a first-round husk is NOT waved through as progress");
|
|
387
|
+
assertEquals(out.huskRetry, true, "the first-round husk auto-retries the same round");
|
|
388
|
+
assertEquals(out.noProgressReason, "husk");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("progress-check: with a roundEntryHead baseline, a first-round terminal no-advance (head == roundEntryHead, terminal instance) now escalates as no-advance — the other side closed", async () => {
|
|
392
|
+
// The mirror of the categorical fix: a real terminal attempt that pushed nothing on the FIRST round
|
|
393
|
+
// is a genuine no-advance and now escalates immediately — previously it failed open for want of a
|
|
394
|
+
// baseline. The within-round baseline makes both husk and no-advance classifiable from round one.
|
|
395
|
+
const handler = await makeUnderTest(async () => "sha-1", async () => true);
|
|
396
|
+
const { app } = fakeApp();
|
|
397
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 1, huskRetries: 0, roundEntryHead: "sha-1" } } as any, app as any);
|
|
398
|
+
assertEquals(out.progressed, false, "a first-round terminal no-advance escalates");
|
|
399
|
+
assertEquals(out.huskRetry, false, "a no-advance never auto-retries");
|
|
400
|
+
assertEquals(out.noProgressReason, "no-advance");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("progress-check: a real push within the round (currentHead != roundEntryHead) is progress, never a false husk", async () => {
|
|
404
|
+
// The complement: capture-head recorded the entry head, the agent pushed a commit, so currentHead
|
|
405
|
+
// advances past roundEntryHead → progress, regardless of the (non-terminal) instance state. This is
|
|
406
|
+
// the false-husk direction the within-round baseline closes.
|
|
407
|
+
const handler = await makeUnderTest(async () => "sha-2", async () => false);
|
|
408
|
+
const { app } = fakeApp();
|
|
409
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 1, huskRetries: 0, roundEntryHead: "sha-1" } } as any, app as any);
|
|
410
|
+
assertEquals(out.progressed, true, "a head advance within the round is progress");
|
|
411
|
+
assertEquals(out.huskRetry, undefined);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("progress-check: roundEntryHead takes PRECEDENCE over the persisted last_round_head; an empty-string roundEntryHead (capture read failed) falls back to last_round_head", async () => {
|
|
415
|
+
// Precedence: capture-head's within-round entry head is the primary baseline.
|
|
416
|
+
const h1 = await makeUnderTest(async () => "sha-9", async () => false);
|
|
417
|
+
const a1 = fakeApp({ last_round_head: "stale-old" });
|
|
418
|
+
const o1 = await h1({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 3, huskRetries: 0, roundEntryHead: "sha-9" } } as any, a1.app as any);
|
|
419
|
+
assertEquals(o1.progressed, false, "head==roundEntryHead is a no-advance even though it != last_round_head");
|
|
420
|
+
assertEquals(o1.noProgressReason, "husk");
|
|
421
|
+
// Fallback: an empty-string roundEntryHead (capture-head could not read the entry head) is treated
|
|
422
|
+
// as no round-entry baseline, so the persisted last_round_head is used instead.
|
|
423
|
+
const h2 = await makeUnderTest(async () => "sha-1", async () => false);
|
|
424
|
+
const a2 = fakeApp({ last_round_head: "sha-1" });
|
|
425
|
+
const o2 = await h2({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 3, huskRetries: 0, roundEntryHead: "" } } as any, a2.app as any);
|
|
426
|
+
assertEquals(o2.progressed, false, "empty roundEntryHead falls back to last_round_head baseline");
|
|
427
|
+
assertEquals(o2.noProgressReason, "husk");
|
|
428
|
+
});
|
|
429
|
+
|
|
148
430
|
test("progress-check: an unreadable head fails open and does not clobber the baseline", async () => {
|
|
149
431
|
const handler = await makeUnderTest(async () => null);
|
|
150
432
|
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
151
|
-
const out = await handler({ variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1 } } as any, app as any);
|
|
152
|
-
assertEquals(out,
|
|
153
|
-
|
|
433
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 2 } } as any, app as any);
|
|
434
|
+
assertEquals(out.progressed, true, "a null head fails open");
|
|
435
|
+
// A null head is never written as a baseline (it must not clobber the good `last_round_head`), but
|
|
436
|
+
// the round still fails OPEN to the review wait, so progress-check parks it (persist-round no
|
|
437
|
+
// longer does). The single write is therefore the PARK, carrying no `last_round_head`.
|
|
438
|
+
assertEquals(updates.length, 1, "only the review-wait park is written");
|
|
439
|
+
assertEquals(updates[0]!.patch.last_round_head, undefined, "a null head never overwrites the baseline");
|
|
440
|
+
assertEquals(updates[0]!.patch.status, "waiting_review", "a fail-open round still parks for review");
|
|
154
441
|
});
|
|
155
442
|
|
|
156
443
|
test("progress-check: resolves repo/prNumber from the prKey when the vars are absent", async () => {
|
|
@@ -160,10 +447,234 @@ test("progress-check: resolves repo/prNumber from the prKey when the vars are ab
|
|
|
160
447
|
return "sha-2";
|
|
161
448
|
});
|
|
162
449
|
const { app } = fakeApp({ last_round_head: "sha-1" });
|
|
163
|
-
await handler({ variables: { prKey: "o/r#1", status: "addressed" } } as any, app as any);
|
|
450
|
+
await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", round: 2 } } as any, app as any);
|
|
164
451
|
assertEquals(seen, ["o/r", 1], "falls back to parsing owner/repo#N from the prKey");
|
|
165
452
|
});
|
|
166
453
|
|
|
454
|
+
test("progress-check: reads agent-instances on EVERY addressed round (incl. a progressing one) to maintain the attempt watermark (#789)", async () => {
|
|
455
|
+
// Copilot #789: the husk verdict is only consulted on the no-advance path, but the read must ALSO
|
|
456
|
+
// run on a progressing round so its fresh `review-round` instance is CONSUMED into the watermark —
|
|
457
|
+
// otherwise the next round's pre-registration husk would see that stale terminal instance as
|
|
458
|
+
// "newer than the watermark" and mis-escalate. The advancing head still fails open to progress.
|
|
459
|
+
let agentReads = 0;
|
|
460
|
+
const handler = await makeUnderTest(async () => "sha-2", async () => {
|
|
461
|
+
agentReads++;
|
|
462
|
+
return true;
|
|
463
|
+
});
|
|
464
|
+
const { app } = fakeApp({ last_round_head: "sha-1" });
|
|
465
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 2 } } as any, app as any);
|
|
466
|
+
assertEquals(agentReads, 1, "a progressing round still reads once to advance the attempt watermark");
|
|
467
|
+
assertEquals(out.progressed, true, "an advancing head is still progress regardless of the read");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("progress-check: an unreadable head STILL reads the agent channel to advance the watermark (Copilot #789 worker.ts:372)", async () => {
|
|
471
|
+
// A GitHub outage that blanks the current head must not also skip consuming this round's own
|
|
472
|
+
// `review-round` instance into the attempt watermark: if it did, a later pre-registration husk
|
|
473
|
+
// would see that unconsumed terminal instance as newer than the stale watermark and mis-classify
|
|
474
|
+
// itself as no-advance. So the agent read runs even with a null head; the head-diff still fails
|
|
475
|
+
// open to progress, but the watermark advances.
|
|
476
|
+
let agentReads = 0;
|
|
477
|
+
const handler = await makeUnderTest(async () => null, async () => {
|
|
478
|
+
agentReads++;
|
|
479
|
+
return { work: true, consumedKey: "77" };
|
|
480
|
+
});
|
|
481
|
+
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
482
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 2 } } as any, app as any);
|
|
483
|
+
assertEquals(agentReads, 1, "an unreadable head still reads the channel to advance the watermark");
|
|
484
|
+
assertEquals(out.progressed, true, "an unreadable head fails open to progress");
|
|
485
|
+
assertEquals(
|
|
486
|
+
updates[0]!.patch.last_progress_agent_watermark,
|
|
487
|
+
"77",
|
|
488
|
+
"the round consumes its review instance into the watermark despite the head-read outage",
|
|
489
|
+
);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// ── The DEFAULT (availability-aware) agent-work reader over app.engine.searchAgentInstances ──────
|
|
493
|
+
// These exercise agentWorkFromEngine directly (no injected readAgentWork), via fakeApp's injectable
|
|
494
|
+
// engine, to lock the Option 1 (#786) semantics: BOTH-empty→unknown→no-advance; scoped-empty-but-
|
|
495
|
+
// channel-present→husk; non-terminal→husk; all-terminal→no-advance; correlation is by the COMPLETING
|
|
496
|
+
// element-instance, not a round count.
|
|
497
|
+
|
|
498
|
+
test("progress-check default reader: a WHOLLY-empty process instance (absent channel) fails safe to no-advance, never a husk", async () => {
|
|
499
|
+
// Two-tier availability probe: on a channel-absent engine (the testkit double returns [] for EVERY
|
|
500
|
+
// search — both the scoped review-round query AND the process-wide probe) the channel is UNKNOWN,
|
|
501
|
+
// so empty ⇒ no-advance — NOT a husk auto-retry that could loop forever on a non-agentic engine.
|
|
502
|
+
const handler = await makeUnderTest(async () => "sha-1"); // no injected readAgentWork -> default
|
|
503
|
+
const { app } = fakeApp({ last_round_head: "sha-1" }, async () => []);
|
|
504
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 5, huskRetries: 0 } } as any, app as any);
|
|
505
|
+
assertEquals(out.progressed, false);
|
|
506
|
+
assertEquals(out.huskRetry, false, "an absent channel must never auto-retry");
|
|
507
|
+
assertEquals(out.noProgressReason, "no-advance");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("progress-check default reader: an empty review-round search but a CHANNEL-PRESENT process (another agent instance exists) ⇒ husk — the round husked before registering (#789)", async () => {
|
|
511
|
+
// Copilot #789: review-round is an external-agent serviceTask, so a job that husks BEFORE the
|
|
512
|
+
// worker registers its AgentInstance mints NOTHING — the scoped search is empty even on a
|
|
513
|
+
// channel-present engine. The process-wide probe disambiguates: another agent instance (e.g.
|
|
514
|
+
// classify-scope, or an earlier round) proves the channel is PRESENT, so the empty review-round is
|
|
515
|
+
// a genuine husk (auto-retry), not an absent channel.
|
|
516
|
+
const handler = await makeUnderTest(async () => "sha-1");
|
|
517
|
+
const { app } = fakeApp({ last_round_head: "sha-1" }, async (arg) => {
|
|
518
|
+
// Filter-aware double: the scoped review-round query is EMPTY (the round husked pre-registration),
|
|
519
|
+
// but the process-wide probe returns a classify-scope instance (channel present).
|
|
520
|
+
const f = (arg ?? {}) as { elementId?: string };
|
|
521
|
+
if (f.elementId === "review-round") return [];
|
|
522
|
+
return [{ status: "completed", completionDate: "2024-01-01T00:00:00Z", elementInstanceKeys: ["50"] }];
|
|
523
|
+
});
|
|
524
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 5, huskRetries: 0 } } as any, app as any);
|
|
525
|
+
assertEquals(out.progressed, false);
|
|
526
|
+
assertEquals(out.huskRetry, true, "a channel-present empty review-round is a husk, so it auto-retries");
|
|
527
|
+
assertEquals(out.noProgressReason, "husk");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("progress-check default reader: a non-terminal review-round instance ⇒ husk (auto-retry under cap)", async () => {
|
|
531
|
+
// Channel present, the completing attempt (newest element-instance) is stuck non-terminal
|
|
532
|
+
// (husked) => false => husk. The stale terminal instance is an EARLIER attempt (lower key).
|
|
533
|
+
const handler = await makeUnderTest(async () => "sha-1");
|
|
534
|
+
const { app } = fakeApp({ last_round_head: "sha-1" }, async () => [
|
|
535
|
+
{ status: "completed", completionDate: "2024-01-01T00:00:00Z", elementInstanceKeys: ["100"] },
|
|
536
|
+
{ status: "THINKING", completionDate: null, elementInstanceKeys: ["200"] },
|
|
537
|
+
]);
|
|
538
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 5, huskRetries: 0 } } as any, app as any);
|
|
539
|
+
assertEquals(out.progressed, false);
|
|
540
|
+
assertEquals(out.huskRetry, true, "a non-terminal completing attempt is a husk");
|
|
541
|
+
assertEquals(out.noProgressReason, "husk");
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("progress-check default reader: ALL-terminal review-round instances ⇒ no-advance (never auto-retry)", async () => {
|
|
545
|
+
// Channel present, every instance (including the completing attempt) is terminal => true =>
|
|
546
|
+
// genuine no-advance — the agent ran to completion but produced no head change, a human question.
|
|
547
|
+
const handler = await makeUnderTest(async () => "sha-1");
|
|
548
|
+
const { app } = fakeApp({ last_round_head: "sha-1" }, async () => [
|
|
549
|
+
{ status: "completed", completionDate: "2024-01-01T00:00:00Z", elementInstanceKeys: ["100"] },
|
|
550
|
+
{ status: "failed", completionDate: "2024-01-02T00:00:00Z", elementInstanceKeys: ["200"] },
|
|
551
|
+
]);
|
|
552
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 5, huskRetries: 0 } } as any, app as any);
|
|
553
|
+
assertEquals(out.progressed, false);
|
|
554
|
+
assertEquals(out.huskRetry, false, "an all-terminal round is a genuine no-advance, not a husk");
|
|
555
|
+
assertEquals(out.noProgressReason, "no-advance");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("progress-check default reader: a STALE non-terminal prior attempt does NOT mask a terminal completing attempt (no false husk)", async () => {
|
|
559
|
+
// #786 correlation defect: `.every(isTerminalInstance)` over ALL historical instances mis-flags a
|
|
560
|
+
// genuine no-advance as a husk whenever an EARLIER attempt is still stuck non-terminal. The newest
|
|
561
|
+
// element-instance (greatest engine key) is the COMPLETING attempt and it is terminal => no-advance,
|
|
562
|
+
// regardless of the stale husked prior instance.
|
|
563
|
+
const handler = await makeUnderTest(async () => "sha-1");
|
|
564
|
+
const { app } = fakeApp({ last_round_head: "sha-1" }, async () => [
|
|
565
|
+
{ status: "THINKING", completionDate: null, elementInstanceKeys: ["100"] }, // stale prior husk
|
|
566
|
+
{ status: "completed", completionDate: "2024-01-02T00:00:00Z", elementInstanceKeys: ["200"] }, // newest, terminal
|
|
567
|
+
]);
|
|
568
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 5, huskRetries: 0 } } as any, app as any);
|
|
569
|
+
assertEquals(out.progressed, false);
|
|
570
|
+
assertEquals(out.huskRetry, false, "a stale non-terminal prior attempt must not fabricate a husk");
|
|
571
|
+
assertEquals(out.noProgressReason, "no-advance");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("progress-check default reader: a same-round husk AFTER an earlier terminal attempt is a husk (newest instance wins)", async () => {
|
|
575
|
+
// The mirror case: an earlier attempt in this round ran to a terminal instance, then a resume
|
|
576
|
+
// husked. The newest element-instance (greatest engine key) is the non-terminal resume => husk,
|
|
577
|
+
// not masked by the earlier terminal instance.
|
|
578
|
+
const handler = await makeUnderTest(async () => "sha-1");
|
|
579
|
+
const { app } = fakeApp({ last_round_head: "sha-1" }, async () => [
|
|
580
|
+
{ status: "completed", completionDate: "2024-01-01T00:00:00Z", elementInstanceKeys: ["300"] }, // earlier terminal
|
|
581
|
+
{ status: "THINKING", completionDate: null, elementInstanceKeys: ["400"] }, // newest, husked
|
|
582
|
+
]);
|
|
583
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 5, huskRetries: 0 } } as any, app as any);
|
|
584
|
+
assertEquals(out.progressed, false);
|
|
585
|
+
assertEquals(out.huskRetry, true, "the newest attempt husked, so the round is a husk");
|
|
586
|
+
assertEquals(out.noProgressReason, "husk");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("progress-check default reader: an engine read that THROWS degrades to no-advance (unknown), never a husk", async () => {
|
|
590
|
+
const handler = await makeUnderTest(async () => "sha-1");
|
|
591
|
+
const { app } = fakeApp({ last_round_head: "sha-1" }, async () => {
|
|
592
|
+
throw new Error("engine unreachable");
|
|
593
|
+
});
|
|
594
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 5, huskRetries: 0 } } as any, app as any);
|
|
595
|
+
assertEquals(out.progressed, false);
|
|
596
|
+
assertEquals(out.huskRetry, false, "a read outage must never auto-retry");
|
|
597
|
+
assertEquals(out.noProgressReason, "no-advance");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("progress-check default reader: a prior round's TERMINAL instance does NOT mask a current pre-registration husk (#789)", async () => {
|
|
601
|
+
// Copilot #789 suppressed comment (worker.ts:184): if an earlier round's `review-round` instance
|
|
602
|
+
// is terminal and the CURRENT attempt husks BEFORE registering any AgentInstance, the scoped
|
|
603
|
+
// search still returns that earlier terminal row as `newest` — which, read naively, returns `true`
|
|
604
|
+
// and routes to a no-advance escalation, BYPASSING the bounded husk retry. The attempt watermark
|
|
605
|
+
// fixes it: the persisted watermark (100) already ACCOUNTED FOR that terminal instance, so a scoped
|
|
606
|
+
// search whose greatest key is still 100 (no NEWER instance) means the current attempt registered
|
|
607
|
+
// nothing new — a genuine husk (auto-retry), not a no-advance.
|
|
608
|
+
const handler = await makeUnderTest(async () => "sha-1");
|
|
609
|
+
const { app, updates } = fakeApp(
|
|
610
|
+
{ last_round_head: "sha-1", last_progress_agent_watermark: "100" },
|
|
611
|
+
async () => [
|
|
612
|
+
{ status: "completed", completionDate: "2024-01-01T00:00:00Z", elementInstanceKeys: ["100"] }, // prior terminal, already consumed
|
|
613
|
+
],
|
|
614
|
+
);
|
|
615
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 6, huskRetries: 0 } } as any, app as any);
|
|
616
|
+
assertEquals(out.progressed, false);
|
|
617
|
+
assertEquals(out.huskRetry, true, "no instance newer than the watermark ⇒ the current attempt husked pre-registration");
|
|
618
|
+
assertEquals(out.noProgressReason, "husk");
|
|
619
|
+
// The watermark stays where it was — nothing new was consumed.
|
|
620
|
+
const wm = updates.at(-1)?.patch.last_progress_agent_watermark;
|
|
621
|
+
assertEquals(wm, "100", "a pre-registration husk leaves the watermark unchanged");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("progress-check default reader: a NEW terminal attempt past the watermark ⇒ no-advance and ADVANCES the watermark (#789)", async () => {
|
|
625
|
+
// The complement: the current attempt DID register (a `review-round` instance keyed 200, newer than
|
|
626
|
+
// the watermark 100), and it is terminal — a genuine no-advance. The watermark advances to 200 so
|
|
627
|
+
// the NEXT round can tell the attempt after it from this one.
|
|
628
|
+
const handler = await makeUnderTest(async () => "sha-1");
|
|
629
|
+
const { app, updates } = fakeApp(
|
|
630
|
+
{ last_round_head: "sha-1", last_progress_agent_watermark: "100" },
|
|
631
|
+
async () => [
|
|
632
|
+
{ status: "completed", completionDate: "2024-01-01T00:00:00Z", elementInstanceKeys: ["100"] }, // prior, consumed
|
|
633
|
+
{ status: "completed", completionDate: "2024-01-02T00:00:00Z", elementInstanceKeys: ["200"] }, // fresh terminal attempt
|
|
634
|
+
],
|
|
635
|
+
);
|
|
636
|
+
const out = await handler({ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 6, huskRetries: 0 } } as any, app as any);
|
|
637
|
+
assertEquals(out.progressed, false);
|
|
638
|
+
assertEquals(out.huskRetry, false, "a fresh terminal attempt is a genuine no-advance");
|
|
639
|
+
assertEquals(out.noProgressReason, "no-advance");
|
|
640
|
+
const wm = updates.at(-1)?.patch.last_progress_agent_watermark;
|
|
641
|
+
assertEquals(wm, "200", "consuming a fresh attempt advances the watermark");
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("progress-check: a straggler from a SUPERSEDED process instance does NOT write into the re-opened row (process-instance fence #789)", async () => {
|
|
645
|
+
// Copilot #789 (worker.ts:290): after `submitPr` re-opens a PR it clears the per-run fields
|
|
646
|
+
// (including the idempotency stamp) and starts a NEW convergence instance, setting the row's
|
|
647
|
+
// `process_key` to that new instance. An OLD progress-check job from the SUPERSEDED instance can
|
|
648
|
+
// slip past the (now-cleared) replay guard and reach `commit`; writing its stale baseline / status
|
|
649
|
+
// / watermark would clobber the fresh run. The commit re-reads the row's current `process_key` and
|
|
650
|
+
// drops the write when the job's own `processInstanceKey` no longer owns the row.
|
|
651
|
+
const handler = await makeUnderTest(async () => "sha-stale", async () => true);
|
|
652
|
+
const { app, updates } = fakeApp({ last_round_head: "sha-new", process_key: "PI-NEW" });
|
|
653
|
+
const out = await handler(
|
|
654
|
+
// The straggler job belongs to the OLD instance PI-OLD, but the row is now owned by PI-NEW.
|
|
655
|
+
{ jobKey: "job-old", processInstanceKey: "PI-OLD", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 3, huskRetries: 0 } } as any,
|
|
656
|
+
app as any,
|
|
657
|
+
);
|
|
658
|
+
// The job still ACKs with a computed outcome (its token lives in a terminated instance), but it
|
|
659
|
+
// must persist NOTHING — the fresh run's row is untouched.
|
|
660
|
+
assertEquals(updates.length, 0, "the superseded straggler writes nothing into the re-opened row");
|
|
661
|
+
assertEquals(typeof out.progressed, "boolean", "the straggler still returns an outcome to ACK");
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("progress-check: a job whose processInstanceKey OWNS the row still writes normally (fence fails open on match)", async () => {
|
|
665
|
+
// The complement of the fence: when the job's `processInstanceKey` matches the row's current
|
|
666
|
+
// `process_key`, the write proceeds exactly as before — the fence only drops a SUPERSEDED straggler.
|
|
667
|
+
const handler = await makeUnderTest(async () => "sha-2");
|
|
668
|
+
const { app, updates } = fakeApp({ last_round_head: "sha-1", process_key: "PI-CURRENT" });
|
|
669
|
+
const out = await handler(
|
|
670
|
+
{ processInstanceKey: "PI-CURRENT", variables: { prKey: "o/r#1", status: "addressed", repo: "o/r", prNumber: 1, round: 2 } } as any,
|
|
671
|
+
app as any,
|
|
672
|
+
);
|
|
673
|
+
assertEquals(out.progressed, true, "the owning job progresses");
|
|
674
|
+
assertEquals(updates.length, 1, "the owning job writes its baseline/park normally");
|
|
675
|
+
assertEquals(updates[0]!.patch.last_round_head, "sha-2");
|
|
676
|
+
});
|
|
677
|
+
|
|
167
678
|
// ── Structural guard over the committed BPMN (no engine) ─────────────────────
|
|
168
679
|
|
|
169
680
|
const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
@@ -193,21 +704,146 @@ test("check-progress feeds the gw-progress gateway", () => {
|
|
|
193
704
|
assertStringIncludes(flat, 'type="pr.progress-check"');
|
|
194
705
|
});
|
|
195
706
|
|
|
196
|
-
test("gw-progress
|
|
707
|
+
test("gw-progress routes a no-progress round into the husk gate on an explicit progressed = false condition", () => {
|
|
197
708
|
const f = flowElement("f_noProgress");
|
|
198
709
|
assert(f, "f_noProgress flow missing");
|
|
199
|
-
assertStringIncludes(f, 'targetRef="
|
|
710
|
+
assertStringIncludes(f, 'targetRef="gw-husk"');
|
|
200
711
|
assertStringIncludes(f, "progressed = false");
|
|
201
712
|
});
|
|
202
713
|
|
|
203
|
-
test("gw-
|
|
714
|
+
test("gw-husk auto-retries a husk back into review-round, and defaults to the human escalation", () => {
|
|
715
|
+
// #786: a husked round (no commit AND no terminal review-round agent-instance) is re-run onto a
|
|
716
|
+
// healthy worker — bounded by the worker's huskRetries cap — before parking a human. The bound
|
|
717
|
+
// lives in the pr.progress-check worker (app/roundProgress.ts decideProgress), so the gateway only
|
|
718
|
+
// routes on the worker's `huskRetry` output.
|
|
719
|
+
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-husk"[^>]*>/);
|
|
720
|
+
assert(gw, "gw-husk gateway missing");
|
|
721
|
+
assertStringIncludes(gw[0], 'default="f_huskEscalate"');
|
|
722
|
+
const retry = flowElement("f_huskRetry");
|
|
723
|
+
assert(retry, "f_huskRetry flow missing");
|
|
724
|
+
assertStringIncludes(retry, 'sourceRef="gw-husk"');
|
|
725
|
+
assertStringIncludes(retry, 'targetRef="capture-head"');
|
|
726
|
+
assertStringIncludes(retry, "huskRetry = true");
|
|
727
|
+
const esc = flowElement("f_huskEscalate");
|
|
728
|
+
assert(esc, "f_huskEscalate flow missing");
|
|
729
|
+
assertStringIncludes(esc, 'sourceRef="gw-husk"');
|
|
730
|
+
assertStringIncludes(esc, 'targetRef="persist-escalation-noprogress"');
|
|
731
|
+
assert(!/conditionExpression/.test(esc), "the escalate arm is the unconditioned default");
|
|
732
|
+
// The husk-retry re-enters review-round (via capture-head, which re-captures the round-entry head)
|
|
733
|
+
// WITHOUT going through the round-incrementing review wait, so it re-runs the SAME round rather than
|
|
734
|
+
// advancing the counter. capture-head owns f_huskRetry; review-round is entered from f_capture.
|
|
735
|
+
const captureHead = flat.match(/<bpmn:serviceTask\b[^>]*\bid="capture-head"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
736
|
+
assert(captureHead, "capture-head task missing");
|
|
737
|
+
assertStringIncludes(captureHead[0], "<bpmn:incoming>f_huskRetry</bpmn:incoming>");
|
|
738
|
+
const reviewRound = flat.match(/<bpmn:serviceTask\b[^>]*\bid="review-round"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
739
|
+
assert(reviewRound, "review-round task missing");
|
|
740
|
+
assertStringIncludes(reviewRound[0], "<bpmn:incoming>f_capture</bpmn:incoming>");
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("capture-head runs BEFORE review-round on EVERY round entry (round-entry head baseline, #786/#789)", () => {
|
|
744
|
+
// The categorical fix for the no-baseline husk: `pr.capture-head` records the head into
|
|
745
|
+
// `roundEntryHead` immediately before `review-round` on ALL four entry paths (Start, review-loop
|
|
746
|
+
// re-enter, human-answer resume, husk auto-retry), so progress-check always has a within-round
|
|
747
|
+
// baseline. This guards the structural invariant that no path reaches review-round without first
|
|
748
|
+
// passing through capture-head.
|
|
749
|
+
const capture = flat.match(/<bpmn:serviceTask\b[^>]*\bid="capture-head"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
750
|
+
assert(capture, "capture-head task missing");
|
|
751
|
+
assertStringIncludes(capture[0], 'type="pr.capture-head"');
|
|
752
|
+
// All four round-entry flows target capture-head, not review-round.
|
|
753
|
+
for (const [id, src] of [
|
|
754
|
+
["f_start", "Start"],
|
|
755
|
+
["f_reviewLoop", "wait-review"],
|
|
756
|
+
["f_answerLoop", "record-answer"],
|
|
757
|
+
["f_huskRetry", "gw-husk"],
|
|
758
|
+
] as const) {
|
|
759
|
+
const f = flowElement(id);
|
|
760
|
+
assert(f, `${id} flow missing`);
|
|
761
|
+
assertStringIncludes(f, `sourceRef="${src}"`);
|
|
762
|
+
assertStringIncludes(f, 'targetRef="capture-head"');
|
|
763
|
+
assertStringIncludes(capture[0], `<bpmn:incoming>${id}</bpmn:incoming>`);
|
|
764
|
+
}
|
|
765
|
+
// capture-head's single outgoing is the ONLY way into review-round.
|
|
766
|
+
const cap = flowElement("f_capture");
|
|
767
|
+
assert(cap, "f_capture flow missing");
|
|
768
|
+
assertStringIncludes(cap, 'sourceRef="capture-head"');
|
|
769
|
+
assertStringIncludes(cap, 'targetRef="review-round"');
|
|
770
|
+
assertStringIncludes(capture[0], "<bpmn:outgoing>f_capture</bpmn:outgoing>");
|
|
771
|
+
const reviewRound = flat.match(/<bpmn:serviceTask\b[^>]*\bid="review-round"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
772
|
+
assert(reviewRound, "review-round task missing");
|
|
773
|
+
assertStringIncludes(reviewRound[0], "<bpmn:incoming>f_capture</bpmn:incoming>");
|
|
774
|
+
// review-round is entered ONLY via f_capture — no direct round-entry flow survives.
|
|
775
|
+
for (const id of ["f_start", "f_reviewLoop", "f_answerLoop", "f_huskRetry"]) {
|
|
776
|
+
assert(!reviewRound[0].includes(`<bpmn:incoming>${id}</bpmn:incoming>`), `review-round must not still take ${id} directly`);
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("record-answer resets huskRetries on every human resume (Copilot #789)", () => {
|
|
781
|
+
// #789 worker.ts:172: `huskRetries` is a process variable bounding the husk auto-retry chain. Every
|
|
782
|
+
// escalation resume funnels through the SINGLE chokepoint gw-escalated → wait-answer → record-answer
|
|
783
|
+
// → review-round, so record-answer must reset the counter to 0 — otherwise a fresh review attempt
|
|
784
|
+
// after a human intervention inherits a `huskRetries` a prior escalation left at MAX and the next
|
|
785
|
+
// husk escalates immediately with no self-heal. Scoping the reset here bounds the counter to one
|
|
786
|
+
// uninterrupted husk-retry chain.
|
|
787
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="record-answer"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
788
|
+
assert(task, "record-answer task missing");
|
|
789
|
+
assertStringIncludes(task[0], 'source="=0"');
|
|
790
|
+
assertStringIncludes(task[0], 'target="huskRetries"');
|
|
791
|
+
// record-answer is the loop-back into review-round via capture-head (the human-resume chokepoint
|
|
792
|
+
// the reset guards); capture-head re-captures the round-entry head before the re-run.
|
|
793
|
+
assertStringIncludes(task[0], "<bpmn:outgoing>f_answerLoop</bpmn:outgoing>");
|
|
794
|
+
const loop = flowElement("f_answerLoop");
|
|
795
|
+
assert(loop, "f_answerLoop flow missing");
|
|
796
|
+
assertStringIncludes(loop, 'sourceRef="record-answer"');
|
|
797
|
+
assertStringIncludes(loop, 'targetRef="capture-head"');
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test("gw-progress default (progress) arm routes to the round-cap guard, then the review wait", () => {
|
|
801
|
+
// After #786/#789 the round-cap guard (gw-guard) moved DOWNSTREAM of progress classification: a
|
|
802
|
+
// progressing round routes gw-progress → gw-guard → (round<max) gw-review-wait, so the round cap
|
|
803
|
+
// gates the NEXT review round only. A husk auto-retry (gw-husk → capture-head) therefore bypasses
|
|
804
|
+
// the cap entirely — a dead-worker husk is re-tried onto a healthy worker even on the final round.
|
|
204
805
|
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-progress"[^>]*>/);
|
|
205
806
|
assert(gw, "gw-progress gateway missing");
|
|
206
807
|
assertStringIncludes(gw[0], 'default="f_progressOk"');
|
|
207
808
|
const ok = flowElement("f_progressOk");
|
|
208
809
|
assert(ok, "f_progressOk flow missing");
|
|
209
|
-
assertStringIncludes(ok, 'targetRef="gw-
|
|
810
|
+
assertStringIncludes(ok, 'targetRef="gw-guard"');
|
|
210
811
|
assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
|
|
812
|
+
// The guard's continue arm (not the max-rounds arm) is what re-enters the review wait.
|
|
813
|
+
const guardOk = flowElement("f_guardOk");
|
|
814
|
+
assert(guardOk, "f_guardOk flow missing");
|
|
815
|
+
assertStringIncludes(guardOk, 'sourceRef="gw-guard"');
|
|
816
|
+
assertStringIncludes(guardOk, 'targetRef="gw-review-wait"');
|
|
817
|
+
assert(!/conditionExpression/.test(guardOk), "the guard continue arm is the default (no condition)");
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("the round-cap guard is downstream of progress classification, so a husk auto-retry bypasses it (#786/#789)", () => {
|
|
821
|
+
// gw-guard is fed ONLY by the progress arm (f_progressOk), NOT by the addressed/waiting status
|
|
822
|
+
// arms — those now go straight to persist-round. This is the structural guarantee that a husk on
|
|
823
|
+
// the final configured round still reaches gw-husk and gets its bounded retries, instead of being
|
|
824
|
+
// escalated as "max rounds" before it is ever classified.
|
|
825
|
+
const guard = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-guard"[^>]*>[\s\S]*?<\/bpmn:exclusiveGateway>/);
|
|
826
|
+
assert(guard, "gw-guard gateway missing");
|
|
827
|
+
assertStringIncludes(guard[0], "<bpmn:incoming>f_progressOk</bpmn:incoming>");
|
|
828
|
+
for (const id of ["f_addressed", "f_waiting", "f_escReenter"]) {
|
|
829
|
+
assert(!guard[0].includes(`<bpmn:incoming>${id}</bpmn:incoming>`), `gw-guard must not sit before progress classification (still takes ${id})`);
|
|
830
|
+
}
|
|
831
|
+
// The max-rounds arm still fires on the round cap, but only AFTER a progressing round.
|
|
832
|
+
const max = flowElement("f_guardMax");
|
|
833
|
+
assert(max, "f_guardMax flow missing");
|
|
834
|
+
assertStringIncludes(max, 'sourceRef="gw-guard"');
|
|
835
|
+
assertStringIncludes(max, 'targetRef="persist-escalation-maxrounds"');
|
|
836
|
+
assertStringIncludes(max, "round >= maxRounds");
|
|
837
|
+
// The husk retry re-enters via capture-head, never touching gw-guard.
|
|
838
|
+
const retry = flowElement("f_huskRetry");
|
|
839
|
+
assert(retry, "f_huskRetry flow missing");
|
|
840
|
+
assertStringIncludes(retry, 'targetRef="capture-head"');
|
|
841
|
+
// The addressed/waiting status arms feed persist-round directly (not the guard).
|
|
842
|
+
for (const id of ["f_addressed", "f_waiting"]) {
|
|
843
|
+
const f = flowElement(id);
|
|
844
|
+
assert(f, `${id} flow missing`);
|
|
845
|
+
assertStringIncludes(f, 'targetRef="persist-round"');
|
|
846
|
+
}
|
|
211
847
|
});
|
|
212
848
|
|
|
213
849
|
test("the no-progress escalation routes through gw-escalated toward the human wait-answer task", () => {
|
|
@@ -230,3 +866,89 @@ test("the no-progress escalation routes through gw-escalated toward the human wa
|
|
|
230
866
|
// It must NOT double-record the round persist-round already recorded.
|
|
231
867
|
assertStringIncludes(task[0], 'target="recordRound"');
|
|
232
868
|
});
|
|
869
|
+
|
|
870
|
+
// ── makeDefaultReadHead: the real head reader's branch-ref-over-stale-head.sha preference ─────────
|
|
871
|
+
// #786 regression guard: the handler tests inject `readHead`, and app/github.test.ts exercises
|
|
872
|
+
// fetchBranchHead in isolation, so nothing pinned the DEFAULT reader's wiring — a change that stopped
|
|
873
|
+
// reading the branch ref, or fell back to the PR object's denormalized head.sha, would leave the
|
|
874
|
+
// suite green. These drive makeDefaultReadHead with injected fetchers to lock that contract.
|
|
875
|
+
async function makeReader(deps: {
|
|
876
|
+
fetchPrHead: (repo: string, n: number | string, token: string) => Promise<{ headRef: string | null; headSha: string | null; baseRef: string | null; headRepo: string | null } | null>;
|
|
877
|
+
fetchBranchHead: (repo: string, branch: string, token: string) => Promise<string | null>;
|
|
878
|
+
}) {
|
|
879
|
+
const { makeDefaultReadHead } = await import("../workers/progress-check/worker.ts");
|
|
880
|
+
return makeDefaultReadHead(deps as any);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
test("makeDefaultReadHead: prefers the atomic branch ref over a stale PR head.sha", async () => {
|
|
884
|
+
let branchReads = 0;
|
|
885
|
+
const read = await makeReader({
|
|
886
|
+
fetchPrHead: async () => ({ headRef: "feat/x", headSha: "stale-denormalized-sha", baseRef: "main", headRepo: "o/r" }),
|
|
887
|
+
fetchBranchHead: async (_r, branch) => {
|
|
888
|
+
branchReads++;
|
|
889
|
+
assertEquals(branch, "feat/x", "the branch ref read targets the PR's head ref");
|
|
890
|
+
return "fresh-atomic-sha";
|
|
891
|
+
},
|
|
892
|
+
});
|
|
893
|
+
assertEquals(await read("o/r", 1), "fresh-atomic-sha", "the branch ref SHA wins over the stale head.sha");
|
|
894
|
+
assertEquals(branchReads, 1, "the branch ref was actually consulted");
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
test("makeDefaultReadHead: a failed branch-ref read fails OPEN to null, never falling back to head.sha", async () => {
|
|
898
|
+
const read = await makeReader({
|
|
899
|
+
fetchPrHead: async () => ({ headRef: "feat/x", headSha: "stale-sha", baseRef: "main", headRepo: "o/r" }),
|
|
900
|
+
fetchBranchHead: async () => {
|
|
901
|
+
throw new Error("ref 404 / transport hiccup");
|
|
902
|
+
},
|
|
903
|
+
});
|
|
904
|
+
assertEquals(await read("o/r", 1), null, "a ref-read failure must not fall back to the denormalized head.sha");
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
test("makeDefaultReadHead: with NO head ref (e.g. detached) falls back to the PR head.sha", async () => {
|
|
908
|
+
let branchReads = 0;
|
|
909
|
+
const read = await makeReader({
|
|
910
|
+
fetchPrHead: async () => ({ headRef: null, headSha: "only-head-sha", baseRef: "main", headRepo: "o/r" }),
|
|
911
|
+
fetchBranchHead: async () => {
|
|
912
|
+
branchReads++;
|
|
913
|
+
return "unused";
|
|
914
|
+
},
|
|
915
|
+
});
|
|
916
|
+
assertEquals(await read("o/r", 1), "only-head-sha", "with no head ref the PR head.sha is the only signal");
|
|
917
|
+
assertEquals(branchReads, 0, "the branch ref is not read when there is no head ref");
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test("makeDefaultReadHead: an unreadable PR (null) fails OPEN to null", async () => {
|
|
921
|
+
const read = await makeReader({
|
|
922
|
+
fetchPrHead: async () => null,
|
|
923
|
+
fetchBranchHead: async () => "never",
|
|
924
|
+
});
|
|
925
|
+
assertEquals(await read("o/r", 1), null, "a null PR read fails open (the guard treats null as continue)");
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test("makeDefaultReadHead: a fork PR reads the FORK repo's ref, not the (colliding) base-repo ref (#786)", async () => {
|
|
929
|
+
// A cross-repo PR whose head branch shares a name with a base-repo branch: querying the base repo
|
|
930
|
+
// would read the unrelated base-branch SHA. The reader must target the head branch's OWNING repo.
|
|
931
|
+
const queried: Array<{ repo: string; branch: string }> = [];
|
|
932
|
+
const read = await makeReader({
|
|
933
|
+
fetchPrHead: async () => ({ headRef: "feat/x", headSha: "sha", baseRef: "main", headRepo: "fork-owner/r" }),
|
|
934
|
+
fetchBranchHead: async (repo, branch) => {
|
|
935
|
+
queried.push({ repo, branch });
|
|
936
|
+
return repo === "fork-owner/r" ? "fork-head-sha" : "unrelated-base-sha";
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
assertEquals(await read("base-owner/r", 1), "fork-head-sha", "the head ref is resolved in the fork, not the base repo");
|
|
940
|
+
assertEquals(queried, [{ repo: "fork-owner/r", branch: "feat/x" }], "the branch ref read targets the fork repository");
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
test("makeDefaultReadHead: an unresolvable head repository (deleted fork) fails OPEN to null (#786)", async () => {
|
|
944
|
+
let branchReads = 0;
|
|
945
|
+
const read = await makeReader({
|
|
946
|
+
fetchPrHead: async () => ({ headRef: "feat/x", headSha: "sha", baseRef: "main", headRepo: null }),
|
|
947
|
+
fetchBranchHead: async () => {
|
|
948
|
+
branchReads++;
|
|
949
|
+
return "never";
|
|
950
|
+
},
|
|
951
|
+
});
|
|
952
|
+
assertEquals(await read("o/r", 1), null, "a null head repo fails open rather than falling back to the base repo");
|
|
953
|
+
assertEquals(branchReads, 0, "no base-repo branch read is attempted when the head repo is unresolvable");
|
|
954
|
+
});
|