@nanobpm/nano-workforce 0.205.1 → 0.206.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.
@@ -10,12 +10,32 @@
10
10
  // waiting for the queue, NOT a direct `gh pr merge` — which that repo refuses. The actual gh/API
11
11
  // calls live in app/github.ts; this worker records the attempt in the `merges` audit table and
12
12
  // shapes the escalation payload on a block.
13
+ //
14
+ // MERGE-LOOP BACKSTOP (issue #923 §3): the convergence gate's verdict is only as fresh as the head
15
+ // it reviewed — a head that MOVED between convergence and landing, or a review that never existed
16
+ // (the #1443 fail-open), must not slip through at land time. Before running ANY land protocol this
17
+ // worker re-checks that a HEAD-CURRENT Copilot review exists; a missing/stale review blocks the
18
+ // landing (escalating to a human) instead of enqueueing/merging unreviewed code. The check fails
19
+ // OPEN only when the GitHub state is UNREADABLE (no transport / a hiccup) — the merge-stage
20
+ // mergeability polling is the independent backstop there, and a hard block on a transient read
21
+ // would wedge every merge on a network blip.
13
22
  import type { AppJobHandler } from "@nanobpm/urban";
14
23
  import { matchTags, tag } from "@nanobpm/urban/effect";
15
24
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
16
25
  import { checkBaseTarget, classifyBaseGuard } from "../../app/baseGuard.ts";
