@nanobpm/nano-workforce 0.209.0 → 0.210.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +11 -1
  3. package/app/behindBaseRemedy.test.ts +115 -0
  4. package/app/behindBaseRemedy.ts +95 -0
  5. package/app/convergeAdversarial.test.ts +152 -0
  6. package/app/feature.test.ts +31 -0
  7. package/app/feature.ts +12 -0
  8. package/app/github.test.ts +137 -2
  9. package/app/github.ts +137 -1
  10. package/app/mergeLoopBehaviour.test.ts +97 -0
  11. package/app/pullRequestReadModel.test.ts +1 -1
  12. package/app/service.test.ts +125 -1
  13. package/app/service.ts +70 -1
  14. package/build-info.json +4 -4
  15. package/db/migrations/134_pull_requests_custom_instructions.sql +8 -0
  16. package/db/migrations/135_pull_requests_read_model_custom_instructions.sql +52 -0
  17. package/db/migrations/136_pull_requests_custom_instructions_text.sql +11 -0
  18. package/db/migrations/137_feature_runs_custom_instructions.sql +66 -0
  19. package/db/migrations/138_pull_requests_read_model_custom_instructions_text.sql +53 -0
  20. package/docs/agent-guide.md +1 -0
  21. package/openapi.yaml +24 -0
  22. package/operations/listActivePrs.test.ts +17 -0
  23. package/operations/startAndMessage.test.ts +80 -0
  24. package/operations/startConvergenceLoop.ts +13 -1
  25. package/package.json +1 -1
  26. package/pages/home.page.json +7 -2
  27. package/resources/processes/convergence-loop.bpmn +8 -2
  28. package/resources/processes/feature.bpmn +1 -0
  29. package/resources/processes/merge-loop.bpmn +8 -3
  30. package/workers/converge-feature/worker.ts +10 -1
  31. package/workers/merge-stall-probe/worker.test.ts +119 -0
  32. package/workers/merge-stall-probe/worker.ts +69 -8
  33. package/workers/reconverge/worker.test.ts +50 -0
  34. package/workers/reconverge/worker.ts +7 -0
  35. package/workers/request-review/worker.test.ts +27 -0
  36. package/workers/request-review/worker.ts +42 -2
@@ -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 { classifyMergeability, fetchPrState } from "../../app/github.ts";
21
- import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
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
- const handler: AppJobHandler<In, Out> = async (job, app) => {
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 fetchPrState(repo, prNumber, token, { withReviewThreads: true }).catch(() => null);
79
+ const st = await deps.readState(repo, prNumber, token).catch(() => null);
61
80
  if (st === null) {
62
- return { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 };
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;
@@ -108,3 +108,53 @@ test("a head read that resolves no SHA degrades to the checkpoint restore (no ov
108
108
  await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
109
109
  assertEquals(args[11], null, "no live head SHA → no override (checkpoint restore, the pre-fix behaviour)");
110
110
  });
111
+
112
+ // Issue #956 review (Copilot thread 4547353262): a merge-stage reconverge re-enters `submitPr`
113
+ // WITHOUT the run's operator custom instructions, so the replacement convergence loop runs
114
+ // unsteered (and `submitPr`'s run-scoped reopen resets `custom_instructions` to 0). The worker must
115
+ // read the durable text off the PR row (`custom_instructions_text`, stamped by `submitPr`) and pass
116
+ // it through, so convergence → merge → reconvergence keeps the steering.
117
+ test("preserves the run's custom instructions across the merge→reconvergence handoff (#956)", async () => {
118
+ const app = {
119
+ data: {
120
+ table: (name: string) => ({
121
+ get: async () =>
122
+ name === "pull_requests"
123
+ ? { auto_resolve_escalations: 1, custom_instructions_text: " commit as josh.wulf@camunda.com " }
124
+ : undefined,
125
+ insert: async () => 1,
126
+ }),
127
+ },
128
+ engine: {},
129
+ log: noopLog(),
130
+ } as any;
131
+ let args: unknown[] = [];
132
+ const submit = (async (...a: unknown[]) => {
133
+ args = a;
134
+ return { prKey: "acme/w#7", processKey: "999" };
135
+ }) as unknown as Submit;
136
+ const head = (async () => null) as unknown as FetchPrHead;
137
+ await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
138
+ assertEquals(args[12], " commit as josh.wulf@camunda.com ", "the durable instruction text rides the handoff into the fresh convergence run");
139
+ });
140
+
141
+ test("a PR row with no stored instructions hands off with null (no steering)", async () => {
142
+ const app = {
143
+ data: {
144
+ table: (name: string) => ({
145
+ get: async () => (name === "pull_requests" ? { auto_resolve_escalations: 0, custom_instructions_text: null } : undefined),
146
+ insert: async () => 1,
147
+ }),
148
+ },
149
+ engine: {},
150
+ log: noopLog(),
151
+ } as any;
152
+ let args: unknown[] = [];
153
+ const submit = (async (...a: unknown[]) => {
154
+ args = a;
155
+ return { prKey: "acme/w#7", processKey: "999" };
156
+ }) as unknown as Submit;
157
+ const head = (async () => null) as unknown as FetchPrHead;
158
+ await makeHandler(submit, head)({ processInstanceKey: "4242", variables: { prKey: "acme/w#7" } } as any, app);
159
+ assertEquals(args[12], null, "no stored text → null (the fresh run's prompts stay byte-identical to no-instructions)");
160
+ });
@@ -27,6 +27,12 @@ export function makeHandler(submit: Submit, fetchHead: FetchPrHead = fetchPrHead
27
27
  const row = await app.data.table("pull_requests", "pr_key").get(prKey);
28
28
  // biome-ignore lint/plugin: runtime/framework contract boundary for the untyped row shape
29
29
  const optIn = (row as { auto_resolve_escalations?: number } | undefined)?.auto_resolve_escalations === 1;
30
+ // Operator steering (issue #956 review): re-supply the run's durable instruction text so the
31
+ // merge→reconvergence handoff never starts an unsteered replacement loop (and never resets the
32
+ // `custom_instructions` projection). Read the SAME `pull_requests` row the opt-in came from.
33
+ // biome-ignore lint/plugin: runtime/framework contract boundary for the untyped row shape
34
+ const rowInstructions = (row as { custom_instructions_text?: string | null } | undefined)?.custom_instructions_text;
35
+ const customInstructions = typeof rowInstructions === "string" && rowInstructions.trim() !== "" ? rowInstructions : null;
30
36
  await app.data.table("merges", "id").insert({
31
37
  pr_key: prKey,
32
38
  outcome: "reconverge",
@@ -63,6 +69,7 @@ export function makeHandler(submit: Submit, fetchHead: FetchPrHead = fetchPrHead
63
69
  true,
64
70
  job.processInstanceKey != null ? String(job.processInstanceKey) : null,
65
71
  headSha,
72
+ customInstructions,
66
73
  );
67
74
  // `submitPr` can return `processKey: null` after rewriting the row to `converging` with no owner
68
75
  // (the create-instance failure window). Completing HERE would end the merge loop at
@@ -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 { hasPendingCopilotReviewer, removeCopilotReviewer, requestCopilotReview } from "../../app/github.ts";
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
- const res = await ops.request(repo, prNumber);
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}`);