@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.
@@ -11,7 +11,7 @@ import { memDataFor } from "../test/worldDb.ts";
11
11
  import { withTrackingViews } from "../test/trackingViews.ts";
12
12
  import { DurableResumeRegistry } from "./durableResume.ts";
13
13
  import { WorldStore } from "./world/index.ts";
14
- import { abandonClosedPr, isPrSettled, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
14
+ import { abandonClosedPr, isPrSettled, MAX_ACK_RETRIES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
15
15
  import { trackingTargetFor } from "./instanceTracking.ts";
16
16
  import type { DataLayer } from "@nanobpm/urban";
17
17
 
@@ -378,6 +378,45 @@ test("submitPr defaults convergeOnly to false so the global auto-merge default g
378
378
  });
379
379
  });
380
380
 
381
+ // #796 auto-ack budget seeding: `submitPr` is the ONLY production write that makes the retry budget
382
+ // available to a fresh convergence instance — the engine behaviour tests seed `ackRetryRound` /
383
+ // `ackRetryMax` directly and never exercise `submitPr`, so a regression dropping or misconfiguring
384
+ // this seed would leave deployed loops on the escalation default while every added behaviour test
385
+ // still passes. Assert both the initial counter and the configured max propagate onto the instance.
386
+ function captureVars() {
387
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
388
+ pull_requests: { rows: [], key: "pr_key" },
389
+ escalations: { rows: [], key: "id" },
390
+ pr_dependencies: { rows: [], key: "pr_key" },
391
+ };
392
+ const data = {
393
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
394
+ } as any;
395
+ let captured: Record<string, unknown> | undefined;
396
+ const engine = {
397
+ createInstance: (req: { variables?: Record<string, unknown> }) => {
398
+ captured = req.variables;
399
+ return Promise.resolve({ processInstanceKey: "PI-1" });
400
+ },
401
+ } as any;
402
+ return { data, engine, get: () => captured };
403
+ }
404
+
405
+ test("submitPr seeds the #796 auto-ack budget onto the instance (ackRetryRound=0, ackRetryMax=MAX_ACK_RETRIES)", async () => {
406
+ await withGithubOff(async () => {
407
+ const { data, engine, get } = captureVars();
408
+ await submitPr(data, engine, {
409
+ repo: "owner/repo",
410
+ number: 10,
411
+ url: "https://github.com/owner/repo/pull/10",
412
+ prKey: "owner/repo#10",
413
+ });
414
+ const vars = get();
415
+ assertEquals(vars?.ackRetryRound, 0);
416
+ assertEquals(vars?.ackRetryMax, MAX_ACK_RETRIES);
417
+ });
418
+ });
419
+
381
420
  // Lineage threading (issue #245): `submitPr` persists the origin `root_request_key` on the PR row
382
421
  // and carries it onto the convergence instance; `startMerge` reads it back off the row onto the
383
422
  // merge instance. A human/webhook submit that supplies no root self-roots on the `pr_key` (its own
package/app/service.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  CONFORMANCE_ESCALATION_ELEMENT,
27
27
  conformanceEscalationQuestion,
28
28
  } from "./conformance.ts";
29
+ import { makeDefaultReadHead } from "./currentHead.ts";
29
30
  import { isUniqueConstraintFence } from "./dbFence.ts";
30
31
  import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
31
32
  import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
