@nanobpm/nano-workforce 0.187.5 → 0.188.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.
@@ -125,13 +125,14 @@ test("convergence-loop golden has arbitrary-graph features the structured builde
125
125
  const body = rest.slice(0, close);
126
126
  return (body.match(new RegExp(`<bpmn:${tag}\\b`, "g")) ?? []).length;
127
127
  };
128
- // (a) the loop head is a serviceTask that MERGES four back-edges directly — after #786/#789 the
128
+ // (a) the loop head is a serviceTask that MERGES its back-edges directly — after #786/#789 the
129
129
  // round-entry head capture (`capture-head`) is the loop head sitting BEFORE `review-round`, so it
130
- // is `capture-head` that absorbs the four back-edges (the review loop, the answer resume, the
131
- // escalation re-enter, and the #786 husk auto-retry); `review-round` then takes its single
132
- // `f_capture` in-edge. A serviceTask merging four back-edges directly is the arbitrary-graph shape
133
- // the structured builder cannot emit — the feature this asserts, now on `capture-head`.
134
- assertEquals(between("capture-head", "serviceTask", "incoming"), 4, "capture-head should merge 4 flows on the task itself");
130
+ // is `capture-head` that absorbs the back-edges (f_start, the review loop, the answer resume, the
131
+ // #786 husk auto-retry, and the #796 bounded auto-ack re-entry f_ackRetry); `review-round` then
132
+ // takes its single `f_capture` in-edge. A serviceTask merging FIVE back-edges directly is the
133
+ // arbitrary-graph shape the structured builder cannot emit — the feature this asserts, now on
134
+ // `capture-head`.
135
+ assertEquals(between("capture-head", "serviceTask", "incoming"), 5, "capture-head should merge 5 flows on the task itself");
135
136
  assertEquals(between("review-round", "serviceTask", "incoming"), 1, "review-round now takes the single f_capture in-edge");
136
137
  // (b) a single exclusive gateway forks FOUR heterogeneous-condition out-edges.
137
138
  assertEquals(between("gw-status", "exclusiveGateway", "outgoing"), 4, "gw-status should be a 4-way exclusive gateway");
