@nanobpm/nano-workforce 0.206.1 → 0.206.3
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/app/github.test.ts +43 -0
- package/app/github.ts +11 -1
- package/app/mergeBlockedThreads.test.ts +3 -0
- package/app/mergeLoopBehaviour.test.ts +241 -4
- package/app/mergeOutOfBandTerminal.test.ts +57 -1
- package/app/mergeQueueEviction.test.ts +11 -1
- package/app/service.ts +24 -4
- package/build-info.json +4 -4
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/merge-loop.bpmn +129 -70
- package/test/memStatusCas.ts +23 -0
- package/workers/landed-probe/worker.test.ts +41 -0
- package/workers/landed-probe/worker.ts +43 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.206.3](https://github.com/nanobpm/nano-workforce/compare/v0.206.2...v0.206.3) (2026-11-09)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **merge:** never escalate a PR that already landed when the queue landing timeout fires ([#935](https://github.com/nanobpm/nano-workforce/issues/935)) ([f731dc6](https://github.com/nanobpm/nano-workforce/commit/f731dc60dc0cb7480b425e88d1d216b95d63a5c1)), closes [#933](https://github.com/nanobpm/nano-workforce/issues/933)
|
|
6
|
+
|
|
7
|
+
## [0.206.2](https://github.com/nanobpm/nano-workforce/compare/v0.206.1...v0.206.2) (2026-11-09)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **merge:** let a DIRTY state outrank absent required checks so a conflicting PR reaches the rebase arm ([#930](https://github.com/nanobpm/nano-workforce/issues/930)) ([71984d5](https://github.com/nanobpm/nano-workforce/commit/71984d5d607151b2c3690a12d6b05537b24c3ece)), closes [#392](https://github.com/nanobpm/nano-workforce/issues/392) [nanobpm/nano-bpm#1444](https://github.com/nanobpm/nano-bpm/issues/1444) [#929](https://github.com/nanobpm/nano-workforce/issues/929)
|
|
12
|
+
|
|
1
13
|
## [0.206.1](https://github.com/nanobpm/nano-workforce/compare/v0.206.0...v0.206.1) (2026-11-08)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/app/github.test.ts
CHANGED
|
@@ -719,6 +719,49 @@ const MERGE_CASES: MergeCase[] = [
|
|
|
719
719
|
protocol: protocolWith({ requiredChecks: reqChecks("build"), waitForChecks: true }),
|
|
720
720
|
want: "waiting",
|
|
721
721
|
},
|
|
722
|
+
// ── DIRTY outranks an ABSENT/PENDING required check (issue #929) ─────────────────────────────
|
|
723
|
+
// GitHub does not run `pull_request` workflows on a PR with merge conflicts, so a DIRTY head
|
|
724
|
+
// reports its declared-required checks as ABSENT (or skipped). Mapping that absence to `waiting`
|
|
725
|
+
// deadlocks the merge loop: the checks can never arrive until the conflict is resolved, and the
|
|
726
|
+
// conflict is never routed to the rebase arm because the checks read as "not yet run". The
|
|
727
|
+
// conflict is ground truth whose remedy (a rebase) is also exactly what re-triggers CI, so DIRTY
|
|
728
|
+
// outranks absence — but NOT a red declared-required check, which still routes to fix-ci.
|
|
729
|
+
{
|
|
730
|
+
name: "DIRTY + declared-required check ABSENT -> conflict, not waiting (the #929 deadlock)",
|
|
731
|
+
state: { mergeStateStatus: "DIRTY", rollup: [{ name: "summary", conclusion: "SKIPPED" }] },
|
|
732
|
+
protocol: protocolWith({ requiredChecks: reqChecks("typecheck + test (Node)", "whole-repo invariants") }),
|
|
733
|
+
want: "conflict",
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
name: "DIRTY + no check runs at all -> conflict (absence is a consequence of the conflict)",
|
|
737
|
+
state: { mergeStateStatus: "DIRTY" },
|
|
738
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build"), waitForChecks: true }),
|
|
739
|
+
want: "conflict",
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
name: "DIRTY + declared-required check PENDING -> conflict (rebase, then CI re-runs on the new head)",
|
|
743
|
+
state: { mergeStateStatus: "DIRTY", rollup: [{ name: "build", conclusion: "QUEUED" }] },
|
|
744
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
745
|
+
want: "conflict",
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
name: "DIRTY + declared-required check present-but-unconcluded -> conflict",
|
|
749
|
+
state: { mergeStateStatus: "DIRTY", rollup: [{ name: "build", conclusion: "IN_PROGRESS" }] },
|
|
750
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
751
|
+
want: "conflict",
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
name: "DIRTY + RED declared-required check -> blocked (a red check still outranks the conflict)",
|
|
755
|
+
state: { mergeStateStatus: "DIRTY", rollup: [{ name: "build", conclusion: "FAILURE" }] },
|
|
756
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
757
|
+
want: "blocked",
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
name: "DIRTY + green declared-required checks -> conflict (unchanged pre-#929 behaviour)",
|
|
761
|
+
state: { mergeStateStatus: "DIRTY", rollup: [{ name: "build", conclusion: "SUCCESS" }] },
|
|
762
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
763
|
+
want: "conflict",
|
|
764
|
+
},
|
|
722
765
|
];
|
|
723
766
|
|
|
724
767
|
for (const c of MERGE_CASES) {
|
package/app/github.ts
CHANGED
|
@@ -1621,7 +1621,6 @@ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Merg
|
|
|
1621
1621
|
// settled-thread routing below.
|
|
1622
1622
|
const gate = requiredChecksVerdict(s, protocol);
|
|
1623
1623
|
if (gate === "blocked") return "blocked";
|
|
1624
|
-
if (gate === "waiting") return "waiting";
|
|
1625
1624
|
// A GitHub-identified merge conflict (`mergeStateStatus: "DIRTY"`) is ground truth whose remedy is
|
|
1626
1625
|
// a rebase, dispatched off the `conflict` verdict. It outranks the thread gate below (PR #922
|
|
1627
1626
|
// review, thread 1): a FAILED thread probe (`THREAD_PROBE_FAILED`) returning `waiting` here would
|
|
@@ -1630,7 +1629,18 @@ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Merg
|
|
|
1630
1629
|
// round that cannot clear it. Surface the conflict first; the threads are re-triaged once the
|
|
1631
1630
|
// rebased head is re-read. (A red declared-required check still outranks even this — see the
|
|
1632
1631
|
// backstop above — so a conflicting PR with failing CI routes to fix-ci, not a doomed rebase.)
|
|
1632
|
+
//
|
|
1633
|
+
// DIRTY also outranks a `waiting` required-checks verdict (issue #929): GitHub does NOT run
|
|
1634
|
+
// `pull_request` workflows on a PR with merge conflicts, so a conflicting head reports its
|
|
1635
|
+
// declared-required checks as ABSENT (or skipped) — and `requiredChecksVerdict` maps absence to
|
|
1636
|
+
// `waiting`. Evaluated in the old order that deadlocked the merge loop: the checks can never
|
|
1637
|
+
// arrive until the conflict is resolved, and the conflict was never routed to the rebase arm
|
|
1638
|
+
// because the absent checks read as "not yet run". The absence is a CONSEQUENCE of the conflict,
|
|
1639
|
+
// and the conflict's remedy (a rebase) is exactly what re-triggers CI on the new head — so the
|
|
1640
|
+
// conflict owns the verdict. Only a RED declared-required check (`blocked`, handled above) may
|
|
1641
|
+
// still outrank DIRTY.
|
|
1633
1642
|
if (s.mergeStateStatus === "DIRTY") return "conflict";
|
|
1643
|
+
if (gate === "waiting") return "waiting";
|
|
1634
1644
|
// The merge-stage thread gate (issue #921, PR #922 review): a read that opted into the
|
|
1635
1645
|
// review-threads probe and found ≥1 open thread (`unresolvedThreads > 0`) is NEVER merge-ready,
|
|
1636
1646
|
// whatever `mergeStateStatus` says. A repo without branch-protection
|
|
@@ -17,6 +17,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { assert, assertEquals } from "#test-assert";
|
|
20
|
+
import { memStatusCas } from "../test/memStatusCas.ts";
|
|
20
21
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
21
22
|
import { pollMerges } from "./service.ts";
|
|
22
23
|
|
|
@@ -73,6 +74,8 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
73
74
|
table: (n: string, pk?: string) => tbl(n, pk),
|
|
74
75
|
open: () => ({
|
|
75
76
|
exec: async (sql: string, params: any[] = []) => {
|
|
77
|
+
const cas = memStatusCas(stores, sql, params);
|
|
78
|
+
if (cas) return cas;
|
|
76
79
|
const m = sql.match(/^\s*DELETE\s+FROM\s+"?(\w+)"?\s+WHERE\s+"?(\w+)"?\s*=\s*\?\s*$/i);
|
|
77
80
|
if (!m) throw new Error(`memData.open().exec: unsupported statement: ${sql}`);
|
|
78
81
|
const rows = (stores[m[1]] ??= [] as any[]);
|
|
@@ -52,6 +52,7 @@ const ALL_JOB_TYPES = [
|
|
|
52
52
|
"pr.arm-merge",
|
|
53
53
|
"pr.merge",
|
|
54
54
|
"pr.merge-stall-probe",
|
|
55
|
+
"pr.landed-probe",
|
|
55
56
|
"pr.mark-merged",
|
|
56
57
|
"senior:fix-ci",
|
|
57
58
|
"senior:rebase",
|
|
@@ -66,6 +67,7 @@ const DEFAULT_RESPONSES: Record<string, Responder> = {
|
|
|
66
67
|
"pr.record-dependency": {},
|
|
67
68
|
"pr.answer-escalation": {},
|
|
68
69
|
"pr.persist-escalation": { escalated: true },
|
|
70
|
+
"pr.landed-probe": { landedVerdict: "waiting" },
|
|
69
71
|
};
|
|
70
72
|
|
|
71
73
|
// Every FEEL expression in the model references these; start them defined (null,
|
|
@@ -239,6 +241,30 @@ test("a queued merge that never lands escalates when the landing timeout fires (
|
|
|
239
241
|
assertStringIncludes(String(escalation(engine).question ?? ""), "did not land within the merge-queue landing timeout", "the escalation must name the landing-timeout trigger");
|
|
240
242
|
});
|
|
241
243
|
|
|
244
|
+
test("a landing timeout firing on a PR that already MERGED completes merged with no escalation (#933)", async () => {
|
|
245
|
+
// The queue landed the PR, but the timer won the `eg-landed` race before the poller published
|
|
246
|
+
// `merge-landed`. The timeout arm must re-read ground truth and finalize — never escalate a landed PR.
|
|
247
|
+
const engine = await boot({
|
|
248
|
+
responses: { "pr.merge": { mergeStatus: "queued" }, "pr.landed-probe": { landedVerdict: "landed" } },
|
|
249
|
+
});
|
|
250
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
251
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
252
|
+
await engine.advanceTime(LANDED_WAIT_MS + 1);
|
|
253
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasNoIncident().hasCompletedElements("landed-probe", "mark-merged");
|
|
254
|
+
assert(!completedElementIds(engine).has("merge-esc-landed"), "a landed PR must never be escalated");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("a landing timeout on a PR evicted from the queue re-arms the merge poller (#933)", async () => {
|
|
258
|
+
const engine = await boot({
|
|
259
|
+
responses: { "pr.merge": { mergeStatus: "queued" }, "pr.landed-probe": { landedVerdict: "evicted" } },
|
|
260
|
+
});
|
|
261
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
262
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
263
|
+
await engine.advanceTime(LANDED_WAIT_MS + 1);
|
|
264
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
265
|
+
assert(!completedElementIds(engine).has("merge-esc-landed"), "an evicted PR re-arms rather than escalating");
|
|
266
|
+
});
|
|
267
|
+
|
|
242
268
|
test("answering the landing-timeout escalation re-arms the merge poller (#556)", async () => {
|
|
243
269
|
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
|
|
244
270
|
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
@@ -498,7 +524,9 @@ test("the 'waiting' re-poll is bounded — exhausting mergeStallMax escalates to
|
|
|
498
524
|
const engine = await boot({
|
|
499
525
|
vars: { mergeStallMax: 0 },
|
|
500
526
|
responses: {
|
|
501
|
-
|
|
527
|
+
// The probe always emits the full tri-state (a failed read reports reviewThreads: -1) — the
|
|
528
|
+
// escalation wording reads it, so the test payload must carry it.
|
|
529
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 },
|
|
502
530
|
},
|
|
503
531
|
});
|
|
504
532
|
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
@@ -509,6 +537,186 @@ test("the 'waiting' re-poll is bounded — exhausting mergeStallMax escalates to
|
|
|
509
537
|
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("wait-mergeable-repoll", "merge-stall-probe", "merge-esc-conflict");
|
|
510
538
|
});
|
|
511
539
|
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
// #929 — a DIRTY PR whose required checks are ABSENT (GitHub skips pull_request
|
|
542
|
+
// workflows on a conflicting head) must route to the rebase arm, never to the
|
|
543
|
+
// misleading "all checks green … needs approval" escalation.
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
test("a DIRTY PR with absent required checks reaches SP_rebase via the stall probe (#929)", async () => {
|
|
547
|
+
// The incident (nanobpm/nano-bpm#1444): REST reported mergeable=false/dirty and every
|
|
548
|
+
// declared-required check was ABSENT (GitHub skips CI on a conflicting head), yet the loop
|
|
549
|
+
// escalated "needs approval" twice and never dispatched the rebase. With the classifier fix,
|
|
550
|
+
// the stall probe re-derives `conflict` and the token reaches the rebase agent.
|
|
551
|
+
const engine = await boot({
|
|
552
|
+
responses: {
|
|
553
|
+
"pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 0 },
|
|
554
|
+
"senior:rebase": { status: "rebased" },
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
558
|
+
// The poller's pre-fix verdict was `waiting` (absent required checks outranked DIRTY), so the
|
|
559
|
+
// instance is parked on the mergeable wait when the stall timer fires.
|
|
560
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
561
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
562
|
+
.isActive()
|
|
563
|
+
.hasActiveElement("wait-mergeable")
|
|
564
|
+
.hasCompletedElements("merge-stall-probe", "rebase") // the rebase agent ran — SP_rebase was reached
|
|
565
|
+
.hasVariable("rebaseRound", 1);
|
|
566
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "a DIRTY PR must rebase, not escalate 'needs approval'");
|
|
567
|
+
const openTasks = await engine.searchUserTasks({});
|
|
568
|
+
assert(!openTasks.some((t) => t.elementId === "wait-merge-answer"), "no human escalation on the rebase path");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("a stall escalation on a DIRTY PR never claims 'all checks green' — it names the conflict (#929)", async () => {
|
|
572
|
+
// The pre-fix escalation built "all checks green (none failing, none pending)" from
|
|
573
|
+
// `failingChecks = 0 and pendingChecks = 0` — but on a DIRTY head those zeros mean GitHub
|
|
574
|
+
// SKIPPED the required checks, not that they passed. The approval wording must stay gated on a
|
|
575
|
+
// PROBED-and-clear thread count; a DIRTY PR (whose `conflict` verdict is only reachable via the
|
|
576
|
+
// rebase arm) that still lands here keeps the conservative wording instead of the false claim.
|
|
577
|
+
const engine = await boot({
|
|
578
|
+
vars: { mergeStallMax: 0, rebaseMax: 0 },
|
|
579
|
+
responses: {
|
|
580
|
+
"pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 },
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
584
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
585
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
586
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
|
|
587
|
+
const q = String(escalation(engine).question ?? "");
|
|
588
|
+
assert(!q.includes("all checks green"), "absent/unprobed checks must never read as 'all checks green'");
|
|
589
|
+
assert(!q.includes("required review approval"), "a conflict must never read as a review-approval wait");
|
|
590
|
+
assertStringIncludes(q, "merge conflict", "the conflict escalation must name the conflict");
|
|
591
|
+
// PR #930 review: the verdict alone does not establish that the required checks are ABSENT —
|
|
592
|
+
// a conflicting head can carry successful required checks, and a failed read reports the same
|
|
593
|
+
// zeros — so the escalation may only note that GitHub MAY not have run them, never assert it.
|
|
594
|
+
assert(!q.includes("absent, NOT green"), "the verdict alone never establishes that the required checks are absent");
|
|
595
|
+
assertStringIncludes(q, "may not have run", "the escalation notes the check-skip caveat without asserting it");
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("a conflict-verdict escalation with GREEN required checks still names only the conflict (#930 review)", async () => {
|
|
599
|
+
// The mirror case the review called out: `conflict` with every required check successful. The
|
|
600
|
+
// escalation must not claim the checks are absent or not-green — only the conflict is known.
|
|
601
|
+
const engine = await boot({
|
|
602
|
+
vars: { mergeStallMax: 0, rebaseMax: 0 },
|
|
603
|
+
responses: {
|
|
604
|
+
"pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 0 },
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
608
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
609
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
610
|
+
const q = String(escalation(engine).question ?? "");
|
|
611
|
+
assertStringIncludes(q, "merge conflict", "the conflict escalation must name the conflict");
|
|
612
|
+
assert(!q.includes("NOT green"), "green required checks must never be described as not-green");
|
|
613
|
+
assert(!q.includes("all checks green"), "the escalation asserts the conflict, not the check state either way");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("a stalled 'waiting' with clear threads and zero checks never asserts the required checks ran (#930 review)", async () => {
|
|
617
|
+
// `waiting` + reviewThreads>0 + failing=0/pending=0 is ambiguous: the required checks may be
|
|
618
|
+
// absent (never ran on a conflicting head) rather than green. The escalation must float both
|
|
619
|
+
// candidates — a conflict whose checks never ran, or a review-approval requirement — and send
|
|
620
|
+
// the human to the PR, never assert "no failing or pending checks" as if the rollup were green.
|
|
621
|
+
const engine = await boot({
|
|
622
|
+
vars: { mergeStallMax: 0 },
|
|
623
|
+
responses: {
|
|
624
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 0 },
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
628
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
629
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
630
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
|
|
631
|
+
const q = String(escalation(engine).question ?? "");
|
|
632
|
+
assert(!q.includes("all checks green"), "zero counts with clear threads must never claim green checks");
|
|
633
|
+
assert(!q.includes("NO failing or pending checks"), "zero counts do not establish that the required checks ran");
|
|
634
|
+
assert(!q.includes("required review approval is outstanding"), "an ambiguous read must never assert a verified review-approval blocker");
|
|
635
|
+
assertStringIncludes(q, "not verified", "the approval blocker must be floated, not asserted");
|
|
636
|
+
assertStringIncludes(q, "may never have run", "the escalation must allow that the required checks never ran");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("a stalled 'waiting' with unresolved threads reports their count and asks to resolve them (#930 review)", async () => {
|
|
640
|
+
// Absent required checks can yield `waiting` before the thread gate runs; the verified unresolved
|
|
641
|
+
// threads must be surfaced, since resolving a conflict or approving does not clear them.
|
|
642
|
+
const engine = await boot({
|
|
643
|
+
vars: { mergeStallMax: 0 },
|
|
644
|
+
responses: {
|
|
645
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: 2, pendingChecks: 0 },
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
649
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
650
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
651
|
+
const q = String(escalation(engine).question ?? "");
|
|
652
|
+
assertStringIncludes(q, "2 unresolved review thread(s)", "the verified thread count must be reported");
|
|
653
|
+
assertStringIncludes(q, "Resolve those conversations", "the operator must be asked to resolve the threads");
|
|
654
|
+
assertStringIncludes(q, "may never have run", "the check state stays unverified");
|
|
655
|
+
assert(!q.includes("all checks green"), "never claim green checks");
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("a stall escalation after a FAILED GitHub read says the state could not be read — never a confirmed conflict (#930 review)", async () => {
|
|
659
|
+
// The stall probe's catch maps a failed read to `waiting`, reviewThreads -1, zero checks — the
|
|
660
|
+
// exact payload the pre-fix wording read as a confirmed conflict. The escalation must report an
|
|
661
|
+
// unreadable state and nothing more specific.
|
|
662
|
+
const engine = await boot({
|
|
663
|
+
vars: { mergeStallMax: 0 },
|
|
664
|
+
responses: {
|
|
665
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 },
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
669
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
670
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
671
|
+
const q = String(escalation(engine).question ?? "");
|
|
672
|
+
assertStringIncludes(q, "could not be fully read", "a failed read must be named as such");
|
|
673
|
+
assert(!q.includes("CONFLICTING"), "a failed read must never assert a confirmed conflict");
|
|
674
|
+
assert(!q.includes("required review approval"), "a failed read must never assert a review-approval blocker");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("a stall give-up with no remediation attempted never claims a remediation budget was exhausted (#930 review)", async () => {
|
|
678
|
+
for (const mergeState of ["conflict", "blocked"]) {
|
|
679
|
+
const engine = await boot({
|
|
680
|
+
vars: { mergeStallMax: 0 },
|
|
681
|
+
responses: {
|
|
682
|
+
"pr.merge-stall-probe": { mergeState, failingChecks: mergeState === "blocked" ? 1 : 0, failingChecksList: mergeState === "blocked" ? "ci" : "", reviewThreads: 0, pendingChecks: 0 },
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
686
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
687
|
+
const q = String(escalation(engine).question ?? "");
|
|
688
|
+
if (q === "") continue; // routed to a remediation agent rather than the direct give-up
|
|
689
|
+
assert(!/budget|exhausted/.test(q), `${mergeState}: the escalation must not claim an exhausted budget: ${q}`);
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("answering 'retry' on a DIRTY PR's escalation re-arms the poller, which re-derives conflict and rebases (#929)", async () => {
|
|
694
|
+
// The incident's second half: a human answered "retry" and the loop re-classified the same way
|
|
695
|
+
// and escalated again — the rebase was never reached. Post-fix the retry re-arms the poller; the
|
|
696
|
+
// next verdict for the still-DIRTY PR is `conflict`, which dispatches the rebase agent.
|
|
697
|
+
const engine = await boot({
|
|
698
|
+
vars: { mergeStallMax: 0 },
|
|
699
|
+
responses: {
|
|
700
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 },
|
|
701
|
+
"senior:rebase": { status: "rebased" },
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
705
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
706
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
707
|
+
// The human replies "retry" — the answer reconciles then re-arms the poller.
|
|
708
|
+
await engine.completeUserTask(await mergeAnswerTaskKey(engine), { answer: "retry" });
|
|
709
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
710
|
+
// The re-armed poller now classifies the DIRTY PR as `conflict` (absent required checks no
|
|
711
|
+
// longer outrank DIRTY) and the rebase agent is dispatched.
|
|
712
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "conflict" } });
|
|
713
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
714
|
+
.isActive()
|
|
715
|
+
.hasActiveElement("wait-mergeable")
|
|
716
|
+
.hasCompletedElements("record-merge-answer", "rebase")
|
|
717
|
+
.hasVariable("rebaseRound", 1);
|
|
718
|
+
});
|
|
719
|
+
|
|
512
720
|
test("an unclassified verdict routes to auto-rebase (DEFAULT), not to a human (#774)", async () => {
|
|
513
721
|
// The DEFAULT arm is a last-resort self-heal, not a page: an unexpected/garbage `mergeState` routes
|
|
514
722
|
// to the bounded rebase agent (which reconciles from ground truth), never straight to a human.
|
|
@@ -637,7 +845,7 @@ test("the 'never settled' question is reserved for a genuinely unreadable/unsett
|
|
|
637
845
|
const engine = await boot({
|
|
638
846
|
vars: { mergeStallMax: 0 },
|
|
639
847
|
responses: {
|
|
640
|
-
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1 },
|
|
848
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1, pendingChecks: 0 },
|
|
641
849
|
},
|
|
642
850
|
});
|
|
643
851
|
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
@@ -645,8 +853,8 @@ test("the 'never settled' question is reserved for a genuinely unreadable/unsett
|
|
|
645
853
|
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
646
854
|
assertStringIncludes(
|
|
647
855
|
String(escalation(engine).question ?? ""),
|
|
648
|
-
"
|
|
649
|
-
"a genuinely unsettled/unreadable mergeability
|
|
856
|
+
"could not be fully read",
|
|
857
|
+
"a genuinely unsettled/unreadable mergeability names the unreadable state",
|
|
650
858
|
);
|
|
651
859
|
});
|
|
652
860
|
|
|
@@ -871,6 +1079,31 @@ test("a conflict runs the bounded rebase agent and a rebased result re-arms the
|
|
|
871
1079
|
.hasVariable("rebaseRound", 1); // the rebase counter advanced across the sub-process boundary
|
|
872
1080
|
});
|
|
873
1081
|
|
|
1082
|
+
test("a rebase agent cannot spoof the escalation verdict by returning mergeState (#930 self-review)", async () => {
|
|
1083
|
+
// The agent's result keys are hoisted into process scope, and `mergeState` now selects the
|
|
1084
|
+
// shared merge-esc-conflict wording. A rebase agent that returns a bogus `mergeState:"ready"`
|
|
1085
|
+
// must NOT be able to rewrite a "could not resolve the conflict" escalation into a
|
|
1086
|
+
// green-checks/approval message: the trusted `end_reb_blocked` end event re-stamps the verdict.
|
|
1087
|
+
const engine = await boot({ responses: { "senior:rebase": { status: "blocked", mergeState: "ready" } } });
|
|
1088
|
+
await driveToRebase(engine);
|
|
1089
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
1090
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
|
|
1091
|
+
assertStringIncludes(String(escalation(engine).question ?? ""), "rebase agent could not resolve", "the question must name the conflict trigger, not the spoofed verdict");
|
|
1092
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasVariable("mergeState", "conflict");
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
test("a fix-ci agent cannot spoof the escalation verdict by returning mergeState (#930 self-review)", async () => {
|
|
1096
|
+
// Same guard on the CI-fix side: a fix-ci agent that exhausts its budget after returning a bogus
|
|
1097
|
+
// `mergeState` must still escalate with the CI-fix wording — `end_ci_esc` re-stamps the verdict.
|
|
1098
|
+
const engine = await boot({ responses: { "senior:fix-ci": { status: "blocked", pushed: true, mergeState: "ready" } } });
|
|
1099
|
+
await driveToCiFix(engine);
|
|
1100
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
1101
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
|
|
1102
|
+
const q = String(escalation(engine).question ?? "");
|
|
1103
|
+
assertStringIncludes(q, "could not make the required checks green", "the question must name the CI-fix failure, not the spoofed verdict");
|
|
1104
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasVariable("mergeState", "blocked");
|
|
1105
|
+
});
|
|
1106
|
+
|
|
874
1107
|
test("a rebase that cannot resolve escalates with a conflict question", async () => {
|
|
875
1108
|
const engine = await boot({ responses: { "senior:rebase": { status: "blocked" } } });
|
|
876
1109
|
await driveToRebase(engine);
|
|
@@ -967,6 +1200,10 @@ test("the rebase agent SLA interrupts the sub-process and escalates as not-merge
|
|
|
967
1200
|
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-conflict");
|
|
968
1201
|
// #879 regression: the trusted rebase SLA arm must deliver its job-type marker to the shared sink.
|
|
969
1202
|
assertEquals(escalation(engine).agentSlaJobType, "senior:rebase", "the rebase SLA arm must reach the recorder with its job-type marker");
|
|
1203
|
+
// The SLA escalation must never surface the stale `conflict` verdict's wording — the recorder's
|
|
1204
|
+
// SLA builder (keyed on the trusted marker) owns the question, not the verdict FEEL.
|
|
1205
|
+
const q = String(escalation(engine).question ?? "");
|
|
1206
|
+
assert(!q.includes("CONFLICTING") && !q.includes("merge conflict with the base branch"), "an SLA escalation must not read as a verdict escalation");
|
|
970
1207
|
});
|
|
971
1208
|
|
|
972
1209
|
test("a non-SLA escalation carrying a spoofed agentSlaJobType is scrubbed before the recorder (#879 fail-closed)", async () => {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// message THAT wait correlates to; this test asserts the class over every (status × merged/closed).
|
|
14
14
|
import { test } from "node:test";
|
|
15
15
|
import { assertEquals } from "#test-assert";
|
|
16
|
+
import { memStatusCas } from "../test/memStatusCas.ts";
|
|
16
17
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
17
18
|
import { pollMerges } from "./service.ts";
|
|
18
19
|
|
|
@@ -45,7 +46,16 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
45
46
|
},
|
|
46
47
|
};
|
|
47
48
|
}
|
|
48
|
-
const data = {
|
|
49
|
+
const data = {
|
|
50
|
+
table: (n: string, pk?: string) => tbl(n, pk),
|
|
51
|
+
open: () => ({
|
|
52
|
+
exec: async (sql: string, params: any[] = []) => {
|
|
53
|
+
const res = memStatusCas(stores, sql, params);
|
|
54
|
+
if (!res) throw new Error(`memData.open().exec: unsupported statement: ${sql}`);
|
|
55
|
+
return res;
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
} as any as DataLayer;
|
|
49
59
|
return { data, stores };
|
|
50
60
|
}
|
|
51
61
|
|
|
@@ -176,3 +186,49 @@ test("still-open waiting_deps PR with an unmerged dep publishes nothing and stay
|
|
|
176
186
|
assertEquals(messages.length, 0);
|
|
177
187
|
assertEquals(stores["pull_requests"][0].status, "waiting_deps");
|
|
178
188
|
});
|
|
189
|
+
|
|
190
|
+
// PR #935 review: a poll pass reads `queued`, then stalls on GitHub while the process's own landing
|
|
191
|
+
// timeout path completes `mark-merged` (status → `merged`). When the delayed pass resumes, its flip
|
|
192
|
+
// must NOT clobber the terminal `merged` with `merging` (no pass scans `merging`, so it would stick)
|
|
193
|
+
// nor publish a `merge-landed` whose subscription is gone. The flip is a compare-and-set on the
|
|
194
|
+
// status the pass read; a lost race skips the publish entirely.
|
|
195
|
+
// Asserted on `waiting_deps` (the flip is the one shared helper every merge-stage wait uses).
|
|
196
|
+
test("a delayed merge-stage poll never overwrites a concurrently-written terminal status", async () => {
|
|
197
|
+
const { data, stores } = memData();
|
|
198
|
+
const { engine, messages } = recordingEngine();
|
|
199
|
+
stores["pull_requests"] = [prRow("o/r#100", 100, "waiting_deps")];
|
|
200
|
+
stores["pr_dependencies"] = [{ pr_key: "o/r#100", depends_on_key: "o/r#200", created_at: "t" }];
|
|
201
|
+
const inner = githubFetch(new Map<number, Live>([[100, "merged"]]));
|
|
202
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
203
|
+
const prevFetch = globalThis.fetch;
|
|
204
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
205
|
+
// While the poll awaits GitHub, the timeout path's `mark-merged` lands the terminal status.
|
|
206
|
+
globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => {
|
|
207
|
+
// Replace (not mutate) the row: a real DB read hands the pass a snapshot, not a live reference.
|
|
208
|
+
stores["pull_requests"][0] = { ...stores["pull_requests"][0], status: "merged" };
|
|
209
|
+
return inner(url, init);
|
|
210
|
+
}) as typeof fetch;
|
|
211
|
+
try {
|
|
212
|
+
await pollMerges(data, engine, "tok");
|
|
213
|
+
} finally {
|
|
214
|
+
globalThis.fetch = prevFetch;
|
|
215
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
216
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
217
|
+
}
|
|
218
|
+
assertEquals(messages.length, 0, "a lost race must not publish to a vanished subscription");
|
|
219
|
+
assertEquals(stores["pull_requests"][0].status, "merged");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// The publish-failure revert is conditional too: it only undoes its OWN `merging`.
|
|
223
|
+
test("a failed publish reverts merging back to the read status", async () => {
|
|
224
|
+
const { data, stores } = memData();
|
|
225
|
+
const engine = {
|
|
226
|
+
async publishMessage() {
|
|
227
|
+
throw new Error("boom");
|
|
228
|
+
},
|
|
229
|
+
} as any as EngineClient;
|
|
230
|
+
stores["pull_requests"] = [prRow("o/r#100", 100, "waiting_deps")];
|
|
231
|
+
stores["pr_dependencies"] = [{ pr_key: "o/r#100", depends_on_key: "o/r#200", created_at: "t" }];
|
|
232
|
+
await withGithub(new Map<number, Live>([[100, "merged"]]), () => pollMerges(data, engine, "tok").catch(() => {}));
|
|
233
|
+
assertEquals(stores["pull_requests"][0].status, "waiting_deps");
|
|
234
|
+
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// token-transport GitHub stub that serves BOTH the REST PR view and the GraphQL merge-queue probe.
|
|
11
11
|
import { test } from "node:test";
|
|
12
12
|
import { assertEquals } from "#test-assert";
|
|
13
|
+
import { memStatusCas } from "../test/memStatusCas.ts";
|
|
13
14
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
14
15
|
import { fetchMergeQueueMembership } from "./github.ts";
|
|
15
16
|
import { pollMerges } from "./service.ts";
|
|
@@ -42,7 +43,16 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
42
43
|
},
|
|
43
44
|
};
|
|
44
45
|
}
|
|
45
|
-
const data = {
|
|
46
|
+
const data = {
|
|
47
|
+
table: (n: string, pk?: string) => tbl(n, pk),
|
|
48
|
+
open: () => ({
|
|
49
|
+
exec: async (sql: string, params: any[] = []) => {
|
|
50
|
+
const res = memStatusCas(stores, sql, params);
|
|
51
|
+
if (!res) throw new Error(`memData.open().exec: unsupported statement: ${sql}`);
|
|
52
|
+
return res;
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
} as any as DataLayer;
|
|
46
56
|
return { data, stores };
|
|
47
57
|
}
|
|
48
58
|
|
package/app/service.ts
CHANGED
|
@@ -1572,24 +1572,44 @@ async function classifyWaveTarget(
|
|
|
1572
1572
|
* otherwise wedge the PR there forever — the next pass would never pick it back up. Reverting on
|
|
1573
1573
|
* failure keeps the PR on a pollable status so the next pass retries. Single source of truth for
|
|
1574
1574
|
* the flip-then-publish handoff shared by all merge-stage waits below. */
|
|
1575
|
-
async function flipToMergingThenPublish(
|
|
1575
|
+
export async function flipToMergingThenPublish(
|
|
1576
1576
|
data: DataLayer,
|
|
1577
1577
|
engine: EngineClient,
|
|
1578
1578
|
prKey: string,
|
|
1579
1579
|
prevStatus: string,
|
|
1580
1580
|
message: Parameters<EngineClient["publishMessage"]>[0],
|
|
1581
|
-
) {
|
|
1582
|
-
|
|
1581
|
+
): Promise<boolean> {
|
|
1582
|
+
// Compare-and-set (#935 review): the flip only wins if the row is STILL on the status this pass
|
|
1583
|
+
// read. A pass that read `queued`, then stalled on GitHub while the process's own timeout path
|
|
1584
|
+
// completed `mark-merged`, must not clobber the terminal `merged` with `merging` (no pass scans
|
|
1585
|
+
// `merging`, and the completed loop has no worker left to restore it) nor publish a message whose
|
|
1586
|
+
// subscription is gone. A lost race skips the publish entirely.
|
|
1587
|
+
const db = data.open();
|
|
1588
|
+
const flipped = await db.exec(
|
|
1589
|
+
"UPDATE pull_requests SET status = ?, updated_at = ? WHERE pr_key = ? AND status = ?",
|
|
1590
|
+
["merging", now(), prKey, prevStatus],
|
|
1591
|
+
);
|
|
1592
|
+
if (flipped.changed === 0) {
|
|
1593
|
+
console.log(`[poller] ${prKey} left ${prevStatus} concurrently — skipping ${message.name}`);
|
|
1594
|
+
return false;
|
|
1595
|
+
}
|
|
1583
1596
|
try {
|
|
1584
1597
|
await engine.publishMessage(message);
|
|
1585
1598
|
} catch (err) {
|
|
1586
1599
|
try {
|
|
1587
|
-
|
|
1600
|
+
// Conditional revert too: only undo OUR `merging`, never a status another actor wrote since.
|
|
1601
|
+
await db.exec("UPDATE pull_requests SET status = ?, updated_at = ? WHERE pr_key = ? AND status = ?", [
|
|
1602
|
+
prevStatus,
|
|
1603
|
+
now(),
|
|
1604
|
+
prKey,
|
|
1605
|
+
"merging",
|
|
1606
|
+
]);
|
|
1588
1607
|
} catch (revertErr) {
|
|
1589
1608
|
console.error(`[poller] revert ${prKey} -> ${prevStatus} failed: ${revertErr}`);
|
|
1590
1609
|
}
|
|
1591
1610
|
throw err;
|
|
1592
1611
|
}
|
|
1612
|
+
return true;
|
|
1593
1613
|
}
|
|
1594
1614
|
|
|
1595
1615
|
export async function mergeLaneDecisionForPr(data: DataLayer, prKey: string): Promise<PrLaneDecision | null> {
|
package/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.206.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.206.
|
|
5
|
-
"builtAt": "2026-11-
|
|
2
|
+
"version": "0.206.3",
|
|
3
|
+
"gitSha": "f731dc60dc0cb7480b425e88d1d216b95d63a5c1",
|
|
4
|
+
"gitTag": "v0.206.3",
|
|
5
|
+
"builtAt": "2026-11-09T21:25:41.376Z"
|
|
6
6
|
}
|
package/nano.app.json
CHANGED
|
@@ -148,6 +148,10 @@
|
|
|
148
148
|
"taskType": "pr.mark-merged",
|
|
149
149
|
"handler": "workers/mark-merged/worker.ts"
|
|
150
150
|
},
|
|
151
|
+
{
|
|
152
|
+
"taskType": "pr.landed-probe",
|
|
153
|
+
"handler": "workers/landed-probe/worker.ts"
|
|
154
|
+
},
|
|
151
155
|
{
|
|
152
156
|
"taskType": "pr.merge-stall-probe",
|
|
153
157
|
"handler": "workers/merge-stall-probe/worker.ts"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.206.
|
|
3
|
+
"version": "0.206.3",
|
|
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",
|
|
@@ -56,6 +56,14 @@
|
|
|
56
56
|
<nano:extend name="reviewThreads" type="integer" optional="true" />
|
|
57
57
|
<nano:extend name="pendingChecks" type="integer" optional="true" />
|
|
58
58
|
</nano:shape>
|
|
59
|
+
<nano:shape id="LandedProbeIn" name="Landed probe — input">
|
|
60
|
+
<nano:extend name="prKey" type="string" />
|
|
61
|
+
<nano:extend name="repo" type="string" />
|
|
62
|
+
<nano:extend name="prNumber" type="integer" />
|
|
63
|
+
</nano:shape>
|
|
64
|
+
<nano:shape id="LandedProbeOut" name="Landed probe — result">
|
|
65
|
+
<nano:extend name="landedVerdict" type="string" />
|
|
66
|
+
</nano:shape>
|
|
59
67
|
<nano:shape id="MergeReady" name="merge-ready message payload">
|
|
60
68
|
<nano:extend name="mergeState" type="string" />
|
|
61
69
|
<nano:extend name="failingChecks" type="integer" optional="true" />
|
|
@@ -115,6 +123,9 @@
|
|
|
115
123
|
<zeebe:output source="=null" target="status" />
|
|
116
124
|
<zeebe:output source="=null" target="ciBlockedReconciled" />
|
|
117
125
|
<zeebe:output source="=null" target="agentSlaJobType" />
|
|
126
|
+
<zeebe:output source="=null" target="reviewThreads" />
|
|
127
|
+
<zeebe:output source="=null" target="failingChecks" />
|
|
128
|
+
<zeebe:output source="=null" target="pendingChecks" />
|
|
118
129
|
</zeebe:ioMapping>
|
|
119
130
|
</bpmn:extensionElements>
|
|
120
131
|
<bpmn:incoming>f_m_deps</bpmn:incoming>
|
|
@@ -122,6 +133,7 @@
|
|
|
122
133
|
<bpmn:incoming>f_ci_rearm</bpmn:incoming>
|
|
123
134
|
<bpmn:incoming>f_reb_rearm</bpmn:incoming>
|
|
124
135
|
<bpmn:incoming>f_m_evicted</bpmn:incoming>
|
|
136
|
+
<bpmn:incoming>f_lp_evicted</bpmn:incoming>
|
|
125
137
|
<bpmn:incoming>f_m_escReenter</bpmn:incoming>
|
|
126
138
|
<bpmn:incoming>f_mr_go</bpmn:incoming>
|
|
127
139
|
<bpmn:outgoing>f_m_arm</bpmn:outgoing>
|
|
@@ -231,11 +243,28 @@
|
|
|
231
243
|
</bpmn:intermediateCatchEvent>
|
|
232
244
|
<bpmn:intermediateCatchEvent id="wait-landed-timeout" name="Wait: merge-queue landing timeout">
|
|
233
245
|
<bpmn:incoming>f_eg_landedTimeout</bpmn:incoming>
|
|
234
|
-
<bpmn:outgoing>
|
|
246
|
+
<bpmn:outgoing>f_m_landedProbe</bpmn:outgoing>
|
|
235
247
|
<bpmn:timerEventDefinition id="ted_landedTimeout">
|
|
236
248
|
<bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=landedWaitTimeout</bpmn:timeDuration>
|
|
237
249
|
</bpmn:timerEventDefinition>
|
|
238
250
|
</bpmn:intermediateCatchEvent>
|
|
251
|
+
<bpmn:serviceTask id="landed-probe" name="Probe: did the queued PR land?">
|
|
252
|
+
<bpmn:extensionElements>
|
|
253
|
+
<zeebe:taskDefinition type="pr.landed-probe" />
|
|
254
|
+
<zeebe:properties>
|
|
255
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="LandedProbeIn" />
|
|
256
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.out" value="LandedProbeOut" />
|
|
257
|
+
</zeebe:properties>
|
|
258
|
+
</bpmn:extensionElements>
|
|
259
|
+
<bpmn:incoming>f_m_landedProbe</bpmn:incoming>
|
|
260
|
+
<bpmn:outgoing>f_lp_gate</bpmn:outgoing>
|
|
261
|
+
</bpmn:serviceTask>
|
|
262
|
+
<bpmn:exclusiveGateway id="gw-landed-probe" name="landed?" default="f_m_landedStalled">
|
|
263
|
+
<bpmn:incoming>f_lp_gate</bpmn:incoming>
|
|
264
|
+
<bpmn:outgoing>f_lp_landed</bpmn:outgoing>
|
|
265
|
+
<bpmn:outgoing>f_lp_evicted</bpmn:outgoing>
|
|
266
|
+
<bpmn:outgoing>f_m_landedStalled</bpmn:outgoing>
|
|
267
|
+
</bpmn:exclusiveGateway>
|
|
239
268
|
<bpmn:serviceTask id="mark-merged" name="Mark merged">
|
|
240
269
|
<bpmn:extensionElements>
|
|
241
270
|
<zeebe:taskDefinition type="pr.mark-merged" />
|
|
@@ -245,6 +274,7 @@
|
|
|
245
274
|
</bpmn:extensionElements>
|
|
246
275
|
<bpmn:incoming>f_m_gMerged</bpmn:incoming>
|
|
247
276
|
<bpmn:incoming>f_m_landed</bpmn:incoming>
|
|
277
|
+
<bpmn:incoming>f_lp_landed</bpmn:incoming>
|
|
248
278
|
<bpmn:outgoing>f_m_done</bpmn:outgoing>
|
|
249
279
|
</bpmn:serviceTask>
|
|
250
280
|
<bpmn:endEvent id="MergeEnd" name="Merged">
|
|
@@ -263,7 +293,7 @@
|
|
|
263
293
|
</zeebe:properties>
|
|
264
294
|
<zeebe:ioMapping>
|
|
265
295
|
<zeebe:input source="="blocked"" target="status" />
|
|
266
|
-
<zeebe:input source="=if mergeState = "draft" then "This PR is in draft and can't be merged. Mark it ready (gh pr ready), then reply to retry." else if mergeState = "waiting" and reviewThreads = 0 and failingChecks = 0 and pendingChecks = 0 then "This PR is merge-blocked with
|
|
296
|
+
<zeebe:input source="=if agentSlaJobType != null then "An automated merge-stage agent exceeded its time budget (SLA) before this PR could be merged. Check its progress or intervene, then reply to retry." else if mergeState = "draft" then "This PR is in draft and can't be merged. Mark it ready (gh pr ready), then reply to retry." else if mergeState = "conflict" then "This PR has a merge conflict with the base branch (GitHub reports the branch as CONFLICTING / mergeable_state=dirty). GitHub may not have run the pull_request checks on the conflicting head, so their state is unknown from this signal — the conflict alone is what is established. Resolve the conflict on the branch (git rebase / merge the base branch and push) — the rebase re-triggers CI — then reply to retry." else if mergeState = "blocked" then "GitHub reports failing required checks on this PR, and the merge loop could not get them green automatically. Fix the failing checks on the branch (or reply with guidance), then reply to retry." else if mergeState = "waiting" and reviewThreads = 0 and failingChecks = 0 and pendingChecks = 0 then "This PR is merge-blocked with every review thread resolved and no failing or pending checks reported. The likely cause is a required review approval (or code-owner review), but that is not verified: the declared-required checks may never have run on this head (GitHub skips pull_request workflows on a conflicting branch), so their state is not established. Check the PR on GitHub — approve it (or adjust the review requirement), or resolve any conflict (a rebase re-triggers CI) — then reply to retry." else if mergeState = "waiting" and reviewThreads != null and reviewThreads > 0 and failingChecks = 0 and pendingChecks = 0 then "This PR is merge-blocked with " + string(reviewThreads) + " unresolved review thread(s). Resolve those conversations on GitHub. Separately, no failing or pending checks were reported, but the declared-required checks may never have run on this head (GitHub skips pull_request workflows on a conflicting branch), so their state is not established — the block may also be a merge conflict whose checks never ran, or a required review approval. Resolve the threads, then resolve any conflict (a rebase re-triggers CI) or approve — then reply to retry." else if mergeState = "waiting" and (reviewThreads = null or reviewThreads < 0) and failingChecks = 0 and pendingChecks = 0 then "This PR is merge-blocked and its GitHub state could not be fully read (the review-thread probe was unreadable or failed), so neither a conflict nor the check/approval state is established. Check the PR on GitHub — resolve any conflict or failing required check if present — then reply to retry." else if mergeState = "waiting" then "GitHub never settled this PR's mergeability (still computing or checks still pending after repeated re-polls, or the state could not be read). Check the PR on GitHub — resolve any conflict or failing required check if present — then reply to retry." else "This PR is not mergeable (state: " + mergeState + "). Resolve the conflict or failing required check on the branch, then reply to retry."" target="question" />
|
|
267
297
|
</zeebe:ioMapping>
|
|
268
298
|
</bpmn:extensionElements>
|
|
269
299
|
<bpmn:incoming>f_m_mDraft</bpmn:incoming>
|
|
@@ -528,6 +558,7 @@
|
|
|
528
558
|
<zeebe:ioMapping>
|
|
529
559
|
<zeebe:output source="="escalate"" target="ciOutcome" />
|
|
530
560
|
<zeebe:output source="=null" target="agentSlaJobType" />
|
|
561
|
+
<zeebe:output source="="blocked"" target="mergeState" />
|
|
531
562
|
</zeebe:ioMapping>
|
|
532
563
|
</bpmn:extensionElements>
|
|
533
564
|
<bpmn:incoming>f_cib_esc</bpmn:incoming>
|
|
@@ -684,6 +715,7 @@
|
|
|
684
715
|
<zeebe:ioMapping>
|
|
685
716
|
<zeebe:output source="="escalate-attempt"" target="rebaseOutcome" />
|
|
686
717
|
<zeebe:output source="=null" target="agentSlaJobType" />
|
|
718
|
+
<zeebe:output source="="conflict"" target="mergeState" />
|
|
687
719
|
</zeebe:ioMapping>
|
|
688
720
|
</bpmn:extensionElements>
|
|
689
721
|
<bpmn:incoming>f_reb_blocked</bpmn:incoming>
|
|
@@ -787,7 +819,15 @@
|
|
|
787
819
|
<bpmn:sequenceFlow id="f_eg_landed" sourceRef="eg-landed" targetRef="wait-landed" />
|
|
788
820
|
<bpmn:sequenceFlow id="f_eg_evicted" sourceRef="eg-landed" targetRef="wait-evicted" />
|
|
789
821
|
<bpmn:sequenceFlow id="f_eg_landedTimeout" sourceRef="eg-landed" targetRef="wait-landed-timeout" />
|
|
790
|
-
<bpmn:sequenceFlow id="
|
|
822
|
+
<bpmn:sequenceFlow id="f_m_landedProbe" sourceRef="wait-landed-timeout" targetRef="landed-probe" />
|
|
823
|
+
<bpmn:sequenceFlow id="f_lp_gate" sourceRef="landed-probe" targetRef="gw-landed-probe" />
|
|
824
|
+
<bpmn:sequenceFlow id="f_lp_landed" sourceRef="gw-landed-probe" targetRef="mark-merged">
|
|
825
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=landedVerdict = "landed"</bpmn:conditionExpression>
|
|
826
|
+
</bpmn:sequenceFlow>
|
|
827
|
+
<bpmn:sequenceFlow id="f_lp_evicted" sourceRef="gw-landed-probe" targetRef="arm-merge">
|
|
828
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=landedVerdict = "evicted"</bpmn:conditionExpression>
|
|
829
|
+
</bpmn:sequenceFlow>
|
|
830
|
+
<bpmn:sequenceFlow id="f_m_landedStalled" sourceRef="gw-landed-probe" targetRef="merge-esc-landed" />
|
|
791
831
|
<bpmn:sequenceFlow id="f_m_escLanded" sourceRef="merge-esc-landed" targetRef="gw-merge-escalated" />
|
|
792
832
|
<bpmn:sequenceFlow id="f_m_evicted" sourceRef="wait-evicted" targetRef="arm-merge" />
|
|
793
833
|
<bpmn:sequenceFlow id="f_m_gBlocked" name="blocked" sourceRef="gw-merge" targetRef="merge-esc-attempt" />
|
|
@@ -874,7 +914,7 @@
|
|
|
874
914
|
<bpmndi:BPMNShape id="BPMNShape_gw-merge" bpmnElement="gw-merge" isMarkerVisible="true">
|
|
875
915
|
<dc:Bounds x="1363" y="255" width="50" height="50" />
|
|
876
916
|
<bpmndi:BPMNLabel>
|
|
877
|
-
<dc:Bounds x="
|
|
917
|
+
<dc:Bounds x="1302" y="290" width="53" height="28" />
|
|
878
918
|
</bpmndi:BPMNLabel>
|
|
879
919
|
</bpmndi:BPMNShape>
|
|
880
920
|
<bpmndi:BPMNShape id="BPMNShape_gw-merge-retry" bpmnElement="gw-merge-retry" isMarkerVisible="true">
|
|
@@ -904,16 +944,25 @@
|
|
|
904
944
|
<bpmndi:BPMNShape id="BPMNShape_wait-landed-timeout" bpmnElement="wait-landed-timeout">
|
|
905
945
|
<dc:Bounds x="1720" y="102" width="36" height="36" />
|
|
906
946
|
<bpmndi:BPMNLabel>
|
|
907
|
-
<dc:Bounds x="
|
|
947
|
+
<dc:Bounds x="1716" y="143" width="84" height="56" />
|
|
948
|
+
</bpmndi:BPMNLabel>
|
|
949
|
+
</bpmndi:BPMNShape>
|
|
950
|
+
<bpmndi:BPMNShape id="BPMNShape_landed-probe" bpmnElement="landed-probe">
|
|
951
|
+
<dc:Bounds x="1888" y="80" width="100" height="80" />
|
|
952
|
+
</bpmndi:BPMNShape>
|
|
953
|
+
<bpmndi:BPMNShape id="BPMNShape_gw-landed-probe" bpmnElement="gw-landed-probe" isMarkerVisible="true">
|
|
954
|
+
<dc:Bounds x="2088" y="95" width="50" height="50" />
|
|
955
|
+
<bpmndi:BPMNLabel>
|
|
956
|
+
<dc:Bounds x="2087" y="76" width="53" height="14" />
|
|
908
957
|
</bpmndi:BPMNLabel>
|
|
909
958
|
</bpmndi:BPMNShape>
|
|
910
959
|
<bpmndi:BPMNShape id="BPMNShape_mark-merged" bpmnElement="mark-merged">
|
|
911
|
-
<dc:Bounds x="
|
|
960
|
+
<dc:Bounds x="2238" y="240" width="100" height="80" />
|
|
912
961
|
</bpmndi:BPMNShape>
|
|
913
962
|
<bpmndi:BPMNShape id="BPMNShape_MergeEnd" bpmnElement="MergeEnd">
|
|
914
|
-
<dc:Bounds x="
|
|
963
|
+
<dc:Bounds x="2445" y="262" width="36" height="36" />
|
|
915
964
|
<bpmndi:BPMNLabel>
|
|
916
|
-
<dc:Bounds x="
|
|
965
|
+
<dc:Bounds x="2438" y="303" width="50" height="14" />
|
|
917
966
|
</bpmndi:BPMNLabel>
|
|
918
967
|
</bpmndi:BPMNShape>
|
|
919
968
|
<bpmndi:BPMNShape id="BPMNShape_MergeAbandoned" bpmnElement="MergeAbandoned">
|
|
@@ -932,19 +981,19 @@
|
|
|
932
981
|
<dc:Bounds x="1138" y="1680" width="100" height="80" />
|
|
933
982
|
</bpmndi:BPMNShape>
|
|
934
983
|
<bpmndi:BPMNShape id="BPMNShape_gw-merge-escalated" bpmnElement="gw-merge-escalated" isMarkerVisible="true">
|
|
935
|
-
<dc:Bounds x="
|
|
984
|
+
<dc:Bounds x="2438" y="95" width="50" height="50" />
|
|
936
985
|
<bpmndi:BPMNLabel>
|
|
937
|
-
<dc:Bounds x="
|
|
986
|
+
<dc:Bounds x="2406" y="150" width="74" height="28" />
|
|
938
987
|
</bpmndi:BPMNLabel>
|
|
939
988
|
</bpmndi:BPMNShape>
|
|
940
989
|
<bpmndi:BPMNShape id="BPMNShape_merge-esc-landed" bpmnElement="merge-esc-landed">
|
|
941
|
-
<dc:Bounds x="
|
|
990
|
+
<dc:Bounds x="2238" y="80" width="100" height="80" />
|
|
942
991
|
</bpmndi:BPMNShape>
|
|
943
992
|
<bpmndi:BPMNShape id="BPMNShape_wait-merge-answer" bpmnElement="wait-merge-answer">
|
|
944
|
-
<dc:Bounds x="
|
|
993
|
+
<dc:Bounds x="2588" y="80" width="100" height="80" />
|
|
945
994
|
</bpmndi:BPMNShape>
|
|
946
995
|
<bpmndi:BPMNShape id="BPMNShape_record-merge-answer" bpmnElement="record-merge-answer">
|
|
947
|
-
<dc:Bounds x="
|
|
996
|
+
<dc:Bounds x="2788" y="80" width="100" height="80" />
|
|
948
997
|
</bpmndi:BPMNShape>
|
|
949
998
|
<bpmndi:BPMNShape id="BPMNShape_gw-ci-fix" bpmnElement="gw-ci-fix" isMarkerVisible="true">
|
|
950
999
|
<dc:Bounds x="1163" y="1055" width="50" height="50" />
|
|
@@ -1016,14 +1065,14 @@
|
|
|
1016
1065
|
</bpmndi:BPMNEdge>
|
|
1017
1066
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_gMerged" bpmnElement="f_m_gMerged">
|
|
1018
1067
|
<di:waypoint x="1413" y="280" />
|
|
1019
|
-
<di:waypoint x="
|
|
1068
|
+
<di:waypoint x="2238" y="280" />
|
|
1020
1069
|
<bpmndi:BPMNLabel>
|
|
1021
|
-
<dc:Bounds x="
|
|
1070
|
+
<dc:Bounds x="1866" y="258" width="49" height="14" />
|
|
1022
1071
|
</bpmndi:BPMNLabel>
|
|
1023
1072
|
</bpmndi:BPMNEdge>
|
|
1024
1073
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_done" bpmnElement="f_m_done">
|
|
1025
|
-
<di:waypoint x="
|
|
1026
|
-
<di:waypoint x="
|
|
1074
|
+
<di:waypoint x="2338" y="280" />
|
|
1075
|
+
<di:waypoint x="2445" y="280" />
|
|
1027
1076
|
</bpmndi:BPMNEdge>
|
|
1028
1077
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_rearm" bpmnElement="f_ci_rearm">
|
|
1029
1078
|
<di:waypoint x="1563" y="1265" />
|
|
@@ -1070,54 +1119,62 @@
|
|
|
1070
1119
|
<di:waypoint x="1463" y="1060" />
|
|
1071
1120
|
<di:waypoint x="1688" y="1060" />
|
|
1072
1121
|
<bpmndi:BPMNLabel>
|
|
1073
|
-
<dc:Bounds x="
|
|
1122
|
+
<dc:Bounds x="1468" y="829" width="67" height="28" />
|
|
1074
1123
|
</bpmndi:BPMNLabel>
|
|
1075
1124
|
</bpmndi:BPMNEdge>
|
|
1076
1125
|
<bpmndi:BPMNEdge id="BPMNEdge_f_mr_giveup" bpmnElement="f_mr_giveup">
|
|
1077
1126
|
<di:waypoint x="1588" y="760" />
|
|
1078
1127
|
<di:waypoint x="1688" y="760" />
|
|
1079
1128
|
<bpmndi:BPMNLabel>
|
|
1080
|
-
<dc:Bounds x="
|
|
1129
|
+
<dc:Bounds x="1595" y="765" width="67" height="28" />
|
|
1081
1130
|
</bpmndi:BPMNLabel>
|
|
1082
1131
|
</bpmndi:BPMNEdge>
|
|
1083
1132
|
<bpmndi:BPMNEdge id="BPMNEdge_f_eg_landed" bpmnElement="f_eg_landed">
|
|
1084
1133
|
<di:waypoint x="1588" y="440" />
|
|
1085
1134
|
<di:waypoint x="1720" y="440" />
|
|
1086
1135
|
</bpmndi:BPMNEdge>
|
|
1136
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_m_landedStalled" bpmnElement="f_m_landedStalled">
|
|
1137
|
+
<di:waypoint x="2138" y="120" />
|
|
1138
|
+
<di:waypoint x="2238" y="120" />
|
|
1139
|
+
</bpmndi:BPMNEdge>
|
|
1140
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escLanded" bpmnElement="f_m_escLanded">
|
|
1141
|
+
<di:waypoint x="2338" y="120" />
|
|
1142
|
+
<di:waypoint x="2438" y="120" />
|
|
1143
|
+
</bpmndi:BPMNEdge>
|
|
1087
1144
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_landed" bpmnElement="f_m_landed">
|
|
1088
1145
|
<di:waypoint x="1756" y="440" />
|
|
1089
|
-
<di:waypoint x="
|
|
1090
|
-
<di:waypoint x="
|
|
1146
|
+
<di:waypoint x="2288" y="440" />
|
|
1147
|
+
<di:waypoint x="2288" y="320" />
|
|
1091
1148
|
</bpmndi:BPMNEdge>
|
|
1092
1149
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escC" bpmnElement="f_m_escC">
|
|
1093
1150
|
<di:waypoint x="1788" y="1080" />
|
|
1094
|
-
<di:waypoint x="
|
|
1095
|
-
<di:waypoint x="
|
|
1151
|
+
<di:waypoint x="2638" y="1080" />
|
|
1152
|
+
<di:waypoint x="2638" y="160" />
|
|
1096
1153
|
</bpmndi:BPMNEdge>
|
|
1097
1154
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escA" bpmnElement="f_m_escA">
|
|
1098
1155
|
<di:waypoint x="1788" y="760" />
|
|
1099
|
-
<di:waypoint x="
|
|
1100
|
-
<di:waypoint x="
|
|
1101
|
-
<di:waypoint x="
|
|
1102
|
-
<di:waypoint x="
|
|
1103
|
-
<di:waypoint x="
|
|
1156
|
+
<di:waypoint x="2501" y="760" />
|
|
1157
|
+
<di:waypoint x="2501" y="145" />
|
|
1158
|
+
<di:waypoint x="2508" y="145" />
|
|
1159
|
+
<di:waypoint x="2508" y="120" />
|
|
1160
|
+
<di:waypoint x="2488" y="120" />
|
|
1104
1161
|
</bpmndi:BPMNEdge>
|
|
1105
1162
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escReenter" bpmnElement="f_m_escReenter">
|
|
1106
|
-
<di:waypoint x="
|
|
1107
|
-
<di:waypoint x="
|
|
1163
|
+
<di:waypoint x="2463" y="95" />
|
|
1164
|
+
<di:waypoint x="2463" y="60" />
|
|
1108
1165
|
<di:waypoint x="402" y="60" />
|
|
1109
1166
|
<di:waypoint x="402" y="240" />
|
|
1110
1167
|
<bpmndi:BPMNLabel>
|
|
1111
|
-
<dc:Bounds x="
|
|
1168
|
+
<dc:Bounds x="1396" y="13" width="74" height="42" />
|
|
1112
1169
|
</bpmndi:BPMNLabel>
|
|
1113
1170
|
</bpmndi:BPMNEdge>
|
|
1114
1171
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_answerRecord" bpmnElement="f_m_answerRecord">
|
|
1115
|
-
<di:waypoint x="
|
|
1116
|
-
<di:waypoint x="
|
|
1172
|
+
<di:waypoint x="2688" y="120" />
|
|
1173
|
+
<di:waypoint x="2788" y="120" />
|
|
1117
1174
|
</bpmndi:BPMNEdge>
|
|
1118
1175
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_answer" bpmnElement="f_m_answer">
|
|
1119
|
-
<di:waypoint x="
|
|
1120
|
-
<di:waypoint x="
|
|
1176
|
+
<di:waypoint x="2838" y="160" />
|
|
1177
|
+
<di:waypoint x="2838" y="340" />
|
|
1121
1178
|
<di:waypoint x="402" y="340" />
|
|
1122
1179
|
<di:waypoint x="402" y="320" />
|
|
1123
1180
|
</bpmndi:BPMNEdge>
|
|
@@ -1236,15 +1293,15 @@
|
|
|
1236
1293
|
<di:waypoint x="1213" y="440" />
|
|
1237
1294
|
<di:waypoint x="1233" y="440" />
|
|
1238
1295
|
<di:waypoint x="1233" y="465" />
|
|
1239
|
-
<di:waypoint x="
|
|
1240
|
-
<di:waypoint x="
|
|
1241
|
-
<di:waypoint x="
|
|
1242
|
-
<di:waypoint x="
|
|
1296
|
+
<di:waypoint x="2508" y="465" />
|
|
1297
|
+
<di:waypoint x="2508" y="75" />
|
|
1298
|
+
<di:waypoint x="2908" y="75" />
|
|
1299
|
+
<di:waypoint x="2908" y="1125" />
|
|
1243
1300
|
<di:waypoint x="1318" y="1125" />
|
|
1244
1301
|
<di:waypoint x="1318" y="1380" />
|
|
1245
1302
|
<di:waypoint x="1338" y="1380" />
|
|
1246
1303
|
<bpmndi:BPMNLabel>
|
|
1247
|
-
<dc:Bounds x="
|
|
1304
|
+
<dc:Bounds x="2684" y="42" width="49" height="28" />
|
|
1248
1305
|
</bpmndi:BPMNLabel>
|
|
1249
1306
|
</bpmndi:BPMNEdge>
|
|
1250
1307
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_gQueued" bpmnElement="f_m_gQueued">
|
|
@@ -1258,13 +1315,13 @@
|
|
|
1258
1315
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_gRetry" bpmnElement="f_m_gRetry">
|
|
1259
1316
|
<di:waypoint x="1388" y="255" />
|
|
1260
1317
|
<di:waypoint x="1388" y="235" />
|
|
1261
|
-
<di:waypoint x="
|
|
1262
|
-
<di:waypoint x="
|
|
1318
|
+
<di:waypoint x="2568" y="235" />
|
|
1319
|
+
<di:waypoint x="2568" y="805" />
|
|
1263
1320
|
<di:waypoint x="1538" y="805" />
|
|
1264
1321
|
<di:waypoint x="1563" y="805" />
|
|
1265
1322
|
<di:waypoint x="1563" y="785" />
|
|
1266
1323
|
<bpmndi:BPMNLabel>
|
|
1267
|
-
<dc:Bounds x="
|
|
1324
|
+
<dc:Bounds x="2485" y="766" width="78" height="28" />
|
|
1268
1325
|
</bpmndi:BPMNLabel>
|
|
1269
1326
|
</bpmndi:BPMNEdge>
|
|
1270
1327
|
<bpmndi:BPMNEdge id="BPMNEdge_f_eg_evicted" bpmnElement="f_eg_evicted">
|
|
@@ -1281,30 +1338,26 @@
|
|
|
1281
1338
|
<di:waypoint x="1700" y="120" />
|
|
1282
1339
|
<di:waypoint x="1720" y="120" />
|
|
1283
1340
|
</bpmndi:BPMNEdge>
|
|
1341
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_lp_landed" bpmnElement="f_lp_landed">
|
|
1342
|
+
<di:waypoint x="2113" y="145" />
|
|
1343
|
+
<di:waypoint x="2113" y="280" />
|
|
1344
|
+
<di:waypoint x="2238" y="280" />
|
|
1345
|
+
</bpmndi:BPMNEdge>
|
|
1284
1346
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_gBlocked" bpmnElement="f_m_gBlocked">
|
|
1285
|
-
<di:waypoint x="
|
|
1286
|
-
<di:waypoint x="
|
|
1287
|
-
<di:waypoint x="
|
|
1288
|
-
<di:waypoint x="
|
|
1289
|
-
<di:waypoint x="2218" y="165" />
|
|
1290
|
-
<di:waypoint x="2218" y="638" />
|
|
1291
|
-
<di:waypoint x="1668" y="638" />
|
|
1292
|
-
<di:waypoint x="1668" y="740" />
|
|
1293
|
-
<di:waypoint x="1688" y="740" />
|
|
1347
|
+
<di:waypoint x="1413" y="280" />
|
|
1348
|
+
<di:waypoint x="1668" y="280" />
|
|
1349
|
+
<di:waypoint x="1668" y="760" />
|
|
1350
|
+
<di:waypoint x="1688" y="760" />
|
|
1294
1351
|
<bpmndi:BPMNLabel>
|
|
1295
|
-
<dc:Bounds x="
|
|
1352
|
+
<dc:Bounds x="1610" y="412" width="53" height="14" />
|
|
1296
1353
|
</bpmndi:BPMNLabel>
|
|
1297
1354
|
</bpmndi:BPMNEdge>
|
|
1298
1355
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_gAbandoned" bpmnElement="f_m_gAbandoned">
|
|
1299
|
-
<di:waypoint x="1388" y="
|
|
1300
|
-
<di:waypoint x="1388" y="
|
|
1301
|
-
<di:waypoint x="
|
|
1302
|
-
<di:waypoint x="1776" y="165" />
|
|
1303
|
-
<di:waypoint x="2218" y="165" />
|
|
1304
|
-
<di:waypoint x="2218" y="920" />
|
|
1305
|
-
<di:waypoint x="1581" y="920" />
|
|
1356
|
+
<di:waypoint x="1388" y="305" />
|
|
1357
|
+
<di:waypoint x="1388" y="920" />
|
|
1358
|
+
<di:waypoint x="1545" y="920" />
|
|
1306
1359
|
<bpmndi:BPMNLabel>
|
|
1307
|
-
<dc:Bounds x="
|
|
1360
|
+
<dc:Bounds x="1301" y="599" width="82" height="28" />
|
|
1308
1361
|
</bpmndi:BPMNLabel>
|
|
1309
1362
|
</bpmndi:BPMNEdge>
|
|
1310
1363
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_waitDep" bpmnElement="f_ci_waitDep">
|
|
@@ -1338,25 +1391,25 @@
|
|
|
1338
1391
|
<di:waypoint x="1438" y="1400" />
|
|
1339
1392
|
<di:waypoint x="1538" y="1400" />
|
|
1340
1393
|
</bpmndi:BPMNEdge>
|
|
1341
|
-
<bpmndi:BPMNEdge id="
|
|
1394
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_m_landedProbe" bpmnElement="f_m_landedProbe">
|
|
1342
1395
|
<di:waypoint x="1756" y="120" />
|
|
1343
1396
|
<di:waypoint x="1888" y="120" />
|
|
1344
1397
|
</bpmndi:BPMNEdge>
|
|
1345
|
-
<bpmndi:BPMNEdge id="
|
|
1398
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_lp_gate" bpmnElement="f_lp_gate">
|
|
1346
1399
|
<di:waypoint x="1988" y="120" />
|
|
1347
1400
|
<di:waypoint x="2088" y="120" />
|
|
1348
1401
|
</bpmndi:BPMNEdge>
|
|
1349
1402
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escT" bpmnElement="f_m_escT">
|
|
1350
1403
|
<di:waypoint x="1238" y="1720" />
|
|
1351
|
-
<di:waypoint x="
|
|
1352
|
-
<di:waypoint x="
|
|
1353
|
-
<di:waypoint x="
|
|
1354
|
-
<di:waypoint x="
|
|
1355
|
-
<di:waypoint x="
|
|
1404
|
+
<di:waypoint x="2358" y="1720" />
|
|
1405
|
+
<di:waypoint x="2358" y="145" />
|
|
1406
|
+
<di:waypoint x="2418" y="145" />
|
|
1407
|
+
<di:waypoint x="2418" y="120" />
|
|
1408
|
+
<di:waypoint x="2438" y="120" />
|
|
1356
1409
|
</bpmndi:BPMNEdge>
|
|
1357
1410
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_escWait" bpmnElement="f_m_escWait">
|
|
1358
|
-
<di:waypoint x="
|
|
1359
|
-
<di:waypoint x="
|
|
1411
|
+
<di:waypoint x="2488" y="120" />
|
|
1412
|
+
<di:waypoint x="2588" y="120" />
|
|
1360
1413
|
</bpmndi:BPMNEdge>
|
|
1361
1414
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_recheck" bpmnElement="f_ci_recheck">
|
|
1362
1415
|
<di:waypoint x="1563" y="1265" />
|
|
@@ -1391,6 +1444,12 @@
|
|
|
1391
1444
|
<dc:Bounds x="946" y="772" width="49" height="28" />
|
|
1392
1445
|
</bpmndi:BPMNLabel>
|
|
1393
1446
|
</bpmndi:BPMNEdge>
|
|
1447
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_lp_evicted" bpmnElement="f_lp_evicted">
|
|
1448
|
+
<di:waypoint x="2113" y="145" />
|
|
1449
|
+
<di:waypoint x="2113" y="340" />
|
|
1450
|
+
<di:waypoint x="402" y="340" />
|
|
1451
|
+
<di:waypoint x="402" y="320" />
|
|
1452
|
+
</bpmndi:BPMNEdge>
|
|
1394
1453
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_evicted" bpmnElement="f_m_evicted">
|
|
1395
1454
|
<di:waypoint x="1738" y="618" />
|
|
1396
1455
|
<di:waypoint x="1738" y="638" />
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Test-only: apply the poller's compare-and-set status flip (`flipToMergingThenPublish`, app/service.ts)
|
|
2
|
+
// — `UPDATE <t> SET status = ?, updated_at = ? WHERE pr_key = ? AND status = ?` — against an
|
|
3
|
+
// in-memory store, so hand-rolled `DataLayer` fakes model the real conditional write (and its
|
|
4
|
+
// lost-race `changed: 0`) instead of no-op'ing it. Returns `null` for any other statement so the
|
|
5
|
+
// caller can fall through to its own handling (or throw loudly).
|
|
6
|
+
const CAS =
|
|
7
|
+
/^\s*UPDATE\s+"?(\w+)"?\s+SET\s+"?(\w+)"?\s*=\s*\?\s*,\s*"?(\w+)"?\s*=\s*\?\s+WHERE\s+"?(\w+)"?\s*=\s*\?\s+AND\s+"?(\w+)"?\s*=\s*\?\s*$/i;
|
|
8
|
+
|
|
9
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only fake over dynamic row shapes.
|
|
10
|
+
export function memStatusCas(stores: Record<string, any[]>, sql: string, params: unknown[]): { changed: number } | null {
|
|
11
|
+
const m = sql.match(CAS);
|
|
12
|
+
if (!m) return null;
|
|
13
|
+
const [, table, setA, setB, keyCol, condCol] = m;
|
|
14
|
+
let changed = 0;
|
|
15
|
+
for (const r of (stores[table] ??= [])) {
|
|
16
|
+
if (r[keyCol] === params[2] && r[condCol] === params[3]) {
|
|
17
|
+
r[setA] = params[0];
|
|
18
|
+
r[setB] = params[1];
|
|
19
|
+
changed++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { changed };
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { assertEquals } from "#test-assert";
|
|
3
|
+
import type { PrState } from "../../app/github.ts";
|
|
4
|
+
import { probeLanded } from "./worker.ts";
|
|
5
|
+
|
|
6
|
+
const base: PrState = {
|
|
7
|
+
merged: false,
|
|
8
|
+
state: "open",
|
|
9
|
+
mergeStateStatus: "BLOCKED",
|
|
10
|
+
failingChecks: 0,
|
|
11
|
+
failingCheckNames: [],
|
|
12
|
+
totalChecks: 0,
|
|
13
|
+
presentCheckNames: [],
|
|
14
|
+
pendingCheckNames: [],
|
|
15
|
+
checkConclusions: {},
|
|
16
|
+
isDraft: false,
|
|
17
|
+
headRefOid: null,
|
|
18
|
+
mergeQueueEntry: null,
|
|
19
|
+
unresolvedThreads: -1,
|
|
20
|
+
};
|
|
21
|
+
const fake = (st: PrState | null, fail = false) => async () => {
|
|
22
|
+
if (fail) throw new Error("boom");
|
|
23
|
+
return st;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
test("landed-probe: a MERGED PR reads landed (#933)", async () => {
|
|
27
|
+
assertEquals(await probeLanded("a/b", 1, "", fake({ ...base, merged: true, state: "merged" })), "landed");
|
|
28
|
+
});
|
|
29
|
+
test("landed-probe: still queued reads waiting", async () => {
|
|
30
|
+
assertEquals(await probeLanded("a/b", 1, "", fake(base)), "waiting");
|
|
31
|
+
});
|
|
32
|
+
test("landed-probe: dropped from the native queue reads evicted", async () => {
|
|
33
|
+
assertEquals(await probeLanded("a/b", 1, "", fake({ ...base, mergeQueueEntry: false })), "evicted");
|
|
34
|
+
});
|
|
35
|
+
test("landed-probe: closed unmerged reads evicted", async () => {
|
|
36
|
+
assertEquals(await probeLanded("a/b", 1, "", fake({ ...base, state: "closed" })), "evicted");
|
|
37
|
+
});
|
|
38
|
+
test("landed-probe: unreadable state is conservative (waiting)", async () => {
|
|
39
|
+
assertEquals(await probeLanded("a/b", 1, "", fake(null)), "waiting");
|
|
40
|
+
assertEquals(await probeLanded("a/b", 1, "", fake(null, true)), "waiting");
|
|
41
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// pr.landed-probe — the merge-queue landing timer fired (issue #933). Before escalating "did not
|
|
2
|
+
// land", re-read ground truth: the queue may have landed the PR after the poller's last `queued`
|
|
3
|
+
// pass but before (or exactly as) the `landedWaitTimeout` timer won the `eg-landed` race — e.g. a
|
|
4
|
+
// slow Mergify batch that merges right around the timeout, or a poller pass that errored/stalled
|
|
5
|
+
// on this PR. Escalating a landed PR forces a pointless human "retry", so the timeout arm routes
|
|
6
|
+
// through this probe and `gw-landed-probe` instead of straight to `merge-esc-landed`:
|
|
7
|
+
// • landed → mark-merged (never escalate a landed PR)
|
|
8
|
+
// • evicted → arm-merge (re-run the mergeable gate, same as a `merge-evicted` message)
|
|
9
|
+
// • waiting → merge-esc-landed (genuinely never landed — the #556 backstop)
|
|
10
|
+
// The verdict is derived by the poller's own canonical classifier (`queuedVerdict`) so the probe
|
|
11
|
+
// and the poll pass can never drift. An unreadable PR reads as `waiting` (escalate — conservative).
|
|
12
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
13
|
+
import { fetchPrState } from "../../app/github.ts";
|
|
14
|
+
import { queuedVerdict } from "../../app/service.ts";
|
|
15
|
+
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
16
|
+
|
|
17
|
+
type In = WorkerInputs["pr.landed-probe"];
|
|
18
|
+
|
|
19
|
+
interface Out extends Record<string, unknown> {
|
|
20
|
+
landedVerdict: "landed" | "evicted" | "waiting";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function probeLanded(
|
|
24
|
+
repo: string,
|
|
25
|
+
prNumber: number | string,
|
|
26
|
+
token: string,
|
|
27
|
+
fetch: typeof fetchPrState = fetchPrState,
|
|
28
|
+
): Promise<Out["landedVerdict"]> {
|
|
29
|
+
const st = await fetch(repo, prNumber, token, { withMergeQueue: true }).catch(() => null);
|
|
30
|
+
if (st === null) return "waiting";
|
|
31
|
+
// A PR closed WITHOUT merging can never land; re-arm so the merge poller abandons it.
|
|
32
|
+
if (!st.merged && st.state === "closed") return "evicted";
|
|
33
|
+
return queuedVerdict(st);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
37
|
+
const { repo, prNumber, prKey } = job.variables;
|
|
38
|
+
const landedVerdict = await probeLanded(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
39
|
+
app.log.info("landed-probe: landing timeout fired — re-read PR state", { prKey, landedVerdict });
|
|
40
|
+
return { landedVerdict };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default handler;
|