@nanobpm/nano-workforce 0.98.0 → 0.99.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 +14 -0
- package/SPEC.md +16 -12
- package/app/contracts.ts +7 -0
- package/app/github.ts +23 -5
- package/app/mergeEscalationQuestion.test.ts +17 -0
- package/app/mergeRetry.test.ts +90 -0
- package/app/mergeRetryArm.test.ts +100 -0
- package/app/readiness.ts +8 -0
- package/app/reviewReadinessGate.test.ts +92 -0
- package/app/reviewWait.ts +1 -1
- package/app/service.ts +34 -4
- package/main.ts +3 -2
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +3 -8
- package/resources/processes/merge-loop.bpmn +124 -68
- package/workers/merge/worker.test.ts +38 -0
- package/workers/merge/worker.ts +9 -3
- package/workers/readiness-probe/worker.ts +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.99.0](https://github.com/nanobpm/nano-workforce/compare/v0.98.1...v0.99.0) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **review-loop:** re-express the review-ready wait on the ReadinessProbe gate ([#259](https://github.com/nanobpm/nano-workforce/issues/259)) ([#338](https://github.com/nanobpm/nano-workforce/issues/338)) ([4d86509](https://github.com/nanobpm/nano-workforce/commit/4d86509f1f55dd9b31417add323e04da5b574b3d)), closes [#258](https://github.com/nanobpm/nano-workforce/issues/258) [#258](https://github.com/nanobpm/nano-workforce/issues/258)
|
|
7
|
+
|
|
8
|
+
## [0.98.1](https://github.com/nanobpm/nano-workforce/compare/v0.98.0...v0.98.1) (2026-08-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **merge-loop:** transient 'Base branch was modified' merge race → bounded retry, not escalate ([#335](https://github.com/nanobpm/nano-workforce/issues/335)) ([4093d50](https://github.com/nanobpm/nano-workforce/commit/4093d50b0ecd1a226bcfe7a5ecca10ba1db0937a)), closes [#330](https://github.com/nanobpm/nano-workforce/issues/330) [#334](https://github.com/nanobpm/nano-workforce/issues/334)
|
|
14
|
+
|
|
1
15
|
# [0.98.0](https://github.com/nanobpm/nano-workforce/compare/v0.97.1...v0.98.0) (2026-08-19)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -29,7 +29,7 @@ surface are proposed and open for adjustment.
|
|
|
29
29
|
submit (form / webhook)
|
|
30
30
|
│
|
|
31
31
|
▼
|
|
32
|
-
┌───────────────────┐
|
|
32
|
+
┌───────────────────┐ readiness-ready (msg) ┌──────────┐
|
|
33
33
|
│ convergence-loop │◀───────────────────────────────│ poller │
|
|
34
34
|
│ (BPMN) │ └────┬─────┘
|
|
35
35
|
└─────────┬─────────┘◀───────────────┐ │ polls
|
|
@@ -48,8 +48,10 @@ surface are proposed and open for adjustment.
|
|
|
48
48
|
agent worker slots and job timeouts are never held hostage to Copilot's reply
|
|
49
49
|
latency.
|
|
50
50
|
- **Poller**: an in-app background loop that watches waiting PRs and publishes
|
|
51
|
-
the `
|
|
52
|
-
|
|
51
|
+
the canonical `readiness-ready` wait-gate message (ADR 0001 §2) when a new
|
|
52
|
+
review lands — the "out-of-band poller-correlated shape" the review-ready wait
|
|
53
|
+
is re-expressed on (#259), so there is one "wait for the world" mechanism (no
|
|
54
|
+
GitHub webhook needed; works behind NAT).
|
|
53
55
|
|
|
54
56
|
## 3. Repository layout
|
|
55
57
|
|
|
@@ -100,7 +102,7 @@ known at submit time, carried as a process variable and stored on the DB row.
|
|
|
100
102
|
│ ├── converged → [Mark converged] → (end: converged)
|
|
101
103
|
│ │
|
|
102
104
|
│ ├── addressed → [Record round] → <event-based gateway: review ready or timeout?>
|
|
103
|
-
│ │ ├──
|
|
105
|
+
│ │ ├── readiness-ready (msg catch, key = prKey) → round++ ─┐
|
|
104
106
|
│ │ └── =reviewWaitTimeout (timer catch) │
|
|
105
107
|
│ │ → [Escalate: review stalled] (blocked) │
|
|
106
108
|
│ │ → [Wait: wait-answer userTask] ─────────────────────┤
|
|
@@ -127,8 +129,9 @@ Guard: before each Review round, if round > MAX_ROUNDS → force an escalation
|
|
|
127
129
|
```
|
|
128
130
|
|
|
129
131
|
Notes:
|
|
130
|
-
- On `addressed`, the loop parks at an **event-based gateway** that races
|
|
131
|
-
`
|
|
132
|
+
- On `addressed`, the loop parks at an **event-based gateway** that races the
|
|
133
|
+
canonical `readiness-ready` wait-gate message (ADR 0001 §2; correlated by the
|
|
134
|
+
poller when a fresh review lands)
|
|
132
135
|
against a `=reviewWaitTimeout` timer (seeded at submit from
|
|
133
136
|
`NANO_PR_REVIEW_WAIT_TIMEOUT`, default `PT20M`). Whichever fires first
|
|
134
137
|
withdraws the other — the message arm advances `round`, the timer arm escalates
|
|
@@ -136,7 +139,8 @@ Notes:
|
|
|
136
139
|
hanging forever. Because `persist-round` already recorded this `round` as
|
|
137
140
|
`addressed` before the gateway, the timer arm opens the escalation **without
|
|
138
141
|
re-recording the round** (it passes `recordRound=false`), so a single round is
|
|
139
|
-
never logged as both `addressed` and `blocked`. This
|
|
142
|
+
never logged as both `addressed` and `blocked`. This wait is re-expressed on the
|
|
143
|
+
ONE `ReadinessProbe` wait-gate primitive (#258, ADR 0001 §2); it replaced a bare
|
|
140
144
|
`review-ready` catch that could hang
|
|
141
145
|
indefinitely: Copilot won't re-review a round with no new commit and routinely
|
|
142
146
|
dismisses a re-request, so with no timeout a review that never arrives wedged
|
|
@@ -210,7 +214,7 @@ Consequences the prompt (`resources/prompts/review-round.md`) encodes:
|
|
|
210
214
|
| message | correlationKey | published by | payload |
|
|
211
215
|
|---|---|---|---|
|
|
212
216
|
| `pr-submitted` | — (start) | submit route/webhook | `{repo, prNumber, prUrl, prKey}` |
|
|
213
|
-
| `
|
|
217
|
+
| `readiness-ready` | `prKey` | **poller** | `{ready, detail?}` (ADR 0001 §2 wait-gate; the review-ready wait, re-expressed on the ReadinessProbe gate — #259) |
|
|
214
218
|
| `deps-cleared` | `prKey` | **poller** (merge) | — (all `Depends-on` PRs merged) |
|
|
215
219
|
| `merge-ready` | `prKey` | **poller** (merge) | `{mergeState}` (`ready` \| `conflict` \| `blocked`); when `blocked`, also `{failingChecks, failingChecksList}` for the `senior:fix-ci` branch |
|
|
216
220
|
| `merge-landed` | `prKey` | **poller** (merge) | — (queued PR merged, or merged out-of-band) |
|
|
@@ -368,8 +372,8 @@ An in-app loop (interval `NANO_PR_POLL_MS`, default 60s):
|
|
|
368
372
|
1. `SELECT pr_key, repo, number, waiting_since, last_review_id, last_nudge_at FROM pull_requests WHERE status = 'waiting_review'`.
|
|
369
373
|
2. For each, GET the PR's reviews from GitHub; find the newest review submitted
|
|
370
374
|
after `waiting_since` with id > `last_review_id`.
|
|
371
|
-
3. If found → publish `
|
|
372
|
-
`last_review_id`.
|
|
375
|
+
3. If found → publish the canonical `readiness-ready` wait-gate message (key =
|
|
376
|
+
`pr_key`, `{ready, detail}`) and set `last_review_id`.
|
|
373
377
|
4. If **not** found → ensure a review is in flight: unless Copilot is already a
|
|
374
378
|
pending reviewer, **re-request** it (REST `requested_reviewers`, exact login
|
|
375
379
|
`copilot-pull-request-reviewer[bot]`) and record `last_nudge_at`. This is
|
|
@@ -377,7 +381,7 @@ An in-app loop (interval `NANO_PR_POLL_MS`, default 60s):
|
|
|
377
381
|
so a re-request Copilot dismisses is retried without hammering the API. A repo
|
|
378
382
|
where Copilot isn't an assignable reviewer (HTTP 422) is left to the process's
|
|
379
383
|
review-wait timer (§4). This closes the stall where Copilot won't spontaneously
|
|
380
|
-
re-review and silently dismisses a re-request, so no `
|
|
384
|
+
re-review and silently dismisses a re-request, so no `readiness-ready` ever fires.
|
|
381
385
|
|
|
382
386
|
Requires a GitHub token (`GITHUB_TOKEN`) or the host `gh` CLI. One cheap API call
|
|
383
387
|
per waiting PR per interval (plus at most one reviewer-state check + re-request per
|
|
@@ -644,7 +648,7 @@ but encode incompatible decisions about a shared contract** — a genuine design
|
|
|
644
648
|
`baseRef`) that let large monorepos provision within the clone timeout (#287); the
|
|
645
649
|
harness is PR-agnostic and provisions from that envelope. The worker stays a pure
|
|
646
650
|
provisioner.
|
|
647
|
-
- **
|
|
651
|
+
- **readiness-ready via GitHub webhook** — same `readiness-ready` message, swappable faster trigger,
|
|
648
652
|
when the app is publicly reachable. Deferred (poller-only for v1).
|
|
649
653
|
- **Supervised vs external worker** — the agent runs as an external
|
|
650
654
|
`c8ctl nano work` daemon by default; a supervised in-server mode is possible
|
package/app/contracts.ts
CHANGED
|
@@ -117,6 +117,13 @@ export const ENV_CONTRACTS = {
|
|
|
117
117
|
semantics: "Maximum rebase attempts per PR.",
|
|
118
118
|
default: "3",
|
|
119
119
|
},
|
|
120
|
+
NANO_PR_MAX_MERGE_RETRIES: {
|
|
121
|
+
category: "env",
|
|
122
|
+
name: "NANO_PR_MAX_MERGE_RETRIES",
|
|
123
|
+
owner: "app/service.ts",
|
|
124
|
+
semantics: "Maximum transient base/head-moved merge-race retries per PR before escalating.",
|
|
125
|
+
default: "5",
|
|
126
|
+
},
|
|
120
127
|
NANO_PR_REVIEW_WAIT_TIMEOUT: {
|
|
121
128
|
category: "env",
|
|
122
129
|
name: "NANO_PR_REVIEW_WAIT_TIMEOUT",
|
package/app/github.ts
CHANGED
|
@@ -348,7 +348,7 @@ export async function hasPendingCopilotReviewer(
|
|
|
348
348
|
}
|
|
349
349
|
|
|
350
350
|
/** Request a fresh Copilot review on the PR (REST reviewers endpoint, exact `[bot]` login), so
|
|
351
|
-
* the process's `
|
|
351
|
+
* the process's `readiness-ready` review wait-gate catch can eventually fire. Returns `"requested"` on success,
|
|
352
352
|
* `"unavailable"` when Copilot is not an assignable reviewer on that repo (HTTP 422 — e.g.
|
|
353
353
|
* Copilot review not enabled there), or `null` when no transport is usable. Never throws for the
|
|
354
354
|
* 422 "not assignable" case; only a genuine transport failure propagates. */
|
|
@@ -822,14 +822,26 @@ export interface MergeOptions {
|
|
|
822
822
|
admin: boolean;
|
|
823
823
|
}
|
|
824
824
|
export interface MergeResult {
|
|
825
|
-
outcome: "merged" | "queued" | "blocked";
|
|
825
|
+
outcome: "merged" | "queued" | "blocked" | "retry";
|
|
826
826
|
detail: string;
|
|
827
827
|
}
|
|
828
828
|
|
|
829
|
+
/** GitHub-flagged *retryable* merge races: the base (or head) branch advanced between the
|
|
830
|
+
* mergeability read and the merge mutation, so GitHub aborted the merge with a "… try the merge
|
|
831
|
+
* again" message. These are transient — GitHub itself tells us to just retry — so the merge loop
|
|
832
|
+
* must re-attempt on the settled base, NOT page a human. Matches GitHub's stable message across
|
|
833
|
+
* both the GraphQL `mergePullRequest` error and its HTTP 405 REST variant. Kept narrow — the exact
|
|
834
|
+
* "<Base|Head> branch was modified" phrase — so a genuine block (conflict, failing required check,
|
|
835
|
+
* 403 perms, 422 not-mergeable) is never swallowed as transient. */
|
|
836
|
+
export function isTransientMergeRace(detail: string): boolean {
|
|
837
|
+
return /\b(?:base|head) branch was modified\b/i.test(detail);
|
|
838
|
+
}
|
|
839
|
+
|
|
829
840
|
/** Attempt to land the PR. Returns `merged` (landed now), `queued` (added to the repo's merge
|
|
830
|
-
* queue — the poller then watches for it to land),
|
|
831
|
-
*
|
|
832
|
-
*
|
|
841
|
+
* queue — the poller then watches for it to land), `retry` (a transient base/head-moved race —
|
|
842
|
+
* GitHub says to re-attempt on the settled base, no human needed), or `blocked` (GitHub refused —
|
|
843
|
+
* a human must resolve it, then reply to retry). `null` when no transport is usable. Never throws
|
|
844
|
+
* for a refused merge; only a genuine transport failure propagates. */
|
|
833
845
|
export async function mergePr(
|
|
834
846
|
repo: string,
|
|
835
847
|
number: number | string,
|
|
@@ -850,6 +862,9 @@ export async function mergePr(
|
|
|
850
862
|
// A merge-queue-required branch surfaces as an error on older gh; treat as queued when the
|
|
851
863
|
// message says so, otherwise it is a genuine block (conflict, failing gate, perms).
|
|
852
864
|
if (/added to the merge queue|enqueued/i.test(msg)) return { outcome: "queued", detail: msg };
|
|
865
|
+
// A base/head-moved race is transient (GitHub says to retry) — re-enter the merge loop
|
|
866
|
+
// rather than escalate. Checked before the catch-all block so it is never swallowed as blocked.
|
|
867
|
+
if (isTransientMergeRace(msg)) return { outcome: "retry", detail: msg };
|
|
853
868
|
return { outcome: "blocked", detail: msg };
|
|
854
869
|
}
|
|
855
870
|
}
|
|
@@ -877,6 +892,9 @@ export async function mergePr(
|
|
|
877
892
|
return { outcome: "queued", detail: "merge accepted; PR not yet landed (awaiting merge queue)" };
|
|
878
893
|
}
|
|
879
894
|
const detail = `github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim();
|
|
895
|
+
// The REST merge endpoint returns 405 "Base branch was modified. Review and try the merge again."
|
|
896
|
+
// for the same transient race — classify it as retry, not a human-actionable block.
|
|
897
|
+
if (isTransientMergeRace(detail)) return { outcome: "retry", detail };
|
|
880
898
|
return { outcome: "blocked", detail };
|
|
881
899
|
}
|
|
882
900
|
|
|
@@ -103,6 +103,23 @@ test("the question distinguishes all four blocked/SLA triggers rather than a sin
|
|
|
103
103
|
assertStringIncludes(el, 'agentVerdict = "blocked"', "must branch CI could-not-fix vs SLA on the agent verdict binding, not the overwritten status");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
test("retry-budget-exhausted escalation reads as a repeated race, not a generic merge refusal", () => {
|
|
107
|
+
// `f_mr_giveup` (transient merge-retry budget exhausted) routes into merge-esc-attempt with
|
|
108
|
+
// mergeState = "ready" AND mergeStatus = "retry". Without a dedicated branch this reused the
|
|
109
|
+
// generic gate-blocked ("Investigate why GitHub refused the merge") text, which is misleading for
|
|
110
|
+
// a repeated base/head-moved race whose retry budget simply ran out. The question must branch on
|
|
111
|
+
// mergeStatus = "retry" — ahead of the generic `mergeState = "ready"` arm — and name the budget.
|
|
112
|
+
assert(escAttempt, "merge-esc-attempt service task must exist");
|
|
113
|
+
const el = escAttempt![0];
|
|
114
|
+
assertStringIncludes(el, 'mergeStatus = "retry"', "must branch the retry-budget-exhausted escalation on mergeStatus = retry");
|
|
115
|
+
assertStringIncludes(el, "mergeRetryMax", "the retry-exhausted question must surface the retry budget");
|
|
116
|
+
// The retry branch must precede the generic `mergeState = "ready"` branch, or the generic arm
|
|
117
|
+
// (also true here) would shadow it and re-emit the misleading refusal text.
|
|
118
|
+
const retryIdx = el.indexOf('mergeStatus = "retry"');
|
|
119
|
+
const readyIdx = el.indexOf('mergeState = "ready"');
|
|
120
|
+
assert(retryIdx !== -1 && readyIdx !== -1 && retryIdx < readyIdx, "the retry branch must be evaluated before the generic ready branch");
|
|
121
|
+
});
|
|
122
|
+
|
|
106
123
|
test("a gw-merge-escalated guard honours persist-escalation's escalated:false (mirrors the convergence loop)", () => {
|
|
107
124
|
// The escalation output no longer flows UNCONDITIONALLY into the durable answer wait: it passes
|
|
108
125
|
// through a gateway that reads the worker's `escalated` output.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Unit tests for the transient merge-race classification (issue #334).
|
|
2
|
+
//
|
|
3
|
+
// #334: a GitHub-flagged *retryable* merge race — the base (or head) branch advanced between the
|
|
4
|
+
// mergeability read and the merge mutation, so GitHub aborted with "… try the merge again" — was
|
|
5
|
+
// misclassified by `mergePr`'s catch-all as `blocked`, producing a misleading human escalation on
|
|
6
|
+
// a PR that was actually mergeable once the base settled. The fix classifies the stable
|
|
7
|
+
// base/head-moved messages as a new `retry` outcome (the merge loop re-attempts on the settled
|
|
8
|
+
// base), while every genuine block (conflict / failing check / 403 perms / 422 not-mergeable)
|
|
9
|
+
// stays `blocked`. These tests pin that split so a real block is never swallowed as transient.
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { assertEquals } from "#test-assert";
|
|
12
|
+
import { isTransientMergeRace, mergePr } from "./github.ts";
|
|
13
|
+
|
|
14
|
+
test("isTransientMergeRace: the base/head-moved races are transient", () => {
|
|
15
|
+
// GraphQL mergePullRequest error (observed live on nano-workforce #330).
|
|
16
|
+
assertEquals(
|
|
17
|
+
isTransientMergeRace(
|
|
18
|
+
"GraphQL: Base branch was modified. Review and try the merge again. (mergePullRequest)",
|
|
19
|
+
),
|
|
20
|
+
true,
|
|
21
|
+
);
|
|
22
|
+
// The head-branch-moved sibling.
|
|
23
|
+
assertEquals(
|
|
24
|
+
isTransientMergeRace("Head branch was modified. Review and try the merge again."),
|
|
25
|
+
true,
|
|
26
|
+
);
|
|
27
|
+
// The HTTP 405 REST variant.
|
|
28
|
+
assertEquals(
|
|
29
|
+
isTransientMergeRace(
|
|
30
|
+
"github 405 Method Not Allowed: Base branch was modified. Review and try the merge again.",
|
|
31
|
+
),
|
|
32
|
+
true,
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("isTransientMergeRace: genuine blocks are NOT transient (never swallowed)", () => {
|
|
37
|
+
assertEquals(isTransientMergeRace("Pull Request is not mergeable"), false); // conflict / failing check
|
|
38
|
+
assertEquals(isTransientMergeRace("github 403 Forbidden: Resource not accessible"), false); // perms
|
|
39
|
+
assertEquals(
|
|
40
|
+
isTransientMergeRace("github 422 Unprocessable Entity: Required status check is expected"),
|
|
41
|
+
false,
|
|
42
|
+
); // not-mergeable gate
|
|
43
|
+
assertEquals(isTransientMergeRace("Merge conflict; base branch has conflicts"), false); // a base conflict is a real block
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Drive `mergePr`'s REST (token) transport: the 405 base-moved race must surface as `retry`, while
|
|
47
|
+
// a genuine 405 refusal stays `blocked`.
|
|
48
|
+
async function withMergePut(
|
|
49
|
+
status: number,
|
|
50
|
+
statusText: string,
|
|
51
|
+
body: string,
|
|
52
|
+
run: () => Promise<void>,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
55
|
+
const prevFetch = globalThis.fetch;
|
|
56
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
57
|
+
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
|
|
58
|
+
const url = String(input);
|
|
59
|
+
if (/\/pulls\/\d+\/merge$/.test(url) && (init?.method ?? "").toUpperCase() === "PUT") {
|
|
60
|
+
return Promise.resolve(new Response(body, { status, statusText }));
|
|
61
|
+
}
|
|
62
|
+
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
63
|
+
}) as typeof fetch;
|
|
64
|
+
try {
|
|
65
|
+
await run();
|
|
66
|
+
} finally {
|
|
67
|
+
globalThis.fetch = prevFetch;
|
|
68
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
69
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test("mergePr: a 405 base-moved race → outcome 'retry'", async () => {
|
|
74
|
+
await withMergePut(
|
|
75
|
+
405,
|
|
76
|
+
"Method Not Allowed",
|
|
77
|
+
"Base branch was modified. Review and try the merge again.",
|
|
78
|
+
async () => {
|
|
79
|
+
const res = await mergePr("acme/widgets", 42, "test-token", { method: "squash", admin: false });
|
|
80
|
+
assertEquals(res?.outcome, "retry");
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("mergePr: a genuine 405 refusal → outcome 'blocked' (not swallowed as transient)", async () => {
|
|
86
|
+
await withMergePut(405, "Method Not Allowed", "Pull Request is not mergeable", async () => {
|
|
87
|
+
const res = await mergePr("acme/widgets", 42, "test-token", { method: "squash", admin: false });
|
|
88
|
+
assertEquals(res?.outcome, "blocked");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Structural regression guard for the merge-loop transient-retry arm (issue #334).
|
|
2
|
+
//
|
|
3
|
+
// #334: a transient, GitHub-flagged-retryable merge race ("Base branch was modified. Review and
|
|
4
|
+
// try the merge again.") was mapped to `blocked` → a decision-required human escalation on a PR
|
|
5
|
+
// that was actually mergeable once the base settled. The fix adds a `retry` merge outcome that
|
|
6
|
+
// re-enters the merge loop on the settled base through a *bounded* budget gate — mirroring the
|
|
7
|
+
// `gw-ci-fix` within-budget / budget-exhausted pattern — WITHOUT any remediation agent, and
|
|
8
|
+
// escalates via the existing `merge-esc-attempt` only when the retry budget is exhausted (so a
|
|
9
|
+
// continuously-moving base still escalates promptly rather than spinning forever).
|
|
10
|
+
//
|
|
11
|
+
// This test asserts the arm's topology on the committed model so it cannot regress silently. It is
|
|
12
|
+
// a pure text assertion over the BPMN (no engine), matching the repo's lightweight model-guard style.
|
|
13
|
+
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import { assert, assertStringIncludes } from "#test-assert";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
|
|
18
|
+
const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
19
|
+
const flat = bpmn.replace(/\s+/g, " ");
|
|
20
|
+
|
|
21
|
+
function hasFlow(source: string, target: string): boolean {
|
|
22
|
+
const re = new RegExp(
|
|
23
|
+
`<bpmn:sequenceFlow\\b[^>]*\\bsourceRef="${source}"[^>]*\\btargetRef="${target}"|` +
|
|
24
|
+
`<bpmn:sequenceFlow\\b[^>]*\\btargetRef="${target}"[^>]*\\bsourceRef="${source}"`,
|
|
25
|
+
);
|
|
26
|
+
return re.test(flat);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function flowHasId(id: string, source: string, target: string): boolean {
|
|
30
|
+
const m = flat.match(new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*/?>`));
|
|
31
|
+
if (!m) return false;
|
|
32
|
+
const tag = m[0];
|
|
33
|
+
return tag.includes(`sourceRef="${source}"`) && tag.includes(`targetRef="${target}"`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function gatewayDefault(id: string, def: string): boolean {
|
|
37
|
+
const m = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${id}"[^>]*>`));
|
|
38
|
+
if (!m) return false;
|
|
39
|
+
return m[0].includes(`default="${def}"`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Assert the conditionExpression *of a specific sequenceFlow* contains `needle`, so the guard
|
|
43
|
+
// cannot be satisfied by the same substring appearing on an unrelated flow (e.g. the
|
|
44
|
+
// merge-esc-attempt question FEEL also mentions `mergeStatus = "retry"`).
|
|
45
|
+
function flowHasCondition(id: string, needle: string): boolean {
|
|
46
|
+
const m = flat.match(
|
|
47
|
+
new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*>(.*?)</bpmn:sequenceFlow>`),
|
|
48
|
+
);
|
|
49
|
+
if (!m) return false;
|
|
50
|
+
const cond = m[1].match(/<bpmn:conditionExpression\b[^>]*>(.*?)<\/bpmn:conditionExpression>/);
|
|
51
|
+
return cond ? cond[1].includes(needle) : false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("gw-merge routes the retry outcome to a dedicated budget gate, not to a human", () => {
|
|
55
|
+
// A `retry` merge result must reach the retry-budget gate…
|
|
56
|
+
assert(hasFlow("gw-merge", "gw-merge-retry"), "gw-merge → gw-merge-retry (retry) missing");
|
|
57
|
+
// …guarded by the exact retry condition (mergeStatus = "retry").
|
|
58
|
+
assert(flowHasId("f_m_gRetry", "gw-merge", "gw-merge-retry"), "f_m_gRetry must be gw-merge → gw-merge-retry");
|
|
59
|
+
// Assert the retry condition on f_m_gRetry ITSELF — not merely anywhere in the model — so the
|
|
60
|
+
// guard cannot be satisfied by the identical substring in the merge-esc-attempt question FEEL.
|
|
61
|
+
assert(
|
|
62
|
+
flowHasCondition("f_m_gRetry", 'mergeStatus = "retry"'),
|
|
63
|
+
'f_m_gRetry conditionExpression must be mergeStatus = "retry"',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("retry arm mirrors the fix-ci arm: budget gate → re-arm, exhausted → escalate", () => {
|
|
68
|
+
// Within budget → re-arm the merge poller (re-attempt on the settled base). No remediation agent.
|
|
69
|
+
assert(hasFlow("gw-merge-retry", "arm-merge"), "gw-merge-retry → arm-merge (within budget) missing");
|
|
70
|
+
assert(flowHasId("f_mr_go", "gw-merge-retry", "arm-merge"), "f_mr_go must be gw-merge-retry → arm-merge");
|
|
71
|
+
assertStringIncludes(flat, "mergeRetryRound <= mergeRetryMax");
|
|
72
|
+
|
|
73
|
+
// Budget exhausted → the EXISTING human escalation (merge-esc-attempt), and it is the gateway default
|
|
74
|
+
// so a continuously-moving base can never spin past the cap.
|
|
75
|
+
assert(
|
|
76
|
+
hasFlow("gw-merge-retry", "merge-esc-attempt"),
|
|
77
|
+
"gw-merge-retry → merge-esc-attempt (budget exhausted) missing",
|
|
78
|
+
);
|
|
79
|
+
assert(gatewayDefault("gw-merge-retry", "f_mr_giveup"), "gw-merge-retry must default to f_mr_giveup");
|
|
80
|
+
assert(
|
|
81
|
+
flowHasId("f_mr_giveup", "gw-merge-retry", "merge-esc-attempt"),
|
|
82
|
+
"f_mr_giveup must default gw-merge-retry → merge-esc-attempt",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the retry arm advances the attempt counter only on a transient retry outcome", () => {
|
|
87
|
+
// The counter advances ONLY when the merge attempt returned `retry` — mirroring how fix-ci/rebase
|
|
88
|
+
// advance their own rounds only on their own remediation — so unrelated merge attempts (initial,
|
|
89
|
+
// post-rebase, post-fix-ci, post-evict) can't consume the transient-retry budget. N consecutive
|
|
90
|
+
// transient races then trip the `mergeRetryRound <= mergeRetryMax` gate at exactly the cap.
|
|
91
|
+
assertStringIncludes(flat, "=if mergeStatus = "retry" then mergeRetryRound + 1 else mergeRetryRound");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("the retry arm has NO remediation agent (contrast the conflict/rebase arm)", () => {
|
|
95
|
+
// The base-moved race needs only a re-attempt on the settled base — no rebase/CI-fix agent. The
|
|
96
|
+
// within-budget flow goes straight back to arm-merge, never through a `senior:*` task.
|
|
97
|
+
assert(flowHasId("f_mr_go", "gw-merge-retry", "arm-merge"), "retry within-budget must go directly to arm-merge");
|
|
98
|
+
// Sanity: the untouched blocked path still escalates directly (a genuine refusal is unchanged).
|
|
99
|
+
assert(flowHasId("f_m_gBlocked", "gw-merge", "merge-esc-attempt"), "blocked → merge-esc-attempt must remain");
|
|
100
|
+
});
|
package/app/readiness.ts
CHANGED
|
@@ -150,6 +150,14 @@ export const DEFAULT_ATTEMPT_TIMEOUT_MS = 60_000;
|
|
|
150
150
|
/** Default gate timeout when neither the descriptor nor `NANO_READINESS_POLL_TIMEOUT` supplies one. */
|
|
151
151
|
export const DEFAULT_READINESS_TIMEOUT = "PT30M";
|
|
152
152
|
|
|
153
|
+
/** The ONE canonical readiness-signal message the wait-gate correlates on to release the wait
|
|
154
|
+
* (ADR 0001 §2) — the correlation key is per-caller (`=gateKey` for the in-flow probe worker,
|
|
155
|
+
* `=prKey` for the review-ready wait), not a single fixed key. Both the in-flow probe worker
|
|
156
|
+
* (`pr.readiness-probe`) and the out-of-band
|
|
157
|
+
* poller-correlated shape used by the review-ready migration (#259, per #258 pinned decision 3)
|
|
158
|
+
* publish under this ONE name — so there is a single "wait for the world" message, no bespoke twin. */
|
|
159
|
+
export const READINESS_READY_MESSAGE = "readiness-ready";
|
|
160
|
+
|
|
153
161
|
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
|
|
154
162
|
const num = (v: unknown): number | undefined =>
|
|
155
163
|
typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Structural guard for the review-ready wait re-expressed on the canonical ReadinessProbe
|
|
2
|
+
// wait-gate contract (ADR 0001 §2, issue #259).
|
|
3
|
+
//
|
|
4
|
+
// #258 landed the generic durable wait-gate: an event-based gateway racing a canonical
|
|
5
|
+
// `readiness-ready` signal against a bounded timer that escalates. #259 collapses nwf's bespoke
|
|
6
|
+
// review-ready poll path onto that ONE primitive — so there is a single "wait for the world"
|
|
7
|
+
// message, no drift-prone twin. The out-of-band poller-correlated shape (#258 pinned decision 3)
|
|
8
|
+
// keeps the canonical self-scheduling poller (`pollReviews` in app/service.ts) publishing the
|
|
9
|
+
// signal when a fresh review lands, while the convergence-loop parks on the canonical gate.
|
|
10
|
+
//
|
|
11
|
+
// These pure text assertions over the committed BPMN lock the migrated wait: it now catches the
|
|
12
|
+
// canonical `readiness-ready` message (the bespoke `review-ready` message is GONE), and it stays
|
|
13
|
+
// BOUNDED — the event-based gateway still races the signal against the `reviewWaitTimeout` timer,
|
|
14
|
+
// whose arm escalates to a human. Matches the repo's model-guard style (see roundResultDefault.test.ts).
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { test } from "node:test";
|
|
18
|
+
import { assert, assertStringIncludes } from "#test-assert";
|
|
19
|
+
import { READINESS_READY_MESSAGE } from "./readiness.ts";
|
|
20
|
+
|
|
21
|
+
const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
22
|
+
const flat = bpmn.replace(/\s+/g, " ");
|
|
23
|
+
|
|
24
|
+
test("the review wait catches the canonical readiness-ready message, not a bespoke twin", () => {
|
|
25
|
+
// The one canonical wait-gate signal (#258), re-used here — no separate `review-ready` message.
|
|
26
|
+
const msg = flat.match(/<bpmn:message\b[^>]*\bname="readiness-ready"[\s\S]*?<\/bpmn:message>/);
|
|
27
|
+
assert(msg, "convergence-loop must declare the canonical readiness-ready message");
|
|
28
|
+
assertStringIncludes(msg![0], 'correlationKey="=prKey"', "correlation shape is preserved (=prKey)");
|
|
29
|
+
|
|
30
|
+
// The bespoke review-ready message/shape must be fully retired (one mechanism, no drift).
|
|
31
|
+
assert(!/name="review-ready"/.test(flat), "the bespoke review-ready message must be gone");
|
|
32
|
+
assert(!/id="Message_reviewReady"/.test(flat), "the bespoke Message_reviewReady must be gone");
|
|
33
|
+
assert(!/id="ReviewReady"/.test(flat), "the bespoke ReviewReady payload shape must be gone");
|
|
34
|
+
|
|
35
|
+
// And the constant the poller publishes under is the very same canonical name.
|
|
36
|
+
assert(new RegExp(`name="${READINESS_READY_MESSAGE}"`).test(flat), "message name matches the canonical constant");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("the review-ready catch subscribes to the canonical readiness-ready message", () => {
|
|
40
|
+
const wait = flat.match(/<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-review"[\s\S]*?<\/bpmn:intermediateCatchEvent>/);
|
|
41
|
+
assert(wait, "the wait-review catch event must exist");
|
|
42
|
+
assertStringIncludes(
|
|
43
|
+
wait![0],
|
|
44
|
+
'messageRef="Message_readinessReady"',
|
|
45
|
+
"wait-review must catch the canonical readiness-ready message",
|
|
46
|
+
);
|
|
47
|
+
// The round increment (forward progress) is preserved on the same catch.
|
|
48
|
+
assertStringIncludes(wait![0], 'target="round"', "wait-review still advances the round on a fresh signal");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("the migrated wait stays bounded: an event-based gateway races the signal against a timer", () => {
|
|
52
|
+
// The gate shape from #258: an event-based gateway forks to the signal catch and a timer catch.
|
|
53
|
+
const gw = flat.match(/<bpmn:eventBasedGateway\b[^>]*\bid="gw-review-wait"[^>]*>/);
|
|
54
|
+
assert(gw, "the review wait must be an event-based gateway race (bounded gate)");
|
|
55
|
+
|
|
56
|
+
const timeout = flat.match(
|
|
57
|
+
/<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-review-timeout"[\s\S]*?<\/bpmn:intermediateCatchEvent>/,
|
|
58
|
+
);
|
|
59
|
+
assert(timeout, "the bounded timer arm (wait-review-timeout) must exist");
|
|
60
|
+
assertStringIncludes(timeout![0], "<bpmn:timerEventDefinition", "the timer arm bounds the wait");
|
|
61
|
+
assertStringIncludes(timeout![0], "=reviewWaitTimeout", "the timer is seeded with the review-wait timeout");
|
|
62
|
+
|
|
63
|
+
// The gateway forks to BOTH the signal catch and the timer catch (the race).
|
|
64
|
+
assert(
|
|
65
|
+
/sourceRef="gw-review-wait"[^>]*targetRef="wait-review"|targetRef="wait-review"[^>]*sourceRef="gw-review-wait"/.test(
|
|
66
|
+
flat,
|
|
67
|
+
),
|
|
68
|
+
"the gateway races toward the readiness signal catch",
|
|
69
|
+
);
|
|
70
|
+
assert(
|
|
71
|
+
/sourceRef="gw-review-wait"[^>]*targetRef="wait-review-timeout"|targetRef="wait-review-timeout"[^>]*sourceRef="gw-review-wait"/.test(
|
|
72
|
+
flat,
|
|
73
|
+
),
|
|
74
|
+
"the gateway races toward the bounded timer catch",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the timeout arm escalates to a human (the wait cannot hang forever)", () => {
|
|
79
|
+
// timer catch → persist-review-stalled (records the escalation) → wait-answer (native userTask).
|
|
80
|
+
assert(
|
|
81
|
+
/sourceRef="wait-review-timeout"[^>]*targetRef="persist-review-stalled"|targetRef="persist-review-stalled"[^>]*sourceRef="wait-review-timeout"/.test(
|
|
82
|
+
flat,
|
|
83
|
+
),
|
|
84
|
+
"a timed-out review wait routes to the stalled-review escalation",
|
|
85
|
+
);
|
|
86
|
+
assert(
|
|
87
|
+
/sourceRef="persist-review-stalled"[^>]*targetRef="wait-answer"|targetRef="wait-answer"[^>]*sourceRef="persist-review-stalled"/.test(
|
|
88
|
+
flat,
|
|
89
|
+
),
|
|
90
|
+
"the stalled-review escalation parks on the human answer userTask",
|
|
91
|
+
);
|
|
92
|
+
});
|
package/app/reviewWait.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Two knobs govern the review-wait watchdog:
|
|
7
7
|
// • the *timeout* — an ISO-8601 duration handed to the process's `wait-review-timeout` timer
|
|
8
|
-
// catch (the far side of the event-based-gateway race against `
|
|
8
|
+
// catch (the far side of the event-based-gateway race against `readiness-ready`). If no fresh
|
|
9
9
|
// review arrives within it, the loop escalates to a human instead of hanging forever.
|
|
10
10
|
// • the *nudge cooldown* — how long the poller waits between automatic Copilot re-requests for
|
|
11
11
|
// one waiting PR, so a re-request that Copilot dismisses is retried without hammering the API.
|
package/app/service.ts
CHANGED
|
@@ -54,7 +54,14 @@ import {
|
|
|
54
54
|
planTasks,
|
|
55
55
|
} from "./plan.ts";
|
|
56
56
|
import { derivePromotionState, isPromotable, promotionPrBody, promotionPrTitle } from "./promotion.ts";
|
|
57
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
defaultProbeExec,
|
|
59
|
+
type ProbeExec,
|
|
60
|
+
probeOnce,
|
|
61
|
+
READINESS_READY_MESSAGE,
|
|
62
|
+
type ReadinessProbe,
|
|
63
|
+
readinessTimeout,
|
|
64
|
+
} from "./readiness.ts";
|
|
58
65
|
import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
59
66
|
import { trialMergeAudits } from "./trialMerge.ts";
|
|
60
67
|
import {
|
|
@@ -104,6 +111,14 @@ export const MAX_CI_FIX_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_CI_FIX
|
|
|
104
111
|
* immediately). Reuses the CI-fix budget clamp (allows 0 = disable, ceiling-capped). */
|
|
105
112
|
export const MAX_REBASE_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_REBASE_ROUNDS, 3);
|
|
106
113
|
|
|
114
|
+
/** How many times the merge stage will re-attempt a merge that GitHub aborted with a *transient*
|
|
115
|
+
* base/head-moved race (see `isTransientMergeRace`) before giving up and escalating to a human.
|
|
116
|
+
* The re-attempt runs on the settled base (no remediation agent); the cap keeps a continuously
|
|
117
|
+
* moving base — or a persistently-failing merge — from spinning forever, escalating within
|
|
118
|
+
* seconds-to-minutes instead. Default 5; set `NANO_PR_MAX_MERGE_RETRIES=0` to disable transient
|
|
119
|
+
* retry (a race escalates immediately). Reuses the CI-fix budget clamp (allows 0 = disable). */
|
|
120
|
+
export const MAX_MERGE_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_MERGE_RETRIES, 5);
|
|
121
|
+
|
|
107
122
|
/** How long a merge-loop AGENT service task (rebase / fix-ci) may sit without completing before its
|
|
108
123
|
* interrupting timer boundary fires and the PR escalates for human attention. Seeded as the
|
|
109
124
|
* `agentSlaTimeout` process variable at merge start and evaluated by those tasks' boundary timers.
|
|
@@ -577,6 +592,8 @@ export async function startMerge(
|
|
|
577
592
|
ciFixMax: MAX_CI_FIX_ROUNDS,
|
|
578
593
|
rebaseRound: 0,
|
|
579
594
|
rebaseMax: MAX_REBASE_ROUNDS,
|
|
595
|
+
mergeRetryRound: 0,
|
|
596
|
+
mergeRetryMax: MAX_MERGE_RETRIES,
|
|
580
597
|
agentSlaTimeout: AGENT_SLA_TIMEOUT,
|
|
581
598
|
// Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
|
|
582
599
|
rootRequestKey,
|
|
@@ -658,7 +675,17 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
|
|
|
658
675
|
|
|
659
676
|
/** One review-ready poll pass (SPEC §10): for every PR waiting on a review, fetch its GitHub
|
|
660
677
|
* reviews (via the host `gh` CLI or a token — see `app/github.ts`) and, on a fresh one,
|
|
661
|
-
* correlate
|
|
678
|
+
* correlate the canonical `readiness-ready` message to resume the loop.
|
|
679
|
+
*
|
|
680
|
+
* This is the review-ready wait re-expressed on the ONE `ReadinessProbe` wait-gate contract
|
|
681
|
+
* (ADR 0001 §2, issue #259): the convergence-loop parks on the canonical `readiness-ready` gate
|
|
682
|
+
* (event-based gateway racing the signal against the bounded `reviewWaitTimeout` timer), and this
|
|
683
|
+
* canonical self-scheduling poller publishes that signal out-of-band when a fresh review lands —
|
|
684
|
+
* the "out-of-band poller-correlated shape" #258 pinned decision 3 reserved for exactly this
|
|
685
|
+
* migration. There is no bespoke `review-ready` message any more: one mechanism, no drift. The
|
|
686
|
+
* fresh-review detection + Copilot nudge below stay here because review freshness is inherently
|
|
687
|
+
* STATEFUL (keyed off `last_review_id`/`waiting_since`), which the stateless probe matchers cannot
|
|
688
|
+
* subsume — the poller owns the forward-progress guarantee, the gate owns the bounded wait. */
|
|
662
689
|
async function pollReviews(data: DataLayer, engine: EngineClient, token: string) {
|
|
663
690
|
const waiting = await prs(data).find({ status: "waiting_review" });
|
|
664
691
|
for (const pr of waiting) {
|
|
@@ -682,9 +709,12 @@ async function pollReviews(data: DataLayer, engine: EngineClient, token: string)
|
|
|
682
709
|
}
|
|
683
710
|
await prs(data).update(prKey, { last_review_id: fresh.id, status: "converging", updated_at: now() });
|
|
684
711
|
await engine.publishMessage({
|
|
685
|
-
name:
|
|
712
|
+
name: READINESS_READY_MESSAGE,
|
|
686
713
|
correlationKey: prKey,
|
|
687
|
-
|
|
714
|
+
// The canonical readiness-signal payload (`ReadinessReady` shape): the gate only needs to
|
|
715
|
+
// know the probe went green. The fresh review's id/state is bookkept above on the PR row,
|
|
716
|
+
// not carried on the message — the convergence-loop consumes only the round increment.
|
|
717
|
+
variables: { ready: true, detail: `review ${fresh.id} (${fresh.state})` },
|
|
688
718
|
});
|
|
689
719
|
console.log(`[poller] review ${fresh.id} (${fresh.state}) -> ${prKey}`);
|
|
690
720
|
} catch (err) {
|
package/main.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// control endpoints plus the webhook operations under `/app/api/hooks/*` (ADR 0059).
|
|
10
10
|
//
|
|
11
11
|
// The only thing that isn't declarative is the review-ready poller: it does arbitrary GitHub
|
|
12
|
-
// polling and then correlates
|
|
12
|
+
// polling and then correlates the canonical `readiness-ready` wait-gate message (#259). A cron
|
|
13
|
+
// trigger can only fire an engine
|
|
13
14
|
// start/message action, not this custom I/O glue, so it stays app-side here — driving the same
|
|
14
15
|
// engine client the runtime uses, over `app.data`.
|
|
15
16
|
//
|
|
@@ -89,7 +90,7 @@ if (httpServer instanceof Server) {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
// Review-ready poller. Self-scheduling (not setInterval) so a slow GitHub call can never
|
|
92
|
-
// overlap two passes (which could double-signal `
|
|
93
|
+
// overlap two passes (which could double-signal `readiness-ready`); the next pass is scheduled
|
|
93
94
|
// only after the previous one settles.
|
|
94
95
|
let shuttingDown = false;
|
|
95
96
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.99.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",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
2
|
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" xmlns:nano="https://nanobpm.io/schema/shapes/1.0" id="Definitions_nano_workforce" targetNamespace="http://nanobpm.io/nano-workforce">
|
|
3
|
-
<bpmn:message id="
|
|
3
|
+
<bpmn:message id="Message_readinessReady" name="readiness-ready">
|
|
4
4
|
<bpmn:extensionElements>
|
|
5
5
|
<zeebe:subscription correlationKey="=prKey" />
|
|
6
6
|
<zeebe:properties>
|
|
7
|
-
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="
|
|
7
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="ReadinessReady" />
|
|
8
8
|
</zeebe:properties>
|
|
9
9
|
</bpmn:extensionElements>
|
|
10
10
|
</bpmn:message>
|
|
@@ -82,11 +82,6 @@
|
|
|
82
82
|
<nano:extend name="convergeOnly" type="boolean" optional="true" />
|
|
83
83
|
<nano:extend name="abandonUrl" type="string" optional="true" />
|
|
84
84
|
</nano:shape>
|
|
85
|
-
<nano:shape id="ReviewReady" name="review-ready message payload">
|
|
86
|
-
<nano:extend name="reviewId" type="integer" />
|
|
87
|
-
<nano:extend name="reviewState" type="string" />
|
|
88
|
-
<nano:extend name="submittedAt" type="datetime" optional="true" />
|
|
89
|
-
</nano:shape>
|
|
90
85
|
</nano:shapes>
|
|
91
86
|
</bpmn:extensionElements>
|
|
92
87
|
<bpmn:startEvent id="Start" name="PR submitted">
|
|
@@ -181,7 +176,7 @@
|
|
|
181
176
|
</bpmn:extensionElements>
|
|
182
177
|
<bpmn:incoming>f_toReviewWait</bpmn:incoming>
|
|
183
178
|
<bpmn:outgoing>f_reviewLoop</bpmn:outgoing>
|
|
184
|
-
<bpmn:messageEventDefinition id="
|
|
179
|
+
<bpmn:messageEventDefinition id="med_readinessReady" messageRef="Message_readinessReady" />
|
|
185
180
|
</bpmn:intermediateCatchEvent>
|
|
186
181
|
<bpmn:intermediateCatchEvent id="wait-review-timeout" name="Wait: review timeout">
|
|
187
182
|
<bpmn:incoming>f_toReviewTimeout</bpmn:incoming>
|
|
@@ -106,6 +106,7 @@
|
|
|
106
106
|
<bpmn:incoming>f_reb_rebased</bpmn:incoming>
|
|
107
107
|
<bpmn:incoming>f_m_evicted</bpmn:incoming>
|
|
108
108
|
<bpmn:incoming>f_m_escReenter</bpmn:incoming>
|
|
109
|
+
<bpmn:incoming>f_mr_go</bpmn:incoming>
|
|
109
110
|
<bpmn:outgoing>f_m_arm</bpmn:outgoing>
|
|
110
111
|
</bpmn:serviceTask>
|
|
111
112
|
<bpmn:intermediateCatchEvent id="wait-mergeable" name="Wait: mergeable">
|
|
@@ -127,6 +128,9 @@
|
|
|
127
128
|
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="MergeAttemptIn" />
|
|
128
129
|
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="MergeAttemptOut" />
|
|
129
130
|
</zeebe:properties>
|
|
131
|
+
<zeebe:ioMapping>
|
|
132
|
+
<zeebe:output source="=if mergeStatus = "retry" then mergeRetryRound + 1 else mergeRetryRound" target="mergeRetryRound" />
|
|
133
|
+
</zeebe:ioMapping>
|
|
130
134
|
</bpmn:extensionElements>
|
|
131
135
|
<bpmn:incoming>f_m_mReady</bpmn:incoming>
|
|
132
136
|
<bpmn:outgoing>f_m_attempt</bpmn:outgoing>
|
|
@@ -135,8 +139,14 @@
|
|
|
135
139
|
<bpmn:incoming>f_m_attempt</bpmn:incoming>
|
|
136
140
|
<bpmn:outgoing>f_m_gMerged</bpmn:outgoing>
|
|
137
141
|
<bpmn:outgoing>f_m_gQueued</bpmn:outgoing>
|
|
142
|
+
<bpmn:outgoing>f_m_gRetry</bpmn:outgoing>
|
|
138
143
|
<bpmn:outgoing>f_m_gBlocked</bpmn:outgoing>
|
|
139
144
|
</bpmn:exclusiveGateway>
|
|
145
|
+
<bpmn:exclusiveGateway id="gw-merge-retry" name="retry within budget?" default="f_mr_giveup">
|
|
146
|
+
<bpmn:incoming>f_m_gRetry</bpmn:incoming>
|
|
147
|
+
<bpmn:outgoing>f_mr_go</bpmn:outgoing>
|
|
148
|
+
<bpmn:outgoing>f_mr_giveup</bpmn:outgoing>
|
|
149
|
+
</bpmn:exclusiveGateway>
|
|
140
150
|
<bpmn:eventBasedGateway id="eg-landed" name="landed or evicted?">
|
|
141
151
|
<bpmn:incoming>f_m_gQueued</bpmn:incoming>
|
|
142
152
|
<bpmn:outgoing>f_eg_landed</bpmn:outgoing>
|
|
@@ -193,7 +203,7 @@
|
|
|
193
203
|
</zeebe:properties>
|
|
194
204
|
<zeebe:ioMapping>
|
|
195
205
|
<zeebe:input source="=status" target="agentVerdict" />
|
|
196
|
-
<zeebe:input source="="This PR cannot be merged and needs a human decision. " + (if mergeState = "conflict" then "The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry." else if mergeState = "ready" then "The merge attempt did not land (merge result: " + mergeStatus + "). Investigate why GitHub refused the merge, then reply to retry." else if agentVerdict = "blocked" then "The automated CI-fix agent could not make the required checks green. Fix the failing checks on the branch (or reply with guidance), then reply to retry." else "The automated CI-fix agent exceeded its time budget (SLA) before the required checks went green. Check its progress or intervene, then reply to retry.")" target="question" />
|
|
206
|
+
<zeebe:input source="="This PR cannot be merged and needs a human decision. " + (if mergeState = "conflict" then "The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry." else if mergeStatus = "retry" then "The merge kept losing a race with concurrent updates to the base branch and exhausted its automatic retry budget (" + string(mergeRetryMax) + " retries). This is a repeated transient race, not a conflict, failing check, or permissions block. Reply to retry, or investigate why the base branch keeps moving." else if mergeState = "ready" then "The merge attempt did not land (merge result: " + mergeStatus + "). Investigate why GitHub refused the merge, then reply to retry." else if agentVerdict = "blocked" then "The automated CI-fix agent could not make the required checks green. Fix the failing checks on the branch (or reply with guidance), then reply to retry." else "The automated CI-fix agent exceeded its time budget (SLA) before the required checks went green. Check its progress or intervene, then reply to retry.")" target="question" />
|
|
197
207
|
<zeebe:input source="="blocked"" target="status" />
|
|
198
208
|
</zeebe:ioMapping>
|
|
199
209
|
</bpmn:extensionElements>
|
|
@@ -201,6 +211,7 @@
|
|
|
201
211
|
<bpmn:incoming>f_ci_blocked</bpmn:incoming>
|
|
202
212
|
<bpmn:incoming>f_reb_blocked</bpmn:incoming>
|
|
203
213
|
<bpmn:incoming>f_ci_sla</bpmn:incoming>
|
|
214
|
+
<bpmn:incoming>f_mr_giveup</bpmn:incoming>
|
|
204
215
|
<bpmn:outgoing>f_m_escA</bpmn:outgoing>
|
|
205
216
|
</bpmn:serviceTask>
|
|
206
217
|
<bpmn:exclusiveGateway id="gw-merge-escalated" name="escalation opened?" default="f_m_escReenter">
|
|
@@ -370,6 +381,13 @@
|
|
|
370
381
|
<bpmn:sequenceFlow id="f_m_gQueued" name="queued" sourceRef="gw-merge" targetRef="eg-landed">
|
|
371
382
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStatus = "queued"</bpmn:conditionExpression>
|
|
372
383
|
</bpmn:sequenceFlow>
|
|
384
|
+
<bpmn:sequenceFlow id="f_m_gRetry" name="retry (base moved)" sourceRef="gw-merge" targetRef="gw-merge-retry">
|
|
385
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStatus = "retry"</bpmn:conditionExpression>
|
|
386
|
+
</bpmn:sequenceFlow>
|
|
387
|
+
<bpmn:sequenceFlow id="f_mr_go" name="within budget" sourceRef="gw-merge-retry" targetRef="arm-merge">
|
|
388
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeRetryRound <= mergeRetryMax</bpmn:conditionExpression>
|
|
389
|
+
</bpmn:sequenceFlow>
|
|
390
|
+
<bpmn:sequenceFlow id="f_mr_giveup" name="budget exhausted" sourceRef="gw-merge-retry" targetRef="merge-esc-attempt" />
|
|
373
391
|
<bpmn:sequenceFlow id="f_eg_landed" sourceRef="eg-landed" targetRef="wait-landed" />
|
|
374
392
|
<bpmn:sequenceFlow id="f_eg_evicted" sourceRef="eg-landed" targetRef="wait-evicted" />
|
|
375
393
|
<bpmn:sequenceFlow id="f_m_evicted" sourceRef="wait-evicted" targetRef="arm-merge" />
|
|
@@ -425,6 +443,12 @@
|
|
|
425
443
|
<dc:Bounds x="1022" y="150" width="53" height="28" />
|
|
426
444
|
</bpmndi:BPMNLabel>
|
|
427
445
|
</bpmndi:BPMNShape>
|
|
446
|
+
<bpmndi:BPMNShape id="BPMNShape_gw-merge-retry" bpmnElement="gw-merge-retry" isMarkerVisible="true">
|
|
447
|
+
<dc:Bounds x="1263" y="1215" width="50" height="50" />
|
|
448
|
+
<bpmndi:BPMNLabel>
|
|
449
|
+
<dc:Bounds x="1344" y="1250" width="88" height="28" />
|
|
450
|
+
</bpmndi:BPMNLabel>
|
|
451
|
+
</bpmndi:BPMNShape>
|
|
428
452
|
<bpmndi:BPMNShape id="BPMNShape_eg-landed" bpmnElement="eg-landed" isMarkerVisible="true">
|
|
429
453
|
<dc:Bounds x="1263" y="255" width="50" height="50" />
|
|
430
454
|
<bpmndi:BPMNLabel>
|
|
@@ -453,27 +477,27 @@
|
|
|
453
477
|
</bpmndi:BPMNLabel>
|
|
454
478
|
</bpmndi:BPMNShape>
|
|
455
479
|
<bpmndi:BPMNShape id="BPMNShape_merge-esc-conflict" bpmnElement="merge-esc-conflict">
|
|
456
|
-
<dc:Bounds x="1238" y="
|
|
480
|
+
<dc:Bounds x="1238" y="1680" width="100" height="80" />
|
|
457
481
|
</bpmndi:BPMNShape>
|
|
458
482
|
<bpmndi:BPMNShape id="BPMNShape_merge-esc-attempt" bpmnElement="merge-esc-attempt">
|
|
459
|
-
<dc:Bounds x="1438" y="
|
|
483
|
+
<dc:Bounds x="1438" y="1200" width="100" height="80" />
|
|
460
484
|
</bpmndi:BPMNShape>
|
|
461
485
|
<bpmndi:BPMNShape id="BPMNShape_gw-merge-escalated" bpmnElement="gw-merge-escalated" isMarkerVisible="true">
|
|
462
|
-
<dc:Bounds x="1663" y="
|
|
486
|
+
<dc:Bounds x="1663" y="1215" width="50" height="50" />
|
|
463
487
|
<bpmndi:BPMNLabel>
|
|
464
|
-
<dc:Bounds x="1651" y="
|
|
488
|
+
<dc:Bounds x="1651" y="1182" width="74" height="28" />
|
|
465
489
|
</bpmndi:BPMNLabel>
|
|
466
490
|
</bpmndi:BPMNShape>
|
|
467
491
|
<bpmndi:BPMNShape id="BPMNShape_wait-merge-answer" bpmnElement="wait-merge-answer">
|
|
468
|
-
<dc:Bounds x="1838" y="
|
|
492
|
+
<dc:Bounds x="1838" y="1200" width="100" height="80" />
|
|
469
493
|
</bpmndi:BPMNShape>
|
|
470
494
|
<bpmndi:BPMNShape id="BPMNShape_record-merge-answer" bpmnElement="record-merge-answer">
|
|
471
|
-
<dc:Bounds x="2038" y="
|
|
495
|
+
<dc:Bounds x="2038" y="1200" width="100" height="80" />
|
|
472
496
|
</bpmndi:BPMNShape>
|
|
473
497
|
<bpmndi:BPMNShape id="BPMNShape_gw-ci-fix" bpmnElement="gw-ci-fix" isMarkerVisible="true">
|
|
474
|
-
<dc:Bounds x="863" y="
|
|
498
|
+
<dc:Bounds x="863" y="1695" width="50" height="50" />
|
|
475
499
|
<bpmndi:BPMNLabel>
|
|
476
|
-
<dc:Bounds x="769" y="
|
|
500
|
+
<dc:Bounds x="769" y="1713" width="89" height="14" />
|
|
477
501
|
</bpmndi:BPMNLabel>
|
|
478
502
|
</bpmndi:BPMNShape>
|
|
479
503
|
<bpmndi:BPMNShape id="BPMNShape_fix-ci" bpmnElement="fix-ci">
|
|
@@ -512,7 +536,7 @@
|
|
|
512
536
|
<bpmndi:BPMNShape id="BPMNShape_be_rebase_sla" bpmnElement="be_rebase_sla">
|
|
513
537
|
<dc:Bounds x="1070" y="942" width="36" height="36" />
|
|
514
538
|
<bpmndi:BPMNLabel>
|
|
515
|
-
<dc:Bounds x="1046" y="
|
|
539
|
+
<dc:Bounds x="1046" y="1023" width="84" height="14" />
|
|
516
540
|
</bpmndi:BPMNLabel>
|
|
517
541
|
</bpmndi:BPMNShape>
|
|
518
542
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_start" bpmnElement="f_m_start">
|
|
@@ -554,10 +578,10 @@
|
|
|
554
578
|
<di:waypoint x="1870" y="120" />
|
|
555
579
|
</bpmndi:BPMNEdge>
|
|
556
580
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_giveup" bpmnElement="f_ci_giveup">
|
|
557
|
-
<di:waypoint x="913" y="
|
|
558
|
-
<di:waypoint x="1238" y="
|
|
581
|
+
<di:waypoint x="913" y="1720" />
|
|
582
|
+
<di:waypoint x="1238" y="1720" />
|
|
559
583
|
<bpmndi:BPMNLabel>
|
|
560
|
-
<dc:Bounds x="1032" y="
|
|
584
|
+
<dc:Bounds x="1032" y="1687" width="67" height="28" />
|
|
561
585
|
</bpmndi:BPMNLabel>
|
|
562
586
|
</bpmndi:BPMNEdge>
|
|
563
587
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_reconcile" bpmnElement="f_ci_reconcile">
|
|
@@ -574,11 +598,11 @@
|
|
|
574
598
|
<di:waypoint x="933" y="280" />
|
|
575
599
|
<di:waypoint x="933" y="305" />
|
|
576
600
|
<di:waypoint x="1333" y="305" />
|
|
577
|
-
<di:waypoint x="1333" y="
|
|
578
|
-
<di:waypoint x="1288" y="
|
|
579
|
-
<di:waypoint x="1288" y="
|
|
601
|
+
<di:waypoint x="1333" y="1660" />
|
|
602
|
+
<di:waypoint x="1288" y="1660" />
|
|
603
|
+
<di:waypoint x="1288" y="1680" />
|
|
580
604
|
<bpmndi:BPMNLabel>
|
|
581
|
-
<dc:Bounds x="1338" y="
|
|
605
|
+
<dc:Bounds x="1338" y="1406" width="67" height="28" />
|
|
582
606
|
</bpmndi:BPMNLabel>
|
|
583
607
|
</bpmndi:BPMNEdge>
|
|
584
608
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_reconcile" bpmnElement="f_reb_reconcile">
|
|
@@ -590,6 +614,13 @@
|
|
|
590
614
|
<dc:Bounds x="804" y="939" width="82" height="56" />
|
|
591
615
|
</bpmndi:BPMNLabel>
|
|
592
616
|
</bpmndi:BPMNEdge>
|
|
617
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_mr_giveup" bpmnElement="f_mr_giveup">
|
|
618
|
+
<di:waypoint x="1313" y="1240" />
|
|
619
|
+
<di:waypoint x="1438" y="1240" />
|
|
620
|
+
<bpmndi:BPMNLabel>
|
|
621
|
+
<dc:Bounds x="1352" y="1307" width="67" height="28" />
|
|
622
|
+
</bpmndi:BPMNLabel>
|
|
623
|
+
</bpmndi:BPMNEdge>
|
|
593
624
|
<bpmndi:BPMNEdge id="BPMNEdge_f_eg_landed" bpmnElement="f_eg_landed">
|
|
594
625
|
<di:waypoint x="1313" y="280" />
|
|
595
626
|
<di:waypoint x="1470" y="280" />
|
|
@@ -600,40 +631,44 @@
|
|
|
600
631
|
<di:waypoint x="1688" y="160" />
|
|
601
632
|
</bpmndi:BPMNEdge>
|
|
602
633
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escC" bpmnElement="f_m_escC">
|
|
603
|
-
<di:waypoint x="1338" y="
|
|
604
|
-
<di:waypoint x="1888" y="
|
|
605
|
-
<di:waypoint x="1888" y="
|
|
634
|
+
<di:waypoint x="1338" y="1720" />
|
|
635
|
+
<di:waypoint x="1888" y="1720" />
|
|
636
|
+
<di:waypoint x="1888" y="1280" />
|
|
637
|
+
</bpmndi:BPMNEdge>
|
|
638
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escA" bpmnElement="f_m_escA">
|
|
639
|
+
<di:waypoint x="1538" y="1240" />
|
|
640
|
+
<di:waypoint x="1663" y="1240" />
|
|
606
641
|
</bpmndi:BPMNEdge>
|
|
607
642
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escReenter" bpmnElement="f_m_escReenter">
|
|
608
|
-
<di:waypoint x="1688" y="
|
|
609
|
-
<di:waypoint x="1688" y="
|
|
610
|
-
<di:waypoint x="402" y="
|
|
643
|
+
<di:waypoint x="1688" y="1265" />
|
|
644
|
+
<di:waypoint x="1688" y="1300" />
|
|
645
|
+
<di:waypoint x="402" y="1300" />
|
|
611
646
|
<di:waypoint x="402" y="160" />
|
|
612
647
|
<bpmndi:BPMNLabel>
|
|
613
|
-
<dc:Bounds x="1008" y="
|
|
648
|
+
<dc:Bounds x="1008" y="1305" width="74" height="42" />
|
|
614
649
|
</bpmndi:BPMNLabel>
|
|
615
650
|
</bpmndi:BPMNEdge>
|
|
616
651
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_answerRecord" bpmnElement="f_m_answerRecord">
|
|
617
|
-
<di:waypoint x="1938" y="
|
|
618
|
-
<di:waypoint x="2038" y="
|
|
652
|
+
<di:waypoint x="1938" y="1240" />
|
|
653
|
+
<di:waypoint x="2038" y="1240" />
|
|
619
654
|
</bpmndi:BPMNEdge>
|
|
620
655
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_answer" bpmnElement="f_m_answer">
|
|
621
|
-
<di:waypoint x="2088" y="
|
|
622
|
-
<di:waypoint x="2088" y="
|
|
623
|
-
<di:waypoint x="402" y="
|
|
656
|
+
<di:waypoint x="2088" y="1280" />
|
|
657
|
+
<di:waypoint x="2088" y="1300" />
|
|
658
|
+
<di:waypoint x="402" y="1300" />
|
|
624
659
|
<di:waypoint x="402" y="160" />
|
|
625
660
|
</bpmndi:BPMNEdge>
|
|
626
661
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_mCiFix" bpmnElement="f_m_mCiFix">
|
|
627
662
|
<di:waypoint x="713" y="145" />
|
|
628
663
|
<di:waypoint x="713" y="318" />
|
|
629
664
|
<di:waypoint x="1358" y="318" />
|
|
630
|
-
<di:waypoint x="1358" y="
|
|
631
|
-
<di:waypoint x="863" y="
|
|
632
|
-
<di:waypoint x="863" y="
|
|
633
|
-
<di:waypoint x="888" y="
|
|
634
|
-
<di:waypoint x="888" y="
|
|
665
|
+
<di:waypoint x="1358" y="1180" />
|
|
666
|
+
<di:waypoint x="863" y="1180" />
|
|
667
|
+
<di:waypoint x="863" y="1675" />
|
|
668
|
+
<di:waypoint x="888" y="1675" />
|
|
669
|
+
<di:waypoint x="888" y="1695" />
|
|
635
670
|
<bpmndi:BPMNLabel>
|
|
636
|
-
<dc:Bounds x="
|
|
671
|
+
<dc:Bounds x="1011" y="1133" width="60" height="42" />
|
|
637
672
|
</bpmndi:BPMNLabel>
|
|
638
673
|
</bpmndi:BPMNEdge>
|
|
639
674
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_mRebase" bpmnElement="f_m_mRebase">
|
|
@@ -648,33 +683,33 @@
|
|
|
648
683
|
<di:waypoint x="713" y="145" />
|
|
649
684
|
<di:waypoint x="713" y="318" />
|
|
650
685
|
<di:waypoint x="1333" y="318" />
|
|
651
|
-
<di:waypoint x="1333" y="
|
|
652
|
-
<di:waypoint x="1218" y="
|
|
653
|
-
<di:waypoint x="1218" y="
|
|
654
|
-
<di:waypoint x="1238" y="
|
|
686
|
+
<di:waypoint x="1333" y="1180" />
|
|
687
|
+
<di:waypoint x="1218" y="1180" />
|
|
688
|
+
<di:waypoint x="1218" y="1700" />
|
|
689
|
+
<di:waypoint x="1238" y="1700" />
|
|
655
690
|
<bpmndi:BPMNLabel>
|
|
656
|
-
<dc:Bounds x="1243" y="
|
|
691
|
+
<dc:Bounds x="1243" y="722" width="85" height="14" />
|
|
657
692
|
</bpmndi:BPMNLabel>
|
|
658
693
|
</bpmndi:BPMNEdge>
|
|
659
694
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_go" bpmnElement="f_ci_go">
|
|
660
|
-
<di:waypoint x="888" y="
|
|
661
|
-
<di:waypoint x="888" y="
|
|
662
|
-
<di:waypoint x="2158" y="
|
|
695
|
+
<di:waypoint x="888" y="1745" />
|
|
696
|
+
<di:waypoint x="888" y="1765" />
|
|
697
|
+
<di:waypoint x="2158" y="1765" />
|
|
663
698
|
<di:waypoint x="2158" y="555" />
|
|
664
699
|
<di:waypoint x="1018" y="555" />
|
|
665
700
|
<di:waypoint x="1018" y="580" />
|
|
666
701
|
<di:waypoint x="1038" y="580" />
|
|
667
702
|
<bpmndi:BPMNLabel>
|
|
668
|
-
<dc:Bounds x="2163" y="
|
|
703
|
+
<dc:Bounds x="2163" y="1146" width="49" height="28" />
|
|
669
704
|
</bpmndi:BPMNLabel>
|
|
670
705
|
</bpmndi:BPMNEdge>
|
|
671
706
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_blocked" bpmnElement="f_ci_blocked">
|
|
672
707
|
<di:waypoint x="1313" y="600" />
|
|
673
708
|
<di:waypoint x="1418" y="600" />
|
|
674
|
-
<di:waypoint x="1418" y="
|
|
675
|
-
<di:waypoint x="1438" y="
|
|
709
|
+
<di:waypoint x="1418" y="1240" />
|
|
710
|
+
<di:waypoint x="1438" y="1240" />
|
|
676
711
|
<bpmndi:BPMNLabel>
|
|
677
|
-
<dc:Bounds x="1423" y="
|
|
712
|
+
<dc:Bounds x="1423" y="853" width="89" height="14" />
|
|
678
713
|
</bpmndi:BPMNLabel>
|
|
679
714
|
</bpmndi:BPMNEdge>
|
|
680
715
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_wait" bpmnElement="f_ci_wait">
|
|
@@ -689,21 +724,23 @@
|
|
|
689
724
|
<di:waypoint x="913" y="280" />
|
|
690
725
|
<di:waypoint x="933" y="280" />
|
|
691
726
|
<di:waypoint x="933" y="305" />
|
|
692
|
-
<di:waypoint x="
|
|
693
|
-
<di:waypoint x="
|
|
694
|
-
<di:waypoint x="
|
|
727
|
+
<di:waypoint x="1733" y="305" />
|
|
728
|
+
<di:waypoint x="1733" y="1660" />
|
|
729
|
+
<di:waypoint x="1243" y="1660" />
|
|
730
|
+
<di:waypoint x="1243" y="1195" />
|
|
731
|
+
<di:waypoint x="1418" y="1195" />
|
|
695
732
|
<di:waypoint x="1418" y="875" />
|
|
696
733
|
<di:waypoint x="1018" y="875" />
|
|
697
734
|
<di:waypoint x="1018" y="900" />
|
|
698
735
|
<di:waypoint x="1038" y="900" />
|
|
699
736
|
<bpmndi:BPMNLabel>
|
|
700
|
-
<dc:Bounds x="
|
|
737
|
+
<dc:Bounds x="1248" y="1414" width="49" height="28" />
|
|
701
738
|
</bpmndi:BPMNLabel>
|
|
702
739
|
</bpmndi:BPMNEdge>
|
|
703
740
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_blocked" bpmnElement="f_reb_blocked">
|
|
704
741
|
<di:waypoint x="1313" y="920" />
|
|
705
742
|
<di:waypoint x="1488" y="920" />
|
|
706
|
-
<di:waypoint x="1488" y="
|
|
743
|
+
<di:waypoint x="1488" y="1200" />
|
|
707
744
|
<bpmndi:BPMNLabel>
|
|
708
745
|
<dc:Bounds x="1429" y="887" width="64" height="28" />
|
|
709
746
|
</bpmndi:BPMNLabel>
|
|
@@ -724,6 +761,20 @@
|
|
|
724
761
|
<dc:Bounds x="1093" y="206" width="46" height="14" />
|
|
725
762
|
</bpmndi:BPMNLabel>
|
|
726
763
|
</bpmndi:BPMNEdge>
|
|
764
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_m_gRetry" bpmnElement="f_m_gRetry">
|
|
765
|
+
<di:waypoint x="1088" y="95" />
|
|
766
|
+
<di:waypoint x="1088" y="75" />
|
|
767
|
+
<di:waypoint x="1926" y="75" />
|
|
768
|
+
<di:waypoint x="1926" y="318" />
|
|
769
|
+
<di:waypoint x="1643" y="318" />
|
|
770
|
+
<di:waypoint x="1643" y="1285" />
|
|
771
|
+
<di:waypoint x="1263" y="1285" />
|
|
772
|
+
<di:waypoint x="1288" y="1285" />
|
|
773
|
+
<di:waypoint x="1288" y="1265" />
|
|
774
|
+
<bpmndi:BPMNLabel>
|
|
775
|
+
<dc:Bounds x="1746" y="285" width="78" height="28" />
|
|
776
|
+
</bpmndi:BPMNLabel>
|
|
777
|
+
</bpmndi:BPMNEdge>
|
|
727
778
|
<bpmndi:BPMNEdge id="BPMNEdge_f_eg_evicted" bpmnElement="f_eg_evicted">
|
|
728
779
|
<di:waypoint x="1288" y="305" />
|
|
729
780
|
<di:waypoint x="1288" y="440" />
|
|
@@ -735,10 +786,10 @@
|
|
|
735
786
|
<di:waypoint x="1926" y="75" />
|
|
736
787
|
<di:waypoint x="1926" y="820" />
|
|
737
788
|
<di:waypoint x="1418" y="820" />
|
|
738
|
-
<di:waypoint x="1418" y="
|
|
739
|
-
<di:waypoint x="1438" y="
|
|
789
|
+
<di:waypoint x="1418" y="1220" />
|
|
790
|
+
<di:waypoint x="1438" y="1220" />
|
|
740
791
|
<bpmndi:BPMNLabel>
|
|
741
|
-
<dc:Bounds x="1931" y="
|
|
792
|
+
<dc:Bounds x="1931" y="562" width="53" height="14" />
|
|
742
793
|
</bpmndi:BPMNLabel>
|
|
743
794
|
</bpmndi:BPMNEdge>
|
|
744
795
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_done" bpmnElement="f_ci_done">
|
|
@@ -749,20 +800,16 @@
|
|
|
749
800
|
<di:waypoint x="1138" y="920" />
|
|
750
801
|
<di:waypoint x="1263" y="920" />
|
|
751
802
|
</bpmndi:BPMNEdge>
|
|
752
|
-
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escA" bpmnElement="f_m_escA">
|
|
753
|
-
<di:waypoint x="1538" y="1080" />
|
|
754
|
-
<di:waypoint x="1663" y="1080" />
|
|
755
|
-
</bpmndi:BPMNEdge>
|
|
756
803
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escWait" bpmnElement="f_m_escWait">
|
|
757
|
-
<di:waypoint x="1713" y="
|
|
758
|
-
<di:waypoint x="1838" y="
|
|
804
|
+
<di:waypoint x="1713" y="1240" />
|
|
805
|
+
<di:waypoint x="1838" y="1240" />
|
|
759
806
|
</bpmndi:BPMNEdge>
|
|
760
807
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_sla" bpmnElement="f_reb_sla">
|
|
761
808
|
<di:waypoint x="1088" y="978" />
|
|
762
809
|
<di:waypoint x="1088" y="998" />
|
|
763
810
|
<di:waypoint x="1218" y="998" />
|
|
764
|
-
<di:waypoint x="1218" y="
|
|
765
|
-
<di:waypoint x="1238" y="
|
|
811
|
+
<di:waypoint x="1218" y="1720" />
|
|
812
|
+
<di:waypoint x="1238" y="1720" />
|
|
766
813
|
<bpmndi:BPMNLabel>
|
|
767
814
|
<dc:Bounds x="1118" y="965" width="70" height="28" />
|
|
768
815
|
</bpmndi:BPMNLabel>
|
|
@@ -772,12 +819,12 @@
|
|
|
772
819
|
<di:waypoint x="1088" y="678" />
|
|
773
820
|
<di:waypoint x="1018" y="678" />
|
|
774
821
|
<di:waypoint x="1018" y="540" />
|
|
775
|
-
<di:waypoint x="
|
|
776
|
-
<di:waypoint x="
|
|
822
|
+
<di:waypoint x="1558" y="540" />
|
|
823
|
+
<di:waypoint x="1558" y="820" />
|
|
777
824
|
<di:waypoint x="1526" y="820" />
|
|
778
|
-
<di:waypoint x="1526" y="
|
|
825
|
+
<di:waypoint x="1526" y="1200" />
|
|
779
826
|
<bpmndi:BPMNLabel>
|
|
780
|
-
<dc:Bounds x="
|
|
827
|
+
<dc:Bounds x="1253" y="507" width="70" height="28" />
|
|
781
828
|
</bpmndi:BPMNLabel>
|
|
782
829
|
</bpmndi:BPMNEdge>
|
|
783
830
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_fixed" bpmnElement="f_ci_fixed">
|
|
@@ -795,7 +842,7 @@
|
|
|
795
842
|
<di:waypoint x="402" y="1000" />
|
|
796
843
|
<di:waypoint x="402" y="160" />
|
|
797
844
|
<bpmndi:BPMNLabel>
|
|
798
|
-
<dc:Bounds x="
|
|
845
|
+
<dc:Bounds x="819" y="1008" width="53" height="14" />
|
|
799
846
|
</bpmndi:BPMNLabel>
|
|
800
847
|
</bpmndi:BPMNEdge>
|
|
801
848
|
<bpmndi:BPMNEdge id="BPMNEdge_f_dep_rewait" bpmnElement="f_dep_rewait">
|
|
@@ -804,6 +851,15 @@
|
|
|
804
851
|
<di:waypoint x="234" y="820" />
|
|
805
852
|
<di:waypoint x="234" y="138" />
|
|
806
853
|
</bpmndi:BPMNEdge>
|
|
854
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_mr_go" bpmnElement="f_mr_go">
|
|
855
|
+
<di:waypoint x="1288" y="1265" />
|
|
856
|
+
<di:waypoint x="1288" y="1285" />
|
|
857
|
+
<di:waypoint x="402" y="1285" />
|
|
858
|
+
<di:waypoint x="402" y="160" />
|
|
859
|
+
<bpmndi:BPMNLabel>
|
|
860
|
+
<dc:Bounds x="808" y="1252" width="49" height="28" />
|
|
861
|
+
</bpmndi:BPMNLabel>
|
|
862
|
+
</bpmndi:BPMNEdge>
|
|
807
863
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_evicted" bpmnElement="f_m_evicted">
|
|
808
864
|
<di:waypoint x="1488" y="458" />
|
|
809
865
|
<di:waypoint x="1488" y="478" />
|
|
@@ -207,3 +207,41 @@ test("pr.merge routes a refused merge through the blocked branch (escalation pay
|
|
|
207
207
|
},
|
|
208
208
|
);
|
|
209
209
|
});
|
|
210
|
+
|
|
211
|
+
test("pr.merge routes a transient base-moved race through the retry branch (no escalation payload)", async () => {
|
|
212
|
+
// #334: GitHub aborts the merge PUT with the 405 "Base branch was modified. Review and try the
|
|
213
|
+
// merge again." race. `mergePr` classifies it as `retry` (not `blocked`), and the retry arm of
|
|
214
|
+
// `matchTags` returns ONLY the loop-terminal `mergeStatus` — no `status`/`question` escalation
|
|
215
|
+
// payload — so the model re-attempts on the settled base instead of paging a human.
|
|
216
|
+
await withGithub(
|
|
217
|
+
(url) => {
|
|
218
|
+
if (/\/pulls\/\d+\/merge$/.test(url))
|
|
219
|
+
return new Response("Base branch was modified. Review and try the merge again.", {
|
|
220
|
+
status: 405,
|
|
221
|
+
statusText: "Method Not Allowed",
|
|
222
|
+
});
|
|
223
|
+
if (/\/pulls\/\d+$/.test(url)) return new Response(JSON.stringify({ merged: false, mergeable_state: "clean" }));
|
|
224
|
+
return null; // no AGENTS.md / merge-protocol.json → DEFAULT gh-merge protocol
|
|
225
|
+
},
|
|
226
|
+
async (calls) => {
|
|
227
|
+
const { app, stores } = fakeApp();
|
|
228
|
+
const out = (await handler(
|
|
229
|
+
{ variables: { prKey: "acme/widgets#11", repo: "acme/widgets", prNumber: 11 } } as any,
|
|
230
|
+
app,
|
|
231
|
+
)) as Record<string, unknown>;
|
|
232
|
+
|
|
233
|
+
// Retry arm: transient — surfaces the loop-terminal `mergeStatus` only, NO escalation fields.
|
|
234
|
+
assertEquals(out, { mergeStatus: "retry" });
|
|
235
|
+
assertEquals(out.status, undefined);
|
|
236
|
+
assertEquals(out.question, undefined);
|
|
237
|
+
|
|
238
|
+
// Attempted a real merge PUT and recorded the retry audit row (not a swallowed block).
|
|
239
|
+
assertEquals(
|
|
240
|
+
calls.some((c) => /\/pulls\/\d+\/merge$/.test(c.url) && c.method === "PUT"),
|
|
241
|
+
true,
|
|
242
|
+
);
|
|
243
|
+
assertEquals(stores.merges.length, 1);
|
|
244
|
+
assertEquals(stores.merges[0].outcome, "retry");
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
});
|
package/workers/merge/worker.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// pr.merge — attempt to land the PR (SPEC §11). Returns `mergeStatus`:
|
|
2
2
|
// • merged — landed now (direct merge) → process marks it merged
|
|
3
3
|
// • queued — added to the repo's merge queue → process waits for `merge-landed`
|
|
4
|
+
// • retry — transient base/head-moved race → process re-attempts on the settled base
|
|
5
|
+
// via the bounded retry gate (no human, no remediation agent)
|
|
4
6
|
// • blocked — GitHub refused (conflict / failing gate / perms) → escalate to a human, who
|
|
5
7
|
// resolves it and replies to retry (the process re-arms and re-polls).
|
|
6
8
|
// HOW it lands is governed by the target repo's published merge protocol (#43): a `mergify-queue`
|
|
@@ -21,7 +23,7 @@ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
|
21
23
|
type In = WorkerInputs["pr.merge"];
|
|
22
24
|
|
|
23
25
|
interface Out extends Record<string, unknown> {
|
|
24
|
-
mergeStatus: "merged" | "queued" | "blocked";
|
|
26
|
+
mergeStatus: "merged" | "queued" | "blocked" | "retry";
|
|
25
27
|
status?: string;
|
|
26
28
|
question?: string;
|
|
27
29
|
}
|
|
@@ -92,7 +94,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
92
94
|
const protocol = await loadMergeProtocol(repo, token).catch(() => null);
|
|
93
95
|
const method = protocol?.land.method ?? "gh-merge";
|
|
94
96
|
|
|
95
|
-
let outcome: "merged" | "queued" | "blocked";
|
|
97
|
+
let outcome: "merged" | "queued" | "blocked" | "retry";
|
|
96
98
|
let detail: string;
|
|
97
99
|
let auditMethod: string;
|
|
98
100
|
|
|
@@ -130,7 +132,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
130
132
|
|
|
131
133
|
// Exhaustive dispatch on the land outcome. Modelled as a tagged value so
|
|
132
134
|
// `matchTags` forces a handler for every case — adding a new outcome to the
|
|
133
|
-
// `"merged" | "queued" | "blocked"` union becomes a compile error here rather
|
|
135
|
+
// `"merged" | "queued" | "blocked" | "retry"` union becomes a compile error here rather
|
|
134
136
|
// than silently falling through to the "blocked" branch.
|
|
135
137
|
const docHint = protocol?.doc ? ` See the repo's merge protocol (${protocol.doc}).` : "";
|
|
136
138
|
return await matchTags(tag(outcome, { detail }), {
|
|
@@ -142,6 +144,10 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
142
144
|
return { mergeStatus: "queued" };
|
|
143
145
|
},
|
|
144
146
|
merged: async () => ({ mergeStatus: "merged" }),
|
|
147
|
+
// retry → a transient base/head-moved race (GitHub told us to re-attempt). Re-enter the merge
|
|
148
|
+
// loop on the settled base via the model's bounded retry gate — NO human escalation, NO
|
|
149
|
+
// remediation agent. The gate caps the attempts so a continuously-moving base still escalates.
|
|
150
|
+
retry: async () => ({ mergeStatus: "retry" }),
|
|
145
151
|
// blocked → hand the escalation machinery a concrete question.
|
|
146
152
|
blocked: async (o) => ({
|
|
147
153
|
mergeStatus: "blocked",
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
parseProbe,
|
|
26
26
|
probeBudgetMs,
|
|
27
27
|
probeOnce,
|
|
28
|
+
READINESS_READY_MESSAGE,
|
|
28
29
|
type ReadinessProbe,
|
|
29
30
|
redactTarget,
|
|
30
31
|
} from "../../app/readiness.ts";
|
|
@@ -36,8 +37,11 @@ import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io
|
|
|
36
37
|
type In = WorkerInputs["pr.readiness-probe"];
|
|
37
38
|
type Out = WorkerOutputs["pr.readiness-probe"];
|
|
38
39
|
|
|
39
|
-
/** The message the gate's event-based gateway correlates on `=gateKey` to release the wait.
|
|
40
|
-
|
|
40
|
+
/** The message the gate's event-based gateway correlates on `=gateKey` to release the wait.
|
|
41
|
+
* Re-exported from the canonical source (`app/readiness.ts`) so the worker and every out-of-band
|
|
42
|
+
* publisher (the review-ready poller, #259) share ONE message name — no drift-prone local twin.
|
|
43
|
+
* Re-exports the binding already imported above rather than re-referencing the module. */
|
|
44
|
+
export { READINESS_READY_MESSAGE };
|
|
41
45
|
|
|
42
46
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
43
47
|
|