@nanobpm/nano-workforce 0.187.6 → 0.188.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -0
- package/SPEC.md +69 -21
- package/app/contracts.ts +8 -0
- package/app/convergeAutoAck.test.ts +252 -0
- package/app/convergeGate.test.ts +236 -5
- package/app/convergeGate.ts +41 -5
- package/app/github.ts +47 -13
- package/app/service.test.ts +40 -1
- package/app/service.ts +19 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +56 -22
- package/test/derivation-parity/README.md +5 -2
- package/test/derivation-parity/derivation-parity.test.ts +10 -8
- package/test/derivation-parity/flows.ts +6 -4
- package/workers/converge-gate/worker.ts +42 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.188.0](https://github.com/nanobpm/nano-workforce/compare/v0.187.6...v0.188.0) (2026-09-17)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **convergence:** bounded agent auto-ack before human escalation on ack-only converge blocks ([#800](https://github.com/nanobpm/nano-workforce/issues/800)) ([be0f7a2](https://github.com/nanobpm/nano-workforce/commit/be0f7a2f04ef768a2acdfdf229131e7158834026)), closes [#789](https://github.com/nanobpm/nano-workforce/issues/789) [Magikcraft/nano-bpm#1199](https://github.com/Magikcraft/nano-bpm/issues/1199) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#787](https://github.com/nanobpm/nano-workforce/issues/787) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [787/#796](https://github.com/787/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [787/#796](https://github.com/787/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796)
|
|
6
|
+
|
|
1
7
|
## [0.187.6](https://github.com/nanobpm/nano-workforce/compare/v0.187.5...v0.187.6) (2026-09-16)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
package/README.md
CHANGED
|
@@ -360,6 +360,7 @@ agent at that URL to author, compile, and submit a graph unaided. See
|
|
|
360
360
|
| `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
|
|
361
361
|
| `NANO_PR_MERGE_ADMIN` | `0` | pass `--admin` to override failing non-required checks (use with care) |
|
|
362
362
|
| `NANO_PR_MAX_CI_FIX_ROUNDS` | `3` | max `senior:fix-ci` attempts to green a `blocked` PR before escalating; `0` disables (escalate immediately), clamped 0–20 |
|
|
363
|
+
| `NANO_PR_MAX_ACK_RETRIES` | `2` | max bounded review-round re-dispatches to recover an ack-only converge block — unacknowledged suppressed advisories and/or unresolved canonical `nano-ack:` threads (`app/convergeGate.ts` `ackOnly`) — before escalating to a human (#796); `0` escalates on the first ack-only block, clamped 0–20 |
|
|
363
364
|
| `NANO_PR_REVIEW_WAIT_TIMEOUT` | `PT30M` | ISO-8601 duration the loop waits for a fresh review before escalating a stalled review (timer arm of the `wait-review` gateway) |
|
|
364
365
|
| `NANO_PR_REVIEW_NUDGE_MINUTES` | `5` | cooldown between the poller's automatic reviewer re-request nudges for one waiting PR (clamped 1–1440) |
|
|
365
366
|
| `NANO_WORKFORCE_BASE_URL` | `http://localhost:3000` | externally-reachable base URL for the capability hooks (`/app/api/hooks/*`). Must resolve from **wherever the agent runs** — set it to the app's LAN address (or console-proxy URL) for a remote fleet. See [Fleet networking](#fleet-networking-remote-workers) |
|
package/SPEC.md
CHANGED
|
@@ -99,27 +99,36 @@ known at submit time, carried as a process variable and stored on the DB row.
|
|
|
99
99
|
│ │
|
|
100
100
|
│ ▼
|
|
101
101
|
│ <gateway: status>
|
|
102
|
-
│ ├── converged → [
|
|
103
|
-
│ │
|
|
104
|
-
│ ├── addressed
|
|
102
|
+
│ ├── converged → [Check review comments] (pr.converge-gate; ++ackRetryRound on ack-only block)
|
|
103
|
+
│ │ → <gateway: comments addressed?>
|
|
104
|
+
│ │ ├── addressed → [Scope classifier] → … → [Mark converged] → (end)
|
|
105
|
+
│ │ ├── review stale (#799) → [Record round] (re-solicit fresh review) ┐
|
|
106
|
+
│ │ └── unaddressed → <gateway: auto-ack within budget?> │
|
|
107
|
+
│ │ ├── convergeAckOnly and ackRetryRound ≤ ackRetryMax │
|
|
108
|
+
│ │ │ → re-dispatch review-round (round unchanged) ───────────┤
|
|
109
|
+
│ │ └── unresolved thread / budget exhausted │
|
|
110
|
+
│ │ → [Escalate: unaddressed comments] (blocked) │
|
|
111
|
+
│ │ → [Wait: wait-answer userTask] ────────────────────────────┤
|
|
112
|
+
│ │ │
|
|
113
|
+
│ ├── addressed → [Record round] → [Check progress] (did the PR head advance?) │
|
|
105
114
|
│ │ ├── progressed → <guard: round ≥ maxRounds → escalate "not converged"> │
|
|
106
115
|
│ │ │ → <event-based gateway: review ready or timeout?> │
|
|
107
|
-
│ │ │ ├── readiness-ready (msg catch, key = prKey) → round++ ─┐
|
|
108
|
-
│ │ │ └── =reviewWaitTimeout (timer catch)
|
|
109
|
-
│ │ │ → [Escalate: review stalled] (blocked) │
|
|
110
|
-
│ │ │ → [Wait: wait-answer userTask] ────────────────────┤
|
|
111
|
-
│ │ └── no progress → <husk? no commit AND no terminal instance> │
|
|
116
|
+
│ │ │ ├── readiness-ready (msg catch, key = prKey) → round++ ─┐ │
|
|
117
|
+
│ │ │ └── =reviewWaitTimeout (timer catch) │ │
|
|
118
|
+
│ │ │ → [Escalate: review stalled] (blocked) │ │
|
|
119
|
+
│ │ │ → [Wait: wait-answer userTask] ────────────────────┤ │
|
|
120
|
+
│ │ └── no progress → <husk? no commit AND no terminal instance> │ │
|
|
112
121
|
│ │ ├── husk & retries < MAX → re-enter [Review round] (bypasses the round-cap guard) │
|
|
113
|
-
│ │ └── no-advance / husk cap → [Escalate: no progress] │
|
|
114
|
-
│ │ → [Wait: wait-answer userTask] ───────────────────┤
|
|
115
|
-
│ │ │
|
|
116
|
-
│ └── needs_input [Record escalation] │ │
|
|
117
|
-
│ or blocked → (kind = question | blocker) │ │
|
|
118
|
-
│ → [Wait: wait-answer userTask] │ │
|
|
119
|
-
│ → [record-answer: pr.answer-escalation] │ │
|
|
120
|
-
│ → set answer ──────────────────────────────┤ │
|
|
121
|
-
│ │ │
|
|
122
|
-
|
|
122
|
+
│ │ └── no-advance / husk cap → [Escalate: no progress] │ │
|
|
123
|
+
│ │ → [Wait: wait-answer userTask] ───────────────────┤ │
|
|
124
|
+
│ │ │ │
|
|
125
|
+
│ └── needs_input [Record escalation] │ │ │
|
|
126
|
+
│ or blocked → (kind = question | blocker) │ │ │
|
|
127
|
+
│ → [Wait: wait-answer userTask] │ │ │
|
|
128
|
+
│ → [record-answer: pr.answer-escalation] │ │ │
|
|
129
|
+
│ → set answer ──────────────────────────────┤ │ │
|
|
130
|
+
│ │ │ │
|
|
131
|
+
└────────────────────────────────────────────────────────────────────┴───────────────┴────────────┘
|
|
123
132
|
|
|
124
133
|
Both `needs_input` (the agent has a question) and `blocked` (the agent is stuck
|
|
125
134
|
on something external — auth, a failing push, a missing secret) route to the
|
|
@@ -145,9 +154,14 @@ Notes:
|
|
|
145
154
|
- **Convergence comment-gate + stale-review re-solicitation (issue #799).** The
|
|
146
155
|
agent's self-reported `converged` does not finalize directly: it first runs the
|
|
147
156
|
deterministic `pr.converge-gate` (`check-converge` → `gw-converge-gate`). That
|
|
148
|
-
gate **blocks** convergence (`convergeBlocked = true`
|
|
149
|
-
|
|
150
|
-
|
|
157
|
+
gate **blocks** convergence (`convergeBlocked = true`) while any review thread
|
|
158
|
+
is unresolved or any suppressed advisory lacks a resolved `nano-ack:` thread.
|
|
159
|
+
A block whose *sole* outstanding items are unresolved `nano-ack:` threads is
|
|
160
|
+
classified **ack-only** and does **not** immediately escalate to a human:
|
|
161
|
+
within the `ackRetryMax` budget the bounded auto-ack retry re-dispatches
|
|
162
|
+
`review-round` (round unchanged) to finish the acknowledgements; only a
|
|
163
|
+
substantive unresolved thread — or an exhausted ack-retry budget — escalates
|
|
164
|
+
("unaddressed comments"). Otherwise the gate proceeds to the scope
|
|
151
165
|
classifier and finalizes. A third arm handles a **stale review** — one whose
|
|
152
166
|
`commit_id` predates the PR's current HEAD (its advisories describe code the
|
|
153
167
|
head has moved past, e.g. an advisory already fixed in a later commit). Rather
|
|
@@ -182,6 +196,40 @@ Notes:
|
|
|
182
196
|
backstop when even repeated nudges fail.
|
|
183
197
|
- On `needs_input`, the same `round` is retried after the answer (the answer is
|
|
184
198
|
added to the agent's context; the round number does not advance).
|
|
199
|
+
- On `converged`, the run does **not** finalize blindly: it first runs the
|
|
200
|
+
deterministic **converge gate** (`pr.converge-gate`, `Check review comments`),
|
|
201
|
+
which re-reads GitHub and re-blocks (`convergeBlocked=true`) while **any
|
|
202
|
+
substantive** review thread is unresolved or **any** suppressed advisory lacks a
|
|
203
|
+
resolved `nano-ack:` thread. An unresolved `nano-ack:` **ack thread** is **never
|
|
204
|
+
dropped** from the gate — it is a genuinely-open GitHub thread, so it still
|
|
205
|
+
**blocks** convergence — but a block whose only open threads are unresolved acks
|
|
206
|
+
is classified **ack-only**: a partially-completed acknowledgement the bounded
|
|
207
|
+
auto-ack retry can finish (post-and-resolve), not a code-review finding, so it
|
|
208
|
+
stays on the recoverable path instead of escalating. (An ack thread is one whose
|
|
209
|
+
*root* comment carries a canonical `nano-ack: <path> :: <text>` marker; a
|
|
210
|
+
substantive reviewer finding never does, so it escalates. Because an unresolved
|
|
211
|
+
ack still blocks either way, this classification is **fail-closed**: even a
|
|
212
|
+
mislabelled root cannot finalize the gate with an open thread — worst case it
|
|
213
|
+
routes to the bounded ack-retry, which cannot ack a non-advisory and so escalates
|
|
214
|
+
to a human on exhaustion.)
|
|
215
|
+
A block whose SOLE cause is unacknowledged suppressed
|
|
216
|
+
advisories or unresolved ack threads (no unresolved *substantive* thread) is
|
|
217
|
+
flagged **ack-only**
|
|
218
|
+
(`convergeAckOnly=true`) and is routine + recoverable: rather than pulling a
|
|
219
|
+
human in first, the loop makes a **bounded auto-ack re-dispatch** of
|
|
220
|
+
`review-round` — up to `ackRetryMax` times, advancing `ackRetryRound` on each
|
|
221
|
+
ack-only block (seeded from `NANO_PR_MAX_ACK_RETRIES`). It escalates to the human
|
|
222
|
+
`wait-answer` only when the block is **not** ack-only (an unresolved inline
|
|
223
|
+
thread), or the budget is exhausted. Both counters are process variables.
|
|
224
|
+
- **Contested advisory → human is via the agent's `needs_input`, not a decline
|
|
225
|
+
(#787 / #796).** A resolved `Declined, false positive. nano-ack: …` thread is a
|
|
226
|
+
*considered agent adjudication* and, by design (#787), keeps the advisory
|
|
227
|
+
acknowledged so the gate **converges** — a stateless gate cannot re-block a
|
|
228
|
+
decline without re-introducing the #787 per-round-escalation livelock. The
|
|
229
|
+
"genuinely contested advisory surfaces to a human" path of #796 is reached when
|
|
230
|
+
the (re-dispatched) agent cannot decide and returns **`needs_input`** — that
|
|
231
|
+
routes through the normal status-escalation arm to `wait-answer`. Decline =
|
|
232
|
+
agent-adjudicated → converge; `needs_input` = agent defers → human.
|
|
185
233
|
- **No-progress guard + husk classification (issue #786).** Before the review
|
|
186
234
|
wait, an `addressed` round passes through `pr.progress-check`
|
|
187
235
|
(`workers/progress-check/worker.ts`, mirrored by `app/roundProgress.ts`): it
|
package/app/contracts.ts
CHANGED
|
@@ -132,6 +132,14 @@ export const ENV_CONTRACTS = {
|
|
|
132
132
|
semantics: "Maximum transient base/head-moved merge-race retries per PR before escalating.",
|
|
133
133
|
default: "5",
|
|
134
134
|
},
|
|
135
|
+
NANO_PR_MAX_ACK_RETRIES: {
|
|
136
|
+
category: "env",
|
|
137
|
+
name: "NANO_PR_MAX_ACK_RETRIES",
|
|
138
|
+
owner: "app/service.ts",
|
|
139
|
+
semantics:
|
|
140
|
+
"Maximum bounded review-round re-dispatches the convergence loop makes to auto-ack unacked suppressed advisories before escalating to a human (#796); 0 escalates on the first ack-only block.",
|
|
141
|
+
default: "2",
|
|
142
|
+
},
|
|
135
143
|
NANO_PR_MAX_MERGE_STALL_ROUNDS: {
|
|
136
144
|
category: "env",
|
|
137
145
|
name: "NANO_PR_MAX_MERGE_STALL_ROUNDS",
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Behavioural coverage for the bounded agent auto-ack step on the converge-gate-blocked path
|
|
2
|
+
// (issue #796).
|
|
3
|
+
//
|
|
4
|
+
// The converge-gate fails CLOSED and used to route EVERY block straight to the human `wait-answer`.
|
|
5
|
+
// But a block whose SOLE cause is unacknowledged Copilot *suppressed advisories* (no unresolved
|
|
6
|
+
// inline threads) is routine and recoverable: simply re-dispatching the review-round agent posts the
|
|
7
|
+
// missing `nano-ack:` threads and converges. Pulling a human in as the FIRST recovery — before any
|
|
8
|
+
// bounded agent retry — is the defect (#789/#1199 on merlin parked a human for exactly this).
|
|
9
|
+
//
|
|
10
|
+
// The fix models a bounded auto-ack retry (mirroring the merge loop's `gw-merge-retry`): an ack-only
|
|
11
|
+
// block re-enters `review-round` up to `ackRetryMax` times, advancing `ackRetryRound` on each
|
|
12
|
+
// ack-only block, and only escalates to the human when the block is NOT ack-only (an unresolved
|
|
13
|
+
// inline thread), the re-dispatched agent needs input, or the budget is exhausted.
|
|
14
|
+
//
|
|
15
|
+
// These deploy the committed convergence-loop into the real WASM engine (`@nanobpm/urban-testkit`)
|
|
16
|
+
// and drive tokens through it, asserting the observable invariant (completed elements, budget
|
|
17
|
+
// counter, terminal state, escalation user task) — protecting what the loop DOES, not how it is drawn.
|
|
18
|
+
import { after, test } from "node:test";
|
|
19
|
+
import { assert } from "#test-assert";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { assertThatInstance, byProcessId, createWasmEngineClient, type WasmEngineClient } from "@nanobpm/urban-testkit";
|
|
22
|
+
|
|
23
|
+
const MODEL = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
24
|
+
|
|
25
|
+
type Output = Record<string, unknown>;
|
|
26
|
+
type Responder = Output | Output[] | ((job: { variables: Record<string, unknown> }) => Output);
|
|
27
|
+
|
|
28
|
+
const ALL_JOB_TYPES = [
|
|
29
|
+
"senior:pr-review",
|
|
30
|
+
"pr.capture-head",
|
|
31
|
+
"pr.persist-round",
|
|
32
|
+
"pr.progress-check",
|
|
33
|
+
"pr.converge-gate",
|
|
34
|
+
"senior:scope-classify",
|
|
35
|
+
"pr.finalize",
|
|
36
|
+
"pr.persist-escalation",
|
|
37
|
+
"pr.answer-escalation",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
const DEFAULT_RESPONSES: Record<string, Responder> = {
|
|
41
|
+
"senior:pr-review": { status: "converged", summary: "done" },
|
|
42
|
+
"pr.capture-head": { roundEntryHead: "sha-entry" },
|
|
43
|
+
"pr.persist-round": {},
|
|
44
|
+
"pr.progress-check": { progressed: true },
|
|
45
|
+
"senior:scope-classify": { scopeBlocked: false, scopeBlockReason: "" },
|
|
46
|
+
"pr.finalize": {},
|
|
47
|
+
"pr.persist-escalation": { escalated: true },
|
|
48
|
+
"pr.answer-escalation": {},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Every FEEL expression the model touches, seeded so a missing-variable access can never raise a
|
|
52
|
+
// spurious incident. `ackRetryMax` is the budget under test; overridden per scenario.
|
|
53
|
+
const DEFAULT_VARS: Record<string, unknown> = {
|
|
54
|
+
prKey: "o/r#1",
|
|
55
|
+
repo: "o/r",
|
|
56
|
+
prNumber: 1,
|
|
57
|
+
prUrl: "https://example.test/pr/1",
|
|
58
|
+
round: 1,
|
|
59
|
+
maxRounds: 20,
|
|
60
|
+
reviewWaitTimeout: "PT30M",
|
|
61
|
+
ackRetryRound: 0,
|
|
62
|
+
ackRetryMax: 2,
|
|
63
|
+
abandonBrief: null,
|
|
64
|
+
status: null,
|
|
65
|
+
question: null,
|
|
66
|
+
summary: null,
|
|
67
|
+
answer: null,
|
|
68
|
+
scopePending: false,
|
|
69
|
+
scopeAnswer: null,
|
|
70
|
+
convergeBlocked: null,
|
|
71
|
+
convergeBlockReason: null,
|
|
72
|
+
convergeAckOnly: null,
|
|
73
|
+
reviewStale: null,
|
|
74
|
+
roundEntryHead: null,
|
|
75
|
+
huskRetries: 0,
|
|
76
|
+
progressed: true,
|
|
77
|
+
escalated: null,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
async function startLoop(opts: {
|
|
81
|
+
responses?: Record<string, Responder | null>;
|
|
82
|
+
vars?: Record<string, unknown>;
|
|
83
|
+
} = {}): Promise<WasmEngineClient> {
|
|
84
|
+
const engine = await createWasmEngineClient();
|
|
85
|
+
await engine.deployResources([{ name: "convergence-loop.bpmn", content: MODEL, contentType: "text/xml" }]);
|
|
86
|
+
const responses: Record<string, Responder | null> = { ...DEFAULT_RESPONSES, ...(opts.responses ?? {}) };
|
|
87
|
+
for (const jobType of ALL_JOB_TYPES) {
|
|
88
|
+
const responder = jobType in responses ? responses[jobType] : undefined;
|
|
89
|
+
if (responder === null) continue; // park the token
|
|
90
|
+
const queue = Array.isArray(responder) ? [...responder] : null;
|
|
91
|
+
await engine.registerWorker(jobType, (job) => {
|
|
92
|
+
if (queue) return queue.length > 1 ? queue.shift()! : (queue[0] ?? {});
|
|
93
|
+
if (typeof responder === "function") return responder(job as { variables: Record<string, unknown> });
|
|
94
|
+
return (responder as Output | undefined) ?? {};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
await engine.createInstance({
|
|
98
|
+
processDefinitionId: "convergence-loop",
|
|
99
|
+
awaitCompletion: false,
|
|
100
|
+
variables: { ...DEFAULT_VARS, ...(opts.vars ?? {}) },
|
|
101
|
+
});
|
|
102
|
+
return engine;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Element ids completed by the single instance. */
|
|
106
|
+
function completedElementIds(engine: WasmEngineClient): Set<string> {
|
|
107
|
+
const snap = engine.snapshot() as { elementStats?: { elementId: string; completed: number }[] };
|
|
108
|
+
return new Set((snap.elementStats ?? []).filter((s) => s.completed > 0).map((s) => s.elementId));
|
|
109
|
+
}
|
|
110
|
+
/** The number of times an element completed (review-round runs multiple times across retries). */
|
|
111
|
+
function completions(engine: WasmEngineClient, elementId: string): number {
|
|
112
|
+
const snap = engine.snapshot() as { elementStats?: { elementId: string; completed: number }[] };
|
|
113
|
+
return (snap.elementStats ?? []).find((s) => s.elementId === elementId)?.completed ?? 0;
|
|
114
|
+
}
|
|
115
|
+
function instanceVars(engine: WasmEngineClient): Record<string, unknown> {
|
|
116
|
+
const snap = engine.snapshot() as { instances?: { variables?: Record<string, unknown> }[] };
|
|
117
|
+
return snap.instances?.[0]?.variables ?? {};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function asReadModelApp(engine: WasmEngineClient): WasmEngineClient {
|
|
121
|
+
(engine as unknown as { engine: WasmEngineClient }).engine = engine;
|
|
122
|
+
return engine;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const engines: WasmEngineClient[] = [];
|
|
126
|
+
async function boot(opts?: Parameters<typeof startLoop>[0]): Promise<WasmEngineClient> {
|
|
127
|
+
const engine = asReadModelApp(await startLoop(opts));
|
|
128
|
+
engines.push(engine);
|
|
129
|
+
return engine;
|
|
130
|
+
}
|
|
131
|
+
after(async () => {
|
|
132
|
+
await Promise.all(engines.map((e) => e.close()));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
async function openWaitAnswer(engine: WasmEngineClient): Promise<boolean> {
|
|
136
|
+
const tasks = await engine.searchUserTasks({});
|
|
137
|
+
return tasks.some((t) => t.elementId === "wait-answer");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Acceptance: an ack-only block auto-acks and converges WITHOUT a human ─────
|
|
141
|
+
|
|
142
|
+
test("an ack-only converge block re-dispatches the review-round agent and converges — no human", async () => {
|
|
143
|
+
// First gate call: blocked SOLELY on unacked advisories (ack-only). After the review-round agent
|
|
144
|
+
// is re-dispatched (auto-ack), the second gate call clears.
|
|
145
|
+
const engine = await boot({
|
|
146
|
+
responses: {
|
|
147
|
+
"pr.converge-gate": [
|
|
148
|
+
{ convergeBlocked: true, convergeBlockReason: "1 unacknowledged suppressed advisory", convergeAckOnly: true },
|
|
149
|
+
{ convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).hasCompleted().hasNoIncident();
|
|
154
|
+
// The instance finalized without parking a human escalation.
|
|
155
|
+
assert(!(await openWaitAnswer(engine)), "no wait-answer user task must be opened on the auto-ack path");
|
|
156
|
+
// The review-round agent ran twice: the initial round + the one bounded auto-ack re-dispatch.
|
|
157
|
+
assert(completions(engine, "review-round") === 2, `review-round should run twice; ran ${completions(engine, "review-round")}`);
|
|
158
|
+
// (A COMPLETED instance's variables are folded away — assert the retry via the element run count
|
|
159
|
+
// above and the terminal converged element below, not a post-completion ackRetryRound read.)
|
|
160
|
+
assert(completedElementIds(engine).has("persist-converged"), "the PR must finalize as converged");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── Acceptance: a NON-ack-only block (unresolved thread) still escalates ──────
|
|
164
|
+
|
|
165
|
+
test("a block with an unresolved inline thread is NOT ack-only and escalates to a human as before", async () => {
|
|
166
|
+
const engine = await boot({
|
|
167
|
+
responses: {
|
|
168
|
+
"pr.converge-gate": { convergeBlocked: true, convergeBlockReason: "1 unresolved review thread", convergeAckOnly: false },
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
172
|
+
assert(await openWaitAnswer(engine), "a non-ack-only block must park the human wait-answer");
|
|
173
|
+
// It never re-dispatched the agent for an auto-ack — the review-round ran only its initial round.
|
|
174
|
+
assert(completions(engine, "review-round") === 1, "a non-ack-only block must not auto-ack-retry");
|
|
175
|
+
assert(instanceVars(engine).ackRetryRound === 0, "a non-ack-only block must not consume the ack-retry budget");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── Acceptance: the auto-ack pass is BOUNDED — exhausting the budget escalates ─
|
|
179
|
+
|
|
180
|
+
test("an ack-only block with a zero budget escalates to a human on the first block (bounded)", async () => {
|
|
181
|
+
const engine = await boot({
|
|
182
|
+
vars: { ackRetryMax: 0 },
|
|
183
|
+
responses: {
|
|
184
|
+
"pr.converge-gate": { convergeBlocked: true, convergeBlockReason: "1 unacknowledged suppressed advisory", convergeAckOnly: true },
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
188
|
+
assert(await openWaitAnswer(engine), "an exhausted ack-retry budget must escalate to the human");
|
|
189
|
+
assert(completedElementIds(engine).has("persist-escalation-blockedcomments"), "escalation must be the blocked-comments arm");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── Acceptance: a POSITIVE budget is exhausted — max-1 re-dispatches once, then escalates ─
|
|
193
|
+
|
|
194
|
+
test("an ack-only block with a max-1 budget re-dispatches EXACTLY once, then escalates on the second block", async () => {
|
|
195
|
+
// Two CONSECUTIVE ack-only blocks with a budget of 1. This is the positive-budget exhaustion the
|
|
196
|
+
// max-2 (converges after one block) and max-0 (escalates on the first block) cases never exercise:
|
|
197
|
+
// it proves the `ackRetryRound <= ackRetryMax` gate performs exactly ONE re-dispatch and then, on
|
|
198
|
+
// the SECOND ack-only block, routes to the human — catching an off-by-one in the `<=` / counter.
|
|
199
|
+
const engine = await boot({
|
|
200
|
+
vars: { ackRetryMax: 1 },
|
|
201
|
+
responses: {
|
|
202
|
+
"pr.converge-gate": [
|
|
203
|
+
{ convergeBlocked: true, convergeBlockReason: "1 unacknowledged suppressed advisory", convergeAckOnly: true },
|
|
204
|
+
{ convergeBlocked: true, convergeBlockReason: "1 unacknowledged suppressed advisory", convergeAckOnly: true },
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
209
|
+
assert(await openWaitAnswer(engine), "a max-1 budget must escalate on the SECOND ack-only block");
|
|
210
|
+
assert(
|
|
211
|
+
completedElementIds(engine).has("persist-escalation-blockedcomments"),
|
|
212
|
+
"escalation must be the blocked-comments arm",
|
|
213
|
+
);
|
|
214
|
+
// Exactly one re-dispatch: the initial round + one auto-ack retry = two review-round runs (not three).
|
|
215
|
+
assert(
|
|
216
|
+
completions(engine, "review-round") === 2,
|
|
217
|
+
`review-round should run exactly twice; ran ${completions(engine, "review-round")}`,
|
|
218
|
+
);
|
|
219
|
+
// The budget counter advanced once per ack-only block (0→1→2), landing PAST the max (1) — the exact
|
|
220
|
+
// condition that flipped `ackRetryRound <= ackRetryMax` false on the second block. The instance is
|
|
221
|
+
// still ACTIVE (parked on the human), so its variables are readable (a COMPLETED instance folds them).
|
|
222
|
+
assert(
|
|
223
|
+
instanceVars(engine).ackRetryRound === 2,
|
|
224
|
+
`ackRetryRound should be 2 at the escalation; was ${instanceVars(engine).ackRetryRound}`,
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ── Acceptance: a re-dispatched agent that needs input surfaces to a human ────
|
|
229
|
+
//
|
|
230
|
+
// This is the ONLY path a genuinely-contested advisory reaches a human (#796 reconciled with #787):
|
|
231
|
+
// the "contested → human" trigger is the agent returning `needs_input` (it cannot decide), NOT the
|
|
232
|
+
// agent posting a reasoned `Declined, false positive. nano-ack: …`. A resolved decline is an agent
|
|
233
|
+
// adjudication that converges by design (#787) — a stateless converge-gate cannot re-block a decline
|
|
234
|
+
// without re-introducing the #787 per-round-escalation livelock. Decline = adjudicated → converge;
|
|
235
|
+
// needs_input = deferred → human.
|
|
236
|
+
|
|
237
|
+
test("a contested advisory — the re-dispatched agent returns needs_input — surfaces to a human", async () => {
|
|
238
|
+
const engine = await boot({
|
|
239
|
+
responses: {
|
|
240
|
+
"senior:pr-review": [
|
|
241
|
+
{ status: "converged", summary: "round 1" },
|
|
242
|
+
{ status: "needs_input", summary: "contested", question: "Is this advisory a real defect?" },
|
|
243
|
+
],
|
|
244
|
+
"pr.converge-gate": { convergeBlocked: true, convergeBlockReason: "1 unacknowledged suppressed advisory", convergeAckOnly: true },
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
248
|
+
assert(await openWaitAnswer(engine), "a re-dispatched agent that needs input must reach the human wait-answer");
|
|
249
|
+
// It DID try the bounded auto-ack once (the second review-round run), then the agent's needs_input
|
|
250
|
+
// verdict routed it to the human via the normal status escalation arm.
|
|
251
|
+
assert(completions(engine, "review-round") === 2, `review-round should run twice; ran ${completions(engine, "review-round")}`);
|
|
252
|
+
});
|