17
- import { classifyPrLiveness, enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
26
+ import { type HeadReader, makeDefaultReadHead } from "../../app/currentHead.ts";
27
+ import {
28
+ classifyPrLiveness,
29
+ enqueueViaComment,
30
+ fetchBranchHead,
31
+ fetchLatestCopilotReview,
32
+ fetchPrHead,
33
+ fetchPrState,
34
+ mergePr,
35
+ parseReviewOpenFindings,
36
+ } from "../../app/github.ts";
18
37
  import { classifyMergeLanding, DEFAULT_MERGE_PROTOCOL, loadMergeProtocol } from "../../app/mergeProtocol.ts";
38
+ import { isReviewMissingOrStale } from "../../app/reviewWait.ts";
19
39
  import { abandonClosedPr, ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
20
40
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
21
41
 
@@ -28,7 +48,25 @@ interface Out extends Record<string, unknown> {
28
48
  question?: string;
29
49
  }
30
50
 
31
- const handler: AppJobHandler<In, Out> = async (job, app) => {
51
+ // The backstop's review reader — the SAME shape the converge-gate binds (`{ body, commitId }`,
52
+ // `null` = unverifiable). Injectable so tests drive the missing/stale/head-current matrix without
53
+ // a transport.
54
+ export type MergeReviewReader = (repo: string, prNumber: number) => Promise<{ body: string; commitId: string | null } | null>;
55
+
56
+ const defaultReadReview: MergeReviewReader = (repo, prNumber) =>
57
+ fetchLatestCopilotReview(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
58
+ // The SAME branch-ref-over-stale-`head.sha` reader the converge-gate / progress-check / poller
59
+ // bind (#786/#799), so the backstop's staleness comparison sees the exact head the gate did.
60
+ const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
61
+
62
+ const BLOCK_NO_HEAD_CURRENT_REVIEW =
63
+ "no head-current Copilot review exists for the PR's current HEAD (the head moved after the last review, or no review was ever submitted). A head-current reviewer verdict is required before landing";
64
+
65
+ /** Build the handler with injectable GitHub readers. The default export binds the real readers;
66
+ * tests inject stubs for the backstop matrix. */
67
+ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha: HeadReader }): AppJobHandler<In, Out> {
68
+ const { readReview, readHeadSha } = deps;
69
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
32
70
  const { prKey, repo, prNumber, round, abandonUrl } = job.variables;
33
71
  const token = process.env.GITHUB_TOKEN ?? "";
34
72
  const now = new Date().toISOString();
@@ -88,6 +126,68 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
88
126
  return { mergeStatus: "abandoned" };
89
127
  }
90
128
 
129
+ // MERGE-LOOP BACKSTOP (issue #923 §3): refuse to land without a HEAD-CURRENT Copilot review. The
130
+ // convergence gate's verdict is only as fresh as the head it reviewed — a head that MOVED between
131
+ // convergence and landing (a fix-ci / rebase push, an out-of-band commit), or a review that never
132
+ // existed at all (the nano-bpm#1443 fail-open: merged with ZERO reviews), must not slip through
133
+ // at land time. Runs AFTER the merged/closed short-circuits (an already-terminal PR needs no
134
+ // verdict) and BEFORE any land protocol (the `@mergifyio queue` comment is itself the landing
135
+ // for a queue repo). Fails OPEN only when the GitHub state is UNREADABLE (no transport / a
136
+ // hiccup → `null` review or head): a transient read failure must not wedge every merge — the
137
+ // merge-stage mergeability polling is the independent backstop there.
138
+ const [backstopReview, backstopHead] = await Promise.all([
139
+ readReview(repo, prNumber).catch(() => null),
140
+ readHeadSha(repo, prNumber).catch(() => null),
141
+ ]);
142
+ if (backstopReview !== null && isReviewMissingOrStale(backstopReview.commitId, backstopHead)) {
143
+ await app.data.table("merges", "id").insert({
144
+ pr_key: prKey,
145
+ outcome: "blocked",
146
+ method: "review-backstop",
147
+ detail: BLOCK_NO_HEAD_CURRENT_REVIEW,
148
+ at: now,
149
+ });
150
+ return {
151
+ mergeStatus: "blocked",
152
+ status: "blocked",
153
+ question:
154
+ `Merge refused: ${BLOCK_NO_HEAD_CURRENT_REVIEW}. ` +
155
+ `Push no further commits, wait for the Copilot review of the current HEAD to land (or request one: ` +
156
+ `gh api repos/${repo}/pulls/${prNumber}/requested_reviewers -X POST -f "reviewers[]=copilot-pull-request-reviewer[bot]"), ` +
157
+ `let the convergence loop gate on it, then reply to retry.`,
158
+ };
159
+ }
160
+
161
+ // A head-current review whose OWN summary still reports open findings (the same canonical
162
+ // verdict parser the converge-gate uses) must block too: a summary-only "Changes recommended"
163
+ // review that landed after convergence has no unresolved thread for mergeability polling to see.
164
+ // Only evaluate the verdict once head-currentness is VERIFIED (review read AND head read succeeded
165
+ // and match) — an unreadable head fails open here like the stale check above, never blocking on a
166
+ // possibly-stale review's body.
167
+ const reviewIsHeadCurrent =
168
+ backstopReview !== null && backstopHead !== null && backstopHead !== "" && backstopReview.commitId === backstopHead;
169
+ const openFindings = reviewIsHeadCurrent ? parseReviewOpenFindings(backstopReview.body) : [];
170
+ if (openFindings.length > 0) {
171
+ const detail = `the head-current Copilot review reports open findings (${openFindings.join(", ")})`;
172
+ await app.data.table("merges", "id").insert({
173
+ pr_key: prKey,
174
+ outcome: "blocked",
175
+ method: "review-backstop",
176
+ detail,
177
+ at: now,
178
+ });
179
+ return {
180
+ mergeStatus: "blocked",
181
+ status: "blocked",
182
+ question:
183
+ `Merge refused: ${detail}. Address the review's findings on the branch (or reply with ` +
184
+ `guidance on which to push back on), let the convergence loop gate on a clean review, then reply to retry.`,
185
+ };
186
+ }
187
+ // The head the backstop verified — pinned through the land call so a push between this check
188
+ // and the merge can never land a later, unreviewed head.
189
+ const verifiedHead = backstopReview !== null && backstopHead ? backstopHead : undefined;
190
+
91
191
  // Dead-end-base guard (#60): never land a PR into a base branch that has itself already merged
92
192
  // to the default branch — the merge would land into a dead branch and never reach `main`.
93
193
  // GitHub only auto-retargets a PR when its base is *deleted* on merge; a merged-but-undeleted
@@ -125,9 +225,17 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
125
225
  // Land via the repo's on-demand queue: post the enqueue comment; the poller's queued→landed
126
226
  // watch (service.ts block 3) then advances the process when the queue merges it.
127
227
  const comment = protocol?.land.comment ?? "@mergifyio queue";
128
- const ok = await enqueueViaComment(repo, prNumber, token, comment);
129
- outcome = ok ? "queued" : "blocked";
130
- detail = ok ? `enqueued via "${comment}"` : `failed to post enqueue comment "${comment}"`;
228
+ // A comment can't pin a SHA, so re-read the head right before enqueueing: if it moved past the
229
+ // verified head, re-enter via the bounded retry (which re-runs the backstop on the new head).
230
+ const headNow = verifiedHead ? await readHeadSha(repo, prNumber).catch(() => null) : null;
231
+ if (verifiedHead && headNow !== null && headNow !== verifiedHead) {
232
+ outcome = "retry";
233
+ detail = `head moved from ${verifiedHead} to ${headNow} after the review backstop; re-verifying before enqueue`;
234
+ } else {
235
+ const ok = await enqueueViaComment(repo, prNumber, token, comment);
236
+ outcome = ok ? "queued" : "blocked";
237
+ detail = ok ? `enqueued via "${comment}"` : `failed to post enqueue comment "${comment}"`;
238
+ }
131
239
  auditMethod = "queue-comment";
132
240
  } else if (classifyMergeLanding(protocol ?? DEFAULT_MERGE_PROTOCOL) === "decision-required") {
133
241
  // The repo requires a human to click Merge (`land.method=ui`); Merlin can't. This is the
@@ -137,7 +245,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
137
245
  auditMethod = "ui";
138
246
  } else {
139
247
  const admin = method === "admin" || MERGE_ADMIN;
140
- const res = await mergePr(repo, prNumber, token, { method: MERGE_METHOD, admin });
248
+ const res = await mergePr(repo, prNumber, token, { method: MERGE_METHOD, admin, expectedHeadSha: verifiedHead });
141
249
  // No usable transport → treat as a block so a human is asked to configure/merge, rather than
142
250
  // silently completing the process without landing the PR.
143
251
  outcome = res?.outcome ?? "blocked";
@@ -180,6 +288,9 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
180
288
  `Resolve it on GitHub (rebase / fix a required check / grant merge rights), then reply to retry.${docHint}`,
181
289
  }),
