@nanobpm/nano-workforce 0.204.1 → 0.205.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +10 -7
- package/app/agentOutputEnvelope.test.ts +50 -0
- package/app/feature.ts +3 -3
- package/app/featureDelivery.test.ts +2 -0
- package/app/github.test.ts +285 -1
- package/app/github.ts +160 -8
- package/app/mergeApproval.test.ts +20 -1
- package/app/mergeBlockedThreads.test.ts +551 -0
- package/app/mergeLoopBehaviour.test.ts +129 -0
- package/app/mergeStatus.test.ts +169 -0
- package/app/readiness.ts +1 -0
- package/app/service.test.ts +25 -0
- package/app/service.ts +257 -10
- package/app/userTasks.ts +1 -0
- package/docs/agent-guide.md +33 -0
- package/openapi.yaml +34 -0
- package/operations/getAgentInstructions.test.ts +16 -1
- package/operations/listActivePrs.ts +1 -1
- package/operations/startAndMessage.test.ts +13 -3
- package/operations/startConvergenceLoop.ts +10 -4
- package/operations/startFeature.readiness.integration.test.ts +15 -0
- package/operations/startFeature.ts +6 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +8 -0
- package/resources/processes/merge-loop.bpmn +55 -9
- package/skills/nano-workforce/SKILL.md +7 -0
- package/workers/converge-feature/worker.ts +8 -6
- package/workers/delivery-connector/worker.ts +1 -1
- package/workers/finalize/worker.test.ts +35 -0
- package/workers/finalize/worker.ts +9 -4
- package/workers/merge-stall-probe/worker.ts +17 -3
package/app/github.ts
CHANGED
|
@@ -862,6 +862,21 @@ export interface PrState {
|
|
|
862
862
|
* perpetually-null entry on such a repo must NOT read as an eviction, so the classifier stays
|
|
863
863
|
* conservative and leaves the `landedWaitTimeout` human backstop to cover a never-lands wedge. */
|
|
864
864
|
mergeQueueEntry: boolean | null;
|
|
865
|
+
/** Count of UNRESOLVED review threads on the PR (GraphQL `reviewThreads{isResolved}`). Tri-state
|
|
866
|
+
* (PR #922 review):
|
|
867
|
+
* • `>= 0` — the probe was opted into (`withReviewThreads: true`, the merge-stage
|
|
868
|
+
* `waiting_merge` poll + the `merge-stall-probe` worker) and succeeded: the real count.
|
|
869
|
+
* • `-1` — NOT probed: the caller did not opt in, so threads are not part of its verdict.
|
|
870
|
+
* This keeps {@link classifyMergeability} on exactly its pre-#921 behaviour for every
|
|
871
|
+
* non-merge-stage caller (e.g. the readiness `pr` probe).
|
|
872
|
+
* • `-2` ({@link THREAD_PROBE_FAILED}) — the probe WAS opted into but the read failed, so the
|
|
873
|
+
* count is UNKNOWN. A failed read must never be conflated with `-1` (unprobed): an opted-in
|
|
874
|
+
* merge-stage read that couldn't verify threads is not merge-ready.
|
|
875
|
+
* This is the signal that classifies a `BLOCKED` merge state whose checks are all green: branch
|
|
876
|
+
* protection's `required_conversation_resolution` blocks the merge while any review thread is
|
|
877
|
+
* open, and those threads are REVIEW FINDINGS the convergence loop can act on — not a human
|
|
878
|
+
* problem (issue #921). */
|
|
879
|
+
unresolvedThreads: number;
|
|
865
880
|
}
|
|
866
881
|
|
|
867
882
|
/** Map GitHub's REST `mergeable_state` (lower-case) onto the GraphQL `mergeStateStatus`
|
|
@@ -1115,7 +1130,7 @@ export async function fetchPrState(
|
|
|
1115
1130
|
repo: string,
|
|
1116
1131
|
number: number | string,
|
|
1117
1132
|
token: string,
|
|
1118
|
-
opts?: { withMergeQueue?: boolean },
|
|
1133
|
+
opts?: { withMergeQueue?: boolean; withReviewThreads?: boolean },
|
|
1119
1134
|
): Promise<PrState | null> {
|
|
1120
1135
|
if (await useGh()) {
|
|
1121
1136
|
const out = await runGh([
|
|
@@ -1145,6 +1160,7 @@ export async function fetchPrState(
|
|
|
1145
1160
|
const mergeQueueEntry = opts?.withMergeQueue
|
|
1146
1161
|
? await fetchMergeQueueMembership(repo, number, j.baseRefName ?? "", token)
|
|
1147
1162
|
: null;
|
|
1163
|
+
const unresolvedThreads = await countUnresolvedThreads(repo, number, token, opts);
|
|
1148
1164
|
return {
|
|
1149
1165
|
merged,
|
|
1150
1166
|
state: merged ? "merged" : (j.state ?? "").toUpperCase() === "CLOSED" ? "closed" : "open",
|
|
@@ -1158,6 +1174,7 @@ export async function fetchPrState(
|
|
|
1158
1174
|
isDraft: !!j.isDraft,
|
|
1159
1175
|
headRefOid: j.headRefOid ?? null,
|
|
1160
1176
|
mergeQueueEntry,
|
|
1177
|
+
unresolvedThreads,
|
|
1161
1178
|
};
|
|
1162
1179
|
}
|
|
1163
1180
|
if (!token) return null;
|
|
@@ -1196,9 +1213,43 @@ export async function fetchPrState(
|
|
|
1196
1213
|
isDraft: !!j.draft,
|
|
1197
1214
|
headRefOid: j.head?.sha ?? null,
|
|
1198
1215
|
mergeQueueEntry, // native-queue membership (GraphQL), probed only for block-4 reconciliation
|
|
1216
|
+
unresolvedThreads: await countUnresolvedThreads(repo, number, token, opts),
|
|
1199
1217
|
};
|
|
1200
1218
|
}
|
|
1201
1219
|
|
|
1220
|
+
/** Sentinel for an OPTED-IN review-threads probe that FAILED (PR #922 review). Distinct from the
|
|
1221
|
+
* `-1` "not probed" sentinel: `-1` means the caller never asked (threads are not part of its
|
|
1222
|
+
* verdict — the pre-#921 default); `THREAD_PROBE_FAILED` means the caller asked and the read
|
|
1223
|
+
* errored, so the unresolved-thread count is UNKNOWN. Both are not-merge-ready, but a failed
|
|
1224
|
+
* opted-in read must never be conflated with an unprobed one — a CLEAN PR whose probe errored
|
|
1225
|
+
* must NOT classify `ready` and merge over threads that were never verified resolved. */
|
|
1226
|
+
export const THREAD_PROBE_FAILED = -2;
|
|
1227
|
+
|
|
1228
|
+
/** Count a PR's unresolved review threads for the BLOCKED-cause classification (issue #921), but
|
|
1229
|
+
* ONLY when the caller opted in (`withReviewThreads: true`) — it is an extra GraphQL round-trip, so
|
|
1230
|
+
* every other caller keeps `-1` ("not probed"), the conservative value that leaves
|
|
1231
|
+
* {@link classifyMergeability} on its pre-#921 behaviour.
|
|
1232
|
+
*
|
|
1233
|
+
* Failure semantics (PR #922 review): the two `-1`-producing cases are NOT interchangeable, so an
|
|
1234
|
+
* OPTED-IN read that fails does NOT collapse to the unprobed `-1` sentinel — it returns
|
|
1235
|
+
* {@link THREAD_PROBE_FAILED} (`-2`) instead. `-1` means "this caller never asked, so threads are
|
|
1236
|
+
* not part of its verdict" (the pre-#921 default); `-2` means "this caller asked and the read
|
|
1237
|
+
* failed, so the thread count is UNKNOWN". The classifier treats both as not-merge-ready, but a
|
|
1238
|
+
* failed opted-in read must never be mistaken for an unprobed one — otherwise a CLEAN PR whose
|
|
1239
|
+
* thread probe errored would classify `ready` and merge over threads that were never verified
|
|
1240
|
+
* resolved. */
|
|
1241
|
+
async function countUnresolvedThreads(
|
|
1242
|
+
repo: string,
|
|
1243
|
+
number: number | string,
|
|
1244
|
+
token: string,
|
|
1245
|
+
opts?: { withReviewThreads?: boolean },
|
|
1246
|
+
): Promise<number> {
|
|
1247
|
+
if (!opts?.withReviewThreads) return -1; // not probed — threads are not part of this caller's verdict
|
|
1248
|
+
const threads = await fetchReviewThreads(repo, number, token).catch(() => null);
|
|
1249
|
+
if (threads === null) return THREAD_PROBE_FAILED; // opted in but the read failed → UNKNOWN, not unprobed
|
|
1250
|
+
return threads.filter((t) => !t.isResolved).length;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1202
1253
|
/** Map a PR's live GitHub state to one **liveness** verdict shared by both durable loops (merge +
|
|
1203
1254
|
* convergence), so neither can ever escalate against a non-open PR (#342):
|
|
1204
1255
|
*
|
|
@@ -1423,9 +1474,52 @@ export async function baseBranchLanded(
|
|
|
1423
1474
|
/** A settled landability verdict, or `waiting` when GitHub hasn't determined it yet (or is
|
|
1424
1475
|
* still running checks / awaiting review). `draft` is a settled *not-landable* verdict: a draft PR
|
|
1425
1476
|
* can never be merged (GitHub refuses it outright), regardless of its checks — so it outranks every
|
|
1426
|
-
* other signal and carries its own actionable remedy (mark it ready).
|
|
1427
|
-
*
|
|
1428
|
-
|
|
1477
|
+
* other signal and carries its own actionable remedy (mark it ready). `review-threads` (issue #921)
|
|
1478
|
+
* is likewise a settled *not-landable* verdict: the merge is `BLOCKED` with every check green, but
|
|
1479
|
+
* ≥1 review thread is still unresolved (branch protection `required_conversation_resolution`) —
|
|
1480
|
+
* those threads are review findings the loop acts on, not a "mergeability never settled" stall.
|
|
1481
|
+
* The poller only advances the process on a settled verdict; `waiting` means re-poll later. */
|
|
1482
|
+
export type Mergeability = "ready" | "waiting" | "conflict" | "blocked" | "draft" | "review-threads";
|
|
1483
|
+
|
|
1484
|
+
/** WHY a `mergeStateStatus = BLOCKED` PR is blocked (issue #921). GitHub reports one flat `BLOCKED`
|
|
1485
|
+
* for several distinct causes, each with a different remedy — so the merge loop must classify the
|
|
1486
|
+
* cause instead of escalating the misleading "mergeability never settled":
|
|
1487
|
+
* • `"checks"` — a required check failed (the existing `blocked` verdict → fix-ci).
|
|
1488
|
+
* • `"review-threads"` — checks are green but ≥1 review thread is unresolved (branch protection
|
|
1489
|
+
* `required_conversation_resolution`); the threads are review findings the
|
|
1490
|
+
* loop sends back into convergence, not a human escalation.
|
|
1491
|
+
* • `"reviews"` — checks green, threads probed and ALL resolved, yet still BLOCKED: the
|
|
1492
|
+
* remaining causes are a required human review approval (or a code-owner
|
|
1493
|
+
* review) — nothing an automated loop can produce → escalate with THAT
|
|
1494
|
+
* reason named.
|
|
1495
|
+
* • `"unknown"` — the cause could not be determined (checks unenumerable in token mode,
|
|
1496
|
+
* or the thread read was unverifiable) → stays on the conservative
|
|
1497
|
+
* `waiting` re-poll, exactly as before #921. */
|
|
1498
|
+
export type BlockedCause = "checks" | "review-threads" | "reviews" | "unknown";
|
|
1499
|
+
|
|
1500
|
+
/** Classify why a `BLOCKED` PR is blocked. Pure; unit-tested. Only meaningful for a state read
|
|
1501
|
+
* that opted into the review-threads probe (`unresolvedThreads >= 0`); an unprobed read (`-1`) or a
|
|
1502
|
+
* FAILED probe (`THREAD_PROBE_FAILED`, `-2`) can never distinguish "no threads" from "couldn't read
|
|
1503
|
+
* threads", so it reports `"unknown"` and the caller keeps waiting — a failed read must never
|
|
1504
|
+
* fabricate a "reviews"-caused escalation. */
|
|
1505
|
+
export function classifyBlockedCause(s: PrState): BlockedCause {
|
|
1506
|
+
if (s.failingChecks > 0) return "checks";
|
|
1507
|
+
// Checks unenumerable (token mode, `failingChecks === -1`): "green checks" can't be confirmed, so
|
|
1508
|
+
// no thread count can settle the cause — stay `unknown` (the caller keeps waiting), exactly as
|
|
1509
|
+
// before #921. This guard must precede the thread read or a token-mode BLOCKED with open threads
|
|
1510
|
+
// would fabricate a `review-threads` verdict off an unverifiable "checks green" premise.
|
|
1511
|
+
if (s.failingChecks < 0) return "unknown";
|
|
1512
|
+
// Zero FAILING checks does not mean checks are green: queued/running checks are tracked
|
|
1513
|
+
// separately in `pendingCheckNames`. While any check is still in flight the BLOCKED cause is
|
|
1514
|
+
// undetermined — the pending run may come back red (→ `checks`) — so stay `unknown` and let the
|
|
1515
|
+
// caller keep waiting rather than assign a thread/approval cause off a half-settled rollup
|
|
1516
|
+
// (PR #922 review). This guard must precede the thread read for the same reason the
|
|
1517
|
+
// unenumerable-checks guard does: "checks green" is the premise every later cause stands on.
|
|
1518
|
+
if (s.pendingCheckNames.length > 0) return "unknown";
|
|
1519
|
+
if (s.unresolvedThreads > 0) return "review-threads";
|
|
1520
|
+
if (s.unresolvedThreads === 0) return "reviews";
|
|
1521
|
+
return "unknown";
|
|
1522
|
+
}
|
|
1429
1523
|
|
|
1430
1524
|
/** Intersect a repo's declared `requiredChecks` against the head's actual per-check conclusions —
|
|
1431
1525
|
* an INDEPENDENT backstop that runs BEFORE the `mergeStateStatus` switch, so nwf never merges a red
|
|
@@ -1485,13 +1579,57 @@ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Merg
|
|
|
1485
1579
|
// ready. The poller self-heals this (mark-ready) when the repo's protocol wants a fresh head run,
|
|
1486
1580
|
// else escalates with an actionable message.
|
|
1487
1581
|
if (s.isDraft) return "draft";
|
|
1488
|
-
// Protocol-aware backstop
|
|
1582
|
+
// Protocol-aware backstop (issue #392): honour the repo's declared `requiredChecks`
|
|
1489
1583
|
// against the actual head rollup, so an `UNSTABLE` PR with a red DECLARED-required
|
|
1490
1584
|
// check is no longer blindly `ready`. This never weakens GitHub branch protection (the switch
|
|
1491
1585
|
// below still gates) — it only tightens merges on repos that under-specify their required checks.
|
|
1586
|
+
// It ALSO outranks the thread gate (PR #922 review, thread 2): a declared-required check that is
|
|
1587
|
+
// red (`blocked`) or not-yet-satisfied (`waiting` — e.g. a NEUTRAL build a success-only protocol
|
|
1588
|
+
// rejects, or an absent required check) owns remediation/waiting, so a convergence round must not
|
|
1589
|
+
// start on top of it. Consulting the verdict here keeps the protocol's decisions ahead of the
|
|
1590
|
+
// settled-thread routing below.
|
|
1492
1591
|
const gate = requiredChecksVerdict(s, protocol);
|
|
1493
1592
|
if (gate === "blocked") return "blocked";
|
|
1494
1593
|
if (gate === "waiting") return "waiting";
|
|
1594
|
+
// A GitHub-identified merge conflict (`mergeStateStatus: "DIRTY"`) is ground truth whose remedy is
|
|
1595
|
+
// a rebase, dispatched off the `conflict` verdict. It outranks the thread gate below (PR #922
|
|
1596
|
+
// review, thread 1): a FAILED thread probe (`THREAD_PROBE_FAILED`) returning `waiting` here would
|
|
1597
|
+
// park a KNOWN conflict forever (neither the poller nor the stall probe dispatches the rebase), and
|
|
1598
|
+
// an OPEN thread count returning `review-threads`/`waiting` would route a conflict into a review
|
|
1599
|
+
// round that cannot clear it. Surface the conflict first; the threads are re-triaged once the
|
|
1600
|
+
// rebased head is re-read. (A red declared-required check still outranks even this — see the
|
|
1601
|
+
// backstop above — so a conflicting PR with failing CI routes to fix-ci, not a doomed rebase.)
|
|
1602
|
+
if (s.mergeStateStatus === "DIRTY") return "conflict";
|
|
1603
|
+
// The merge-stage thread gate (issue #921, PR #922 review): a read that opted into the
|
|
1604
|
+
// review-threads probe and found ≥1 open thread (`unresolvedThreads > 0`) is NEVER merge-ready,
|
|
1605
|
+
// whatever `mergeStateStatus` says. A repo without branch-protection
|
|
1606
|
+
// `required_conversation_resolution` reports such a PR CLEAN/UNSTABLE — mergeable by GitHub's
|
|
1607
|
+
// rules — but issue #921's merge contract is zero unresolved threads: those threads are review
|
|
1608
|
+
// findings the loop converges with, so the PR routes back into convergence instead of landing
|
|
1609
|
+
// over them. An UNPROBED read (`-1`) keeps exactly its pre-#921 behaviour — every non-merge-stage
|
|
1610
|
+
// caller (e.g. the readiness `pr` probe, readiness.ts `matchPr`) never opted in and is untouched.
|
|
1611
|
+
// A FAILED opted-in probe (`THREAD_PROBE_FAILED`, `-2`) is likewise never merge-ready: the thread
|
|
1612
|
+
// count is UNKNOWN, so the read holds `waiting` rather than merging over threads it could not
|
|
1613
|
+
// verify (PR #922 review, thread 1).
|
|
1614
|
+
//
|
|
1615
|
+
// This gate runs BEFORE the `mergeStateStatus` switch's `ready` arms so a known open thread can
|
|
1616
|
+
// never fall through to a `ready` (PR #922 review, thread 2) — but AFTER the conflict and
|
|
1617
|
+
// required-check verdicts above, so it can only ever decide between `ready` and a thread verdict,
|
|
1618
|
+
// never suppress a known conflict or a declared-required-check remediation. The verdict splits on
|
|
1619
|
+
// whether the checks are KNOWN settled:
|
|
1620
|
+
// • a failing check (`failingChecks > 0`) owns remediation → `blocked` (fix-ci), never a review
|
|
1621
|
+
// round on top of the CI problem;
|
|
1622
|
+
// • checks settled green (none failing, none pending, enumerable) → the threads OWN the
|
|
1623
|
+
// verdict: `review-threads` routes the PR back into a convergence round;
|
|
1624
|
+
// • otherwise (a pending check, or checks unenumerable in token mode, `failingChecks === -1`) →
|
|
1625
|
+
// the threads still block the merge but the check state owns the verdict, so hold `waiting`
|
|
1626
|
+
// and re-fire the gate on the next pass once the rollup settles.
|
|
1627
|
+
if (s.unresolvedThreads === THREAD_PROBE_FAILED) return "waiting";
|
|
1628
|
+
if (s.unresolvedThreads > 0) {
|
|
1629
|
+
if (s.failingChecks > 0) return "blocked";
|
|
1630
|
+
if (s.failingChecks === 0 && s.pendingCheckNames.length === 0) return "review-threads";
|
|
1631
|
+
return "waiting";
|
|
1632
|
+
}
|
|
1495
1633
|
switch (s.mergeStateStatus) {
|
|
1496
1634
|
case "CLEAN":
|
|
1497
1635
|
case "HAS_HOOKS":
|
|
@@ -1501,9 +1639,23 @@ export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Merg
|
|
|
1501
1639
|
case "DIRTY":
|
|
1502
1640
|
return "conflict";
|
|
1503
1641
|
case "BLOCKED":
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
|
|
1642
|
+
// Classify WHY GitHub blocks the merge (issue #921): a failed required check routes to
|
|
1643
|
+
// fix-ci; unresolved review threads (checks green, branch protection
|
|
1644
|
+
// `required_conversation_resolution`) are a settled not-landable verdict of their own —
|
|
1645
|
+
// `review-threads` — so the loop routes them back into a review round instead of waiting
|
|
1646
|
+
// out the stall budget and escalating the misleading "mergeability never settled". A BLOCKED
|
|
1647
|
+
// whose cause can't be read (checks unenumerable in token mode, or the threads probe
|
|
1648
|
+
// unreadable) stays conservative: wait. A BLOCKED with green checks and zero unresolved
|
|
1649
|
+
// threads (a required human review) also keeps waiting here — the merge-stall escalation
|
|
1650
|
+
// names that specific cause once the re-poll budget is exhausted.
|
|
1651
|
+
switch (classifyBlockedCause(s)) {
|
|
1652
|
+
case "checks":
|
|
1653
|
+
return "blocked";
|
|
1654
|
+
case "review-threads":
|
|
1655
|
+
return "review-threads";
|
|
1656
|
+
default:
|
|
1657
|
+
return "waiting";
|
|
1658
|
+
}
|
|
1507
1659
|
default: // UNKNOWN / "" — GitHub is still computing mergeability
|
|
1508
1660
|
return "waiting";
|
|
1509
1661
|
}
|
|
@@ -80,7 +80,7 @@ test("with humanApproval a converged PR parks for a human, and approve finalizes
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
test("request changes sends the guidance to the review agent on the same PR, then asks again", async () => {
|
|
83
|
-
const { engine, answers } = await boot({ humanApproval: true });
|
|
83
|
+
const { engine, answers, processInstanceKey } = await boot({ humanApproval: true });
|
|
84
84
|
const first = await openApproval(engine);
|
|
85
85
|
assert(first !== undefined, "the approval task is open");
|
|
86
86
|
await engine.completeUserTask(first, { mergeDecision: "revise", answer: "Rename foo to bar." });
|
|
@@ -92,6 +92,10 @@ test("request changes sends the guidance to the review agent on the same PR, the
|
|
|
92
92
|
assertEquals(completions(engine, "persist-converged"), 0);
|
|
93
93
|
const second = await openApproval(engine);
|
|
94
94
|
assert(second !== undefined && second !== first, "approval is asked again after the revision");
|
|
95
|
+
// A revise is NOT an approval: the gate must stay set, so /status keeps reporting
|
|
96
|
+
// `blockedBy: "human-approval"` while the re-asked approval is outstanding (PR #917 review).
|
|
97
|
+
const [row] = await engine.searchVariables({ processInstanceKey: String(processInstanceKey), name: "humanApproval" });
|
|
98
|
+
assertEquals(row ? JSON.parse(row.value) : undefined, true, "a revise keeps the human-approval gate set");
|
|
95
99
|
});
|
|
96
100
|
|
|
97
101
|
test("a live ungated loop narrowed to humanApproval (a gated submitPr adopting it) parks for approval", async () => {
|
|
@@ -113,6 +117,21 @@ test("fail-closed: a completion without an explicit approve never merges", async
|
|
|
113
117
|
assertEquals(completions(engine, "persist-converged"), 0);
|
|
114
118
|
});
|
|
115
119
|
|
|
120
|
+
test("an approve clears the humanApproval gate while finalization is still pending (PR #917 review)", async () => {
|
|
121
|
+
// The /status merge projection derives `blockedBy: "human-approval"` from the loop's
|
|
122
|
+
// `humanApproval` variable alone, so an approved PR whose `pr.finalize` handoff is still queued
|
|
123
|
+
// (or incidented) would otherwise keep reporting a human-approval blocker even though the
|
|
124
|
+
// approval is complete. Hold `pr.finalize` so the loop stays ACTIVE after the approve, then read
|
|
125
|
+
// the variable: it must be cleared by the approval itself, not by some later task.
|
|
126
|
+
const { engine, processInstanceKey } = await boot({ humanApproval: true }, ["pr.finalize"]);
|
|
127
|
+
const key = await openApproval(engine);
|
|
128
|
+
assert(key !== undefined, "the approval task is open");
|
|
129
|
+
await engine.completeUserTask(key, { mergeDecision: "approve" });
|
|
130
|
+
loop(engine).isActive().hasNoIncident();
|
|
131
|
+
const [row] = await engine.searchVariables({ processInstanceKey: String(processInstanceKey), name: "humanApproval" });
|
|
132
|
+
assertEquals(row ? JSON.parse(row.value) : undefined, false, "approval complete — the blocker must clear");
|
|
133
|
+
});
|
|
134
|
+
|
|
116
135
|
test("merge-approval is human-only, and its form requires guidance only for request changes", () => {
|
|
117
136
|
assert(HUMAN_COMPLETABLE_ELEMENTS.has(PR_MERGE_APPROVAL_ELEMENT), "the human completer accepts it");
|
|
118
137
|
assert(!ESCALATION_TASK_ELEMENTS.has(PR_MERGE_APPROVAL_ELEMENT), "an agent must never approve a merge");
|