@@ -143,8 +144,9 @@ test("convergence-loop golden has arbitrary-graph features the structured builde
143
144
  // CLASS 2, empirical — demonstrate WHY the structured builder cannot reproduce
144
145
  // (a): a `loop()` whose body starts with a task derives an exclusive-gateway
145
146
  // loop head that absorbs the back-edge (in>=2), leaving the task itself at
146
- // in=1. The golden instead merges its back-edges directly into `review-round`
147
- // (in=4) with no loop-head gateway a shape the builder cannot express.
147
+ // in=1. The golden instead merges its back-edges directly into `capture-head`
148
+ // (capture-head in=5: f_start, f_reviewLoop, f_answerLoop, the #786 f_huskRetry, and the #796
149
+ // f_ackRetry re-entry) with no loop-head gateway — a shape the builder cannot express.
148
150
  test("loop() inserts a gateway head, so back-edges cannot merge into a task", () => {
149
151
  const probe = defineFlow("loop-head-probe", (w) => {
150
152
  w.loop((b) => {
@@ -23,8 +23,8 @@
23
23
  // (2) ARBITRARY control-flow graph (convergence-loop). Single start/end —
24
24
  // so it clears class (1) — but its topology is NOT expressible with the
25
25
  // structured-only builder (`loop`/`switch`/`branch`), empirically proven
26
- // (see `derivation-parity.test.ts`): its loop head `review-round` is a
27
- // serviceTask that MERGES four back-edges directly (in=4), whereas
26
+ // (see `derivation-parity.test.ts`): its loop head `capture-head` is a
27
+ // serviceTask that MERGES five back-edges directly (in=5), whereas
28
28
  // `loop()` always inserts an exclusive-gateway loop head (the task stays
29
29
  // in=1); `gw-status` is a single exclusive gateway with FOUR
30
30
  // heterogeneous-condition out-edges (two `=x = "v"`, one complex boolean,
@@ -207,8 +207,10 @@ export const PORTS: readonly PortEntry[] = [
207
207
  "blocked (arbitrary control-flow graph): single top-level start/end, but " +
208
208
  "its topology is not expressible with the published @nanobpm/workflow's " +
209
209
  "structured-only builder (loop/switch/branch). Proven in the test suite: " +
210
- "the loop head `review-round` is a serviceTask that merges 4 back-edges " +
211
- "directly (in=4), but loop() always inserts an exclusive-gateway head " +
210
+ "the loop head `capture-head` is a serviceTask that merges 5 back-edges " +
211
+ "directly (in=5: f_start, f_reviewLoop, f_answerLoop, the #786 husk " +
212
+ "auto-retry f_huskRetry, and the #796 bounded auto-ack re-entry " +
213
+ "f_ackRetry), but loop() always inserts an exclusive-gateway head " +
212
214
  "(task stays in=1); `gw-status` is one gateway with 4 heterogeneous-" +
213
215
  "condition out-edges (no switch/branch emits that); `gw-escalated` is one " +
214
216
  "gateway that is at once a 6-way merge and a 2-way split. Awaits an " +
@@ -6,15 +6,28 @@
6
6
  // the comment unaddressed). This step runs on the converged path, BEFORE the scope classifier and
7
7
  // pr.finalize hand off to the merge loop, and blocks handoff while GitHub still shows unaddressed
8
8
  // comments:
9
- // • any review THREAD is still unresolved (GraphQL `isResolved = false`), or
9
+ // • any SUBSTANTIVE review THREAD is still unresolved (GraphQL `isResolved = false`), or an
10
+ // unresolved `nano-ack:` ACK thread is open — an ack thread is a partially-completed
11
+ // acknowledgement the bounded #796 auto-ack retry can finish, so it still BLOCKS (a
12
+ // genuinely-open GitHub thread) but the block stays ack-only (recoverable) rather than escalating
13
+ // to a human; only a substantive open thread escalates, or
10
14
  // • any SUPPRESSED advisory in the latest Copilot review body lacks a matching RESOLVED ack
11
15
  // thread (a `nano-ack: <path> :: <verbatim advisory text>` marker whose line-stable prose
12
16
  // fingerprint matches Copilot's advisory). The bare legacy `nano-ack: <path>:<line>` form is
13
17
  // NOT honoured: keyed only on `path:line`, it is blind to the advisory prose and would let a
14
18
  // resolved ack for one advisory silently acknowledge a genuinely new advisory re-emitted at
15
19
  // that same line (a false-OPEN). Only the prose-keyed `<path> :: <text>` form acknowledges.
16
- // A blocked gate returns `convergeBlocked = true`; the model's `gw-converge-gate` gateway routes to
17
- // the human `wait-answer` escalation (recoverable), never a hard wedge.
20
+ // A blocked gate returns `convergeBlocked = true` (recoverable, never a hard wedge); the route then
21
+ // depends on WHY it blocked. An ack-only block (`convergeAckOnly = true` sole cause is unacked
22
+ // suppressed advisories and/or an unresolved `nano-ack:` ack thread) re-dispatches the `review-round`
23
+ // agent first, bounded by `ackRetryMax`, and only reaches the human `wait-answer` escalation once
24
+ // that budget is exhausted. A substantive unresolved thread routes to `wait-answer` immediately.
25
+ //
26
+ // STALE-REVIEW GUARD (issue #799): when the PR HEAD has advanced PAST the commit the latest Copilot
27
+ // review was submitted against, that review's body describes code the head has moved past, so the
28
+ // gate signals `reviewStale = true` (rather than blocking on the obsolete body) and the process
29
+ // re-enters the review wait for a fresh review of the current HEAD. The head read fails OPEN, so a
30
+ // transport hiccup can never fabricate a stale verdict.
18
31
  //
19
32
  // Scope integrity is NO LONGER judged here. A deterministic regex over the PR description could not
20
33
  // read the closed issue's acceptance criteria, so it false-positived on any body that merely
@@ -30,14 +43,19 @@
30
43
  import type { AppJobHandler } from "@nanobpm/urban";
31
44
  import { type ConvergeGateResult, evaluateConvergeGate } from "../../app/convergeGate.ts";
32
45
  import {
33
- fetchLatestCopilotReviewBody,
46
+ fetchBranchHead,
47
+ fetchLatestCopilotReview,
48
+ fetchPrHead,
34
49
  fetchReviewThreads,
50
+ isAckThread,
35
51
  parseAckedAdvisories,
36
52
  parseSuppressedAdvisories,
37
53
  type ReviewThread,
38
54
  } from "../../app/github.ts";
55
+ import { isReviewStale } from "../../app/reviewWait.ts";
39
56
  import { parsePr } from "../../app/service.ts";
40
57
  import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
58
+ import { type HeadReader, makeDefaultReadHead } from "../progress-check/worker.ts";
41
59
 
42
60
  // Input/output typed off the model data envelopes (`PrConvergeGateIn` / `PrConvergeGateOut` in
43
61
  // convergence-loop.bpmn), the single source of truth for this worker's wire contract (ADR 0040).
@@ -47,14 +65,20 @@ type Out = WorkerOutputs["pr.converge-gate"];
47
65
  // Reads a PR's review threads. `null` = no usable transport (treated as an unverifiable read →
48
66
  // fail closed). Throws propagate to the fail-closed catch below.
49
67
  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>;
68
+ // Reads the latest Copilot review body AND the commit SHA it was submitted against. `null` = no
69
+ // usable transport (unverifiable fail closed); `{ body: "", commitId: null }` = transport usable
70
+ // but no Copilot review yet (verified: no suppressed advisories). The `commitId` drives the
71
+ // stale-review detection below (issue #799).
72
+ export type ReviewReader = (repo: string, prNumber: number) => Promise<{ body: string; commitId: string | null } | null>;
53
73
 
54
74
  const defaultReadThreads: ThreadsReader = (repo, prNumber) =>
55
75
  fetchReviewThreads(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
56
- const defaultReadReviewBody: ReviewBodyReader = (repo, prNumber) =>
57
- fetchLatestCopilotReviewBody(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
76
+ const defaultReadReview: ReviewReader = (repo, prNumber) =>
77
+ fetchLatestCopilotReview(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
78
+ // The PR's current HEAD SHA, read via the same branch-ref-over-stale-`head.sha` reader the
79
+ // capture-head / progress-check steps use (#786), so the gate's staleness comparison sees the exact
80
+ // head those steps do. Fails OPEN to `null` (unreadable head → not-stale, per {@link isReviewStale}).
81
+ const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
58
82
 
59
83
  const BLOCK_UNVERIFIABLE =
60
84
  "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 +87,10 @@ const BLOCK_UNVERIFIABLE =
63
87
  * tests inject stubs. Fails CLOSED — any unreadable/errored state blocks convergence. */
64
88
  export function makeHandler(deps: {
65
89
  readThreads: ThreadsReader;
66
- readReviewBody: ReviewBodyReader;
90
+ readReview: ReviewReader;
91
+ readHeadSha?: HeadReader;
67
92
  }): AppJobHandler<In, Out> {
93
+ const readHeadSha = deps.readHeadSha ?? defaultReadHead;
68
94
  return async (job) => {
69
95
  const { prKey, repo, prNumber } = job.variables;
70
96
  // `parsePr` is total on any input (fails closed to `null` on a missing/non-string prKey), so
@@ -73,43 +99,76 @@ export function makeHandler(deps: {
73
99
  const ghRepo = repo ?? parsed?.repo;
74
100
  const ghNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
75
101
  if (!ghRepo || typeof ghNumber !== "number") {
76
- return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
102
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, convergeAckOnly: false, reviewStale: false };
77
103
  }
78
104
 
79
105
  let result: ConvergeGateResult;
80
106
  try {
81
- const threads = await deps.readThreads(ghRepo, ghNumber);
107
+ const threadsRead = await deps.readThreads(ghRepo, ghNumber);
82
108
  // A null threads read is an unverifiable gate — fail closed. (An empty ARRAY is a verified
83
- // "no threads" and is fine.)
84
- if (threads === null) {
85
- return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
109
+ // "no threads" and is fine.) An unverifiable block is NOT ack-only: it needs a human to
110
+ // confirm GitHub state, so it must not enter the bounded agent auto-ack path (#796).
111
+ if (threadsRead === null) {
112
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, convergeAckOnly: false, reviewStale: false };
113
+ }
114
+ const review = await deps.readReview(ghRepo, ghNumber);
115
+ // A null review read is an unverifiable read (no usable transport) — fail closed, same as a
116
+ // null threads read. (A `{ body: "", commitId: null }` result is a verified "no Copilot
117
+ // review / no advisories".)
118
+ if (review === null) {
119
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, convergeAckOnly: false, reviewStale: false };
86
120
  }
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 };
121
+ // STALE-REVIEW GUARD (issue #799). When the PR HEAD has advanced PAST the commit the latest
122
+ // Copilot review was submitted against, that review's suppressed advisories describe code the
123
+ // head has moved past — e.g. an advisory the agent already FIXED IN CODE (but did not ack) is
124
+ // still re-listed in the obsolete body. Blocking/escalating on it re-escalates a human
125
+ // indefinitely (PR #789). Instead of gating on the stale body, signal `reviewStale` so the
126
+ // process re-enters the review wait and the poller re-solicits a fresh review of the current
127
+ // HEAD to gate on. The head read fails OPEN (null → not stale), so a transport hiccup can never
128
+ // fabricate a stale verdict; the ordinary gate below still runs on a HEAD-current review.
129
+ const headSha = await readHeadSha(ghRepo, ghNumber).catch(() => null);
130
+ if (isReviewStale(review.commitId, headSha)) {
131
+ return { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: true };
92
132
  }
93
- const unresolvedThreadCount = threads.filter((t) => !t.isResolved).length;
94
- const advisories = parseSuppressedAdvisories(reviewBody);
133
+ // Split the unresolved threads into SUBSTANTIVE reviewer findings vs partially-completed
134
+ // `nano-ack:` ACK threads (root carries a canonical `nano-ack: <path> :: <text>` marker — see
135
+ // `isAckThread`). Neither is ever dropped from the gate: a substantive unresolved thread blocks
136
+ // and escalates to a human; an unresolved ack thread ALSO blocks (it is a genuinely-open GitHub
137
+ // thread), but the block stays ack-only (recoverable by the bounded #796 auto-ack retry, which
138
+ // re-posts/resolves it). Keeping the ack thread a BLOCKING condition — rather than filtering it
139
+ // away — is what makes the root-marker classifier fail-CLOSED: a mislabelled substantive thread
140
+ // still blocks (as ack-only) instead of finalizing with the finding open, and the bounded retry
141
+ // cannot ack a non-advisory, so it escalates to a human on exhaustion.
142
+ const unresolved = threadsRead.filter((t) => !t.isResolved);
143
+ const unresolvedAckThreadCount = unresolved.filter((t) => isAckThread(t)).length;
144
+ const unresolvedThreadCount = unresolved.length - unresolvedAckThreadCount;
145
+ const advisories = parseSuppressedAdvisories(review.body);
95
146
  result = evaluateConvergeGate({
96
147
  unresolvedThreadCount,
148
+ unresolvedAckThreadCount,
97
149
  suppressedAdvisories: advisories.map((a) => ({ key: a.key, label: a.label })),
98
- acknowledgedKeys: parseAckedAdvisories(threads),
150
+ acknowledgedKeys: parseAckedAdvisories(threadsRead),
99
151
  });
100
152
  } catch {
101
- return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
153
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, convergeAckOnly: false, reviewStale: false };
102
154
  }
103
155
 
104
156
  return {
105
157
  convergeBlocked: result.convergeBlocked,
106
158
  convergeBlockReason: result.convergeBlockReason,
159
+ // Signals the bounded agent auto-ack path (#796): a block whose sole cause is recoverable —
160
+ // unacked suppressed advisories AND/OR a lone unresolved `nano-ack:` ack thread (a
161
+ // partially-completed acknowledgement), with NO substantive unresolved thread — re-dispatches
162
+ // the review-round agent before escalating to a human.
163
+ convergeAckOnly: result.ackOnly,
164
+ reviewStale: false,
107
165
  };
108
166
  };
109
167
  }
110
168
 
111
169
  const handler = makeHandler({
112
170
  readThreads: defaultReadThreads,
113
- readReviewBody: defaultReadReviewBody,
171
+ readReview: defaultReadReview,
172
+ readHeadSha: defaultReadHead,
114
173
  });
115
174
  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