@nanobpm/nano-workforce 0.187.5 → 0.187.6

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.
@@ -30,14 +30,18 @@
30
30
  import type { AppJobHandler } from "@nanobpm/urban";
31
31
  import { type ConvergeGateResult, evaluateConvergeGate } from "../../app/convergeGate.ts";
32
32
  import {
33
- fetchLatestCopilotReviewBody,
33
+ fetchBranchHead,
34
+ fetchLatestCopilotReview,
35
+ fetchPrHead,
34
36
  fetchReviewThreads,
35
37
  parseAckedAdvisories,
36
38
  parseSuppressedAdvisories,
37
39
  type ReviewThread,
38
40
  } from "../../app/github.ts";
41
+ import { isReviewStale } from "../../app/reviewWait.ts";
39
42
  import { parsePr } from "../../app/service.ts";
40
43
  import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
44
+ import { type HeadReader, makeDefaultReadHead } from "../progress-check/worker.ts";
41
45
 
42
46
  // Input/output typed off the model data envelopes (`PrConvergeGateIn` / `PrConvergeGateOut` in
43
47
  // convergence-loop.bpmn), the single source of truth for this worker's wire contract (ADR 0040).
@@ -47,14 +51,20 @@ type Out = WorkerOutputs["pr.converge-gate"];
47
51
  // Reads a PR's review threads. `null` = no usable transport (treated as an unverifiable read →
48
52
  // fail closed). Throws propagate to the fail-closed catch below.
49
53
  export type ThreadsReader = (repo: string, prNumber: number) => Promise<ReviewThread[] | null>;
50
- // Reads the latest Copilot review body. `null` = no usable transport (unverifiable → fail closed);
51
- // `""` = transport usable but no Copilot review yet (verified: no suppressed advisories).
52
- export type ReviewBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
54
+ // Reads the latest Copilot review body AND the commit SHA it was submitted against. `null` = no
55
+ // usable transport (unverifiable fail closed); `{ body: "", commitId: null }` = transport usable
56
+ // but no Copilot review yet (verified: no suppressed advisories). The `commitId` drives the
57
+ // stale-review detection below (issue #799).
58
+ export type ReviewReader = (repo: string, prNumber: number) => Promise<{ body: string; commitId: string | null } | null>;
53
59
 
54
60
  const defaultReadThreads: ThreadsReader = (repo, prNumber) =>
