@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.
- package/.github/workflows/release.yml +18 -3
- package/.releaserc.json +7 -0
- package/CHANGELOG.md +12 -0
- package/app/contracts.ts +1 -1
- package/app/convergeAdversarial.test.ts +54 -0
- package/app/convergeGate.test.ts +186 -6
- package/app/github.ts +53 -3
- package/app/mergeLoopBehaviour.test.ts +12 -0
- package/app/mergeRetry.test.ts +49 -0
- package/app/reviewWait.test.ts +37 -0
- package/app/reviewWait.ts +26 -0
- package/app/roundProgress.test.ts +63 -2
- package/app/roundProgress.ts +6 -0
- package/app/version.test.ts +204 -0
- package/app/version.ts +64 -4
- package/build-info.json +6 -0
- package/docs/agent-guide.md +5 -0
- package/openapi.yaml +38 -0
- package/operations/getVersion.test.ts +3 -0
- package/operations/listActivePrs.test.ts +15 -0
- package/operations/listActivePrs.ts +5 -2
- package/package.json +2 -1
- package/resources/processes/convergence-loop.bpmn +5 -0
- package/resources/processes/merge-loop.bpmn +1 -1
- package/scripts/write-build-info.test.ts +87 -0
- package/scripts/write-build-info.ts +126 -0
- package/workers/converge-gate/worker.ts +61 -11
- package/workers/merge/worker.test.ts +192 -1
- package/workers/merge/worker.ts +118 -7
- package/workers/progress-check/worker.ts +40 -6
package/workers/merge/worker.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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"
|
|
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(
|
|
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
|
-
{
|
|
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, {
|
|
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
|
-
{
|
|
608
|
+
{
|
|
609
|
+
head: heal.sha,
|
|
610
|
+
status: "waiting_review",
|
|
611
|
+
agentWatermark,
|
|
612
|
+
...(waitCutoff ? { waitingSince: waitCutoff } : {}),
|
|
613
|
+
},
|
|
580
614
|
);
|
|
581
615
|
}
|
|
582
616
|
}
|