@@ -40,6 +41,7 @@ import {
40
41
  coalesceTitle,
41
42
  ensureFreshHeadRun,
42
43
  ensurePromotionPr,
44
+ fetchBranchHead,
43
45
  fetchDefaultBranch,
44
46
  fetchPrHead,
45
47
  fetchPrMeta,
@@ -91,7 +93,7 @@ import {
91
93
  // (submitPr/startMerge) and re-exported below so the long-standing `import { repoEnvelopeVars } from
92
94
  // "./service.ts"` call sites (and its tests) keep resolving.
93
95
  import { repoEnvelopeVars } from "./repoEnvelope.ts";
94
- import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
96
+ import { clampNudgeMinutes, isReviewStale, reviewWaitTimeout } from "./reviewWait.ts";
95
97
  import { trialMergeAudits } from "./trialMerge.ts";
96
98
  import {
97
99
  buildUserTaskRow,
@@ -157,6 +159,20 @@ export const MAX_REBASE_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_REBASE
157
159
  * retry (a race escalates immediately). Reuses the CI-fix budget clamp (allows 0 = disable). */
158
160
  export const MAX_MERGE_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_MERGE_RETRIES, 5);
159
161
 
162
+ /** How many times the convergence loop will re-dispatch the `senior:pr-review` (review-round) agent
163
+ * to auto-ack unacked suppressed advisories before escalating to a human. When the converge-gate
164
+ * blocks SOLELY on unacknowledged suppressed advisories (no unresolved inline threads), the block is
165
+ * recoverable: re-running the review-round agent posts the missing `nano-ack:` threads and converges,
166
+ * so the loop tries that — bounded — before parking the human `wait-answer` (issue #796). A resolved
167
+ * `Declined … nano-ack:` advisory is an acknowledgement and CONVERGES (issue #787), so a decline does
168
+ * not escalate; only the agent returning `needs_input` (a genuinely contested advisory it cannot
169
+ * decide) or `blocked` (an external blocker it reports with a question — the `gw-status` arm at
170
+ * `convergence-loop.bpmn:442-443` routes both to `wait-answer`), or this budget being exhausted,
171
+ * escalates. Default 2; set
172
+ * `NANO_PR_MAX_ACK_RETRIES=0` to escalate on the first ack-only block. Reuses the CI-fix budget clamp
173
+ * (allows 0 = disable, ceiling-capped). */
174
+ export const MAX_ACK_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_ACK_RETRIES, 2);
175
+
160
176
  /** How many times the mergeable-wait timeout backstop (`merge-stall-probe`) will re-derive
161
177
  * mergeability from ground truth and re-arm the merge stage before giving up and escalating to a
162
178
  * human. Bounds the timer arm of the `gw-merge-wait` event-based gateway so a dead in-process poller
@@ -642,6 +658,11 @@ export async function submitPr(
642
658
  round: 1,
643
659
  maxRounds: clampRounds(maxRounds, MAX_ROUNDS),
644
660
  reviewWaitTimeout: REVIEW_WAIT_TIMEOUT,
661
+ // Bounded agent auto-ack (issue #796): the convergence loop re-dispatches the review-round
662
+ // agent up to `ackRetryMax` times to ack suppressed advisories when the converge-gate blocks
663
+ // solely on unacked ones, before escalating to a human. `ackRetryRound` counts those passes.
664
+ ackRetryRound: 0,
665
+ ackRetryMax: MAX_ACK_RETRIES,
645
666
  // Lineage (issue #245): carry the origin identity onto the convergence instance so every
646
667
  // descendant (and any message it correlates) is stitched back to the originating request.
647
668
  // A human/webhook PR that is its own root carries its own `pr_key` (never NULL — see above).
@@ -833,7 +854,12 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
833
854
  * fresh-review detection + Copilot nudge below stay here because review freshness is inherently
834
855
  * STATEFUL (keyed off `last_review_id`/`waiting_since`), which the stateless probe matchers cannot
835
856
  * subsume — the poller owns the forward-progress guarantee, the gate owns the bounded wait. */
836
- async function pollReviews(data: DataLayer, engine: EngineClient, token: string) {
857
+ // The poller's PR current-head reader the SAME branch-ref-preferring reader the converge-gate and
858
+ // progress-check bind (#786/#799), so the poller's stale-review detection sees the exact head those
859
+ // steps do. Fails OPEN to `null` (unreadable head → not stale, per `isReviewStale`).
860
+ const readCurrentHead = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
861
+
862
+ export async function pollReviews(data: DataLayer, engine: EngineClient, token: string) {
837
863
  const waiting = await prs(data).find({ status: "waiting_review" });
838
864
  for (const pr of waiting) {
839
865
  const { repo, number, pr_key: prKey } = pr;
@@ -854,6 +880,34 @@ async function pollReviews(data: DataLayer, engine: EngineClient, token: string)
854
880
  await maybeRerequestReview(data, pr, token);
855
881
  continue;
856
882
  }
883
+ // #799 (FM2): only resume the loop on a review of the CURRENT head. If the PR HEAD has
884
+ // advanced past the commit the newest review was submitted against, that review is STALE — its
885
+ // advisories describe code the head has moved past (e.g. an advisory the agent already fixed in
886
+ // a later commit). Publishing `readiness-ready` on it would resume the loop on obsolete
887
+ // findings and, at the converge-gate, re-escalate a human on an already-fixed advisory (PR
888
+ // #789). Treat a stale review like "no fresh review": (re-)solicit a fresh review of the
889
+ // current head and wait — without bumping `last_review_id`, so the next HEAD-current review is
890
+ // still detected. The head read fails OPEN (null → not stale), so a transport hiccup never
891
+ // strands a genuine review; the review-wait timer remains the backstop.
892
+ //
893
+ // Read the head via the SHARED branch-ref-preferring reader (`makeDefaultReadHead`, #786) — the
894
+ // exact reader the converge-gate uses — NOT `fetchPrHead(...).headSha` directly: the PR object's
895
+ // `head.sha` is an asynchronously-denormalized projection that can still equal `fresh.commit_id`
896
+ // right after a push, which would make the poller advance `last_review_id` on a stale review the
897
+ // gate would then classify stale, wedging the loop. Using the atomic branch ref makes the poller
898
+ // detect the same stale-head case the gate does.
899
+ // Read the head with the SAME per-call token the review fetch above uses (#799) — NOT the
900
+ // env-token default `makeDefaultReadHead` would otherwise fall back to. A caller that supplies
901
+ // a `token` without also setting GITHUB_TOKEN would otherwise get a `null` head here, which
902
+ // `isReviewStale` treats as not-stale (fails OPEN) and would advance `last_review_id` /
903
+ // publish readiness on a genuinely stale review. Passing `token` keeps both reads on one
904
+ // credential so the stale guard sees the real head.
905
+ const headSha = await readCurrentHead(repo, number, token).catch(() => null);
906
+ if (isReviewStale(fresh.commit_id, headSha)) {
907
+ console.log(`[poller] review ${fresh.id} is stale (predates HEAD) -> re-soliciting ${prKey}`);
908
+ await maybeRerequestReview(data, pr, token);
909
+ continue;
910
+ }
857
911
  await prs(data).update(prKey, { last_review_id: fresh.id, status: "converging", updated_at: now() });
858
912
  await engine.publishMessage({
859
913
  name: READINESS_READY_MESSAGE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.187.5",
3
+ "version": "0.188.0",
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",