@nanobpm/nano-workforce 0.209.0 → 0.209.1
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/behindBaseRemedy.test.ts +115 -0
- package/app/behindBaseRemedy.ts +95 -0
- package/app/github.test.ts +137 -2
- package/app/github.ts +137 -1
- package/app/mergeLoopBehaviour.test.ts +49 -0
- package/app/service.ts +7 -0
- package/build-info.json +4 -4
- package/package.json +1 -1
- package/resources/processes/merge-loop.bpmn +2 -1
- package/workers/merge-stall-probe/worker.test.ts +119 -0
- package/workers/merge-stall-probe/worker.ts +69 -8
- package/workers/request-review/worker.test.ts +27 -0
- package/workers/request-review/worker.ts +42 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.209.1](https://github.com/nanobpm/nano-workforce/compare/v0.209.0...v0.209.1) (2026-11-12)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **merge-loop:** land CLEAN mergify-queue PRs instead of escalating; never escalate merged PRs ([#953](https://github.com/nanobpm/nano-workforce/issues/953)) ([5b70c17](https://github.com/nanobpm/nano-workforce/commit/5b70c1733cd919fcc8f43ca118a863d3714030ea)), closes [#951](https://github.com/nanobpm/nano-workforce/issues/951) [#951](https://github.com/nanobpm/nano-workforce/issues/951)
|
|
6
|
+
|
|
1
7
|
## [0.209.0](https://github.com/nanobpm/nano-workforce/compare/v0.208.5...v0.209.0) (2026-11-12)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Issue #951 (comment): failing checks on a head BEHIND its base must be remedied mechanically —
|
|
2
|
+
// update the branch from base (and close/reopen on a reopen-CI repo) — instead of re-escalating
|
|
3
|
+
// "failing required checks" to a human.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals } from "#test-assert";
|
|
6
|
+
import { _resetBehindBaseLatch, BEHIND_BASE_PENDING_MS, type BehindBaseDeps, isBehindBaseUpdatePending, maybeUpdateBehindBranch } from "./behindBaseRemedy.ts";
|
|
7
|
+
import type { PrState } from "./github.ts";
|
|
8
|
+
import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol } from "./mergeProtocol.ts";
|
|
9
|
+
|
|
10
|
+
function st(over: Partial<PrState> = {}): PrState {
|
|
11
|
+
return {
|
|
12
|
+
merged: false,
|
|
13
|
+
state: "open",
|
|
14
|
+
mergeStateStatus: "BLOCKED",
|
|
15
|
+
failingChecks: 1,
|
|
16
|
+
failingCheckNames: ["build"],
|
|
17
|
+
presentCheckNames: ["build"],
|
|
18
|
+
pendingCheckNames: [],
|
|
19
|
+
checkConclusions: { build: "FAILURE" },
|
|
20
|
+
totalChecks: 1,
|
|
21
|
+
isDraft: false,
|
|
22
|
+
headRefOid: "head1",
|
|
23
|
+
mergeQueueEntry: null,
|
|
24
|
+
unresolvedThreads: 0,
|
|
25
|
+
...over,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const REOPEN: MergeProtocol = { ...DEFAULT_MERGE_PROTOCOL, freshHeadRun: "ready-or-reopen" };
|
|
30
|
+
|
|
31
|
+
function deps(behindBy: number, calls: string[], updateOk = true, clock = { t: 0 }): BehindBaseDeps {
|
|
32
|
+
return {
|
|
33
|
+
readBase: () => Promise.resolve("main"),
|
|
34
|
+
compare: () => Promise.resolve({ status: behindBy > 0 ? "diverged" : "ahead", aheadBy: 1, behindBy }),
|
|
35
|
+
updateBranch: (_r, _n, _t, sha) => {
|
|
36
|
+
calls.push(`update@${sha}`);
|
|
37
|
+
return Promise.resolve(updateOk);
|
|
38
|
+
},
|
|
39
|
+
now: () => clock.t,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("failing checks on a behind head, reopen-CI repo: update-branch only — never reopen the stale head (#951)", async () => {
|
|
44
|
+
_resetBehindBaseLatch();
|
|
45
|
+
const calls: string[] = [];
|
|
46
|
+
assertEquals(await maybeUpdateBehindBranch("o/r", 1, "t", st(), REOPEN, deps(3, calls)), true);
|
|
47
|
+
assertEquals(calls, ["update@head1"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("an accepted (async) update HOLDS the old head until it moves — stale failures never reach fix-ci (#951)", async () => {
|
|
51
|
+
_resetBehindBaseLatch();
|
|
52
|
+
const calls: string[] = [];
|
|
53
|
+
const clock = { t: 0 };
|
|
54
|
+
const d = deps(3, calls, true, clock);
|
|
55
|
+
assertEquals(await maybeUpdateBehindBranch("o/r", 1, "t", st(), REOPEN, d), true);
|
|
56
|
+
clock.t = 60_000; // next poll: GitHub has not applied the update yet
|
|
57
|
+
assertEquals(await maybeUpdateBehindBranch("o/r", 1, "t", st(), REOPEN, d), true);
|
|
58
|
+
assertEquals(calls, ["update@head1"]);
|
|
59
|
+
// The new head appears (up to date) and is still red → falls through to fix-ci.
|
|
60
|
+
assertEquals(await maybeUpdateBehindBranch("o/r", 1, "t", st({ headRefOid: "head2" }), REOPEN, deps(0, calls, true, clock)), false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a pending update that never applies stops holding after the bounded window (#951)", async () => {
|
|
64
|
+
_resetBehindBaseLatch();
|
|
65
|
+
const calls: string[] = [];
|
|
66
|
+
const clock = { t: 0 };
|
|
67
|
+
const d = deps(3, calls, true, clock);
|
|
68
|
+
assertEquals(await maybeUpdateBehindBranch("o/r", 1, "t", st(), REOPEN, d), true);
|
|
69
|
+
clock.t = BEHIND_BASE_PENDING_MS;
|
|
70
|
+
assertEquals(await maybeUpdateBehindBranch("o/r", 1, "t", st(), REOPEN, d), false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("failing checks on a behind head, plain repo: update-branch only (#951)", async () => {
|
|
74
|
+
_resetBehindBaseLatch();
|
|
75
|
+
const calls: string[] = [];
|
|
76
|
+
assertEquals(await maybeUpdateBehindBranch("o/r", 1, "t", st(), DEFAULT_MERGE_PROTOCOL, deps(2, calls)), true);
|
|
77
|
+
assertEquals(calls, ["update@head1"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("failing checks on an up-to-date head fall through to fix-ci (#951)", async () => {
|
|
81
|
+
_resetBehindBaseLatch();
|
|
82
|
+
const calls: string[] = [];
|
|
83
|
+
assertEquals(await maybeUpdateBehindBranch("o/r", 1, "t", st(), REOPEN, deps(0, calls)), false);
|
|
84
|
+
assertEquals(calls, []);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("green checks never update the branch (#951)", async () => {
|
|
88
|
+
_resetBehindBaseLatch();
|
|
89
|
+
const calls: string[] = [];
|
|
90
|
+
assertEquals(await maybeUpdateBehindBranch("o/r", 1, "t", st({ failingChecks: 0 }), REOPEN, deps(5, calls)), false);
|
|
91
|
+
assertEquals(calls, []);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("the remedy fires at most once per head (#951)", async () => {
|
|
95
|
+
_resetBehindBaseLatch();
|
|
96
|
+
const calls: string[] = [];
|
|
97
|
+
assertEquals(await maybeUpdateBehindBranch("o/r", 1, "t", st(), REOPEN, deps(3, calls, false)), false);
|
|
98
|
+
assertEquals(await maybeUpdateBehindBranch("o/r", 1, "t", st(), REOPEN, deps(3, calls)), false);
|
|
99
|
+
assertEquals(calls, ["update@head1"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// The merge-stall probe reads `isBehindBaseUpdatePending` to hold remediation on a stale head. It is
|
|
103
|
+
// true only while the SAME head is held inside the bounded window — a moved head or a lapsed window
|
|
104
|
+
// reads false, and it never mutates the pending state (the remedy still owns the lifecycle).
|
|
105
|
+
test("isBehindBaseUpdatePending holds the old head only within the bounded window (#951)", async () => {
|
|
106
|
+
_resetBehindBaseLatch();
|
|
107
|
+
const clock = { t: 0 };
|
|
108
|
+
assertEquals(await maybeUpdateBehindBranch("o/r", 1, "t", st(), REOPEN, deps(3, [], true, clock)), true);
|
|
109
|
+
// Same head, inside the window → pending. A different head, or a lapsed window → not pending.
|
|
110
|
+
assertEquals(isBehindBaseUpdatePending("o/r", 1, "head1", () => clock.t), true);
|
|
111
|
+
assertEquals(isBehindBaseUpdatePending("o/r", 1, "head2", () => clock.t), false);
|
|
112
|
+
assertEquals(isBehindBaseUpdatePending("o/r", 1, "head1", () => clock.t + BEHIND_BASE_PENDING_MS), false);
|
|
113
|
+
// A PR with no accepted update is never pending.
|
|
114
|
+
assertEquals(isBehindBaseUpdatePending("o/r", 999, "head1", () => clock.t), false);
|
|
115
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Behind-base remedy for failing required checks (issue #951).
|
|
2
|
+
//
|
|
3
|
+
// When a PR's required checks FAILED on a head that is BEHIND its base (e.g. CI infra was broken
|
|
4
|
+
// and has since been fixed on the base branch), the mechanical fix is to bring the branch up to
|
|
5
|
+
// date with its base — not to page a human or burn a fix-ci agent round on a defect that no longer
|
|
6
|
+
// exists. GitHub's update-branch is ASYNCHRONOUS (202 Accepted): the head only moves later. So an
|
|
7
|
+
// accepted update is tracked as PENDING and the old head is HELD (no fix-ci on its stale failures)
|
|
8
|
+
// until the poller observes the new head (or a bounded window lapses). The fresh-head CI run for a
|
|
9
|
+
// reopen-CI repo is then produced by the canonical frugal-CI remedy (`maybeEnsureFreshHeadRun`,
|
|
10
|
+
// once per head) when the NEW head reads as "no required run yet" — never by reopening the stale
|
|
11
|
+
// head here. Attempted at most ONCE per head: a still-red, up-to-date head falls through to fix-ci.
|
|
12
|
+
import { compareCommits, fetchPrBase, type PrState, updatePrBranch } from "./github.ts";
|
|
13
|
+
import type { MergeProtocol } from "./mergeProtocol.ts";
|
|
14
|
+
|
|
15
|
+
/** How long an accepted (async) update may leave the old head in place before we stop holding it. */
|
|
16
|
+
export const BEHIND_BASE_PENDING_MS = 10 * 60_000;
|
|
17
|
+
|
|
18
|
+
export interface BehindBaseDeps {
|
|
19
|
+
readBase: (repo: string, number: number, token: string) => Promise<string | null>;
|
|
20
|
+
compare: typeof compareCommits;
|
|
21
|
+
updateBranch: typeof updatePrBranch;
|
|
22
|
+
now: () => number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const realDeps: BehindBaseDeps = {
|
|
26
|
+
readBase: fetchPrBase,
|
|
27
|
+
compare: compareCommits,
|
|
28
|
+
updateBranch: updatePrBranch,
|
|
29
|
+
now: () => Date.now(),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Heads already remedied (`<repo>#<n>@<sha>`) — the once-per-head latch. In-process: a restart
|
|
33
|
+
* may retry once more, which is harmless (an up-to-date head compares `behindBy: 0`). */
|
|
34
|
+
const attempted = new Set<string>();
|
|
35
|
+
|
|
36
|
+
/** Accepted-but-not-yet-applied updates (`<repo>#<n>` → the old head + acceptance time). */
|
|
37
|
+
const pending = new Map<string, { oldHead: string; at: number }>();
|
|
38
|
+
|
|
39
|
+
/** Test-only: reset the once-per-head latch and pending updates. */
|
|
40
|
+
export function _resetBehindBaseLatch(): void {
|
|
41
|
+
attempted.clear();
|
|
42
|
+
pending.clear();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Is an accepted (async) update-branch still holding `headOid` as the old head? The merge-stall
|
|
46
|
+
* probe reads this so it does not classify the old head's stale check failures as `blocked` (and
|
|
47
|
+
* start fix-ci / escalate) while GitHub has accepted — but not yet applied — the branch update
|
|
48
|
+
* (issue #951 review). Bounded by the same BEHIND_BASE_PENDING_MS window the remedy uses, so a
|
|
49
|
+
* never-applied update stops holding once the window lapses. Pure read: never mutates the map. */
|
|
50
|
+
export function isBehindBaseUpdatePending(
|
|
51
|
+
repo: string,
|
|
52
|
+
number: number,
|
|
53
|
+
headOid: string,
|
|
54
|
+
now: () => number = () => Date.now(),
|
|
55
|
+
): boolean {
|
|
56
|
+
const inflight = pending.get(`${repo}#${number}`);
|
|
57
|
+
return inflight !== undefined && inflight.oldHead === headOid && now() - inflight.at < BEHIND_BASE_PENDING_MS;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** If `st` has failing checks on a head that is behind its base, update the branch from base.
|
|
61
|
+
* Returns `true` when an update was accepted OR is still pending on this (old) head — the caller
|
|
62
|
+
* should re-poll instead of dispatching fix-ci/escalating. Never throws. `_protocol` is accepted for
|
|
63
|
+
* call-site stability; the fresh-run nudge for the new head belongs to the frugal-CI remedy. */
|
|
64
|
+
export async function maybeUpdateBehindBranch(
|
|
65
|
+
repo: string,
|
|
66
|
+
number: number,
|
|
67
|
+
token: string,
|
|
68
|
+
st: PrState,
|
|
69
|
+
_protocol: MergeProtocol | null | undefined,
|
|
70
|
+
deps: BehindBaseDeps = realDeps,
|
|
71
|
+
): Promise<boolean> {
|
|
72
|
+
const prId = `${repo}#${number}`;
|
|
73
|
+
const inflight = pending.get(prId);
|
|
74
|
+
if (inflight) {
|
|
75
|
+
if (st.headRefOid === inflight.oldHead && deps.now() - inflight.at < BEHIND_BASE_PENDING_MS) return true;
|
|
76
|
+
pending.delete(prId); // the head moved (update applied) or the window lapsed — judge afresh
|
|
77
|
+
}
|
|
78
|
+
if (st.failingChecks <= 0 || !st.headRefOid || st.state !== "open") return false;
|
|
79
|
+
const latch = `${repo}#${number}@${st.headRefOid}`;
|
|
80
|
+
if (attempted.has(latch)) return false;
|
|
81
|
+
attempted.add(latch);
|
|
82
|
+
try {
|
|
83
|
+
const base = await deps.readBase(repo, number, token);
|
|
84
|
+
if (!base) return false;
|
|
85
|
+
const cmp = await deps.compare(repo, base, st.headRefOid, token);
|
|
86
|
+
if (!cmp || cmp.behindBy <= 0) return false;
|
|
87
|
+
const updated = await deps.updateBranch(repo, number, token, st.headRefOid);
|
|
88
|
+
if (!updated) return false;
|
|
89
|
+
pending.set(prId, { oldHead: st.headRefOid, at: deps.now() });
|
|
90
|
+
console.log(`[poller] failing checks on a head ${cmp.behindBy} behind '${base}' -> update-branch accepted (pending new head) ${repo}#${number}`);
|
|
91
|
+
return true;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
package/app/github.test.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// a COMPLETE file list or a thrown error — never a silently truncated one that under-approximates
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
4
|
import { test } from "node:test";
|
|
5
|
-
import { assertEquals, assertRejects } from "#test-assert";
|
|
6
|
-
import { BaseBranchMustExistError, checkConclusions, classifyBlockedCause, classifyMergeability, classifyPrLiveness, coalesceTitle, compareCommits, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, THREAD_PROBE_FAILED, updateBranchRef, type GhReview, type Mergeability, type PrState } from "./github.ts";
|
|
5
|
+
import { assert, assertEquals, assertRejects } from "#test-assert";
|
|
6
|
+
import { BaseBranchMustExistError, checkConclusions, hasCopilotWorkStarted, classifyBlockedCause, copilotWorkStartedSince, classifyMergeability, classifyPrLiveness, coalesceTitle, compareCommits, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, THREAD_PROBE_FAILED, updateBranchRef, type GhReview, type Mergeability, type PrState } from "./github.ts";
|
|
7
7
|
import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
|
|
8
8
|
|
|
9
9
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
@@ -1505,3 +1505,138 @@ test("updateBranchRef: CAS refuses (false) when the ref is absent/unreadable (nu
|
|
|
1505
1505
|
);
|
|
1506
1506
|
assertEquals(moved, false, "a deleted/unreadable base ref is not the validated state, so the move is refused");
|
|
1507
1507
|
});
|
|
1508
|
+
|
|
1509
|
+
// ── Mergify queue awaiting its enqueue comment is ready, not a "required review" (issue #951) ─────
|
|
1510
|
+
//
|
|
1511
|
+
// A `mergify-queue` repo (nano-bpm) reports a converged, green PR with Mergify's own check sitting at
|
|
1512
|
+
// NEUTRAL ("not queued yet") and GitHub may still report the merge BLOCKED on it. That is the PR
|
|
1513
|
+
// waiting for the declared land step (`@mergifyio queue`), not a human review requirement — so the
|
|
1514
|
+
// classifier must say `ready` (→ attempt-merge posts the queue comment), never `waiting` (→ stall
|
|
1515
|
+
// budget → the misleading "likely a required review approval" escalation).
|
|
1516
|
+
const MERGIFY_PROTOCOL = protocolWith({
|
|
1517
|
+
land: { method: "mergify-queue", comment: "@mergifyio queue" },
|
|
1518
|
+
requiredChecks: reqChecks("build"),
|
|
1519
|
+
waitForChecks: true,
|
|
1520
|
+
});
|
|
1521
|
+
const MERGIFY_ROLLUP = [
|
|
1522
|
+
{ name: "build", conclusion: "SUCCESS" },
|
|
1523
|
+
{ name: "Mergify Merge Queue", conclusion: "NEUTRAL" },
|
|
1524
|
+
];
|
|
1525
|
+
|
|
1526
|
+
for (const status of ["BLOCKED", "CLEAN"]) {
|
|
1527
|
+
test(`classifyMergeability: ${status} + NEUTRAL Mergify queue check + mergify-queue land → ready (#951)`, () => {
|
|
1528
|
+
const s = mergePrState({ mergeStateStatus: status, unresolvedThreads: 0, rollup: MERGIFY_ROLLUP });
|
|
1529
|
+
assertEquals(classifyMergeability(s, MERGIFY_PROTOCOL), "ready");
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
test("classifyMergeability: BLOCKED + NEUTRAL Mergify check on a gh-merge repo still waits (#951)", () => {
|
|
1534
|
+
const s = mergePrState({ mergeStateStatus: "BLOCKED", unresolvedThreads: 0, rollup: MERGIFY_ROLLUP });
|
|
1535
|
+
assertEquals(classifyMergeability(s, protocolWith({ requiredChecks: reqChecks("build") })), "waiting");
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
test("classifyMergeability: BLOCKED mergify-queue repo with NO Mergify queue check still waits (#951)", () => {
|
|
1539
|
+
const s = mergePrState({
|
|
1540
|
+
mergeStateStatus: "BLOCKED",
|
|
1541
|
+
unresolvedThreads: 0,
|
|
1542
|
+
rollup: [{ name: "build", conclusion: "SUCCESS" }],
|
|
1543
|
+
});
|
|
1544
|
+
assertEquals(classifyMergeability(s, MERGIFY_PROTOCOL), "waiting");
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
test("classifyMergeability: BLOCKED mergify-queue repo with another NEUTRAL check still waits (#951)", () => {
|
|
1548
|
+
const s = mergePrState({
|
|
1549
|
+
mergeStateStatus: "BLOCKED",
|
|
1550
|
+
unresolvedThreads: 0,
|
|
1551
|
+
rollup: [...MERGIFY_ROLLUP, { name: "lint", conclusion: "NEUTRAL" }],
|
|
1552
|
+
});
|
|
1553
|
+
assertEquals(classifyMergeability(s, MERGIFY_PROTOCOL), "waiting");
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
test("copilotWorkStartedSince: only a copilot_work_started at/after the request counts (#951)", () => {
|
|
1557
|
+
const since = "2026-11-11T10:00:00Z";
|
|
1558
|
+
assertEquals(copilotWorkStartedSince([{ event: "review_requested", created_at: "2026-11-11T10:00:05Z" }], since), false);
|
|
1559
|
+
assertEquals(copilotWorkStartedSince([{ event: "copilot_work_started", created_at: "2026-11-11T09:59:00Z" }], since), false);
|
|
1560
|
+
assertEquals(copilotWorkStartedSince([{ event: "copilot_work_started", created_at: "2026-11-11T10:00:30Z" }], since), true);
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
test("copilotWorkStartedSince: a start in the SAME second as a ms-precise request counts (#951)", () => {
|
|
1564
|
+
const since = "2026-11-11T10:00:00.750Z";
|
|
1565
|
+
assertEquals(copilotWorkStartedSince([{ event: "copilot_work_started", created_at: "2026-11-11T10:00:00Z" }], since), true);
|
|
1566
|
+
assertEquals(copilotWorkStartedSince([{ event: "copilot_work_started", created_at: "2026-11-11T09:59:59Z" }], since), false);
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
test("hasCopilotWorkStarted (token): paginates the timeline; a start on page 2 counts (#951)", async () => {
|
|
1570
|
+
const pages = [
|
|
1571
|
+
Array.from({ length: 100 }, () => ({ event: "commented", created_at: "2026-11-11T10:00:01Z" })),
|
|
1572
|
+
[{ event: "copilot_work_started", created_at: "2026-11-11T10:00:02Z" }],
|
|
1573
|
+
];
|
|
1574
|
+
const requests: string[] = [];
|
|
1575
|
+
// biome-ignore lint/plugin: test stub shares the review-page fetch shape
|
|
1576
|
+
const got = await withTokenFetch(reviewFetch(pages as unknown as GhReview[][], requests) as typeof fetch, () =>
|
|
1577
|
+
hasCopilotWorkStarted("o/r", 1, "t", "2026-11-11T10:00:00Z"),
|
|
1578
|
+
);
|
|
1579
|
+
assertEquals(got, true);
|
|
1580
|
+
assertEquals(requests.length, 2);
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
test("hasCopilotWorkStarted (token): a failed later page is UNKNOWN (null), not false (#951)", async () => {
|
|
1584
|
+
const fetchImpl = (url: string | URL | Request): Promise<Response> => {
|
|
1585
|
+
const page = Number(new URL(String(url)).searchParams.get("page") ?? "1");
|
|
1586
|
+
if (page === 1) {
|
|
1587
|
+
const headers = new Headers({ link: '<https://api.github.com/x?page=2>; rel="next"' });
|
|
1588
|
+
return Promise.resolve(new Response(JSON.stringify([{ event: "commented" }]), { status: 200, headers }));
|
|
1589
|
+
}
|
|
1590
|
+
return Promise.resolve(new Response("boom", { status: 502 }));
|
|
1591
|
+
};
|
|
1592
|
+
const got = await withTokenFetch(fetchImpl as typeof fetch, () => hasCopilotWorkStarted("o/r", 1, "t", "2026-11-11T10:00:00Z"));
|
|
1593
|
+
assertEquals(got, null);
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
// Issue #951 review: the verification caller has a fixed deadline, so the read must carry an abort
|
|
1597
|
+
// signal bounded by the remaining budget — a stalled read must not run unbounded and starve the
|
|
1598
|
+
// BPMN review-wait timer. Assert the token path passes a (timed) AbortSignal to fetch.
|
|
1599
|
+
test("hasCopilotWorkStarted (token): a timeout budget attaches an abort signal to the read (#951)", async () => {
|
|
1600
|
+
let sawSignal: AbortSignal | undefined;
|
|
1601
|
+
const fetchImpl = (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
1602
|
+
sawSignal = init?.signal ?? undefined;
|
|
1603
|
+
return Promise.resolve(new Response(JSON.stringify([{ event: "copilot_work_started", created_at: "2026-11-11T10:00:02Z" }]), { status: 200 }));
|
|
1604
|
+
};
|
|
1605
|
+
const got = await withTokenFetch(fetchImpl as typeof fetch, () =>
|
|
1606
|
+
hasCopilotWorkStarted("o/r", 1, "t", "2026-11-11T10:00:00Z", { timeoutMs: 5_000 }),
|
|
1607
|
+
);
|
|
1608
|
+
assertEquals(got, true);
|
|
1609
|
+
assertEquals(sawSignal instanceof AbortSignal, true);
|
|
1610
|
+
assertEquals(sawSignal?.aborted, false);
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
test("hasCopilotWorkStarted (token): ONE abort signal bounds every page of the walk (#951)", async () => {
|
|
1614
|
+
const signals: (AbortSignal | null | undefined)[] = [];
|
|
1615
|
+
const fetchImpl = (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
1616
|
+
signals.push(init?.signal);
|
|
1617
|
+
const more = signals.length < 3;
|
|
1618
|
+
return Promise.resolve(
|
|
1619
|
+
new Response(JSON.stringify([{ event: "commented", created_at: "2026-11-11T10:00:02Z" }]), {
|
|
1620
|
+
status: 200,
|
|
1621
|
+
headers: more ? { link: '<https://api.github.com/x?page=2>; rel="next"' } : {},
|
|
1622
|
+
}),
|
|
1623
|
+
);
|
|
1624
|
+
};
|
|
1625
|
+
await withTokenFetch(fetchImpl as typeof fetch, () =>
|
|
1626
|
+
hasCopilotWorkStarted("o/r", 1, "t", "2026-11-11T10:00:00Z", { timeoutMs: 5_000 }),
|
|
1627
|
+
);
|
|
1628
|
+
assertEquals(signals.length, 3);
|
|
1629
|
+
assert(signals[0] instanceof AbortSignal);
|
|
1630
|
+
assert(signals.every((s) => s === signals[0]), "every page must share the one budget signal");
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
test("hasCopilotWorkStarted (token): an aborted (timed-out) read is UNKNOWN (null), not false (#951)", async () => {
|
|
1634
|
+
const fetchImpl = (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
1635
|
+
// Simulate a read that exceeds the budget: reject with the abort reason.
|
|
1636
|
+
return Promise.reject(init?.signal?.reason ?? new Error("aborted"));
|
|
1637
|
+
};
|
|
1638
|
+
const got = await withTokenFetch(fetchImpl as typeof fetch, () =>
|
|
1639
|
+
hasCopilotWorkStarted("o/r", 1, "t", "2026-11-11T10:00:00Z", { timeoutMs: 1 }),
|
|
1640
|
+
);
|
|
1641
|
+
assertEquals(got, null);
|
|
1642
|
+
});
|
package/app/github.ts
CHANGED
|
@@ -716,7 +716,10 @@ export async function requestCopilotReview(
|
|
|
716
716
|
const path = `repos/${repo}/pulls/${number}/requested_reviewers`;
|
|
717
717
|
if (await useGh()) {
|
|
718
718
|
try {
|
|
719
|
-
|
|
719
|
+
// `gh pr edit --add-reviewer @copilot` (issue #951): the REST POST with the bot login can add a
|
|
720
|
+
// `review_requested` event yet never start Copilot (`copilot_work_started` never appears); the
|
|
721
|
+
// gh-native @copilot reviewer path is the one that reliably triggers the review.
|
|
722
|
+
await runGh(["pr", "edit", String(number), "--repo", repo, "--add-reviewer", "@copilot"]);
|
|
720
723
|
return "requested";
|
|
721
724
|
} catch (err) {
|
|
722
725
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -743,6 +746,73 @@ export async function requestCopilotReview(
|
|
|
743
746
|
throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
|
|
744
747
|
}
|
|
745
748
|
|
|
749
|
+
/** Whether Copilot has STARTED working on a review request since `sinceIso` — a
|
|
750
|
+
* `copilot_work_started` event on the PR's issue timeline (issue #951). A request that produced
|
|
751
|
+
* `review_requested` but never `copilot_work_started` was silently dropped. `null` when no transport
|
|
752
|
+
* is usable or the read failed (unknown — callers must not treat it as "dropped").
|
|
753
|
+
*
|
|
754
|
+
* `opts.timeoutMs` bounds the WHOLE read (issue #951 review): the verification caller has a fixed
|
|
755
|
+
* deadline, so a stalled `gh` or REST read must not run past it and starve the BPMN review-wait
|
|
756
|
+
* timer. The `gh` path passes it to `runGh`'s process `timeout`; the REST path creates ONE
|
|
757
|
+
* `AbortSignal.timeout` before pagination and shares it across every page, so the whole walk —
|
|
758
|
+
* not each page — is bounded by the budget. A timeout surfaces as `null` (unknown), never `false`. */
|
|
759
|
+
export async function hasCopilotWorkStarted(
|
|
760
|
+
repo: string,
|
|
761
|
+
number: number | string,
|
|
762
|
+
token: string,
|
|
763
|
+
sinceIso: string,
|
|
764
|
+
opts: { timeoutMs?: number } = {},
|
|
765
|
+
): Promise<boolean | null> {
|
|
766
|
+
const path = `repos/${repo}/issues/${number}/timeline?per_page=100`;
|
|
767
|
+
try {
|
|
768
|
+
let events: { event?: string; created_at?: string }[];
|
|
769
|
+
if (await useGh()) {
|
|
770
|
+
const out = await runGh(
|
|
771
|
+
["api", path, "--paginate", "--slurp", "-H", "Accept: application/vnd.github+json"],
|
|
772
|
+
opts.timeoutMs === undefined ? undefined : { timeoutMs: opts.timeoutMs },
|
|
773
|
+
);
|
|
774
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
775
|
+
events = (JSON.parse(out) as { event?: string; created_at?: string }[][]).flat();
|
|
776
|
+
} else {
|
|
777
|
+
if (!token) return null;
|
|
778
|
+
// Walk EVERY page (mirrors the `gh --paginate` path): a `copilot_work_started` beyond the first
|
|
779
|
+
// 100 events must not read as "never started". An incomplete read (non-OK page, or the page cap
|
|
780
|
+
// still advertising `rel="next"`) is UNKNOWN (`null`), never `false`.
|
|
781
|
+
const MAX_PAGES = 20;
|
|
782
|
+
const signal = opts.timeoutMs === undefined ? undefined : AbortSignal.timeout(opts.timeoutMs);
|
|
783
|
+
events = [];
|
|
784
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
785
|
+
const r = await fetch(`https://api.github.com/${path}&page=${page}`, {
|
|
786
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
787
|
+
...(signal === undefined ? {} : { signal }),
|
|
788
|
+
});
|
|
789
|
+
if (!r.ok) return null;
|
|
790
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
791
|
+
const batch = (await r.json()) as { event?: string; created_at?: string }[];
|
|
792
|
+
if (!Array.isArray(batch)) return null;
|
|
793
|
+
events.push(...batch);
|
|
794
|
+
const hasNext = /<[^>]*>;\s*rel="next"/.test(r.headers.get("link") ?? "");
|
|
795
|
+
if (!hasNext) break;
|
|
796
|
+
if (page === MAX_PAGES) return copilotWorkStartedSince(events, sinceIso) ? true : null;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return copilotWorkStartedSince(events, sinceIso);
|
|
800
|
+
} catch {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Pure: does the timeline carry a `copilot_work_started` at/after `sinceIso`? GitHub timeline
|
|
806
|
+
* timestamps have WHOLE-SECOND precision, so the lower bound is floored to the second — otherwise a
|
|
807
|
+
* start in the same second as a millisecond-precise request time would never count. */
|
|
808
|
+
export function copilotWorkStartedSince(events: { event?: string; created_at?: string }[], sinceIso: string): boolean {
|
|
809
|
+
const parsed = Date.parse(sinceIso);
|
|
810
|
+
const since = Number.isNaN(parsed) ? parsed : Math.floor(parsed / 1000) * 1000;
|
|
811
|
+
return events.some(
|
|
812
|
+
(e) => e.event === "copilot_work_started" && (Number.isNaN(since) || Date.parse(e.created_at ?? "") >= since),
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
746
816
|
/** Withdraw a pending Copilot review request (REST reviewers DELETE). Used by the merge loop's
|
|
747
817
|
* head-current review request (issue #939) when a prior request was silently ignored: Copilot stays
|
|
748
818
|
* listed as pending but never reviews, and a second POST is a no-op — removing and re-adding it is
|
|
@@ -1631,6 +1701,32 @@ function requiredChecksVerdict(s: PrState, protocol?: MergeProtocol): "blocked"
|
|
|
1631
1701
|
return "pass";
|
|
1632
1702
|
}
|
|
1633
1703
|
|
|
1704
|
+
/** True for Mergify's merge-queue check run (e.g. "Mergify Merge Queue" / "Mergify — Merge Queue"). */
|
|
1705
|
+
export function isMergifyQueueCheck(name: string): boolean {
|
|
1706
|
+
return /mergify/i.test(name) && /queue/i.test(name);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
const SETTLED_OK_CONCLUSIONS = new Set(["SUCCESS", "SKIPPED"]);
|
|
1710
|
+
|
|
1711
|
+
/** Issue #951: is this BLOCKED PR merely waiting for the repo's declared `mergify-queue` land step?
|
|
1712
|
+
* True only when the protocol lands via `mergify-queue`, checks are enumerable and settled (none
|
|
1713
|
+
* failing, none pending), and every non-SUCCESS/SKIPPED check is a Mergify queue check at NEUTRAL —
|
|
1714
|
+
* with at least one such check present. Anything else keeps the conservative `waiting`. */
|
|
1715
|
+
export function awaitingMergifyEnqueue(s: PrState, protocol?: MergeProtocol): boolean {
|
|
1716
|
+
if (protocol?.land.method !== "mergify-queue") return false;
|
|
1717
|
+
if (s.failingChecks !== 0 || s.pendingCheckNames.length > 0) return false;
|
|
1718
|
+
let sawMergifyNeutral = false;
|
|
1719
|
+
for (const [name, conclusion] of Object.entries(s.checkConclusions)) {
|
|
1720
|
+
if (SETTLED_OK_CONCLUSIONS.has(conclusion)) continue;
|
|
1721
|
+
if (conclusion === "NEUTRAL" && isMergifyQueueCheck(name)) {
|
|
1722
|
+
sawMergifyNeutral = true;
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
return false;
|
|
1726
|
+
}
|
|
1727
|
+
return sawMergifyNeutral;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1634
1730
|
export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Mergeability {
|
|
1635
1731
|
// A draft PR is NEVER landable — GitHub refuses the merge outright, whatever its checks say — so
|
|
1636
1732
|
// draft outranks every other signal (issue #454). Surface it as a first-class verdict rather than
|
|
@@ -1723,6 +1819,11 @@ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Merg
|
|
|
1723
1819
|
return "blocked";
|
|
1724
1820
|
case "review-threads":
|
|
1725
1821
|
return "review-threads";
|
|
1822
|
+
case "reviews":
|
|
1823
|
+
// Issue #951: on a `mergify-queue` repo a green, thread-free PR whose ONLY non-success
|
|
1824
|
+
// check is Mergify's own queue check at NEUTRAL ("not queued yet") is waiting for the
|
|
1825
|
+
// declared land step (`@mergifyio queue`), not a human review — land it.
|
|
1826
|
+
return awaitingMergifyEnqueue(s, protocol) ? "ready" : "waiting";
|
|
1726
1827
|
default:
|
|
1727
1828
|
return "waiting";
|
|
1728
1829
|
}
|
|
@@ -2321,3 +2422,38 @@ export async function ensurePromotionPr(
|
|
|
2321
2422
|
if (!created) return null;
|
|
2322
2423
|
return { ...created, created: true };
|
|
2323
2424
|
}
|
|
2425
|
+
|
|
2426
|
+
/** Bring a PR's head up to date with its base (GitHub's "Update branch" — `PUT
|
|
2427
|
+
* /pulls/{n}/update-branch`, the API behind `gh pr update-branch`). `expectedHeadSha` pins the head
|
|
2428
|
+
* the caller judged, so a concurrent push makes GitHub refuse (422) rather than merge base into a
|
|
2429
|
+
* head nobody inspected. Returns whether GitHub accepted the update; never throws (best-effort —
|
|
2430
|
+
* a refusal falls through to the caller's existing remediation). Issue #951. */
|
|
2431
|
+
export async function updatePrBranch(
|
|
2432
|
+
repo: string,
|
|
2433
|
+
number: number | string,
|
|
2434
|
+
token: string,
|
|
2435
|
+
expectedHeadSha: string | null,
|
|
2436
|
+
): Promise<boolean> {
|
|
2437
|
+
const apiPath = `repos/${repo}/pulls/${number}/update-branch`;
|
|
2438
|
+
try {
|
|
2439
|
+
if (await useGh()) {
|
|
2440
|
+
const args = ["api", "-X", "PUT", apiPath, "-H", "Accept: application/vnd.github+json"];
|
|
2441
|
+
if (expectedHeadSha) args.push("-f", `expected_head_sha=${expectedHeadSha}`);
|
|
2442
|
+
await runGh(args);
|
|
2443
|
+
return true;
|
|
2444
|
+
}
|
|
2445
|
+
if (!token) return false;
|
|
2446
|
+
const r = await fetch(`https://api.github.com/${apiPath}`, {
|
|
2447
|
+
method: "PUT",
|
|
2448
|
+
headers: {
|
|
2449
|
+
authorization: `Bearer ${token}`,
|
|
2450
|
+
accept: "application/vnd.github+json",
|
|
2451
|
+
"content-type": "application/json",
|
|
2452
|
+
},
|
|
2453
|
+
body: JSON.stringify(expectedHeadSha ? { expected_head_sha: expectedHeadSha } : {}),
|
|
2454
|
+
});
|
|
2455
|
+
return r.ok;
|
|
2456
|
+
} catch {
|
|
2457
|
+
return false;
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
@@ -523,6 +523,55 @@ test("the stall-round cap escalates to a human instead of looping forever (#636)
|
|
|
523
523
|
assert(!completedElementIds(engine).has("mark-merged"), "an exhausted stall must not mark-merged");
|
|
524
524
|
});
|
|
525
525
|
|
|
526
|
+
test("an exhausted stall budget re-polls (not escalates) while a behind-base update is pending (#951)", async () => {
|
|
527
|
+
// The probe reports `waiting` + `updatePending: true` (an accepted update-branch still exposes the
|
|
528
|
+
// old head). The remedy's own bounded hold owns liveness, so `gw-merge-stall` must re-poll rather
|
|
529
|
+
// than escalate even with `mergeStallMax` spent.
|
|
530
|
+
const engine = await boot({
|
|
531
|
+
vars: { mergeStallMax: 0 },
|
|
532
|
+
responses: {
|
|
533
|
+
"pr.merge-stall-probe": { mergeState: "waiting", updatePending: true, failingChecks: 0, failingChecksList: "" },
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
537
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
538
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable-repoll");
|
|
539
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "a pending update must not escalate");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("an exhausted stall budget still escalates an ordinary waiting verdict (#951)", async () => {
|
|
543
|
+
const engine = await boot({
|
|
544
|
+
vars: { mergeStallMax: 0 },
|
|
545
|
+
responses: {
|
|
546
|
+
"pr.merge-stall-probe": { mergeState: "waiting", updatePending: false, failingChecks: 0, failingChecksList: "" },
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
550
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
551
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("an exhausted stall budget whose probe re-reads a MERGED PR completes merged — never escalates (#951)", async () => {
|
|
555
|
+
// The probe re-reads PR state and surfaces `ready` for an already-merged PR (a human queued it while
|
|
556
|
+
// the loop waited). Even with the stall budget exhausted, `gw-merge-stall` must route a `ready`
|
|
557
|
+
// re-derivation to `attempt-merge` (whose already-merged short-circuit returns `merged`) rather
|
|
558
|
+
// than opening a "merge-blocked" escalation against a PR that has already landed.
|
|
559
|
+
const engine = await boot({
|
|
560
|
+
vars: { mergeStallMax: 0 },
|
|
561
|
+
responses: {
|
|
562
|
+
"pr.merge-stall-probe": { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
|
|
563
|
+
"pr.merge": { mergeStatus: "merged" },
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
567
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
568
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
569
|
+
.hasCompleted()
|
|
570
|
+
.hasNoIncident()
|
|
571
|
+
.hasCompletedElements("merge-stall-probe", "attempt-merge", "mark-merged");
|
|
572
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "a merged PR must never escalate");
|
|
573
|
+
});
|
|
574
|
+
|
|
526
575
|
// ---------------------------------------------------------------------------
|
|
527
576
|
// Async-UNKNOWN "waiting" verdict → bounded re-poll, NOT a human (issue #774)
|
|
528
577
|
// ---------------------------------------------------------------------------
|
package/app/service.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { matchAdjudication, prAdjudications, resetAdjudications } from "./adjudi
|
|
|
13
13
|
import { AGENT_SLA_JOB_TYPES, recordAgentJobObservation } from "./agentActivationLedger.ts";
|
|
14
14
|
import { completeEscalationAutoApplied, escalationFormId } from "./agentCompletion.ts";
|
|
15
15
|
import { AGENT_SLA_TIMEOUT } from "./agentSla.ts";
|
|
16
|
+
import { maybeUpdateBehindBranch } from "./behindBaseRemedy.ts";
|
|
16
17
|
import {
|
|
17
18
|
CAPS_RESOLVED_MESSAGE,
|
|
18
19
|
type CapabilityNeed,
|
|
@@ -2646,6 +2647,12 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
2646
2647
|
);
|
|
2647
2648
|
continue;
|
|
2648
2649
|
}
|
|
2650
|
+
if (verdict === "blocked" && (await maybeUpdateBehindBranch(repo, number, token, st, protocol))) {
|
|
2651
|
+
// Issue #951: the checks failed on a head BEHIND its base (e.g. a CI-infra fix since landed on
|
|
2652
|
+
// base) — update the branch (and reopen on a reopen-CI repo) and re-poll the fresh head
|
|
2653
|
+
// instead of dispatching fix-ci / escalating "failing required checks". Once per head.
|
|
2654
|
+
continue;
|
|
2655
|
+
}
|
|
2649
2656
|
if (verdict === "ready") {
|
|
2650
2657
|
const lane = await mergeLaneDecisionForPr(data, prKey);
|
|
2651
2658
|
if (lane?.isHeld) {
|
package/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.209.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.209.
|
|
5
|
-
"builtAt": "2026-11-
|
|
2
|
+
"version": "0.209.1",
|
|
3
|
+
"gitSha": "5b70c1733cd919fcc8f43ca118a863d3714030ea",
|
|
4
|
+
"gitTag": "v0.209.1",
|
|
5
|
+
"builtAt": "2026-11-12T07:16:21.905Z"
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.209.
|
|
3
|
+
"version": "0.209.1",
|
|
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",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
<nano:extend name="failingChecksList" type="string" optional="true" />
|
|
56
56
|
<nano:extend name="reviewThreads" type="integer" optional="true" />
|
|
57
57
|
<nano:extend name="pendingChecks" type="integer" optional="true" />
|
|
58
|
+
<nano:extend name="updatePending" type="boolean" optional="true" />
|
|
58
59
|
</nano:shape>
|
|
59
60
|
<nano:shape id="LandedProbeIn" name="Landed probe — input">
|
|
60
61
|
<nano:extend name="prKey" type="string" />
|
|
@@ -846,7 +847,7 @@
|
|
|
846
847
|
<bpmn:sequenceFlow id="f_mw_stall" sourceRef="wait-mergeable-timeout" targetRef="merge-stall-probe" />
|
|
847
848
|
<bpmn:sequenceFlow id="f_msp_gate" sourceRef="merge-stall-probe" targetRef="gw-merge-stall" />
|
|
848
849
|
<bpmn:sequenceFlow id="f_msp_rederive" name="re-derived — route to arm" sourceRef="gw-merge-stall" targetRef="gw-mergeable">
|
|
849
|
-
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStallRounds <= mergeStallMax</bpmn:conditionExpression>
|
|
850
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStallRounds <= mergeStallMax or mergeState = "ready" or updatePending = true</bpmn:conditionExpression>
|
|
850
851
|
</bpmn:sequenceFlow>
|
|
851
852
|
<bpmn:sequenceFlow id="f_msp_giveup" name="stall budget exhausted" sourceRef="gw-merge-stall" targetRef="merge-esc-conflict" />
|
|
852
853
|
<bpmn:sequenceFlow id="f_m_ready" sourceRef="wait-mergeable" targetRef="gw-mergeable" />
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// pr.merge-stall-probe — issue #951: the probe must re-read PR state before the merge loop can
|
|
2
|
+
// escalate. A PR that already MERGED (a human queued it while the loop waited) must surface `ready`
|
|
3
|
+
// so `gw-merge-stall` routes it to `attempt-merge` (whose already-merged short-circuit ends the loop
|
|
4
|
+
// as merged) instead of exhausting the stall budget into a "merge-blocked" escalation.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assertEquals } from "#test-assert";
|
|
7
|
+
import type { PrState } from "../../app/github.ts";
|
|
8
|
+
import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol } from "../../app/mergeProtocol.ts";
|
|
9
|
+
import { noopLog } from "../../test/log.ts";
|
|
10
|
+
import { makeHandler } from "./worker.ts";
|
|
11
|
+
|
|
12
|
+
function state(over: Partial<PrState>): PrState {
|
|
13
|
+
return {
|
|
14
|
+
merged: false,
|
|
15
|
+
state: "open",
|
|
16
|
+
mergeStateStatus: "BLOCKED",
|
|
17
|
+
failingChecks: 0,
|
|
18
|
+
failingCheckNames: [],
|
|
19
|
+
presentCheckNames: ["build", "Mergify Merge Queue"],
|
|
20
|
+
pendingCheckNames: [],
|
|
21
|
+
checkConclusions: { build: "SUCCESS", "Mergify Merge Queue": "NEUTRAL" },
|
|
22
|
+
totalChecks: 2,
|
|
23
|
+
isDraft: false,
|
|
24
|
+
headRefOid: "abc",
|
|
25
|
+
mergeQueueEntry: null,
|
|
26
|
+
unresolvedThreads: 0,
|
|
27
|
+
...over,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MERGIFY: MergeProtocol = {
|
|
32
|
+
...DEFAULT_MERGE_PROTOCOL,
|
|
33
|
+
land: { method: "mergify-queue", comment: "@mergifyio queue" },
|
|
34
|
+
requiredChecks: [{ name: "build", acceptedConclusions: ["success"] }],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
async function run(st: PrState, protocol: MergeProtocol | null, isUpdatePending = false): Promise<Record<string, unknown>> {
|
|
38
|
+
const handler = makeHandler({
|
|
39
|
+
readState: () => Promise.resolve(st),
|
|
40
|
+
readProtocol: () => Promise.resolve(protocol),
|
|
41
|
+
isUpdatePending: () => isUpdatePending,
|
|
42
|
+
});
|
|
43
|
+
const job: any = { variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } };
|
|
44
|
+
const app: any = { log: noopLog() };
|
|
45
|
+
return (await handler(job, app)) as Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function runNullRead(isUpdatePending = false): Promise<Record<string, unknown>> {
|
|
49
|
+
const handler = makeHandler({
|
|
50
|
+
readState: () => Promise.reject(new Error("GitHub unreachable")),
|
|
51
|
+
readProtocol: () => Promise.resolve(null),
|
|
52
|
+
isUpdatePending: () => isUpdatePending,
|
|
53
|
+
});
|
|
54
|
+
const job: any = { variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } };
|
|
55
|
+
const app: any = { log: noopLog() };
|
|
56
|
+
return (await handler(job, app)) as Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("merge-stall-probe: a PR already MERGED surfaces `ready` (never an escalation) (#951)", async () => {
|
|
60
|
+
const out = await run(state({ merged: true, state: "merged", mergeStateStatus: "UNKNOWN" }), MERGIFY);
|
|
61
|
+
assertEquals(out.mergeState, "ready");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("merge-stall-probe: a CLOSED PR surfaces `ready` so attempt-merge abandons it (#951)", async () => {
|
|
65
|
+
const out = await run(state({ state: "closed", mergeStateStatus: "UNKNOWN" }), null);
|
|
66
|
+
assertEquals(out.mergeState, "ready");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("merge-stall-probe: green PR awaiting `@mergifyio queue` is `ready`, not a review stall (#951)", async () => {
|
|
70
|
+
const out = await run(state({}), MERGIFY);
|
|
71
|
+
assertEquals(out.mergeState, "ready");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("merge-stall-probe: BLOCKED gh-merge PR with green checks still waits (#951)", async () => {
|
|
75
|
+
const out = await run(state({}), { ...MERGIFY, land: { method: "gh-merge" } });
|
|
76
|
+
assertEquals(out.mergeState, "waiting");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Issue #951 review: while an accepted (async) update-branch still exposes the old head, the probe
|
|
80
|
+
// must NOT classify that head's stale check failures as `blocked` (which starts fix-ci / escalates).
|
|
81
|
+
// It defers to `waiting` (bounded re-poll) until the head moves or the remedy's window lapses.
|
|
82
|
+
test("merge-stall-probe: a blocked verdict on a pending-update old head defers to waiting (#951)", async () => {
|
|
83
|
+
const blocked = state({ failingChecks: 1, failingCheckNames: ["build"], checkConclusions: { build: "FAILURE" } });
|
|
84
|
+
// Sanity: without a pending update this IS blocked (would route to fix-ci).
|
|
85
|
+
assertEquals((await run(blocked, MERGIFY, false)).mergeState, "blocked");
|
|
86
|
+
// With the update still pending on this head, the probe holds remediation.
|
|
87
|
+
const held = await run(blocked, MERGIFY, true);
|
|
88
|
+
assertEquals(held.mergeState, "waiting");
|
|
89
|
+
// The hold is surfaced so gw-merge-stall re-polls past the stall cap (only for this hold).
|
|
90
|
+
assertEquals(held.updatePending, true);
|
|
91
|
+
assertEquals((await run(blocked, MERGIFY, false)).updatePending, false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("merge-stall-probe: a non-blocked verdict is never downgraded by a pending update (#951)", async () => {
|
|
95
|
+
// A ready PR stays ready even if (spuriously) an update is pending — the hold only masks `blocked`.
|
|
96
|
+
const out = await run(state({}), MERGIFY, true);
|
|
97
|
+
assertEquals(out.mergeState, "ready");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// PR #953 review: a probe that emits `updatePending: true` is followed (next re-poll) by a read that
|
|
101
|
+
// does NOT recompute the hold — a failed/null state read, or a terminal (merged/closed) liveness
|
|
102
|
+
// short-circuit. Those paths must explicitly clear the flag: an omitted field would leave the stale
|
|
103
|
+
// `true` standing, and `gw-merge-stall` would keep bypassing the stall cap on it even after the
|
|
104
|
+
// remedy's bounded hold expired — a GitHub outage would turn the bounded hold into an indefinite
|
|
105
|
+
// re-poll.
|
|
106
|
+
test("merge-stall-probe: a failed/null state read clears updatePending (no stale cap bypass) (#953)", async () => {
|
|
107
|
+
const out = await runNullRead(true);
|
|
108
|
+
assertEquals(out.mergeState, "waiting");
|
|
109
|
+
assertEquals(out.updatePending, false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("merge-stall-probe: a terminal (merged/closed) read clears updatePending (#953)", async () => {
|
|
113
|
+
const merged = await run(state({ merged: true, state: "merged", mergeStateStatus: "UNKNOWN" }), MERGIFY, true);
|
|
114
|
+
assertEquals(merged.mergeState, "ready");
|
|
115
|
+
assertEquals(merged.updatePending, false);
|
|
116
|
+
const closed = await run(state({ state: "closed", mergeStateStatus: "UNKNOWN" }), null, true);
|
|
117
|
+
assertEquals(closed.mergeState, "ready");
|
|
118
|
+
assertEquals(closed.updatePending, false);
|
|
119
|
+
});
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
// (incremented by this task's output mapping, capped by `mergeStallMax`) stops the probe from
|
|
18
18
|
// re-arming forever: once exhausted, `gw-merge-stall` routes to human escalation instead.
|
|
19
19
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { isBehindBaseUpdatePending } from "../../app/behindBaseRemedy.ts";
|
|
21
|
+
import { classifyMergeability, classifyPrLiveness, fetchPrState, type PrState } from "../../app/github.ts";
|
|
22
|
+
import { loadMergeProtocol, type MergeProtocol } from "../../app/mergeProtocol.ts";
|
|
22
23
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
23
24
|
|
|
24
25
|
// Input typed off the model data envelope (`MergeStallProbeIn` in merge-loop.bpmn) — ADR 0040.
|
|
@@ -43,7 +44,25 @@ interface Out extends Record<string, unknown> {
|
|
|
43
44
|
pendingChecks: number;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
/** Injectable GitHub readers (tests stub them); the default export binds the real ones. */
|
|
48
|
+
export interface StallProbeDeps {
|
|
49
|
+
readState: (repo: string, prNumber: number, token: string) => Promise<PrState | null>;
|
|
50
|
+
readProtocol: (repo: string, token: string) => Promise<MergeProtocol | null>;
|
|
51
|
+
/** Issue #951 review: is an accepted (async) update-branch still holding this head? While it is,
|
|
52
|
+
* the old head's stale check failures must NOT classify as `blocked` (which would start fix-ci /
|
|
53
|
+
* escalate) — the branch update is in flight and will move the head. Bounded by the remedy's own
|
|
54
|
+
* window, so a never-applied update stops holding. */
|
|
55
|
+
isUpdatePending: (repo: string, prNumber: number, headOid: string) => boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const realDeps: StallProbeDeps = {
|
|
59
|
+
readState: (repo, prNumber, token) => fetchPrState(repo, prNumber, token, { withReviewThreads: true }),
|
|
60
|
+
readProtocol: (repo, token) => loadMergeProtocol(repo, token),
|
|
61
|
+
isUpdatePending: (repo, prNumber, headOid) => isBehindBaseUpdatePending(repo, prNumber, headOid),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function makeHandler(deps: StallProbeDeps): AppJobHandler<In, Out> {
|
|
65
|
+
return async (job, app) => {
|
|
47
66
|
const { repo, prNumber } = job.variables;
|
|
48
67
|
const token = process.env.GITHUB_TOKEN ?? "";
|
|
49
68
|
|
|
@@ -57,12 +76,50 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
57
76
|
// classified by its real cause (issue #921) — unresolved review threads surface as the
|
|
58
77
|
// `review-threads` verdict and route to their own arm instead of re-polling until the budget
|
|
59
78
|
// exhausts into the misleading "mergeability never settled" escalation.
|
|
60
|
-
const st = await
|
|
79
|
+
const st = await deps.readState(repo, prNumber, token).catch(() => null);
|
|
61
80
|
if (st === null) {
|
|
62
|
-
|
|
81
|
+
// Explicitly clear `updatePending` (PR #953 review): a failed/null read returns `waiting`
|
|
82
|
+
// WITHOUT recomputing the hold, and an omitted field would leave any earlier `true` standing —
|
|
83
|
+
// `gw-merge-stall` would then keep bypassing the stall cap on the stale flag even after the
|
|
84
|
+
// remedy's bounded hold expired. Returning `false` keeps a GitHub outage from turning the
|
|
85
|
+
// bounded hold into an indefinite re-poll.
|
|
86
|
+
return { mergeState: "waiting", updatePending: false, failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 };
|
|
87
|
+
}
|
|
88
|
+
// Issue #951: re-read PR liveness BEFORE any verdict. A PR that already MERGED (e.g. a human
|
|
89
|
+
// queued it while the loop waited) or was CLOSED must never reach an escalation: surface `ready`,
|
|
90
|
+
// which `gw-merge-stall` always routes (even past the stall budget) to `attempt-merge`, whose
|
|
91
|
+
// already-merged / closed short-circuits finish the loop as merged / abandoned.
|
|
92
|
+
const liveness = classifyPrLiveness(st);
|
|
93
|
+
if (liveness === "merged" || liveness === "closed") {
|
|
94
|
+
app.log.info("merge-stall-probe: PR already terminal — routing to attempt-merge short-circuit", {
|
|
95
|
+
prKey: job.variables.prKey,
|
|
96
|
+
liveness,
|
|
97
|
+
});
|
|
98
|
+
// `updatePending: false` here too: a terminal PR short-circuits the merge, so a stale hold
|
|
99
|
+
// from an earlier round must not survive into `gw-merge-stall`'s bypass-cap condition.
|
|
100
|
+
return { mergeState: "ready", updatePending: false, failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 0 };
|
|
101
|
+
}
|
|
102
|
+
const protocol = await deps.readProtocol(repo, token).catch(() => null);
|
|
103
|
+
let mergeState = classifyMergeability(st, protocol ?? undefined);
|
|
104
|
+
|
|
105
|
+
// Issue #951 review: a `blocked` verdict on the OLD head while an accepted update-branch is still
|
|
106
|
+
// pending is a stale-read, not a real block — the poller's behind-base remedy is holding that head
|
|
107
|
+
// (no fix-ci on its stale failures) until GitHub applies the update and the head moves. Mirroring
|
|
108
|
+
// that hold here, the probe downgrades the verdict to `waiting` so `gw-mergeable` routes to the
|
|
109
|
+
// bounded re-poll (`wait-mergeable-repoll`) instead of dispatching fix-ci / escalating against a
|
|
110
|
+
// head that is about to change. The hold shares the remedy's bounded window, so a never-applied
|
|
111
|
+
// update stops holding and the next probe judges the (still-red) head afresh.
|
|
112
|
+
// `updatePending` is surfaced so `gw-merge-stall` keeps re-polling inside the remedy's bounded hold
|
|
113
|
+
// window even once `mergeStallMax` is spent (an ordinary `waiting` still respects the stall cap).
|
|
114
|
+
let updatePending = false;
|
|
115
|
+
if (mergeState === "blocked" && st.headRefOid && deps.isUpdatePending(repo, prNumber, st.headRefOid)) {
|
|
116
|
+
updatePending = true;
|
|
117
|
+
app.log.info("merge-stall-probe: behind-base update pending — deferring remediation until the head moves", {
|
|
118
|
+
prKey: job.variables.prKey,
|
|
119
|
+
head: st.headRefOid,
|
|
120
|
+
});
|
|
121
|
+
mergeState = "waiting";
|
|
63
122
|
}
|
|
64
|
-
const protocol = await loadMergeProtocol(repo, token).catch(() => null);
|
|
65
|
-
const mergeState = classifyMergeability(st, protocol ?? undefined);
|
|
66
123
|
|
|
67
124
|
app.log.info("merge-stall-probe: poller stalled — re-derived mergeability", {
|
|
68
125
|
prKey: job.variables.prKey,
|
|
@@ -72,6 +129,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
72
129
|
|
|
73
130
|
return {
|
|
74
131
|
mergeState,
|
|
132
|
+
updatePending,
|
|
75
133
|
// Carried for the CI-fix arm (mergeState "blocked" = a failed required check), mirroring the
|
|
76
134
|
// poller's `merge-ready` payload so fix-ci knows which gates to green.
|
|
77
135
|
failingChecks: st.failingChecks,
|
|
@@ -79,6 +137,9 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
79
137
|
reviewThreads: st.unresolvedThreads,
|
|
80
138
|
pendingChecks: st.pendingCheckNames.length,
|
|
81
139
|
};
|
|
82
|
-
};
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const handler: AppJobHandler<In, Out> = makeHandler(realDeps);
|
|
83
144
|
|
|
84
145
|
export default handler;
|
|
@@ -21,6 +21,7 @@ function ops(over: Partial<ReviewRequestOps>, calls: string[]): ReviewRequestOps
|
|
|
21
21
|
hasPending: async () => (calls.push("pending?"), false),
|
|
22
22
|
remove: async () => calls.push("remove"),
|
|
23
23
|
request: async () => (calls.push("request"), "requested"),
|
|
24
|
+
awaitStarted: async () => true,
|
|
24
25
|
...over,
|
|
25
26
|
};
|
|
26
27
|
}
|
|
@@ -52,3 +53,29 @@ test("Copilot not assignable reports unavailable; a transport error reports fail
|
|
|
52
53
|
);
|
|
53
54
|
assertEquals(await makeHandler(ops({ request: async () => null }, []))(job(0), app), { reviewRequest: "failed" });
|
|
54
55
|
});
|
|
56
|
+
|
|
57
|
+
// Issue #951: a request that produced `review_requested` but never `copilot_work_started` is stuck —
|
|
58
|
+
// verify via the timeline and withdraw + retry ONCE before leaving it to the bounded wait.
|
|
59
|
+
test("a request that never starts Copilot is withdrawn and retried once (#951)", async () => {
|
|
60
|
+
const calls: string[] = [];
|
|
61
|
+
const { app } = fakeApp();
|
|
62
|
+
const out = await makeHandler(
|
|
63
|
+
ops({ awaitStarted: async () => (calls.push("started?"), false) }, calls),
|
|
64
|
+
)(job(0), app);
|
|
65
|
+
assertEquals(out, { reviewRequest: "requested" });
|
|
66
|
+
assertEquals(calls, ["request", "started?", "remove", "request", "started?"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a request that starts Copilot is not retried (#951)", async () => {
|
|
70
|
+
const calls: string[] = [];
|
|
71
|
+
const { app } = fakeApp();
|
|
72
|
+
await makeHandler(ops({ awaitStarted: async () => (calls.push("started?"), true) }, calls))(job(0), app);
|
|
73
|
+
assertEquals(calls, ["request", "started?"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an unverifiable timeline (null) is not treated as a dropped request (#951)", async () => {
|
|
77
|
+
const calls: string[] = [];
|
|
78
|
+
const { app } = fakeApp();
|
|
79
|
+
await makeHandler(ops({ awaitStarted: async () => (calls.push("started?"), null) }, calls))(job(0), app);
|
|
80
|
+
assertEquals(calls, ["request", "started?"]);
|
|
81
|
+
});
|
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
// second POST is then a no-op. On a re-request (round > 0) with Copilot still pending, withdraw the
|
|
12
12
|
// request first and re-add it — the equivalent that re-triggers the review.
|
|
13
13
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
hasCopilotWorkStarted,
|
|
16
|
+
hasPendingCopilotReviewer,
|
|
17
|
+
removeCopilotReviewer,
|
|
18
|
+
requestCopilotReview,
|
|
19
|
+
} from "../../app/github.ts";
|
|
15
20
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
16
21
|
|
|
17
22
|
// Input typed off the model data envelope (`RequestReviewIn` in merge-loop.bpmn) — ADR 0040.
|
|
@@ -28,6 +33,29 @@ export interface ReviewRequestOps {
|
|
|
28
33
|
hasPending: (repo: string, prNumber: number) => Promise<boolean | null>;
|
|
29
34
|
remove: (repo: string, prNumber: number) => Promise<unknown>;
|
|
30
35
|
request: (repo: string, prNumber: number) => Promise<"requested" | "unavailable" | null>;
|
|
36
|
+
/** Issue #951: poll the PR timeline until Copilot STARTS the requested review
|
|
37
|
+
* (`copilot_work_started` at/after `sinceIso`) or the verify window (~2 min) elapses. `true` =
|
|
38
|
+
* started, `false` = the request was silently dropped, `null` = unverifiable (treated as started). */
|
|
39
|
+
awaitStarted: (repo: string, prNumber: number, sinceIso: string) => Promise<boolean | null>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const VERIFY_WINDOW_MS = 120_000;
|
|
43
|
+
const VERIFY_POLL_MS = 15_000;
|
|
44
|
+
|
|
45
|
+
async function pollWorkStarted(repo: string, n: number, sinceIso: string): Promise<boolean | null> {
|
|
46
|
+
const deadline = Date.now() + VERIFY_WINDOW_MS;
|
|
47
|
+
for (;;) {
|
|
48
|
+
// Bound each read by the REMAINING verification budget (issue #951 review): the deadline is
|
|
49
|
+
// otherwise checked only AFTER the read returns, so a stalled `gh`/REST read could run past it
|
|
50
|
+
// and starve the BPMN review-wait timer. A read that exhausts its budget returns null (unknown)
|
|
51
|
+
// — surfaced as "started" (no spurious retry), and the loop stops because the deadline is met.
|
|
52
|
+
const remaining = deadline - Date.now();
|
|
53
|
+
if (remaining <= 0) return false;
|
|
54
|
+
const started = await hasCopilotWorkStarted(repo, n, token(), sinceIso, { timeoutMs: remaining });
|
|
55
|
+
if (started !== false) return started;
|
|
56
|
+
if (Date.now() >= deadline) return false;
|
|
57
|
+
await new Promise((r) => setTimeout(r, Math.min(VERIFY_POLL_MS, deadline - Date.now())));
|
|
58
|
+
}
|
|
31
59
|
}
|
|
32
60
|
|
|
33
61
|
const token = () => process.env.GITHUB_TOKEN ?? "";
|
|
@@ -35,6 +63,7 @@ const defaultOps: ReviewRequestOps = {
|
|
|
35
63
|
hasPending: (repo, n) => hasPendingCopilotReviewer(repo, n, token()),
|
|
36
64
|
remove: (repo, n) => removeCopilotReviewer(repo, n, token()),
|
|
37
65
|
request: (repo, n) => requestCopilotReview(repo, n, token()),
|
|
66
|
+
awaitStarted: pollWorkStarted,
|
|
38
67
|
};
|
|
39
68
|
|
|
40
69
|
export function makeHandler(ops: ReviewRequestOps): AppJobHandler<In, Out> {
|
|
@@ -47,7 +76,18 @@ export function makeHandler(ops: ReviewRequestOps): AppJobHandler<In, Out> {
|
|
|
47
76
|
if (round > 0 && (await ops.hasPending(repo, prNumber)) === true) {
|
|
48
77
|
await ops.remove(repo, prNumber);
|
|
49
78
|
}
|
|
50
|
-
|
|
79
|
+
let since = new Date().toISOString();
|
|
80
|
+
let res = await ops.request(repo, prNumber);
|
|
81
|
+
// Issue #951: verify the request actually STARTED a review. A request that only produced
|
|
82
|
+
// `review_requested` (no `copilot_work_started`) is stuck — withdraw it and retry ONCE before
|
|
83
|
+
// leaving it to the bounded wait (and, past budget, the escalation).
|
|
84
|
+
if (res === "requested" && (await ops.awaitStarted(repo, prNumber, since)) === false) {
|
|
85
|
+
app.log.warn(`request-review: ${prKey} Copilot review request did not start — withdrawing and retrying once`);
|
|
86
|
+
await ops.remove(repo, prNumber);
|
|
87
|
+
since = new Date().toISOString();
|
|
88
|
+
res = await ops.request(repo, prNumber);
|
|
89
|
+
if (res === "requested") await ops.awaitStarted(repo, prNumber, since);
|
|
90
|
+
}
|
|
51
91
|
result = res === "requested" ? "requested" : res === "unavailable" ? "unavailable" : "failed";
|
|
52
92
|
} catch (err) {
|
|
53
93
|
app.log.warn(`request-review: ${prKey} Copilot review request failed: ${err}`);
|