@nanobpm/nano-workforce 0.207.0 → 0.208.1
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/SPEC.md +18 -0
- package/app/github.ts +29 -0
- package/app/mergeLoopBehaviour.test.ts +136 -1
- package/app/service.test.ts +149 -0
- package/app/service.ts +67 -3
- package/build-info.json +4 -4
- package/nano.app.json +8 -0
- package/package.json +2 -2
- package/resources/processes/merge-loop.bpmn +401 -213
- package/workers/merge/worker.test.ts +30 -9
- package/workers/merge/worker.ts +23 -12
- package/workers/reconverge/worker.test.ts +110 -0
- package/workers/reconverge/worker.ts +81 -0
- package/workers/request-review/worker.test.ts +54 -0
- package/workers/request-review/worker.ts +68 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.208.1](https://github.com/nanobpm/nano-workforce/compare/v0.208.0...v0.208.1) (2026-11-10)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **deps:** update dependency @nanobpm/agentic to ^0.16.0 ([#942](https://github.com/nanobpm/nano-workforce/issues/942)) ([9080010](https://github.com/nanobpm/nano-workforce/commit/9080010a73e54c922991be86b54d3f76a1b264e8))
|
|
6
|
+
|
|
7
|
+
## [0.208.0](https://github.com/nanobpm/nano-workforce/compare/v0.207.0...v0.208.0) (2026-11-10)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **merge:** request the head-current Copilot review after a loop-authored head move ([#941](https://github.com/nanobpm/nano-workforce/issues/941)) ([0e2d50e](https://github.com/nanobpm/nano-workforce/commit/0e2d50e8610beb5e939cd3a143d28e62d690f272)), closes [#939](https://github.com/nanobpm/nano-workforce/issues/939)
|
|
12
|
+
|
|
1
13
|
## [0.207.0](https://github.com/nanobpm/nano-workforce/compare/v0.206.4...v0.207.0) (2026-11-09)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/SPEC.md
CHANGED
|
@@ -855,6 +855,24 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
|
|
|
855
855
|
sets `ciBlockedReconciled`), and escalates only if the PR is **still** blocked on the
|
|
856
856
|
re-derived state.
|
|
857
857
|
|
|
858
|
+
- **Head-current review after a loop-authored head move — request it, don't escalate**
|
|
859
|
+
(issue #939) — `pr.merge` refuses to land without a Copilot review of the PR's
|
|
860
|
+
current HEAD (#923 backstop). When the loop's own rebase / merge-from-base / fix push
|
|
861
|
+
moved the head, the only remedy is mechanical, so instead of escalating the worker
|
|
862
|
+
returns `mergeStatus: "review-pending"` and the model **requests the review itself**
|
|
863
|
+
(`pr.request-review` — REST `requested_reviewers` POST of
|
|
864
|
+
`copilot-pull-request-reviewer[bot]`; on a re-request whose prior ask is still pending,
|
|
865
|
+
i.e. silently ignored, it withdraws then re-adds the reviewer), waits on a timer
|
|
866
|
+
(`reviewRequestWait`, derived from the review-nudge window) and re-arms the poller so
|
|
867
|
+
the backstop re-checks the new head. It repeats up to `reviewRequestMax` (3) requests;
|
|
868
|
+
only when no review arrives in that budget — or Copilot is not an assignable reviewer —
|
|
869
|
+
does it escalate, with a question stating what was tried. A head-current review whose
|
|
870
|
+
summary reports open findings returns `mergeStatus: "review-findings"`: `pr.reconverge`
|
|
871
|
+
hands the PR back to the convergence loop through the canonical `submitPr` (declaring
|
|
872
|
+
the owning merge-loop instance as `handoffFrom`) and the merge loop ends — the fresh
|
|
873
|
+
convergence run fixes, pushes, re-reviews and starts a new merge loop on convergence.
|
|
874
|
+
An *unreadable* review/head still fails closed to a human (#927).
|
|
875
|
+
|
|
858
876
|
- **Discovered dependency** — a `senior:fix-ci` or `senior:rebase` agent may find that
|
|
859
877
|
the PR cannot land because **another PR must merge first** (a required linked-issue
|
|
860
878
|
gate a sibling PR will close, a stacked base PR, or a `Depends-on:` the agent read
|
package/app/github.ts
CHANGED
|
@@ -743,6 +743,35 @@ export async function requestCopilotReview(
|
|
|
743
743
|
throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
|
|
744
744
|
}
|
|
745
745
|
|
|
746
|
+
/** Withdraw a pending Copilot review request (REST reviewers DELETE). Used by the merge loop's
|
|
747
|
+
* head-current review request (issue #939) when a prior request was silently ignored: Copilot stays
|
|
748
|
+
* listed as pending but never reviews, and a second POST is a no-op — removing and re-adding it is
|
|
749
|
+
* the equivalent that re-triggers the review. `null` when no transport is usable; throws on a
|
|
750
|
+
* genuine transport failure. */
|
|
751
|
+
export async function removeCopilotReviewer(
|
|
752
|
+
repo: string,
|
|
753
|
+
number: number | string,
|
|
754
|
+
token: string,
|
|
755
|
+
): Promise<"removed" | null> {
|
|
756
|
+
const path = `repos/${repo}/pulls/${number}/requested_reviewers`;
|
|
757
|
+
if (await useGh()) {
|
|
758
|
+
await runGh(["api", path, "-X", "DELETE", "-f", `reviewers[]=${COPILOT_REVIEWER}`]);
|
|
759
|
+
return "removed";
|
|
760
|
+
}
|
|
761
|
+
if (!token) return null;
|
|
762
|
+
const r = await fetch(`https://api.github.com/${path}`, {
|
|
763
|
+
method: "DELETE",
|
|
764
|
+
headers: {
|
|
765
|
+
authorization: `Bearer ${token}`,
|
|
766
|
+
accept: "application/vnd.github+json",
|
|
767
|
+
"content-type": "application/json",
|
|
768
|
+
},
|
|
769
|
+
body: JSON.stringify({ reviewers: [COPILOT_REVIEWER] }),
|
|
770
|
+
});
|
|
771
|
+
if (r.ok) return "removed";
|
|
772
|
+
throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
|
|
773
|
+
}
|
|
774
|
+
|
|
746
775
|
// ── Merge stage (SPEC §11) ──────────────────────────────────────────────────
|
|
747
776
|
// The same two-transport model (gh | token) backs the merge stage: read a PR's merge state to
|
|
748
777
|
// decide when it is landable, and perform the merge (directly or via the repo's merge queue).
|
|
@@ -43,7 +43,8 @@ const MODEL = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
|
43
43
|
const AGENT_SLA_MS = 30 * 60 * 1000; // matches the PT30M we start instances with
|
|
44
44
|
const LANDED_WAIT_MS = 30 * 60 * 1000; // matches the PT30M landedWaitTimeout we start instances with
|
|
45
45
|
const MERGEABLE_WAIT_MS = 30 * 60 * 1000; // matches the PT30M mergeableWaitTimeout we start instances with
|
|
46
|
-
const MERGEABLE_REPOLL_MS = 2 * 60 * 1000;
|
|
46
|
+
const MERGEABLE_REPOLL_MS = 2 * 60 * 1000;
|
|
47
|
+
const REVIEW_REQUEST_WAIT_MS = 10 * 60 * 1000; // matches the PT10M reviewRequestWait we start instances with // matches the PT2M mergeableRepollInterval we start instances with
|
|
47
48
|
|
|
48
49
|
type Output = Record<string, unknown>;
|
|
49
50
|
type Responder = Output | Output[] | ((job: { variables: Record<string, unknown> }) => Output);
|
|
@@ -59,6 +60,8 @@ const ALL_JOB_TYPES = [
|
|
|
59
60
|
"pr.persist-escalation",
|
|
60
61
|
"pr.answer-escalation",
|
|
61
62
|
"pr.record-dependency",
|
|
63
|
+
"pr.request-review",
|
|
64
|
+
"pr.reconverge",
|
|
62
65
|
] as const;
|
|
63
66
|
|
|
64
67
|
const DEFAULT_RESPONSES: Record<string, Responder> = {
|
|
@@ -68,6 +71,8 @@ const DEFAULT_RESPONSES: Record<string, Responder> = {
|
|
|
68
71
|
"pr.answer-escalation": {},
|
|
69
72
|
"pr.persist-escalation": { escalated: true },
|
|
70
73
|
"pr.landed-probe": { landedVerdict: "waiting" },
|
|
74
|
+
"pr.request-review": { reviewRequest: "requested" },
|
|
75
|
+
"pr.reconverge": {},
|
|
71
76
|
};
|
|
72
77
|
|
|
73
78
|
// Every FEEL expression in the model references these; start them defined (null,
|
|
@@ -91,6 +96,10 @@ const DEFAULT_VARS: Record<string, unknown> = {
|
|
|
91
96
|
mergeableRepollInterval: "PT2M",
|
|
92
97
|
mergeStallRounds: 0,
|
|
93
98
|
mergeStallMax: 3,
|
|
99
|
+
reviewRequestRound: 0,
|
|
100
|
+
reviewRequestMax: 3,
|
|
101
|
+
reviewRequestWait: "PT10M",
|
|
102
|
+
reviewRequest: null,
|
|
94
103
|
abandonBrief: "\n\n---\nAbort if cancelled: curl …",
|
|
95
104
|
failingChecksList: null,
|
|
96
105
|
status: null,
|
|
@@ -413,6 +422,59 @@ test("a fix-ci agent echoing abandonBrief cannot corrupt the seeded abort brief
|
|
|
413
422
|
.hasVariable("abandonBrief", "\n\n---\nAbort if cancelled: curl …");
|
|
414
423
|
});
|
|
415
424
|
|
|
425
|
+
test("a fix-ci agent echoing review-request state cannot clobber the seeded budget or timer (#941 round 4)", async () => {
|
|
426
|
+
// Same guard class as the #887 echoes: `reviewRequestRound`/`reviewRequestMax` bound the
|
|
427
|
+
// head-current review re-request loop (`reviewRequestRound < reviewRequestMax` gates a retry) and
|
|
428
|
+
// `reviewRequestWait` arms its timer. An agent completion echoing `reviewRequestMax: 0` would send
|
|
429
|
+
// the FIRST missing-review verdict straight to a human escalation, and a clobbered
|
|
430
|
+
// `reviewRequestWait` would reshape the wait. The snapshot/restore guard must pin all four.
|
|
431
|
+
const engine = await boot({
|
|
432
|
+
responses: {
|
|
433
|
+
"pr.merge-stall-probe": { mergeState: "blocked", failingChecks: 1, failingChecksList: "build" },
|
|
434
|
+
"senior:fix-ci": {
|
|
435
|
+
status: "fixed",
|
|
436
|
+
reviewRequestRound: 99,
|
|
437
|
+
reviewRequestMax: 0,
|
|
438
|
+
reviewRequestWait: "PT1S",
|
|
439
|
+
reviewRequest: "unavailable",
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
444
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
445
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
446
|
+
.isActive()
|
|
447
|
+
.hasCompletedElements("merge-stall-probe", "fix-ci")
|
|
448
|
+
.hasVariable("reviewRequestRound", 0)
|
|
449
|
+
.hasVariable("reviewRequestMax", 3)
|
|
450
|
+
.hasVariable("reviewRequestWait", "PT10M");
|
|
451
|
+
assert(instanceVars(engine).reviewRequest !== "unavailable", "a fix-ci agent must not forge reviewRequest:unavailable");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("a rebase agent echoing review-request state cannot clobber the seeded budget or timer (#941 round 4)", async () => {
|
|
455
|
+
const engine = await boot({
|
|
456
|
+
responses: {
|
|
457
|
+
"pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
|
|
458
|
+
"senior:rebase": {
|
|
459
|
+
status: "rebased",
|
|
460
|
+
reviewRequestRound: 99,
|
|
461
|
+
reviewRequestMax: 0,
|
|
462
|
+
reviewRequestWait: "PT1S",
|
|
463
|
+
reviewRequest: "unavailable",
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
468
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
469
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
470
|
+
.isActive()
|
|
471
|
+
.hasCompletedElements("rebase")
|
|
472
|
+
.hasVariable("reviewRequestRound", 0)
|
|
473
|
+
.hasVariable("reviewRequestMax", 3)
|
|
474
|
+
.hasVariable("reviewRequestWait", "PT10M");
|
|
475
|
+
assert(instanceVars(engine).reviewRequest !== "unavailable", "a rebase agent must not forge reviewRequest:unavailable");
|
|
476
|
+
});
|
|
477
|
+
|
|
416
478
|
test("a blocked PR probed on timeout reaches the CI-fix arm via the timeout path (#636)", async () => {
|
|
417
479
|
const engine = await boot({
|
|
418
480
|
responses: {
|
|
@@ -908,6 +970,79 @@ test("a worker-supplied block question (review backstop #923) reaches the human
|
|
|
908
970
|
assert(!q.includes("Investigate why GitHub refused"), "must not be overwritten by the generic refusal text");
|
|
909
971
|
});
|
|
910
972
|
|
|
973
|
+
// ---------------------------------------------------------------------------
|
|
974
|
+
// Head-current review after a loop-authored head move (issue #939)
|
|
975
|
+
// ---------------------------------------------------------------------------
|
|
976
|
+
|
|
977
|
+
async function driveToAttempt(engine: WasmEngineClient): Promise<void> {
|
|
978
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
979
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
test("#939: a missing head-current review requests one itself, waits, and merges on the re-check — no human", async () => {
|
|
983
|
+
const engine = await boot({ responses: { "pr.merge": [{ mergeStatus: "review-pending" }, { mergeStatus: "merged" }] } });
|
|
984
|
+
await driveToAttempt(engine);
|
|
985
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-review-requested");
|
|
986
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("request-review");
|
|
987
|
+
assertEquals(instanceVars(engine).reviewRequestRound, 1);
|
|
988
|
+
await engine.advanceTime(REVIEW_REQUEST_WAIT_MS + 1);
|
|
989
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
990
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
991
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasCompletedElements("mark-merged");
|
|
992
|
+
assert(!completedElementIds(engine).has("merge-esc-attempt"), "an automatable review gap must never escalate");
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
test("#939 defect-class guard: no automatable merge-gate refusal escalates to a human on its first occurrence", async () => {
|
|
996
|
+
for (const mergeStatus of ["review-pending", "review-findings"]) {
|
|
997
|
+
const engine = await boot({ responses: { "pr.merge": { mergeStatus } } });
|
|
998
|
+
await driveToAttempt(engine);
|
|
999
|
+
const done = completedElementIds(engine);
|
|
1000
|
+
assert(!done.has("merge-esc-attempt") && !done.has("merge-esc-conflict"), `${mergeStatus}: must not escalate`);
|
|
1001
|
+
const open = await engine.searchUserTasks({});
|
|
1002
|
+
assertEquals(open.length, 0, `${mergeStatus}: no human task on the first occurrence`);
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
test("#939: a head-current review with findings re-enters convergence and ends the merge loop", async () => {
|
|
1007
|
+
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "review-findings" } } });
|
|
1008
|
+
await driveToAttempt(engine);
|
|
1009
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasCompletedElements("reconverge", "MergeReconverged");
|
|
1010
|
+
assert(!completedElementIds(engine).has("mark-merged"), "findings must not merge");
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
test("#939: a review that never arrives escalates after the bounded request budget, stating what was tried", async () => {
|
|
1014
|
+
const engine = await boot({
|
|
1015
|
+
responses: { "pr.merge": { mergeStatus: "review-pending" } },
|
|
1016
|
+
vars: { reviewRequestMax: 1 },
|
|
1017
|
+
});
|
|
1018
|
+
await driveToAttempt(engine);
|
|
1019
|
+
await engine.advanceTime(REVIEW_REQUEST_WAIT_MS + 1);
|
|
1020
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
1021
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
1022
|
+
const q = String(escalation(engine).question ?? "");
|
|
1023
|
+
assertStringIncludes(q, "requested a Copilot review of the current HEAD 1 time(s)");
|
|
1024
|
+
assertStringIncludes(q, "PT10M");
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
test("#939: a clean re-check resets the request budget so a later head move gets a fresh budget", async () => {
|
|
1028
|
+
const engine = await boot({
|
|
1029
|
+
responses: { "pr.merge": [{ mergeStatus: "review-pending" }, { mergeStatus: "retry" }, { mergeStatus: "merged" }] },
|
|
1030
|
+
});
|
|
1031
|
+
await driveToAttempt(engine);
|
|
1032
|
+
await engine.advanceTime(REVIEW_REQUEST_WAIT_MS + 1);
|
|
1033
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
1034
|
+
assertEquals(instanceVars(engine).reviewRequestRound, 0);
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test("#939: Copilot not assignable escalates immediately, naming the cause", async () => {
|
|
1038
|
+
const engine = await boot({
|
|
1039
|
+
responses: { "pr.merge": { mergeStatus: "review-pending" }, "pr.request-review": { reviewRequest: "unavailable" } },
|
|
1040
|
+
});
|
|
1041
|
+
await driveToAttempt(engine);
|
|
1042
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
1043
|
+
assertStringIncludes(String(escalation(engine).question ?? ""), "not an assignable reviewer");
|
|
1044
|
+
});
|
|
1045
|
+
|
|
911
1046
|
// ---------------------------------------------------------------------------
|
|
912
1047
|
// CI-fix sub-process (SP_cifix) — mergeCiReattempt (#134), #329
|
|
913
1048
|
// ---------------------------------------------------------------------------
|
package/app/service.test.ts
CHANGED
|
@@ -2563,3 +2563,152 @@ test("pollCapabilityGatesImpl: scopes the barrier subscription search server-sid
|
|
|
2563
2563
|
assertEquals(published.length, 1, "the scoped search still finds THIS task's subscription and releases it");
|
|
2564
2564
|
assertEquals(published[0]?.correlationKey, barrierKey);
|
|
2565
2565
|
});
|
|
2566
|
+
|
|
2567
|
+
// Issue #939: the merge loop hands a PR with head-current review findings back to convergence via
|
|
2568
|
+
// `submitPr`. The OWNING merge-loop instance (the row's `process_key`) is live while it calls, so the
|
|
2569
|
+
// hand-off must bypass the alreadyRunning gate — but ONLY for that exact owner; any other caller still
|
|
2570
|
+
// dedupes against the live merge loop.
|
|
2571
|
+
test("submitPr handoffFrom: only the owning merge-loop instance may re-enter convergence (#939)", async () => {
|
|
2572
|
+
await withGithubOff(async () => {
|
|
2573
|
+
const PR_KEY = "owner/repo#77";
|
|
2574
|
+
const mk = () => {
|
|
2575
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
2576
|
+
pull_requests: {
|
|
2577
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 77, url: "https://github.com/owner/repo/pull/77", title: "t", status: "waiting_merge", current_round: 4, process_key: "PI-MERGE" }],
|
|
2578
|
+
key: "pr_key",
|
|
2579
|
+
},
|
|
2580
|
+
escalations: { rows: [], key: "id" },
|
|
2581
|
+
pr_adjudications: { rows: [], key: "id" },
|
|
2582
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
2583
|
+
};
|
|
2584
|
+
const data = { table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)), open: () => memOpen(stores) } as any;
|
|
2585
|
+
return { stores, data };
|
|
2586
|
+
};
|
|
2587
|
+
let calls = 0;
|
|
2588
|
+
const engine = { createInstance: async () => ({ processInstanceKey: `PI-CONV-${++calls}` }) } as any;
|
|
2589
|
+
const parsed = { repo: "owner/repo", number: 77, url: "https://github.com/owner/repo/pull/77", prKey: PR_KEY };
|
|
2590
|
+
|
|
2591
|
+
const a = mk();
|
|
2592
|
+
const plain = await submitPr(a.data, engine, parsed);
|
|
2593
|
+
assertEquals((plain as any).alreadyRunning, true, "a plain submit dedupes against the live merge loop");
|
|
2594
|
+
const foreign = await submitPr(a.data, engine, parsed, [], undefined, false, null, false, false, true, "PI-OTHER");
|
|
2595
|
+
assertEquals((foreign as any).alreadyRunning, true, "a non-owner hand-off still dedupes");
|
|
2596
|
+
assertEquals(calls, 0);
|
|
2597
|
+
|
|
2598
|
+
const owned = await submitPr(a.data, engine, parsed, [], undefined, false, null, false, false, true, "PI-MERGE");
|
|
2599
|
+
assertEquals((owned as any).alreadyRunning, undefined);
|
|
2600
|
+
assertEquals(calls, 1);
|
|
2601
|
+
const row = a.stores.pull_requests.rows[0];
|
|
2602
|
+
assertEquals(row.status, "converging");
|
|
2603
|
+
assertEquals(row.process_key, "PI-CONV-1");
|
|
2604
|
+
});
|
|
2605
|
+
});
|
|
2606
|
+
|
|
2607
|
+
// Issue #941 review: a redelivered `pr.reconverge` can arrive AFTER its successor has already
|
|
2608
|
+
// settled the PR (merged/abandoned). The stale job still carries the OLD merge-loop instance key as
|
|
2609
|
+
// `handoffFrom`, and the settled row's `process_key` still names that same instance, so an owner
|
|
2610
|
+
// match ALONE would let the bypass treat the dead job as the row's live owner and restart
|
|
2611
|
+
// convergence, clearing the settled state. The internal hand-off channel is fenced against a
|
|
2612
|
+
// TERMINAL row outright: it dedupes idempotently and never resubmits. Only a DELIBERATE PUBLIC
|
|
2613
|
+
// submission (no `handoffFrom`) may re-open a settled PR.
|
|
2614
|
+
test("submitPr handoffFrom: a stale hand-off against a settled row is fenced, a public resubmit still re-opens (#941)", async () => {
|
|
2615
|
+
await withGithubOff(async () => {
|
|
2616
|
+
const PR_KEY = "owner/repo#78";
|
|
2617
|
+
const mk = () => {
|
|
2618
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
2619
|
+
pull_requests: {
|
|
2620
|
+
// The successor convergence run MERGED the PR; the row is terminal (`merged`) but its
|
|
2621
|
+
// `process_key` still names the OLD merge-loop instance the redelivered job belongs to.
|
|
2622
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 78, url: "https://github.com/owner/repo/pull/78", title: "t", status: "merged", current_round: 5, process_key: "PI-MERGE" }],
|
|
2623
|
+
key: "pr_key",
|
|
2624
|
+
},
|
|
2625
|
+
escalations: { rows: [], key: "id" },
|
|
2626
|
+
pr_adjudications: { rows: [], key: "id" },
|
|
2627
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
2628
|
+
};
|
|
2629
|
+
const data = { table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)), open: () => memOpen(stores) } as any;
|
|
2630
|
+
return { stores, data };
|
|
2631
|
+
};
|
|
2632
|
+
const parsed = { repo: "owner/repo", number: 78, url: "https://github.com/owner/repo/pull/78", prKey: PR_KEY };
|
|
2633
|
+
|
|
2634
|
+
// The stale hand-off (owner key matches, but the row is terminal) is FENCED: it dedupes
|
|
2635
|
+
// idempotently, mints no instance, and leaves the settled row byte-for-byte untouched.
|
|
2636
|
+
const a = mk();
|
|
2637
|
+
let calls = 0;
|
|
2638
|
+
const engine = { createInstance: async () => ({ processInstanceKey: `PI-CONV-${++calls}` }) } as any;
|
|
2639
|
+
const stale = await submitPr(a.data, engine, parsed, [], undefined, false, null, false, false, true, "PI-MERGE");
|
|
2640
|
+
assertEquals((stale as any).alreadyRunning, true, "a stale hand-off against a terminal row dedupes instead of re-entering convergence");
|
|
2641
|
+
assertEquals(calls, 0, "no fresh instance is minted for the stale hand-off");
|
|
2642
|
+
assertEquals(a.stores.pull_requests.rows[0].status, "merged", "the settled row's state is untouched");
|
|
2643
|
+
assertEquals(a.stores.pull_requests.rows[0].process_key, "PI-MERGE", "the settled row's owner is untouched");
|
|
2644
|
+
|
|
2645
|
+
// A deliberate PUBLIC resubmission of the same settled PR (no handoffFrom) still re-opens it —
|
|
2646
|
+
// the fence only closes the internal hand-off channel, never the terminal→resubmittable path.
|
|
2647
|
+
const b = mk();
|
|
2648
|
+
const resubmit = await submitPr(b.data, engine, parsed);
|
|
2649
|
+
assertEquals((resubmit as any).alreadyRunning, undefined);
|
|
2650
|
+
assertEquals(calls, 1);
|
|
2651
|
+
assertEquals(b.stores.pull_requests.rows[0].status, "converging");
|
|
2652
|
+
assertEquals(b.stores.pull_requests.rows[0].process_key, "PI-CONV-1");
|
|
2653
|
+
});
|
|
2654
|
+
});
|
|
2655
|
+
|
|
2656
|
+
// Issue #941 review: if the adjudication reset fails AFTER the atomic reopen advanced `process_key`
|
|
2657
|
+
// to the new convergence instance, the rollback cancels that instance — but leaving its key on the
|
|
2658
|
+
// row would make the cancelled run the row's "owner". Once tracking derives `abandoned`, the stale
|
|
2659
|
+
// hand-off fence would treat the reconverge retry as settled and end the merge loop with no live
|
|
2660
|
+
// convergence owner. The rollback must RESTORE the prior row (owner key + status), so the retry is
|
|
2661
|
+
// still the owning hand-off and re-enters convergence.
|
|
2662
|
+
test("submitPr handoffFrom: a failed reset restores the prior owner so the hand-off retry re-enters (#941)", async () => {
|
|
2663
|
+
await withGithubOff(async () => {
|
|
2664
|
+
const PR_KEY = "owner/repo#79";
|
|
2665
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
2666
|
+
pull_requests: {
|
|
2667
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 79, url: "https://github.com/owner/repo/pull/79", title: "t", status: "waiting_merge", current_round: 4, process_key: "PI-MERGE" }],
|
|
2668
|
+
key: "pr_key",
|
|
2669
|
+
},
|
|
2670
|
+
escalations: { rows: [], key: "id" },
|
|
2671
|
+
pr_adjudications: { rows: [], key: "id" },
|
|
2672
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
2673
|
+
};
|
|
2674
|
+
let failReset = true;
|
|
2675
|
+
const open = () => {
|
|
2676
|
+
const real = memOpen(stores);
|
|
2677
|
+
return {
|
|
2678
|
+
...real,
|
|
2679
|
+
exec: async (sql: string, ...rest: unknown[]) => {
|
|
2680
|
+
if (failReset && /DELETE FROM "pr_adjudications"/.test(sql)) throw new Error("boom: reset failed");
|
|
2681
|
+
return (real as any).exec(sql, ...rest);
|
|
2682
|
+
},
|
|
2683
|
+
};
|
|
2684
|
+
};
|
|
2685
|
+
const data = { table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)), open } as any;
|
|
2686
|
+
let calls = 0;
|
|
2687
|
+
const cancelled: string[] = [];
|
|
2688
|
+
const engine = {
|
|
2689
|
+
createInstance: async () => ({ processInstanceKey: `PI-CONV-${++calls}` }),
|
|
2690
|
+
cancelInstance: async (i: { processInstanceKey: string }) => {
|
|
2691
|
+
cancelled.push(i.processInstanceKey);
|
|
2692
|
+
},
|
|
2693
|
+
} as any;
|
|
2694
|
+
const parsed = { repo: "owner/repo", number: 79, url: "https://github.com/owner/repo/pull/79", prKey: PR_KEY };
|
|
2695
|
+
|
|
2696
|
+
let threw = false;
|
|
2697
|
+
try {
|
|
2698
|
+
await submitPr(data, engine, parsed, [], undefined, false, null, false, false, true, "PI-MERGE");
|
|
2699
|
+
} catch {
|
|
2700
|
+
threw = true;
|
|
2701
|
+
}
|
|
2702
|
+
assertEquals(threw, true);
|
|
2703
|
+
assertEquals(cancelled, ["PI-CONV-1"]);
|
|
2704
|
+
const row = stores.pull_requests.rows[0];
|
|
2705
|
+
assertEquals(row.process_key, "PI-MERGE", "rollback restores the prior owner, never leaves the cancelled key");
|
|
2706
|
+
assertEquals(row.status, "waiting_merge", "rollback restores the prior status");
|
|
2707
|
+
assertEquals(row.current_round, 4);
|
|
2708
|
+
|
|
2709
|
+
failReset = false;
|
|
2710
|
+
const retry = await submitPr(data, engine, parsed, [], undefined, false, null, false, false, true, "PI-MERGE");
|
|
2711
|
+
assertEquals((retry as any).alreadyRunning, undefined, "the retried hand-off re-enters convergence");
|
|
2712
|
+
assertEquals(stores.pull_requests.rows[0].process_key, "PI-CONV-2");
|
|
2713
|
+
});
|
|
2714
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -288,6 +288,14 @@ export const MERGEABLE_REPOLL_INTERVAL = mergeableRepollInterval(
|
|
|
288
288
|
* to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
|
|
289
289
|
export const REVIEW_NUDGE_MS = clampNudgeMinutes(process.env.NANO_PR_REVIEW_NUDGE_MINUTES) * 60_000;
|
|
290
290
|
|
|
291
|
+
/** Merge-loop head-current review request (issue #939). When the merge backstop finds no
|
|
292
|
+
* head-current Copilot review (the loop's own rebase/fix push moved the head), the merge loop
|
|
293
|
+
* requests the review itself and waits `REVIEW_REQUEST_WAIT` before re-checking — derived from the
|
|
294
|
+
* SAME nudge window the convergence poller uses, so there is one knob for "how long to give Copilot".
|
|
295
|
+
* It re-requests up to `MAX_REVIEW_REQUESTS` times before escalating (stating what was tried). */
|
|
296
|
+
export const REVIEW_REQUEST_WAIT = `PT${Math.round(REVIEW_NUDGE_MS / 60_000)}M`;
|
|
297
|
+
export const MAX_REVIEW_REQUESTS = 3;
|
|
298
|
+
|
|
291
299
|
/** Grace window (ms) before the `escalated`→`running` self-heal (below) may act on a row. The sole
|
|
292
300
|
* writer of `status="escalated"` — `record-feature-escalation` — runs on the `escalated` arm and stamps
|
|
293
301
|
* `updated_at` IMMEDIATELY BEFORE the engine creates the `feature-escalation` user task. A poll landing
|
|
@@ -632,9 +640,11 @@ export async function submitPr(
|
|
|
632
640
|
humanApproval = false,
|
|
633
641
|
autoResolveEscalations = false,
|
|
634
642
|
autoMerge?: boolean,
|
|
643
|
+
handoffFrom: string | null = null,
|
|
644
|
+
headShaOverride: string | null = null,
|
|
635
645
|
) {
|
|
636
646
|
return withPrSubmitLock(parsed.prKey, () =>
|
|
637
|
-
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge),
|
|
647
|
+
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge, handoffFrom, headShaOverride),
|
|
638
648
|
);
|
|
639
649
|
}
|
|
640
650
|
|
|
@@ -649,6 +659,8 @@ async function submitPrCritical(
|
|
|
649
659
|
humanApproval = false,
|
|
650
660
|
autoResolveEscalations = false,
|
|
651
661
|
autoMerge?: boolean,
|
|
662
|
+
handoffFrom: string | null = null,
|
|
663
|
+
headShaOverride: string | null = null,
|
|
652
664
|
) {
|
|
653
665
|
// Merge intent (issue #916): `autoMerge` is the CANONICAL positive form end to end — the same name
|
|
654
666
|
// the feature/convergence-loop/plan-fanout doors accept. The legacy inverted `convergeOnly`
|
|
@@ -680,7 +692,28 @@ async function submitPrCritical(
|
|
|
680
692
|
// would skip it as non-terminal, never re-enrolling). Treat a keyless non-terminal row as
|
|
681
693
|
// RESUBMITTABLE — fall through and re-enroll, exactly as `startFeature`'s intake guard (feature.ts)
|
|
682
694
|
// treats a keyless `running` feature row. A terminal row (any `process_key`) already falls through.
|
|
683
|
-
|
|
695
|
+
// Merge-loop → convergence hand-back (issue #939): the merge-loop instance that currently OWNS the
|
|
696
|
+
// row (`process_key`) re-enters convergence when a head-current review reports findings. That
|
|
697
|
+
// instance is live (it is the caller, and ends right after this submit), so it must not read as
|
|
698
|
+
// "already running" — only the exact owning instance may hand back; any other key still dedupes.
|
|
699
|
+
// The bypass additionally requires the row to be LIVE (non-terminal, #941 review): a redelivered
|
|
700
|
+
// `pr.reconverge` can arrive after its successor has already settled the PR (merged/abandoned),
|
|
701
|
+
// and the settled row's `process_key` still names that stale job's instance — an owner match alone
|
|
702
|
+
// would let the dead hand-off restart convergence and clear the settled state. So an internal
|
|
703
|
+
// hand-off against a TERMINAL row is fenced off entirely (below): it dedupes as `alreadyRunning`
|
|
704
|
+
// instead of resubmitting. Only a DELIBERATE PUBLIC submission (no `handoffFrom`) may re-open a
|
|
705
|
+
// settled PR — the internal hand-off channel never may. A partially-failed hand-off's retry
|
|
706
|
+
// against a keyless NON-terminal row still works (that row is not terminal, so it falls through
|
|
707
|
+
// and re-enrolls via the phantom-row path).
|
|
708
|
+
const trackedTerminal = trackedExisting != null && TERMINAL_STATUSES.includes(trackedExisting.derived_status);
|
|
709
|
+
const handingBack =
|
|
710
|
+
handoffFrom != null && existing?.process_key != null && existing.process_key === handoffFrom && !trackedTerminal;
|
|
711
|
+
if (handoffFrom != null && trackedTerminal) {
|
|
712
|
+
// Stale internal hand-off against a settled row: report the existing (terminal) owner without
|
|
713
|
+
// minting a fresh run or touching the settled state. The redelivered job completes idempotently.
|
|
714
|
+
return { prKey: parsed.prKey, alreadyRunning: true };
|
|
715
|
+
}
|
|
716
|
+
if (!handingBack && trackedExisting && existing?.process_key != null && !trackedTerminal) {
|
|
684
717
|
// Human approval is monotonic (issue #826): a gated caller adopting a live loop — possibly an
|
|
685
718
|
// ungated one another run started — narrows it to gated instead of inheriting its auto-merge.
|
|
686
719
|
// Idempotent, so a retry against our own gated loop is a no-op. A loop the engine no longer reports
|
|
@@ -801,7 +834,14 @@ async function submitPrCritical(
|
|
|
801
834
|
// fleet advertising `durable-resume`: with no participant it stays null, so the round redrives from
|
|
802
835
|
// scratch (graceful degradation). Absent (null) on a first submit, which leaves the envelope
|
|
803
836
|
// unchanged.
|
|
804
|
-
|
|
837
|
+
//
|
|
838
|
+
// `headShaOverride` (#941 round-4 review) WINS over the checkpoint: the checkpoint is written only
|
|
839
|
+
// by the convergence loop's `pr.persist-round`, so a merge-stage rebase/fix-ci push advances the PR
|
|
840
|
+
// head WITHOUT updating it. A merge→convergence hand-off (`pr.reconverge`) that restored the
|
|
841
|
+
// checkpoint would provision the fresh run at the PRE-MERGE tree, not the head whose review raised
|
|
842
|
+
// the findings — so the hand-off passes the live head SHA it read, and a fresh hand-off restores
|
|
843
|
+
// the CURRENT head. `null` (every other caller) keeps the checkpoint resume.
|
|
844
|
+
const worldSha = headShaOverride ?? (await worldRestoreSha(data, parsed.prKey));
|
|
805
845
|
const { processInstanceKey } = await engine.createInstance({
|
|
806
846
|
processDefinitionId: PROCESS_ID,
|
|
807
847
|
variables: {
|
|
@@ -877,6 +917,11 @@ async function submitPrCritical(
|
|
|
877
917
|
// run auto-applies stale decisions, and (crucially) the row stays in its OLD terminal shape until
|
|
878
918
|
// the atomic reopen write lands, so no straggler ever sees a fresh-run row still owned by the old
|
|
879
919
|
// instance.
|
|
920
|
+
let reopened = false;
|
|
921
|
+
// Snapshot BEFORE the reopen write — the row object may be a live reference the update mutates.
|
|
922
|
+
const prior = existing
|
|
923
|
+
? { status: existing.status, current_round: existing.current_round, process_key: existing.process_key }
|
|
924
|
+
: null;
|
|
880
925
|
try {
|
|
881
926
|
if (existing) {
|
|
882
927
|
// ATOMIC REOPEN (Copilot review of #819): reset the row to the fresh run's shape AND publish the
|
|
@@ -931,6 +976,7 @@ async function submitPrCritical(
|
|
|
931
976
|
process_key: processKey,
|
|
932
977
|
updated_at: ts,
|
|
933
978
|
});
|
|
979
|
+
reopened = true;
|
|
934
980
|
} else if (processKey != null) {
|
|
935
981
|
// First submit: the row was INSERTED keyless before `createInstance` (the phantom-row pattern,
|
|
936
982
|
// #704/#497); publish its `process_key` now that the instance exists.
|
|
@@ -949,6 +995,19 @@ async function submitPrCritical(
|
|
|
949
995
|
await resetAdjudications(data, parsed.prKey);
|
|
950
996
|
}
|
|
951
997
|
} catch (err) {
|
|
998
|
+
// Restore the prior row (#941 review): the atomic reopen already advanced `process_key` to the
|
|
999
|
+
// instance we are about to cancel. Leaving that cancelled key on the row would make a dead run its
|
|
1000
|
+
// "owner" — once tracking derives `abandoned`, a retried merge-loop hand-off would be fenced as
|
|
1001
|
+
// stale (settled row) and the merge loop would end with no live convergence owner. Restoring the
|
|
1002
|
+
// pre-reopen shape (owner key + status) keeps the retry the owning hand-off, and a public retry
|
|
1003
|
+
// still sees the old terminal row as resubmittable.
|
|
1004
|
+
if (reopened && prior) {
|
|
1005
|
+
try {
|
|
1006
|
+
await table.update(parsed.prKey, { ...prior, updated_at: now() });
|
|
1007
|
+
} catch (restoreErr) {
|
|
1008
|
+
console.warn(`[submit] ${parsed.prKey} rollback-restore failed: ${restoreErr}`);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
952
1011
|
if (processInstanceKey != null) {
|
|
953
1012
|
try {
|
|
954
1013
|
await engine.cancelInstance({ processInstanceKey: String(processInstanceKey) });
|
|
@@ -1025,6 +1084,11 @@ export async function startMerge(
|
|
|
1025
1084
|
mergeableRepollInterval: MERGEABLE_REPOLL_INTERVAL,
|
|
1026
1085
|
mergeStallRounds: 0,
|
|
1027
1086
|
mergeStallMax: MAX_MERGE_STALL_ROUNDS,
|
|
1087
|
+
// Head-current review request budget (issue #939): see REVIEW_REQUEST_WAIT.
|
|
1088
|
+
reviewRequestRound: 0,
|
|
1089
|
+
reviewRequestMax: MAX_REVIEW_REQUESTS,
|
|
1090
|
+
reviewRequestWait: REVIEW_REQUEST_WAIT,
|
|
1091
|
+
reviewRequest: null,
|
|
1028
1092
|
// Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
|
|
1029
1093
|
rootRequestKey,
|
|
1030
1094
|
abandonUrl: abUrl,
|
package/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.
|
|
5
|
-
"builtAt": "2026-11-
|
|
2
|
+
"version": "0.208.1",
|
|
3
|
+
"gitSha": "9080010a73e54c922991be86b54d3f76a1b264e8",
|
|
4
|
+
"gitTag": "v0.208.1",
|
|
5
|
+
"builtAt": "2026-11-10T07:05:28.202Z"
|
|
6
6
|
}
|
package/nano.app.json
CHANGED
|
@@ -144,6 +144,14 @@
|
|
|
144
144
|
"taskType": "pr.merge",
|
|
145
145
|
"handler": "workers/merge/worker.ts"
|
|
146
146
|
},
|
|
147
|
+
{
|
|
148
|
+
"taskType": "pr.request-review",
|
|
149
|
+
"handler": "workers/request-review/worker.ts"
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"taskType": "pr.reconverge",
|
|
153
|
+
"handler": "workers/reconverge/worker.ts"
|
|
154
|
+
},
|
|
147
155
|
{
|
|
148
156
|
"taskType": "pr.mark-merged",
|
|
149
157
|
"handler": "workers/mark-merged/worker.ts"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.208.1",
|
|
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",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@nanobpm/agentic": "^0.
|
|
68
|
+
"@nanobpm/agentic": "^0.16.0",
|
|
69
69
|
"@nanobpm/urban": "^0.98.0",
|
|
70
70
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
71
71
|
},
|