182
290
  });
183
- };
291
+ };
292
+ return handler;
293
+ }
184
294
 
295
+ const handler = makeHandler({ readReview: defaultReadReview, readHeadSha: defaultReadHead });
185
296
  export default handler;
@@ -296,7 +296,7 @@ export function makeHandler(deps: {
296
296
  readRounds?: RoundsReader;
297
297
  }): AppJobHandler<In, Out> {
298
298
  return async (job, app) => {
299
- const { prKey, status, repo, prNumber, round, huskRetries, roundEntryHead } = job.variables;
299
+ const { prKey, status, repo, prNumber, round, huskRetries, roundEntryHead, reviewWaitCutoff } = job.variables;
300
300
  const jobKey = job.jobKey;
301
301
 
302
302
  const prs = app.data.table<{
@@ -356,6 +356,13 @@ export function makeHandler(deps: {
356
356
  status?: "waiting_review" | "converging";
357
357
  agentWatermark?: string | null;
358
358
  churnEscalatedRound?: number;
359
+ /** The review-wait start to stamp instead of "now" (issue #923 review): the converge-gate
360
+ * emits `reviewWaitCutoff` (the time of ITS review read) when it signals `reviewStale`, so
361
+ * a head-current review that lands in the window between that read and this park still
362
+ * satisfies `pollReviews`' `submitted_at >= waiting_since` filter instead of being
363
+ * discarded — closing the arrival-window race that otherwise re-solicits and times out
364
+ * despite a valid verdict already sitting unconsumed on the PR. */
365
+ waitingSince?: string;
359
366
  },
360
367
  ): Promise<Out> => {
361
368
  const ts = new Date().toISOString();
@@ -368,7 +375,9 @@ export function makeHandler(deps: {
368
375
  // Only overwrite the baseline when we actually read a head, so a null (unreadable) head
369
376
  // never clobbers a good `last_round_head`.
370
377
  ...(opts.head ? { last_round_head: opts.head } : {}),
371
- ...(opts.status === "waiting_review" ? { status: "waiting_review", waiting_since: ts } : {}),
378
+ ...(opts.status === "waiting_review"
379
+ ? { status: "waiting_review", waiting_since: opts.waitingSince ?? ts }
380
+ : {}),
372
381
  ...(opts.status === "converging" ? { status: "converging" } : {}),
373
382
  // Advance the attempt watermark ONLY when an agent-work read consumed a `review-round`
374
383
  // instance (Copilot #789); `undefined` leaves the persisted watermark untouched (an
@@ -392,8 +401,18 @@ export function makeHandler(deps: {
392
401
  const parsed = parsePr(prKey);
393
402
  const ghRepo = repo ?? parsed?.repo;
394
403
  const ghNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
404
+ // The review-wait start to stamp on a park. When the converge-gate signalled `reviewStale` it
405
+ // emitted `reviewWaitCutoff` (the time of ITS review read); parking with `waiting_since` at that
406
+ // cutoff — rather than "now" — lets a head-current review that landed in the read→park window
407
+ // satisfy the wait (issue #923 review). Only a non-empty string is honoured; anything else
408
+ // (absent on a normal round, or a malformed value) falls back to the park time.
409
+ const waitCutoff =
410
+ typeof reviewWaitCutoff === "string" && reviewWaitCutoff !== "" ? reviewWaitCutoff : undefined;
395
411
  if (!ghRepo || typeof ghNumber !== "number") {
396
- return commit({ progressed: true, huskRetries: 0 }, { status: "waiting_review" });
412
+ return commit(
413
+ { progressed: true, huskRetries: 0 },
414
+ { status: "waiting_review", ...(waitCutoff ? { waitingSince: waitCutoff } : {}) },
415
+ );
397
416
  }
398
417
 
399
418
  // Read the head and record the baseline on EVERY round — including a non-addressed `waiting`
@@ -457,7 +476,12 @@ export function makeHandler(deps: {
457
476
  if (!isAddressedStatus(status)) {
458
477
  return commit(
459
478
  { progressed: true, huskRetries: 0 },
460
- { head: currentHead, status: "waiting_review", agentWatermark },
479
+ {
480
+ head: currentHead,
481
+ status: "waiting_review",
482
+ agentWatermark,
483
+ ...(waitCutoff ? { waitingSince: waitCutoff } : {}),
484
+ },
461
485
  );
462
486
  }
463
487
 
@@ -528,7 +552,12 @@ export function makeHandler(deps: {
528
552
  }
529
553
  // Genuine progress → the loop parks at wait-review. THIS is the review-wait park, written only
530
554
  // once the husk retry has been ruled out, so a husk-retry round never transits `waiting_review`.
531
- return commit(out, { head: currentHead, status: "waiting_review", agentWatermark });
555
+ return commit(out, {
556
+ head: currentHead,
557
+ status: "waiting_review",
558
+ agentWatermark,
559
+ ...(waitCutoff ? { waitingSince: waitCutoff } : {}),
560
+ });
532
561
  }
533
562
 
534
563
  // NO-ADVANCE SELF-HEAL (#818, Layer 2). Before escalating to a human, reconcile the round's work
@@ -576,7 +605,12 @@ export function makeHandler(deps: {
576
605
  // (husk counter reset), so the poller solicits the next Copilot review against the healed head.
577
606
  return commit(
578
607
  { progressed: true, huskRetries: 0 },
579
- { head: heal.sha, status: "waiting_review", agentWatermark },
608
+ {
609
+ head: heal.sha,
610
+ status: "waiting_review",
611
+ agentWatermark,
612
+ ...(waitCutoff ? { waitingSince: waitCutoff } : {}),
613
+ },
580
614
  );
581
615
  }
582
616
  }