@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.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -0
- package/SPEC.md +90 -18
- package/app/contracts.ts +8 -0
- package/app/convergeAutoAck.test.ts +252 -0
- package/app/convergeGate.test.ts +393 -5
- package/app/convergeGate.ts +41 -5
- package/app/currentHead.ts +60 -0
- package/app/github.test.ts +65 -1
- package/app/github.ts +138 -32
- package/app/pollReviewsStale.test.ts +187 -0
- package/app/reviewWait.test.ts +33 -0
- package/app/reviewWait.ts +21 -0
- package/app/service.test.ts +40 -1
- package/app/service.ts +56 -2
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +176 -121
- package/resources/prompts/review-round.md +10 -0
- package/test/derivation-parity/README.md +5 -2
- package/test/derivation-parity/derivation-parity.test.ts +10 -8
- package/test/derivation-parity/flows.ts +6 -4
- package/workers/converge-gate/worker.ts +84 -25
- package/workers/progress-check/worker.ts +6 -44
package/app/service.test.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|