55
61
  fetchReviewThreads(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
56
- const defaultReadReviewBody: ReviewBodyReader = (repo, prNumber) =>
57
- fetchLatestCopilotReviewBody(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
62
+ const defaultReadReview: ReviewReader = (repo, prNumber) =>
63
+ fetchLatestCopilotReview(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
64
+ // The PR's current HEAD SHA, read via the same branch-ref-over-stale-`head.sha` reader the
65
+ // capture-head / progress-check steps use (#786), so the gate's staleness comparison sees the exact
66
+ // head those steps do. Fails OPEN to `null` (unreadable head → not-stale, per {@link isReviewStale}).
67
+ const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
58
68
 
59
69
  const BLOCK_UNVERIFIABLE =
60
70
  "Convergence blocked: could not verify the PR's review comments against GitHub. A human must confirm every Copilot review thread is resolved and every suppressed advisory acknowledged before this PR converges (reply to resume the loop).";
@@ -63,8 +73,10 @@ const BLOCK_UNVERIFIABLE =
63
73
  * tests inject stubs. Fails CLOSED — any unreadable/errored state blocks convergence. */
64
74
  export function makeHandler(deps: {
65
75
  readThreads: ThreadsReader;
66
- readReviewBody: ReviewBodyReader;
76
+ readReview: ReviewReader;
77
+ readHeadSha?: HeadReader;
67
78
  }): AppJobHandler<In, Out> {
79
+ const readHeadSha = deps.readHeadSha ?? defaultReadHead;
68
80
  return async (job) => {
69
81
  const { prKey, repo, prNumber } = job.variables;
70
82
  // `parsePr` is total on any input (fails closed to `null` on a missing/non-string prKey), so
@@ -73,43 +85,58 @@ export function makeHandler(deps: {
73
85
  const ghRepo = repo ?? parsed?.repo;
74
86
  const ghNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
75
87
  if (!ghRepo || typeof ghNumber !== "number") {
76
- return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
88
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, reviewStale: false };
77
89
  }
78
90
 
79
91
  let result: ConvergeGateResult;
80
92
  try {
81
- const threads = await deps.readThreads(ghRepo, ghNumber);
93
+ const threadsRead = await deps.readThreads(ghRepo, ghNumber);
82
94
  // A null threads read is an unverifiable gate — fail closed. (An empty ARRAY is a verified
83
95
  // "no threads" and is fine.)
84
- if (threads === null) {
85
- return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
96
+ if (threadsRead === null) {
97
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, reviewStale: false };
86
98
  }
87
- const reviewBody = await deps.readReviewBody(ghRepo, ghNumber);
88
- // A null review body is an unverifiable read (no usable transport) — fail closed, same as a
89
- // null threads read. (An empty STRING is a verified "no Copilot review / no advisories".)
90
- if (reviewBody === null) {
91
- return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
99
+ const review = await deps.readReview(ghRepo, ghNumber);
100
+ // A null review read is an unverifiable read (no usable transport) — fail closed, same as a
101
+ // null threads read. (A `{ body: "", commitId: null }` result is a verified "no Copilot
102
+ // review / no advisories".)
103
+ if (review === null) {
104
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, reviewStale: false };
92
105
  }
93
- const unresolvedThreadCount = threads.filter((t) => !t.isResolved).length;
94
- const advisories = parseSuppressedAdvisories(reviewBody);
106
+ // STALE-REVIEW GUARD (issue #799). When the PR HEAD has advanced PAST the commit the latest
107
+ // Copilot review was submitted against, that review's suppressed advisories describe code the
108
+ // head has moved past — e.g. an advisory the agent already FIXED IN CODE (but did not ack) is
109
+ // still re-listed in the obsolete body. Blocking/escalating on it re-escalates a human
110
+ // indefinitely (PR #789). Instead of gating on the stale body, signal `reviewStale` so the
111
+ // process re-enters the review wait and the poller re-solicits a fresh review of the current
112
+ // HEAD to gate on. The head read fails OPEN (null → not stale), so a transport hiccup can never
113
+ // fabricate a stale verdict; the ordinary gate below still runs on a HEAD-current review.
114
+ const headSha = await readHeadSha(ghRepo, ghNumber).catch(() => null);
115
+ if (isReviewStale(review.commitId, headSha)) {
116
+ return { convergeBlocked: false, convergeBlockReason: "", reviewStale: true };
117
+ }
118
+ const unresolvedThreadCount = threadsRead.filter((t) => !t.isResolved).length;
119
+ const advisories = parseSuppressedAdvisories(review.body);
95
120
  result = evaluateConvergeGate({
96
121
  unresolvedThreadCount,
97
122
  suppressedAdvisories: advisories.map((a) => ({ key: a.key, label: a.label })),
98
- acknowledgedKeys: parseAckedAdvisories(threads),
123
+ acknowledgedKeys: parseAckedAdvisories(threadsRead),
99
124
  });
100
125
  } catch {
101
- return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
126
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, reviewStale: false };
102
127
  }
103
128
 
104
129
  return {
105
130
  convergeBlocked: result.convergeBlocked,
106
131
  convergeBlockReason: result.convergeBlockReason,
132
+ reviewStale: false,
107
133
  };
108
134
  };
109
135
  }
110
136
 
111
137
  const handler = makeHandler({
112
138
  readThreads: defaultReadThreads,
113
- readReviewBody: defaultReadReviewBody,
139
+ readReview: defaultReadReview,
140
+ readHeadSha: defaultReadHead,
114
141
  });
115
142
  export default handler;
@@ -16,6 +16,7 @@
16
16
  // could not read fails OPEN — the round cap and the review-wait timeout stay the safety nets so a
17
17
  // transient GitHub hiccup can never fabricate a no-progress escalation.
18
18
  import type { AgentInstanceSummary, AppApi, AppJobHandler } from "@nanobpm/urban";
19
+ import { type HeadReader, makeDefaultReadHead } from "../../app/currentHead.ts";
19
20
  import { fetchBranchHead, fetchPrHead } from "../../app/github.ts";
20
21
  import { decideProgress, isAddressedStatus } from "../../app/roundProgress.ts";
21
22
  import { parsePr } from "../../app/service.ts";
@@ -26,16 +27,6 @@ import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io
26
27
  type In = WorkerInputs["pr.progress-check"];
27
28
  type Out = WorkerOutputs["pr.progress-check"];
28
29
 
29
- // Reads a PR's current head SHA. Injectable so unit tests never touch git/network; the default
30
- // binds the real GitHub reader (the shared gh | token transport) and swallows any failure to
31
- // `null` so the guard fails OPEN. It reads the BRANCH ref (`git/ref/heads/<branch>`) — updated
32
- // atomically with the push — in preference to the PR object's asynchronously-denormalized
33
- // `head.sha`, so a lagging PR projection can never fabricate a stale-but-valid no-advance
34
- // escalation (#786). Once a head ref is known this trusts ONLY its atomic ref: a failed/absent
35
- // ref read fails OPEN (`null`), never falling back to `head.sha`. The PR head is used only when
36
- // the PR carries NO head ref at all.
37
- export type HeadReader = (repo: string, prNumber: number) => Promise<string | null>;
38
-
39
30
  /** The outcome of an agent-work corroboration, optionally carrying the ATTEMPT WATERMARK it
40
31
  * consumed. `work` is the husk verdict decideProgress routes on (`true` no-advance / `false` husk /
41
32
  * `null` unknown). `consumedKey` — when present — is the greatest `review-round` instance key this
@@ -76,40 +67,11 @@ function normalizeAgentWork(raw: boolean | null | AgentWorkObservation | undefin
76
67
  return raw;
77
68
  }
78
69
 
79
- /** Build the default head reader over injected GitHub fetchers. Exported (with injectable fetchers)
80
- * so the branch-ref-over-stale-`head.sha` preference the whole point of {@link fetchBranchHead}
81
- * here (#786) is covered by a handler-level regression test, not only inside the private binding:
82
- * a change that stopped reading the branch ref, or fell back to `head.sha`, must turn a test red. */
83
- export function makeDefaultReadHead(deps: {
84
- fetchPrHead: typeof fetchPrHead;
85
- fetchBranchHead: typeof fetchBranchHead;
86
- }): HeadReader {
87
- return async (repo, prNumber) => {
88
- const token = process.env.GITHUB_TOKEN ?? "";
89
- const pr = await deps.fetchPrHead(repo, prNumber, token).catch(() => null);
90
- if (!pr) return null;
91
- // Prefer the branch ref (atomic with the push) over the PR object's denormalized head.sha (#786).
92
- // Once the head branch is known, trust ONLY its atomic ref: a failed/absent ref read fails OPEN
93
- // (`null`) rather than falling back to the PR object's asynchronously-denormalized head.sha, which
94
- // can still report a stale-but-valid SHA after a push and fabricate a no-advance escalation — the
95
- // very projection this branch-ref read exists to avoid. The ref is read in the repository the
96
- // head branch actually lives in (the fork for a cross-repo PR — see below), so a fork PR fails
97
- // open safely instead of comparing an unrelated base-repo SHA. Fall back to the PR head only when
98
- // there is NO head ref.
99
- if (pr.headRef) {
100
- // Resolve the head ref in the repository the head branch actually lives in — the FORK for a
101
- // cross-repo PR (`pr.headRepo`), else the base `repo`. Querying the base repo unconditionally
102
- // would, for a fork PR whose head branch shares a name with a base-repo branch, read the
103
- // unrelated base-branch SHA and fabricate progress/no-progress (#786). When the head repo
104
- // cannot be resolved (a deleted fork ⇒ `headRepo:null`) fail OPEN to `null` rather than fall
105
- // back to the base repo and risk that collision.
106
- const headRepo = pr.headRepo;
107
- if (!headRepo) return null;
108
- return await deps.fetchBranchHead(headRepo, pr.headRef, token).catch(() => null);
109
- }
110
- return pr.headSha ?? null;
111
- };
112
- }
70
+ /** Re-exported from {@link ../../app/currentHead.ts} (the single canonical implementation of the
71
+ * branch-ref-over-stale-`head.sha` head reader, #786/#799) so existing importers of these symbols
72
+ * from this worker keep resolving without a duplicated copy the poller in `app/service.ts` binds
73
+ * the same reader from `currentHead.ts` directly. */
74
+ export { type HeadReader, makeDefaultReadHead };
113
75
 
114
76
  const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
115
77