@nanobpm/nano-workforce 0.205.0 → 0.205.2
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/convergeAdversarial.test.ts +54 -0
- package/app/convergeGate.test.ts +186 -6
- package/app/github.test.ts +285 -1
- package/app/github.ts +213 -11
- package/app/mergeBlockedThreads.test.ts +551 -0
- package/app/mergeLoopBehaviour.test.ts +141 -0
- package/app/mergeRetry.test.ts +49 -0
- package/app/readiness.ts +1 -0
- package/app/reviewWait.test.ts +37 -0
- package/app/reviewWait.ts +26 -0
- package/app/roundProgress.test.ts +63 -2
- package/app/roundProgress.ts +6 -0
- package/app/service.ts +139 -1
- package/app/userTasks.ts +1 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +5 -0
- package/resources/processes/merge-loop.bpmn +56 -10
- package/workers/converge-gate/worker.ts +61 -11
- package/workers/merge/worker.test.ts +192 -1
- package/workers/merge/worker.ts +118 -7
- package/workers/merge-stall-probe/worker.ts +17 -3
- package/workers/progress-check/worker.ts +40 -6
|
@@ -96,6 +96,8 @@ const DEFAULT_VARS: Record<string, unknown> = {
|
|
|
96
96
|
mergeStatus: null,
|
|
97
97
|
ciBlockedReconciled: null,
|
|
98
98
|
failingChecks: 0,
|
|
99
|
+
reviewThreads: null,
|
|
100
|
+
pendingChecks: null,
|
|
99
101
|
round: 2,
|
|
100
102
|
abandonUrl: "http://host/app/api/hooks/abandon?token=tok-seeded",
|
|
101
103
|
};
|
|
@@ -521,6 +523,133 @@ test("an unclassified verdict routes to auto-rebase (DEFAULT), not to a human (#
|
|
|
521
523
|
assert(!openTasks.some((t) => t.elementId === "wait-merge-answer"), "the default arm must not page a human");
|
|
522
524
|
});
|
|
523
525
|
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// BLOCKED by unresolved review threads (issue #921)
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// A PR at MERGEABLE/BLOCKED with all checks green and ≥1 unresolved review thread (branch
|
|
530
|
+
// protection `required_conversation_resolution`) used to classify as "waiting": the poller never
|
|
531
|
+
// published, the mergeable-wait timer fired, the stall probe re-derived the same "waiting", and
|
|
532
|
+
// after the stall budget the escalation read "GitHub never settled this PR's mergeability" — when
|
|
533
|
+
// GitHub HAD settled. The `review-threads` verdict now routes to a dedicated escalation that names
|
|
534
|
+
// the real cause (and parks the shared `wait-merge-answer` task, whose answer re-arms the poller),
|
|
535
|
+
// never to the CI-fix or rebase agents.
|
|
536
|
+
|
|
537
|
+
test("a 'review-threads' verdict escalates naming the unresolved threads — never 'never settled', never an agent (#921)", async () => {
|
|
538
|
+
const engine = await boot();
|
|
539
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
540
|
+
await engine.publishMessage({
|
|
541
|
+
name: "merge-ready",
|
|
542
|
+
correlationKey: "pr-1",
|
|
543
|
+
variables: { mergeState: "review-threads", reviewThreads: 2, failingChecks: 0, failingChecksList: "" },
|
|
544
|
+
});
|
|
545
|
+
// The threads arm parks the shared answer task with the thread-specific question…
|
|
546
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
547
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasCompletedElements("merge-esc-threads");
|
|
548
|
+
const q = String(escalation(engine).question ?? "");
|
|
549
|
+
assertStringIncludes(q, "2 unresolved review thread(s)", "the escalation must name the actual blocker and count");
|
|
550
|
+
assert(!q.includes("never settled"), "a settled BLOCKED must never read as 'mergeability never settled'");
|
|
551
|
+
// …and never misroutes to the remediation agents or the generic conflict escalation.
|
|
552
|
+
assert(!completedElementIds(engine).has("fix-ci"), "green checks must not dispatch fix-ci");
|
|
553
|
+
assert(!completedElementIds(engine).has("rebase"), "a threads block must not dispatch rebase");
|
|
554
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "the threads arm has its own escalation");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("answering the review-threads escalation re-arms the merge poller (#921)", async () => {
|
|
558
|
+
// The human resolves the threads on GitHub and replies; the answer re-arms `arm-merge` so the
|
|
559
|
+
// next poll re-derives mergeability — once the threads are gone the verdict flips to `ready` and
|
|
560
|
+
// the merge proceeds (the merge step is thereby gated on zero unresolved threads).
|
|
561
|
+
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "merged" } } });
|
|
562
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
563
|
+
await engine.publishMessage({
|
|
564
|
+
name: "merge-ready",
|
|
565
|
+
correlationKey: "pr-1",
|
|
566
|
+
variables: { mergeState: "review-threads", reviewThreads: 1, failingChecks: 0 },
|
|
567
|
+
});
|
|
568
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
569
|
+
await engine.completeUserTask(await mergeAnswerTaskKey(engine), { answer: "resolved the threads" });
|
|
570
|
+
// Re-armed at the poller wait — not merged yet (the poller must re-derive)…
|
|
571
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
572
|
+
// …then the re-derived ready verdict lands the merge with no human in the loop.
|
|
573
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready", reviewThreads: 0 } });
|
|
574
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasNoIncident().hasCompletedElements("attempt-merge", "mark-merged");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("a stalled probe that re-derives 'review-threads' routes to the threads arm, not the re-poll loop (#921)", async () => {
|
|
578
|
+
// The poller-dead path: the mergeable-wait timer fires, the stall probe re-derives ground truth,
|
|
579
|
+
// and a `review-threads` verdict takes the same dedicated arm a live poller message would.
|
|
580
|
+
const engine = await boot({
|
|
581
|
+
responses: {
|
|
582
|
+
"pr.merge-stall-probe": { mergeState: "review-threads", failingChecks: 0, failingChecksList: "", reviewThreads: 3 },
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
586
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
587
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
588
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-threads");
|
|
589
|
+
const q = String(escalation(engine).question ?? "");
|
|
590
|
+
assertStringIncludes(q, "3 unresolved review thread(s)", "the probe path must carry the thread count into the question");
|
|
591
|
+
assert(!completedElementIds(engine).has("wait-mergeable-repoll"), "a settled review-threads verdict must not re-poll");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("a stall-budget exhaustion on a settled BLOCKED with zero threads names the review requirement, not 'never settled' (#921)", async () => {
|
|
595
|
+
// GitHub settled: MERGEABLE/BLOCKED, checks green (none failing, none pending), every thread
|
|
596
|
+
// resolved — the block is a required human review approval. The probe reports `waiting` (the
|
|
597
|
+
// conservative verdict) with the probed counts, and the exhausted-budget escalation must name
|
|
598
|
+
// THAT cause.
|
|
599
|
+
const engine = await boot({
|
|
600
|
+
vars: { mergeStallMax: 0 },
|
|
601
|
+
responses: {
|
|
602
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 0 },
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
606
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
607
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
608
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
|
|
609
|
+
const q = String(escalation(engine).question ?? "");
|
|
610
|
+
assertStringIncludes(q, "required review approval", "a settled BLOCKED with clear threads must name the review requirement");
|
|
611
|
+
assert(!q.includes("never settled"), "a settled BLOCKED must never read as 'mergeability never settled'");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("a stall-budget exhaustion with checks still pending must NOT claim a verified review-approval blocker (PR #922 review)", async () => {
|
|
615
|
+
// Zero failing checks + zero unresolved threads, but a check is still queued/running: the BLOCKED
|
|
616
|
+
// cause is undetermined (the pending run may come back red), so the escalation must NOT assert
|
|
617
|
+
// "all checks green … required review approval" — it keeps the conservative unsettled wording.
|
|
618
|
+
const engine = await boot({
|
|
619
|
+
vars: { mergeStallMax: 0 },
|
|
620
|
+
responses: {
|
|
621
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: 0, pendingChecks: 1 },
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
625
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
626
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
627
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
|
|
628
|
+
const q = String(escalation(engine).question ?? "");
|
|
629
|
+
assert(!q.includes("required review approval"), "a pending check must never read as a verified review-approval block");
|
|
630
|
+
assertStringIncludes(q, "never settled", "an undetermined BLOCKED keeps the conservative unsettled wording");
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("the 'never settled' question is reserved for a genuinely unreadable/unsettled mergeability (#921)", async () => {
|
|
634
|
+
// mergeStallMax: 0 + a probe that still reads `waiting` with NOTHING probed (reviewThreads -1,
|
|
635
|
+
// checks unenumerable) — GitHub genuinely never settled (or the state could not be read). Only
|
|
636
|
+
// this case keeps the original message.
|
|
637
|
+
const engine = await boot({
|
|
638
|
+
vars: { mergeStallMax: 0 },
|
|
639
|
+
responses: {
|
|
640
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "", reviewThreads: -1 },
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
644
|
+
await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
|
|
645
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
646
|
+
assertStringIncludes(
|
|
647
|
+
String(escalation(engine).question ?? ""),
|
|
648
|
+
"never settled",
|
|
649
|
+
"a genuinely unsettled/unreadable mergeability keeps the original message",
|
|
650
|
+
);
|
|
651
|
+
});
|
|
652
|
+
|
|
524
653
|
test("an evicted queued merge re-arms the poller rather than completing", async () => {
|
|
525
654
|
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
|
|
526
655
|
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
@@ -559,6 +688,18 @@ test("a retry that exhausts the budget escalates as a repeated race, not a gener
|
|
|
559
688
|
assertStringIncludes(String(escalation(engine).question ?? ""), "retry budget", "the retry escalation must read as a repeated race");
|
|
560
689
|
});
|
|
561
690
|
|
|
691
|
+
test("a worker-supplied block question (review backstop #923) reaches the human escalation verbatim", async () => {
|
|
692
|
+
const engine = await boot({
|
|
693
|
+
responses: { "pr.merge": { mergeStatus: "blocked", status: "blocked", question: "Merge refused: no head-current Copilot review exists." } },
|
|
694
|
+
});
|
|
695
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
696
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
697
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
698
|
+
const q = String(escalation(engine).question ?? "");
|
|
699
|
+
assertStringIncludes(q, "no head-current Copilot review", "the backstop's recovery instructions must reach the human");
|
|
700
|
+
assert(!q.includes("Investigate why GitHub refused"), "must not be overwritten by the generic refusal text");
|
|
701
|
+
});
|
|
702
|
+
|
|
562
703
|
// ---------------------------------------------------------------------------
|
|
563
704
|
// CI-fix sub-process (SP_cifix) — mergeCiReattempt (#134), #329
|
|
564
705
|
// ---------------------------------------------------------------------------
|
package/app/mergeRetry.test.ts
CHANGED
|
@@ -43,6 +43,33 @@ test("isTransientMergeRace: genuine blocks are NOT transient (never swallowed)",
|
|
|
43
43
|
assertEquals(isTransientMergeRace("Merge conflict; base branch has conflicts"), false); // a base conflict is a real block
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
// #923 review (Copilot "previously missed"): the `--match-head-commit` / REST `sha` pin refuses the
|
|
47
|
+
// merge when the PR head advanced past the verified head between the review backstop and the merge
|
|
48
|
+
// mutation. That refusal is the SAME class as the base/head-moved race — a transient check-then-act
|
|
49
|
+
// race GitHub resolves by re-attempting — so it must classify as `retry` (the merge loop's bounded
|
|
50
|
+
// retry re-runs the review backstop on the new head), never `blocked` (which pages a human for a
|
|
51
|
+
// race the loop is designed to absorb).
|
|
52
|
+
test("isTransientMergeRace: the pinned-head mismatch refusal is transient", () => {
|
|
53
|
+
// gh CLI `--match-head-commit` refusal (gh's own wording).
|
|
54
|
+
assertEquals(
|
|
55
|
+
isTransientMergeRace(
|
|
56
|
+
"Head commit of pull request #924 does not match '--match-head-commit' expected f5a6f20",
|
|
57
|
+
),
|
|
58
|
+
true,
|
|
59
|
+
);
|
|
60
|
+
// gh wording variant (no "expected" tail).
|
|
61
|
+
assertEquals(
|
|
62
|
+
isTransientMergeRace("Head commit of pull request #924 does not match '--match-head-commit'"),
|
|
63
|
+
true,
|
|
64
|
+
);
|
|
65
|
+
// REST PUT /pulls/{n}/merge `sha` mismatch — GitHub returns 409 Conflict.
|
|
66
|
+
assertEquals(
|
|
67
|
+
isTransientMergeRace("github 409 Conflict: Head branch was modified. Review and try the merge again."),
|
|
68
|
+
true,
|
|
69
|
+
);
|
|
70
|
+
assertEquals(isTransientMergeRace("github 409 Conflict: SHA does not match expected head"), true);
|
|
71
|
+
});
|
|
72
|
+
|
|
46
73
|
// Drive `mergePr`'s REST (token) transport: the 405 base-moved race must surface as `retry`, while
|
|
47
74
|
// a genuine 405 refusal stays `blocked`.
|
|
48
75
|
async function withMergePut(
|
|
@@ -88,3 +115,25 @@ test("mergePr: a genuine 405 refusal → outcome 'blocked' (not swallowed as tra
|
|
|
88
115
|
assertEquals(res?.outcome, "blocked");
|
|
89
116
|
});
|
|
90
117
|
});
|
|
118
|
+
|
|
119
|
+
test("mergePr: a REST 409 sha-mismatch refusal → outcome 'retry' (not blocked)", async () => {
|
|
120
|
+
await withMergePut(409, "Conflict", "Head branch was modified. Review and try the merge again.", async () => {
|
|
121
|
+
const res = await mergePr("acme/widgets", 42, "test-token", {
|
|
122
|
+
method: "squash",
|
|
123
|
+
admin: false,
|
|
124
|
+
expectedHeadSha: "f5a6f20",
|
|
125
|
+
});
|
|
126
|
+
assertEquals(res?.outcome, "retry");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("mergePr: a genuine 409 refusal → outcome 'blocked' (not swallowed as transient)", async () => {
|
|
131
|
+
await withMergePut(409, "Conflict", "Pull Request is not mergeable", async () => {
|
|
132
|
+
const res = await mergePr("acme/widgets", 42, "test-token", {
|
|
133
|
+
method: "squash",
|
|
134
|
+
admin: false,
|
|
135
|
+
expectedHeadSha: "f5a6f20",
|
|
136
|
+
});
|
|
137
|
+
assertEquals(res?.outcome, "blocked");
|
|
138
|
+
});
|
|
139
|
+
});
|
package/app/readiness.ts
CHANGED
|
@@ -768,6 +768,7 @@ export function parsePrView(payload: unknown): PrObservation {
|
|
|
768
768
|
isDraft: j.isDraft === true,
|
|
769
769
|
headRefOid: str(j.headRefOid).trim() || null,
|
|
770
770
|
mergeQueueEntry: null, // readiness probe doesn't read native-queue membership (merge-loop-only signal)
|
|
771
|
+
unresolvedThreads: -1, // …nor the review-threads probe (merge-stage-only signal, #921) → unprobed
|
|
771
772
|
mergedSha,
|
|
772
773
|
pendingChecks: pending.length,
|
|
773
774
|
};
|
package/app/reviewWait.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DEFAULT_REVIEW_NUDGE_MINUTES,
|
|
10
10
|
DEFAULT_REVIEW_WAIT_TIMEOUT,
|
|
11
11
|
isoDurationToMs,
|
|
12
|
+
isReviewMissingOrStale,
|
|
12
13
|
isReviewStale,
|
|
13
14
|
MAX_REVIEW_NUDGE_MINUTES,
|
|
14
15
|
reviewWaitTimeout,
|
|
@@ -128,3 +129,39 @@ test("isReviewStale: a missing HEAD sha fails safe to NOT stale", () => {
|
|
|
128
129
|
test("isReviewStale: both missing is NOT stale", () => {
|
|
129
130
|
assertEquals(isReviewStale(null, null), false);
|
|
130
131
|
});
|
|
132
|
+
|
|
133
|
+
// ── isReviewMissingOrStale (issue #923) ──────────────────────────────────────
|
|
134
|
+
// The CONVERGENCE-GATE variant of the staleness predicate. `isReviewStale` intentionally fails
|
|
135
|
+
// safe to "not stale" on a missing review commit_id (a review GitHub carried no `commit_id` for
|
|
136
|
+
// must not fabricate a re-solicit loop) — but that same fail-safe is a fail-OPEN when the null
|
|
137
|
+
// means "no Copilot review exists AT ALL" (nano-bpm#1443 merged with ZERO reviews). The gate's
|
|
138
|
+
// precondition is a HEAD-CURRENT reviewer verdict, so a missing review must route to the review
|
|
139
|
+
// wait exactly like a stale one.
|
|
140
|
+
|
|
141
|
+
test("isReviewMissingOrStale: NO review (null commit_id) with a readable HEAD is stale/missing — fail CLOSED", () => {
|
|
142
|
+
// The #923 case: `readReview` verified "no Copilot review" (`commitId: null`) and the head read
|
|
143
|
+
// succeeded — the gate must NOT treat "no review" as "a verified clean review".
|
|
144
|
+
assertEquals(isReviewMissingOrStale(null, "bbb2222"), true);
|
|
145
|
+
assertEquals(isReviewMissingOrStale(undefined, "bbb2222"), true);
|
|
146
|
+
assertEquals(isReviewMissingOrStale("", "bbb2222"), true);
|
|
147
|
+
assertEquals(isReviewMissingOrStale(" ", "bbb2222"), true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("isReviewMissingOrStale: a differing review commit_id and HEAD sha is stale (same as isReviewStale)", () => {
|
|
151
|
+
assertEquals(isReviewMissingOrStale("aaa1111", "bbb2222"), true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("isReviewMissingOrStale: a head-current review is NOT stale/missing", () => {
|
|
155
|
+
assertEquals(isReviewMissingOrStale("aaa1111", "aaa1111"), false);
|
|
156
|
+
assertEquals(isReviewMissingOrStale(" aaa1111 ", "aaa1111"), false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("isReviewMissingOrStale: an UNREADABLE head fails safe to NOT stale (never fabricates a re-solicit loop)", () => {
|
|
160
|
+
// Without the head we cannot prove the review predates it NOR that a missing review should
|
|
161
|
+
// re-solicit — a transport hiccup must not strand the loop re-requesting forever (the same
|
|
162
|
+
// fail-safe `isReviewStale` keeps; the review-wait timeout remains the backstop).
|
|
163
|
+
assertEquals(isReviewMissingOrStale(null, null), false);
|
|
164
|
+
assertEquals(isReviewMissingOrStale("aaa1111", null), false);
|
|
165
|
+
assertEquals(isReviewMissingOrStale("aaa1111", ""), false);
|
|
166
|
+
assertEquals(isReviewMissingOrStale(null, ""), false);
|
|
167
|
+
});
|
package/app/reviewWait.ts
CHANGED
|
@@ -114,3 +114,29 @@ export function isReviewStale(
|
|
|
114
114
|
if (rev === "" || head === "") return false;
|
|
115
115
|
return rev !== head;
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/** Is there NO head-current Copilot review to gate on? (issue #923) — the CONVERGENCE-GATE /
|
|
119
|
+
* merge-backstop variant of {@link isReviewStale}.
|
|
120
|
+
*
|
|
121
|
+
* `isReviewStale` intentionally fails safe to "not stale" on a missing review `commit_id` — for
|
|
122
|
+
* the POLLER, a review GitHub carried no `commit_id` for must not fabricate a re-solicit loop. But
|
|
123
|
+
* the gate's consumer is different: `fetchLatestCopilotReview` returns `commitId: null` both when
|
|
124
|
+
* no Copilot review exists AT ALL and when the newest one lacks a `commit_id`, and the gate's
|
|
125
|
+
* precondition is a HEAD-CURRENT reviewer verdict. Treating "no review" as "a verified clean
|
|
126
|
+
* review" is the fail-open that merged nano-bpm#1443 with ZERO reviews — so for the GATE, a
|
|
127
|
+
* missing review commit is stale/missing, not current.
|
|
128
|
+
*
|
|
129
|
+
* The head-side fail-safe is kept: with an UNREADABLE head we can prove nothing, so we must not
|
|
130
|
+
* fabricate a re-solicit loop on a transport hiccup (the review-wait timeout remains the
|
|
131
|
+
* backstop). Only a READABLE head + a missing/mismatched review commit routes to the review wait. */
|
|
132
|
+
export function isReviewMissingOrStale(
|
|
133
|
+
reviewCommitId: string | null | undefined,
|
|
134
|
+
headSha: string | null | undefined,
|
|
135
|
+
): boolean {
|
|
136
|
+
const head = (headSha ?? "").trim();
|
|
137
|
+
if (head === "") return false; // unverifiable head → fail safe, exactly as isReviewStale
|
|
138
|
+
const rev = (reviewCommitId ?? "").trim();
|
|
139
|
+
// A missing review commit with a readable head = NO head-current verdict → stale/missing.
|
|
140
|
+
if (rev === "") return true;
|
|
141
|
+
return rev !== head;
|
|
142
|
+
}
|
|
@@ -24,8 +24,11 @@ test("routeProgress: an addressed round whose head advanced continues", () => {
|
|
|
24
24
|
|
|
25
25
|
test("routeProgress: an explicit non-addressed round always continues (no push is expected)", () => {
|
|
26
26
|
// These are the statuses gw-status routes AWAY from the addressed/default arm — a legit no-push
|
|
27
|
-
// round. They always continue regardless of the head.
|
|
28
|
-
|
|
27
|
+
// round. They always continue regardless of the head. `review-stale` (#923 §4) joins them: the
|
|
28
|
+
// round re-entered via the converge gate's stale/missing-review path, so no reviewer verdict was
|
|
29
|
+
// obtained and no push was claimed — it must continue to the review wait, never escalate as
|
|
30
|
+
// no-progress.
|
|
31
|
+
for (const status of ["waiting", "converged", "needs_input", "blocked", "review-stale"]) {
|
|
29
32
|
assertEquals(
|
|
30
33
|
routeProgress(status, "sha-1", "sha-1"),
|
|
31
34
|
"continue",
|
|
@@ -280,6 +283,64 @@ test("progress-check: a non-addressed round records the baseline and consumes th
|
|
|
280
283
|
);
|
|
281
284
|
});
|
|
282
285
|
|
|
286
|
+
test("progress-check: a review-stale re-entry parks with waiting_since at the gate's reviewWaitCutoff, not the park time", async () => {
|
|
287
|
+
// Issue #923 review ("previously missed"): the converge-gate decides stale/missing from ITS review
|
|
288
|
+
// read, but the loop parks several tasks later — and the old park stamped `waiting_since` at the
|
|
289
|
+
// (later) park time. A head-current review landing in that read→park window was then REJECTED by
|
|
290
|
+
// `pollReviews` (`submitted_at >= waiting_since`), so the loop re-solicited and could time out
|
|
291
|
+
// despite a valid verdict already sitting unconsumed on the PR. The gate emits `reviewWaitCutoff`
|
|
292
|
+
// (its read time); the park must stamp `waiting_since` at THAT cutoff so the in-window review
|
|
293
|
+
// satisfies the wait.
|
|
294
|
+
const CUTOFF = "2026-11-08T01:00:00.000Z"; // the gate's review-read time (earlier than "now")
|
|
295
|
+
const handler = await makeUnderTest(
|
|
296
|
+
async () => "sha-2",
|
|
297
|
+
async () => ({ work: true, consumedKey: "9" }),
|
|
298
|
+
);
|
|
299
|
+
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
300
|
+
const out = await handler(
|
|
301
|
+
{
|
|
302
|
+
processInstanceKey: "pik",
|
|
303
|
+
variables: {
|
|
304
|
+
prKey: "o/r#1",
|
|
305
|
+
status: "waiting",
|
|
306
|
+
repo: "o/r",
|
|
307
|
+
prNumber: 1,
|
|
308
|
+
reviewWaitCutoff: CUTOFF,
|
|
309
|
+
},
|
|
310
|
+
} as any,
|
|
311
|
+
app as any,
|
|
312
|
+
);
|
|
313
|
+
assertEquals(out, { progressed: true, huskRetries: 0 });
|
|
314
|
+
assertEquals(updates.length, 1, "one atomic park write");
|
|
315
|
+
assertEquals(updates[0]!.patch.status, "waiting_review", "the round parks for review");
|
|
316
|
+
assertEquals(
|
|
317
|
+
updates[0]!.patch.waiting_since,
|
|
318
|
+
CUTOFF,
|
|
319
|
+
"waiting_since is stamped at the gate's review-read cutoff, so a review landing in the read→park window satisfies the wait",
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("progress-check: a normal round (no reviewWaitCutoff) still stamps waiting_since at the park time", async () => {
|
|
324
|
+
// The cutoff is only emitted on a review-stale re-entry. A normal round carries no
|
|
325
|
+
// `reviewWaitCutoff`, so the park falls back to "now" — the pre-fix behaviour every existing test
|
|
326
|
+
// relies on.
|
|
327
|
+
const handler = await makeUnderTest(
|
|
328
|
+
async () => "sha-2",
|
|
329
|
+
async () => ({ work: true, consumedKey: "9" }),
|
|
330
|
+
);
|
|
331
|
+
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
332
|
+
await handler(
|
|
333
|
+
{ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "waiting", repo: "o/r", prNumber: 1 } } as any,
|
|
334
|
+
app as any,
|
|
335
|
+
);
|
|
336
|
+
assertEquals(updates[0]!.patch.status, "waiting_review");
|
|
337
|
+
const stamped = updates[0]!.patch.waiting_since;
|
|
338
|
+
assertEquals(typeof stamped, "string", "a park time is stamped");
|
|
339
|
+
// Not the (absent) cutoff — a fresh "now" timestamp, i.e. close to the wall clock.
|
|
340
|
+
const driftMs = Math.abs(Date.now() - Date.parse(stamped as string));
|
|
341
|
+
assertEquals(driftMs < 60_000, true, "waiting_since falls back to the park time when no cutoff is carried");
|
|
342
|
+
});
|
|
343
|
+
|
|
283
344
|
test("progress-check: a blank/unknown status is treated as addressed — reads the head and can report no progress", async () => {
|
|
284
345
|
let called = false;
|
|
285
346
|
const handler = await makeUnderTest(async () => {
|
package/app/roundProgress.ts
CHANGED
|
@@ -39,6 +39,12 @@ const NON_ADDRESSED_STATUSES: ReadonlySet<string> = new Set([
|
|
|
39
39
|
"converged",
|
|
40
40
|
"needs_input",
|
|
41
41
|
"blocked",
|
|
42
|
+
// `review-stale` (issue #923 §4): the round re-entered persist-round via the converge gate's
|
|
43
|
+
// stale/missing-review path (f_convergeStale) — no head-current reviewer verdict was ever
|
|
44
|
+
// obtained, so the round claims no push and must always continue to the review wait (the poller
|
|
45
|
+
// re-solicits a fresh review of the current head). Treating it as `addressed` would fabricate a
|
|
46
|
+
// no-progress escalation on a round that never claimed progress.
|
|
47
|
+
"review-stale",
|
|
42
48
|
]);
|
|
43
49
|
|
|
44
50
|
/** True when a round's status is `addressed` for no-progress purposes — i.e. NOT one of the
|
package/app/service.ts
CHANGED
|
@@ -1698,6 +1698,77 @@ export async function maybeEnsureFreshHeadRun(
|
|
|
1698
1698
|
* EVERY branch first runs {@link advanceIfTerminalOutOfBand} — one shared "is this PR already
|
|
1699
1699
|
* terminal (merged/closed) out-of-band?" pre-check — so no merge stage can silently strand when a PR
|
|
1700
1700
|
* is landed/closed outside the loop (the `waiting_deps` self-merged wedge, #368). */
|
|
1701
|
+
/** Durably hand a `waiting_merge` PR back into the canonical convergence loop (issue #921, PR #922
|
|
1702
|
+
* review): flip the row to `waiting_review` FIRST (so a review landing in the start window is still
|
|
1703
|
+
* collected by `pollReviews` — it scans on `waiting_review`, not on instance liveness), RETIRE the
|
|
1704
|
+
* live merge instance (while the row's `process_key` names a non-terminal merge instance, `submitPr`'s
|
|
1705
|
+
* idempotency gate classifies the PR `alreadyRunning` and starts NOTHING — the cancel flips the derived
|
|
1706
|
+
* tracking status terminal so the gate falls through), then `submitPr` re-opens the PR into a fresh
|
|
1707
|
+
* convergence instance (round 1). The review-round agent's backlog IS the open threads: it fixes or
|
|
1708
|
+
* pushes back, replies, and resolves them; on `converged` the converge-gate re-verifies zero
|
|
1709
|
+
* unresolved threads and `pr.finalize` re-enters the merge stage — merging resumes automatically.
|
|
1710
|
+
*
|
|
1711
|
+
* The escalation-steward opt-in is carried through the resubmission (PR #922 review): `submitPr`
|
|
1712
|
+
* defaults `autoResolveEscalations` to false and overwrites the row's flag on reopen, so an opted-in PR
|
|
1713
|
+
* would silently lose automated escalation resolution merely because late review threads appeared.
|
|
1714
|
+
*
|
|
1715
|
+
* RETRYABLE on failure (PR #922 review, thread 3): if the cancel succeeds but `submitPr` throws, the
|
|
1716
|
+
* row is restored to `waiting_merge` so the next pass re-attempts the handoff against the
|
|
1717
|
+
* already-retired (terminal) merge key — rather than stranding in `waiting_review`, which `pollMerges`
|
|
1718
|
+
* no longer scans and whose `readiness-ready` resume correlates to nothing once the merge instance is
|
|
1719
|
+
* cancelled. The restored row's `process_key` still names the now-terminal merge instance, so the
|
|
1720
|
+
* unfinished handoff is re-detected next pass by the dead-owner guard in `pollMerges` (which keys off
|
|
1721
|
+
* the terminal `derived_status`, independently of the GitHub verdict). Best-effort throughout: a failed
|
|
1722
|
+
* cancel or restore only logs, leaving the row for the next pass. */
|
|
1723
|
+
async function handoffWaitingMergeToConvergence(
|
|
1724
|
+
data: DataLayer,
|
|
1725
|
+
engine: EngineClient,
|
|
1726
|
+
pr: PullRequest,
|
|
1727
|
+
logContext: string,
|
|
1728
|
+
) {
|
|
1729
|
+
const prKey = pr.pr_key;
|
|
1730
|
+
try {
|
|
1731
|
+
// `waiting_since` bounds the review the fresh loop collects: only a review submitted at or after
|
|
1732
|
+
// the handoff counts, so a pre-merge review (submitted before this flip) can never satisfy the new
|
|
1733
|
+
// run's wait.
|
|
1734
|
+
const handoffTs = now();
|
|
1735
|
+
await prs(data).update(prKey, { status: "waiting_review", waiting_since: handoffTs, updated_at: handoffTs });
|
|
1736
|
+
const mergeKey = pr.process_key;
|
|
1737
|
+
if (mergeKey != null) {
|
|
1738
|
+
try {
|
|
1739
|
+
await engine.cancelInstance({ processInstanceKey: String(mergeKey) });
|
|
1740
|
+
} catch (cancelErr) {
|
|
1741
|
+
console.error(`[poller] ${logContext} handoff ${prKey}: retire merge instance ${mergeKey}: ${cancelErr}`);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
await submitPr(
|
|
1745
|
+
data,
|
|
1746
|
+
engine,
|
|
1747
|
+
{ repo: pr.repo, number: pr.number, url: pr.url, prKey },
|
|
1748
|
+
[],
|
|
1749
|
+
MAX_ROUNDS,
|
|
1750
|
+
false,
|
|
1751
|
+
null,
|
|
1752
|
+
false,
|
|
1753
|
+
pr.auto_resolve_escalations === 1,
|
|
1754
|
+
);
|
|
1755
|
+
console.log(`[poller] ${logContext} -> handed back into convergence -> ${prKey}`);
|
|
1756
|
+
} catch (err) {
|
|
1757
|
+
console.error(`[poller] ${logContext} handoff ${prKey}: ${err}`);
|
|
1758
|
+
// Restore the row to `waiting_merge` so the next pass re-attempts the handoff, and NULL the
|
|
1759
|
+
// `process_key`: the merge instance was retired above (or never lived), so the row genuinely has
|
|
1760
|
+
// NO live owner. The null key is the durable "unfinished handoff" marker the dead-owner guard in
|
|
1761
|
+
// `pollMerges` keys on — and it is what distinguishes this retryable shape from a HUMAN cancel of a
|
|
1762
|
+
// parked merge wait (which leaves `process_key` naming the terminated instance and must NOT be
|
|
1763
|
+
// resurrected). Best-effort: a failed restore still leaves the original error logged.
|
|
1764
|
+
try {
|
|
1765
|
+
await prs(data).update(prKey, { status: "waiting_merge", process_key: null, updated_at: now() });
|
|
1766
|
+
} catch (restoreErr) {
|
|
1767
|
+
console.error(`[poller] ${logContext} handoff ${prKey}: restore waiting_merge: ${restoreErr}`);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1701
1772
|
export async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
|
|
1702
1773
|
// 1) Dependencies merged?
|
|
1703
1774
|
for (const pr of await prs(data).find({ status: "waiting_deps" })) {
|
|
@@ -1731,7 +1802,12 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1731
1802
|
for (const pr of await prs(data).find({ status: "waiting_merge" })) {
|
|
1732
1803
|
const { repo, number, pr_key: prKey } = pr;
|
|
1733
1804
|
try {
|
|
1734
|
-
|
|
1805
|
+
// Opt into the review-threads probe so a `BLOCKED` merge state is classified by its real
|
|
1806
|
+
// cause (issue #921): unresolved review threads (branch protection
|
|
1807
|
+
// `required_conversation_resolution`) surface as the `review-threads` verdict below instead
|
|
1808
|
+
// of polling as "waiting" until the mergeable-wait timer escalates the misleading
|
|
1809
|
+
// "mergeability never settled". One extra GraphQL read per pass per waiting PR.
|
|
1810
|
+
const st = await fetchPrState(repo, number, token, { withReviewThreads: true });
|
|
1735
1811
|
if (st === null) continue; // no transport → skip this PR (others may still advance)
|
|
1736
1812
|
// Out-of-band terminal (merged/closed) → the shared pre-check publishes `merge-ready {ready}`,
|
|
1737
1813
|
// routing through `gw-mergeable → attempt-merge` whose idempotent already-merged check completes
|
|
@@ -1739,6 +1815,39 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1739
1815
|
// (#342/#350). Reuse the `st` we just read so we don't double-fetch. This is the proven terminal
|
|
1740
1816
|
// path the whole class (#368) now shares.
|
|
1741
1817
|
if (await advanceIfTerminalOutOfBand(data, engine, pr, token, st)) continue;
|
|
1818
|
+
// DEAD-OWNER guard (PR #922 review, round 5): a `waiting_merge` row with `process_key === null`
|
|
1819
|
+
// has NO live owner — the unfinished-handoff shape {@link handoffWaitingMergeToConvergence}
|
|
1820
|
+
// leaves when its `submitPr` failed after the merge instance was retired (the restore nulls the
|
|
1821
|
+
// key to mark exactly this). The verdict switch below must NOT run against it: every arm ends in
|
|
1822
|
+
// `flipToMergingThenPublish`, which would publish `merge-ready`/`merge-evicted`/remediation
|
|
1823
|
+
// correlated to a merge instance that no longer exists — the message is dropped and the row wedges
|
|
1824
|
+
// at the transient `merging` with no live owner (a fresh `ready`/`conflict`/`blocked` verdict after
|
|
1825
|
+
// the threads cleared is exactly the case the retry-restore leaves behind). Persist the handoff
|
|
1826
|
+
// INDEPENDENTLY of the current GitHub verdict: re-establish a live convergence owner FIRST,
|
|
1827
|
+
// publishing nothing. Keying on the NULL key — not on the terminal `derived_status` — is what
|
|
1828
|
+
// keeps a HUMAN cancel of a parked merge wait (which leaves `process_key` naming the terminated
|
|
1829
|
+
// instance) OUT of this path: a cancelled run must stay cancelled, never auto-resurrected. A
|
|
1830
|
+
// normal merge wait always carries a live `process_key`, so this fires only on the
|
|
1831
|
+
// ownerless-handoff shape. Placed after the out-of-band terminal pre-check so a merged/closed PR
|
|
1832
|
+
// still takes the proven terminal path first.
|
|
1833
|
+
if (pr.process_key == null) {
|
|
1834
|
+
await handoffWaitingMergeToConvergence(data, engine, pr, "dead merge owner (unfinished handoff)");
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
// HUMAN-CANCEL guard (PR #922 review, round 5): a `waiting_merge` row whose `process_key` names a
|
|
1838
|
+
// TERMINAL merge instance (`derived_status` folded to `abandoned`) was cancelled out-of-band. The
|
|
1839
|
+
// verdict switch must NOT run against it either — publishing `merge-ready`/`merge-evicted`/
|
|
1840
|
+
// remediation to the dead instance is dropped and wedges the row at `merging` (the reviewer's "a
|
|
1841
|
+
// new conflict or check failure likewise sends remediation to the dead owner"). Unlike the
|
|
1842
|
+
// unfinished-handoff shape above, a cancelled run must NOT be resurrected into convergence — the
|
|
1843
|
+
// abandon protocol exists to stop exactly that orphaned side effect — so it is simply SKIPPED
|
|
1844
|
+
// (left cancelled) with nothing published. A LIVE parked merge wait reads a non-terminal
|
|
1845
|
+
// `derived_status` (`waiting_merge` ∈ activeStatuses), so this fires only on the cancelled shape.
|
|
1846
|
+
const trackedMerge = await prsTracking(data).get(prKey);
|
|
1847
|
+
if (trackedMerge && TERMINAL_STATUSES.includes(trackedMerge.derived_status)) {
|
|
1848
|
+
console.log(`[poller] merge owner terminal (cancelled) — leaving ${prKey} abandoned, no merge message published`);
|
|
1849
|
+
continue;
|
|
1850
|
+
}
|
|
1742
1851
|
// Load the repo's merge protocol ONCE per PR iteration and pass it into the classifier so the
|
|
1743
1852
|
// protocol-aware backstop (#392) can gate a red DECLARED-required check even when GitHub reports
|
|
1744
1853
|
// the PR as UNSTABLE. The same handle is reused by the frugal-CI fresh-head-run branch below.
|
|
@@ -1788,6 +1897,25 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1788
1897
|
}
|
|
1789
1898
|
continue; // GitHub still computing / checks pending
|
|
1790
1899
|
}
|
|
1900
|
+
if (verdict === "review-threads") {
|
|
1901
|
+
// BLOCKED by unresolved review threads with every check green (issue #921): the threads are
|
|
1902
|
+
// findings from the reviewer this loop converges with, so route them back into convergence
|
|
1903
|
+
// rather than escalating to a human. Requesting another Copilot review alone does NOT
|
|
1904
|
+
// remediate them: `pollReviews` only scans `waiting_review` rows, so a review landing while
|
|
1905
|
+
// the PR sits in `waiting_merge` is never picked up and no review-fix agent ever activates
|
|
1906
|
+
// (PR #922 review). The handoff is a DURABLE re-entry into the canonical convergence loop
|
|
1907
|
+
// (see {@link handoffWaitingMergeToConvergence}). Once the fresh convergence instance exists,
|
|
1908
|
+
// `pr.progress-check` parks the row back in `waiting_merge` after the round, where this
|
|
1909
|
+
// block's verdict has by then flipped off `review-threads` (the gate guarantees it) — so this
|
|
1910
|
+
// fires at most once per thread-blocked episode.
|
|
1911
|
+
await handoffWaitingMergeToConvergence(
|
|
1912
|
+
data,
|
|
1913
|
+
engine,
|
|
1914
|
+
pr,
|
|
1915
|
+
`blocked by ${st.unresolvedThreads} unresolved review thread(s)`,
|
|
1916
|
+
);
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1791
1919
|
if (verdict === "ready") {
|
|
1792
1920
|
const lane = await mergeLaneDecisionForPr(data, prKey);
|
|
1793
1921
|
if (lane?.isHeld) {
|
|
@@ -1807,6 +1935,16 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1807
1935
|
// fix-ci task appends it to the agent prompt so the agent knows which gates to green.
|
|
1808
1936
|
failingChecks: st.failingChecks,
|
|
1809
1937
|
failingChecksList: st.failingCheckNames.join("\n"),
|
|
1938
|
+
// The probed unresolved-thread count (issue #921) — carried so a later stall-budget
|
|
1939
|
+
// escalation can name the real BLOCKED cause (and so the `review-threads` arm's question
|
|
1940
|
+
// can quote it). `-1` when the probe was unreadable.
|
|
1941
|
+
reviewThreads: st.unresolvedThreads,
|
|
1942
|
+
// The pending-check count (PR #922 review): zero failing checks does not mean checks are
|
|
1943
|
+
// green — a queued/running check leaves the BLOCKED cause undetermined, so the
|
|
1944
|
+
// stall-escalation question gates its "required review approval" wording on this being 0
|
|
1945
|
+
// too. 0 in token mode (checks unenumerable — the conservative `waiting` verdict already
|
|
1946
|
+
// keeps the approval wording from firing there).
|
|
1947
|
+
pendingChecks: st.pendingCheckNames.length,
|
|
1810
1948
|
},
|
|
1811
1949
|
});
|
|
1812
1950
|
console.log(`[poller] mergeable=${verdict} (${st.mergeStateStatus}) -> ${prKey}`);
|
package/app/userTasks.ts
CHANGED
|
@@ -90,6 +90,7 @@ export const PR_ESCALATION_PRODUCER_ELEMENTS: readonly string[] = [
|
|
|
90
90
|
"merge-esc-conflict",
|
|
91
91
|
"merge-esc-attempt",
|
|
92
92
|
"merge-esc-landed",
|
|
93
|
+
"merge-esc-threads",
|
|
93
94
|
];
|
|
94
95
|
|
|
95
96
|
/** The ACP permission-prompt escalation (issue #559, ADR 0056) — the Tasks-inbox kind a bridged
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.205.
|
|
3
|
+
"version": "0.205.2",
|
|
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",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
<nano:extend name="round" type="integer" optional="true" />
|
|
43
43
|
<nano:extend name="huskRetries" type="integer" optional="true" />
|
|
44
44
|
<nano:extend name="roundEntryHead" type="string" optional="true" />
|
|
45
|
+
<nano:extend name="reviewWaitCutoff" type="string" optional="true" />
|
|
45
46
|
</nano:shape>
|
|
46
47
|
<nano:shape id="PrCaptureHeadIn" name="Capture round-entry head — input">
|
|
47
48
|
<nano:extend name="prKey" type="string" />
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
<nano:extend name="convergeBlockReason" type="string" optional="true" />
|
|
69
70
|
<nano:extend name="convergeAckOnly" type="boolean" optional="true" />
|
|
70
71
|
<nano:extend name="reviewStale" type="boolean" optional="true" />
|
|
72
|
+
<nano:extend name="reviewWaitCutoff" type="string" optional="true" />
|
|
71
73
|
</nano:shape>
|
|
72
74
|
<nano:shape id="EscalationIn" name="Record escalation — input">
|
|
73
75
|
<nano:extend name="prKey" type="string" />
|
|
@@ -307,6 +309,9 @@
|
|
|
307
309
|
<zeebe:properties>
|
|
308
310
|
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrPersistRoundIn" />
|
|
309
311
|
</zeebe:properties>
|
|
312
|
+
<zeebe:ioMapping>
|
|
313
|
+
<zeebe:input source="=if reviewStale = true then "review-stale" else status" target="status" />
|
|
314
|
+
</zeebe:ioMapping>
|
|
310
315
|
</bpmn:extensionElements>
|
|
311
316
|
<bpmn:incoming>f_advSkip</bpmn:incoming>
|
|
312
317
|
<bpmn:incoming>f_advClean</bpmn:incoming>
|