@nanobpm/nano-workforce 0.114.0 → 0.115.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 +14 -0
- package/README.md +44 -1
- package/app/agentic/vocab/demand-report.ts +7 -9
- package/app/convergeGate.test.ts +5 -2
- package/app/enginePreflight.test.ts +122 -0
- package/app/enginePreflight.ts +134 -0
- package/app/persist-escalation.test.ts +33 -0
- package/app/scopeGuard.test.ts +38 -0
- package/app/scopeGuard.ts +34 -0
- package/db/migrations/056_escalation_head_override.sql +23 -0
- package/main.ts +13 -6
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +6 -0
- package/workers/converge-gate/worker.test.ts +116 -0
- package/workers/converge-gate/worker.ts +112 -4
- package/workers/persist-escalation/worker.ts +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.115.0](https://github.com/nanobpm/nano-workforce/compare/v0.114.1...v0.115.0) (2026-08-20)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **engine:** startup preflight + canonical engine address resolution ([#391](https://github.com/nanobpm/nano-workforce/issues/391)) ([#404](https://github.com/nanobpm/nano-workforce/issues/404)) ([16fcb71](https://github.com/nanobpm/nano-workforce/commit/16fcb711f190341f03fd84914fa66c1ee704b78c)), closes [Magikcraft/nano-bpm#940](https://github.com/Magikcraft/nano-bpm/issues/940)
|
|
7
|
+
|
|
8
|
+
## [0.114.1](https://github.com/nanobpm/nano-workforce/compare/v0.114.0...v0.114.1) (2026-08-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **convergence-loop:** give the scope-integrity gate a human-override door ([#395](https://github.com/nanobpm/nano-workforce/issues/395)) ([#401](https://github.com/nanobpm/nano-workforce/issues/401)) ([23f8354](https://github.com/nanobpm/nano-workforce/commit/23f83541c57b3474b36a92baa1d88bdd6535df9b))
|
|
14
|
+
|
|
1
15
|
# [0.114.0](https://github.com/nanobpm/nano-workforce/compare/v0.113.0...v0.114.0) (2026-08-20)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -252,7 +252,7 @@ active epic already targets the same custom base. See
|
|
|
252
252
|
|---|---|---|
|
|
253
253
|
| `PR_REVIEW_PORT` | `3000` | app HTTP port |
|
|
254
254
|
| `NANO_APP_DB_URL` | `file:./app.db` | sqlite datasource |
|
|
255
|
-
| `NANOBPMN_BASE_URL` | `http://localhost:8080` | engine base URL (or set `CAMUNDA_REST_ADDRESS` to the `/v2` REST address directly) |
|
|
255
|
+
| `NANOBPMN_BASE_URL` | `http://localhost:8080` | engine base URL (or set `CAMUNDA_REST_ADDRESS` to the `/v2` REST address directly). See [Engine address & the startup preflight](#engine-address--the-startup-preflight) |
|
|
256
256
|
| `GITHUB_TOKEN` | — | token for the review poller / merge (or use the host `gh` CLI) |
|
|
257
257
|
| `NANO_PR_GITHUB_TRANSPORT` | `auto` | how the poller reads GitHub: `gh` (host CLI), `token` (`GITHUB_TOKEN` over HTTP), or `auto` |
|
|
258
258
|
| `NANO_PR_POLL_MS` | `60000` | review-ready poll interval |
|
|
@@ -267,6 +267,49 @@ active epic already targets the same custom base. See
|
|
|
267
267
|
| `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) |
|
|
268
268
|
| `NANO_AGENTIC_SECRET` | — | enables **secure mode** for the agentic visibility channel (`/agentic`): every peer must present the **same** `NANO_AGENTIC_SECRET` value (set the identical env var on the server and every worker box — Tab A → Slot A). Unset = on-by-default **LOCAL mode** — the well-known token is honoured from **any origin** (open on the trusted LAN, matching the engine's posture); exposure is governed by the server bind address, not a shared secret. Also accepts `NANO_PR_WEBHOOK_SECRET` |
|
|
269
269
|
|
|
270
|
+
### Engine address & the startup preflight
|
|
271
|
+
|
|
272
|
+
The app talks to one engine over the Camunda 8 REST API. The REST address is
|
|
273
|
+
resolved with a fixed precedence — set **one** of:
|
|
274
|
+
|
|
275
|
+
1. **`CAMUNDA_REST_ADDRESS`** — used verbatim (it already points at the `/v2`
|
|
276
|
+
REST address, e.g. `http://engine.example:8080/v2`). **Wins** if set.
|
|
277
|
+
2. **`NANOBPMN_BASE_URL`** — the engine *base*; the app appends `/v2`
|
|
278
|
+
(e.g. `http://localhost:7000` → `http://localhost:7000/v2`).
|
|
279
|
+
3. Neither set → the base defaults to **`http://localhost:8080`**.
|
|
280
|
+
|
|
281
|
+
At boot the app **echoes the resolved address and which input it came from**,
|
|
282
|
+
then probes `/v2/topology` and announces **which engine answered** — so a
|
|
283
|
+
misconfigured address is obvious immediately instead of surfacing later as a
|
|
284
|
+
cryptic mid-run engine error:
|
|
285
|
+
|
|
286
|
+
```
|
|
287
|
+
Engine address: http://localhost:8080/v2 (from default (http://localhost:8080))
|
|
288
|
+
Engine: Nano engine (nanobpmn v0.114.1) — Falcon streaming at /falcon at http://localhost:8080/v2.
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The preflight is **informational, never a gate** — Nano Workforce runs against a
|
|
292
|
+
stock **Camunda 8** cluster too, so a non-Nano engine is announced
|
|
293
|
+
(`Engine: Camunda 8 (gateway v8.x) — REST only …`), not rejected. If nothing
|
|
294
|
+
answers, it logs a `warn` (`could not reach … features will fail to start until
|
|
295
|
+
the engine is reachable`) and boot continues. On a **secured** cluster the probe
|
|
296
|
+
sends `CAMUNDA_TOKEN` as a bearer credential; a `401/403` is reported as an auth
|
|
297
|
+
hint (check `CAMUNDA_TOKEN`), not as an unreachable engine.
|
|
298
|
+
|
|
299
|
+
> **Watch the port when launched from a console.** A console-launched app can
|
|
300
|
+
> default `NANOBPMN_BASE_URL` to `http://localhost:8080`. If **another Camunda 8**
|
|
301
|
+
> is already on `:8080`, the app will talk to *that* engine (it works — C8 is
|
|
302
|
+
> supported), which may not be the engine you intended. Check the startup
|
|
303
|
+
> `Engine:` line; to target a Nano engine on a different port, set
|
|
304
|
+
> `NANOBPMN_BASE_URL` (or `CAMUNDA_REST_ADDRESS`) explicitly.
|
|
305
|
+
>
|
|
306
|
+
> When the app *can't* work against the reached engine you'll see a job/instance
|
|
307
|
+
> decode error at first feature start — `MalformedFrameError` / `MalformedJobError`
|
|
308
|
+
> (Falcon) or the Camunda REST client's own `4xx` (older versions surfaced
|
|
309
|
+
> `engine response missing processInstanceKey/key`). The startup `Engine:` line
|
|
310
|
+
> tells you which engine you actually reached, before any such error.
|
|
311
|
+
|
|
312
|
+
|
|
270
313
|
### Fleet networking (remote workers)
|
|
271
314
|
|
|
272
315
|
`nano-workforce` can drive a **distributed worker fleet** — `senior:*` agents running on other LAN
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import type { RegisteredWorker } from "@nanobpm/agentic/vocab";
|
|
21
21
|
import type { Logger } from "@nanobpm/urban";
|
|
22
22
|
import type { RegistryReport as WireRegistryReport } from "../../../nano-generated/api-io.d.ts";
|
|
23
|
+
import { resolveEngineAddress } from "../../enginePreflight.ts";
|
|
23
24
|
import { envVar } from "../../version.ts";
|
|
24
25
|
import { currentPresenceRegistry } from "../families/presence.family.ts";
|
|
25
26
|
import { CREW_VOCAB_VERSION, crewResolver } from "./crew-vocab.ts";
|
|
@@ -40,17 +41,14 @@ export interface RegistryReport extends DemandSupplyReport {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
* Derive the engine's C8 v2 REST base the demand reader targets
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* (
|
|
47
|
-
*
|
|
44
|
+
* Derive the engine's C8 v2 REST base the demand reader targets. Delegates to
|
|
45
|
+
* the canonical {@link resolveEngineAddress} (the single source of truth shared
|
|
46
|
+
* with `main.ts`) so the precedence — explicit `CAMUNDA_REST_ADDRESS` wins, else
|
|
47
|
+
* `NANOBPMN_BASE_URL` (+ `/v2`), defaulting to `http://localhost:8080/v2` — lives
|
|
48
|
+
* in one place and cannot drift between call sites.
|
|
48
49
|
*/
|
|
49
50
|
export function engineRestAddress(): string {
|
|
50
|
-
|
|
51
|
-
if (explicit) return explicit;
|
|
52
|
-
const base = (envVar("NANOBPMN_BASE_URL") ?? "http://localhost:8080").replace(/\/+$/, "");
|
|
53
|
-
return `${base}/v2`;
|
|
51
|
+
return resolveEngineAddress().restAddress;
|
|
54
52
|
}
|
|
55
53
|
|
|
56
54
|
/** The live supply rows (`{ instance, capability }`) from the H1 presence registry, or none when unmounted. */
|
package/app/convergeGate.test.ts
CHANGED
|
@@ -218,11 +218,14 @@ async function makeUnderTest(deps: {
|
|
|
218
218
|
readThreads: (repo: string, n: number) => Promise<ReviewThread[] | null>;
|
|
219
219
|
readReviewBody: (repo: string, n: number) => Promise<string | null>;
|
|
220
220
|
readPrBody?: (repo: string, n: number) => Promise<string | null>;
|
|
221
|
+
readHeadSha?: (repo: string, n: number) => Promise<string | null>;
|
|
221
222
|
}) {
|
|
222
223
|
const { makeHandler } = await import("../workers/converge-gate/worker.ts");
|
|
223
224
|
// Default the scope-guard PR-body read to a verified-empty description so the comment-gate tests
|
|
224
|
-
// below exercise only the review-comment dimension; scope-guard tests pass an explicit body.
|
|
225
|
-
|
|
225
|
+
// below exercise only the review-comment dimension; scope-guard tests pass an explicit body. The
|
|
226
|
+
// HEAD read defaults to null (unreadable) so a scope block stays blocked unless a test opts into
|
|
227
|
+
// the #395 override door with an explicit HEAD — see workers/converge-gate/worker.test.ts.
|
|
228
|
+
return makeHandler({ readPrBody: async () => "", readHeadSha: async () => null, ...deps });
|
|
226
229
|
}
|
|
227
230
|
|
|
228
231
|
test("converge-gate: a clean PR is allowed to converge", async () => {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Tests for engine address resolution + the startup preflight (nano-workforce#391).
|
|
2
|
+
//
|
|
3
|
+
// The resolver and the identity description are pure (an injectable env reader /
|
|
4
|
+
// a plain topology body), and the preflight takes an injectable `fetch`, so none
|
|
5
|
+
// of this needs a live engine or `process.env` mutation.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals } from "#test-assert";
|
|
8
|
+
import {
|
|
9
|
+
announceEngine,
|
|
10
|
+
describeEngine,
|
|
11
|
+
type EngineAddress,
|
|
12
|
+
resolveEngineAddress,
|
|
13
|
+
type TopologyProbe,
|
|
14
|
+
} from "./enginePreflight.ts";
|
|
15
|
+
|
|
16
|
+
const reader = (vars: Record<string, string>) => (name: string): string | null => vars[name] ?? null;
|
|
17
|
+
|
|
18
|
+
test("resolveEngineAddress honours an explicit CAMUNDA_REST_ADDRESS and strips trailing slashes", () => {
|
|
19
|
+
const addr = resolveEngineAddress(reader({ CAMUNDA_REST_ADDRESS: "http://engine.example:8080/v2///" }));
|
|
20
|
+
assertEquals(addr, { restAddress: "http://engine.example:8080/v2", source: "CAMUNDA_REST_ADDRESS" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("resolveEngineAddress derives /v2 from NANOBPMN_BASE_URL when no explicit address", () => {
|
|
24
|
+
const addr = resolveEngineAddress(reader({ NANOBPMN_BASE_URL: "http://engine.example:7000//" }));
|
|
25
|
+
assertEquals(addr, { restAddress: "http://engine.example:7000/v2", source: "NANOBPMN_BASE_URL" });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("resolveEngineAddress defaults to localhost:8080 and labels the source as the default", () => {
|
|
29
|
+
const addr = resolveEngineAddress(reader({}));
|
|
30
|
+
assertEquals(addr, { restAddress: "http://localhost:8080/v2", source: "default (http://localhost:8080)" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("describeEngine reports a Nano engine from the `nano` marker", () => {
|
|
34
|
+
const line = describeEngine({ nano: { engine: "nanobpmn", version: "0.114.1", falconPath: "/falcon" } });
|
|
35
|
+
assert(line.includes("Nano engine (nanobpmn v0.114.1)"), "names the nano engine + version");
|
|
36
|
+
assert(line.includes("/falcon"), "mentions the Falcon path");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("describeEngine reports Camunda 8 (never rejects) when the `nano` marker is absent", () => {
|
|
40
|
+
const line = describeEngine({ gatewayVersion: "8.6.0" });
|
|
41
|
+
assert(line.startsWith("Camunda 8"), "identifies Camunda 8");
|
|
42
|
+
assert(line.includes("8.6.0"), "surfaces the gateway version");
|
|
43
|
+
assert(line.includes("REST only"), "notes Falcon is unavailable");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("describeEngine tolerates an empty / missing body", () => {
|
|
47
|
+
assert(describeEngine(null).startsWith("Camunda 8"), "null body degrades to Camunda 8");
|
|
48
|
+
assert(describeEngine({}).startsWith("Camunda 8"), "empty body degrades to Camunda 8");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// --- announceEngine (injected fetch; never throws) ---
|
|
52
|
+
|
|
53
|
+
const ADDR: EngineAddress = { restAddress: "http://localhost:8080/v2", source: "default (http://localhost:8080)" };
|
|
54
|
+
|
|
55
|
+
function capture() {
|
|
56
|
+
const info: string[] = [];
|
|
57
|
+
const warn: string[] = [];
|
|
58
|
+
return { log: { info: (m: string) => info.push(m), warn: (m: string) => warn.push(m) }, info, warn };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const okResponse = (body: TopologyProbe) =>
|
|
62
|
+
({ ok: true, status: 200, json: () => Promise.resolve(body) }) as unknown as Response;
|
|
63
|
+
|
|
64
|
+
test("announceEngine logs the resolved address + a Nano identity line", async () => {
|
|
65
|
+
const { log, info, warn } = capture();
|
|
66
|
+
let probed = "";
|
|
67
|
+
await announceEngine(ADDR, log, {
|
|
68
|
+
fetchImpl: ((url: string) => {
|
|
69
|
+
probed = url;
|
|
70
|
+
return Promise.resolve(okResponse({ nano: { engine: "nanobpmn", version: "1.2.3", falconPath: "/falcon" } }));
|
|
71
|
+
}) as unknown as typeof fetch,
|
|
72
|
+
});
|
|
73
|
+
assertEquals(probed, "http://localhost:8080/v2/topology");
|
|
74
|
+
assert(info.some((l) => l.includes("Engine address: http://localhost:8080/v2 (from default")), "echoes address+source");
|
|
75
|
+
assert(info.some((l) => l.includes("Nano engine (nanobpmn v1.2.3)")), "announces the Nano engine");
|
|
76
|
+
assertEquals(warn, []);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("announceEngine announces Camunda 8 without rejecting", async () => {
|
|
80
|
+
const { log, info, warn } = capture();
|
|
81
|
+
await announceEngine(ADDR, log, {
|
|
82
|
+
fetchImpl: (() => Promise.resolve(okResponse({ gatewayVersion: "8.6.0" }))) as unknown as typeof fetch,
|
|
83
|
+
});
|
|
84
|
+
assert(info.some((l) => l.includes("Camunda 8")), "announces Camunda 8");
|
|
85
|
+
assertEquals(warn, []);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("announceEngine sends CAMUNDA_TOKEN as a bearer credential on the probe", async () => {
|
|
89
|
+
const { log } = capture();
|
|
90
|
+
let sentAuth: string | undefined;
|
|
91
|
+
await announceEngine(ADDR, log, {
|
|
92
|
+
token: "secret-token",
|
|
93
|
+
fetchImpl: ((_url: string, init: { headers: Record<string, string> }) => {
|
|
94
|
+
sentAuth = init.headers.authorization;
|
|
95
|
+
return Promise.resolve(okResponse({ nano: { engine: "nanobpmn" } }));
|
|
96
|
+
}) as unknown as typeof fetch,
|
|
97
|
+
});
|
|
98
|
+
assertEquals(sentAuth, "Bearer secret-token");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("announceEngine treats 401/403 as an auth hint, not an unreachable warning", async () => {
|
|
102
|
+
const { log, warn } = capture();
|
|
103
|
+
const res = { ok: false, status: 401, json: () => Promise.reject(new Error("unused")) } as unknown as Response;
|
|
104
|
+
await announceEngine(ADDR, log, { fetchImpl: (() => Promise.resolve(res)) as unknown as typeof fetch });
|
|
105
|
+
assert(warn.some((l) => l.includes("HTTP 401") && l.includes("CAMUNDA_TOKEN")), "points at the token, not unreachability");
|
|
106
|
+
assert(!warn.some((l) => l.includes("could not reach")), "does not claim the engine is unreachable");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("announceEngine warns (does not throw) on a non-200 response", async () => {
|
|
110
|
+
const { log, warn } = capture();
|
|
111
|
+
const res = { ok: false, status: 503, json: () => Promise.reject(new Error("unused")) } as unknown as Response;
|
|
112
|
+
await announceEngine(ADDR, log, { fetchImpl: (() => Promise.resolve(res)) as unknown as typeof fetch });
|
|
113
|
+
assert(warn.some((l) => l.includes("HTTP 503")), "warns with the status code");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("announceEngine warns (does not throw) when the engine is unreachable", async () => {
|
|
117
|
+
const { log, warn } = capture();
|
|
118
|
+
await announceEngine(ADDR, log, {
|
|
119
|
+
fetchImpl: (() => Promise.reject(new Error("ECONNREFUSED"))) as unknown as typeof fetch,
|
|
120
|
+
});
|
|
121
|
+
assert(warn.some((l) => l.includes("could not reach") && l.includes("ECONNREFUSED")), "warns with the reason");
|
|
122
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Engine address resolution + a lightweight, non-rejecting startup preflight
|
|
2
|
+
// (nano-workforce#391).
|
|
3
|
+
//
|
|
4
|
+
// Two jobs, one source of truth:
|
|
5
|
+
// 1. `resolveEngineAddress` is the CANONICAL resolver for the engine REST
|
|
6
|
+
// address. Both the engine client (`main.ts`) and the demand reader
|
|
7
|
+
// (`app/agentic/vocab/demand-report.ts`) derive from it, so the precedence
|
|
8
|
+
// lives in exactly one place instead of drifting across call sites.
|
|
9
|
+
// 2. `announceEngine` probes `/v2/topology` at boot and reports which engine
|
|
10
|
+
// answered. It is DELIBERATELY informational: Nano Workforce is a
|
|
11
|
+
// first-class Camunda 8 client, so a non-Nano engine is announced, never
|
|
12
|
+
// rejected. It never throws — a boot preflight must not gate startup.
|
|
13
|
+
//
|
|
14
|
+
// Why: without this, pointing the app at the wrong address (classically another
|
|
15
|
+
// service already on :8080) surfaces only later as a cryptic mid-run engine
|
|
16
|
+
// error. Echoing the resolved address + which engine answered makes the
|
|
17
|
+
// misconfiguration obvious at boot.
|
|
18
|
+
|
|
19
|
+
import { envVar } from "./version.ts";
|
|
20
|
+
|
|
21
|
+
/** The resolved engine REST address plus a human label for where it came from. */
|
|
22
|
+
export interface EngineAddress {
|
|
23
|
+
/** The `/v2` REST base the engine client and demand reader talk to. */
|
|
24
|
+
restAddress: string;
|
|
25
|
+
/** Which input produced `restAddress`, for a legible startup line. */
|
|
26
|
+
source: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Canonical engine REST address resolution — the single source of truth.
|
|
31
|
+
*
|
|
32
|
+
* Precedence: an explicit `CAMUNDA_REST_ADDRESS` (already the `/v2` REST
|
|
33
|
+
* address) wins; otherwise the address is derived from `NANOBPMN_BASE_URL`
|
|
34
|
+
* (+ `/v2`), defaulting the base to `http://localhost:8080`. Trailing slashes
|
|
35
|
+
* are stripped from both inputs.
|
|
36
|
+
*
|
|
37
|
+
* `read` is injectable so the resolution is testable without mutating
|
|
38
|
+
* `process.env`; it defaults to the app's `envVar` (ADR 0004) accessor.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveEngineAddress(
|
|
41
|
+
read: (name: string) => string | null = envVar,
|
|
42
|
+
): EngineAddress {
|
|
43
|
+
const explicit = read("CAMUNDA_REST_ADDRESS")?.replace(/\/+$/, "");
|
|
44
|
+
if (explicit) return { restAddress: explicit, source: "CAMUNDA_REST_ADDRESS" };
|
|
45
|
+
const base = read("NANOBPMN_BASE_URL");
|
|
46
|
+
const normalized = (base ?? "http://localhost:8080").replace(/\/+$/, "");
|
|
47
|
+
return {
|
|
48
|
+
restAddress: `${normalized}/v2`,
|
|
49
|
+
source: base ? "NANOBPMN_BASE_URL" : "default (http://localhost:8080)",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The subset of a `/v2/topology` body we read. A nanobpmn gateway advertises a
|
|
55
|
+
* `nano` object (its own extension) so a single call distinguishes it from a
|
|
56
|
+
* stock Camunda 8 gateway, which returns the same shape without it.
|
|
57
|
+
*/
|
|
58
|
+
export interface TopologyProbe {
|
|
59
|
+
nano?: { engine?: string; version?: string; falconPath?: string } | null;
|
|
60
|
+
gatewayVersion?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A human-readable identity line for whatever answered `/v2/topology`. Never
|
|
65
|
+
* rejects: a Camunda 8 gateway (no `nano` marker) is a supported target, so it
|
|
66
|
+
* is described, not refused.
|
|
67
|
+
*/
|
|
68
|
+
export function describeEngine(body: TopologyProbe | null | undefined): string {
|
|
69
|
+
const nano = body?.nano;
|
|
70
|
+
if (nano?.engine) {
|
|
71
|
+
const version = nano.version ? ` v${nano.version}` : "";
|
|
72
|
+
const falcon = nano.falconPath ?? "/falcon";
|
|
73
|
+
return `Nano engine (${nano.engine}${version}) — Falcon streaming at ${falcon}`;
|
|
74
|
+
}
|
|
75
|
+
const gateway = body?.gatewayVersion ? ` (gateway v${body.gatewayVersion})` : "";
|
|
76
|
+
return `Camunda 8${gateway} — REST only (Nano Falcon streaming unavailable)`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The logging surface `announceEngine` needs (structurally a `Logger`). */
|
|
80
|
+
export interface PreflightLog {
|
|
81
|
+
info(msg: string): void;
|
|
82
|
+
warn(msg: string): void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Log the resolved engine address (and its source), then probe `/v2/topology`
|
|
87
|
+
* and announce which engine answered. Informational only — it swallows every
|
|
88
|
+
* failure into a `warn` and never throws, so a slow or absent engine cannot
|
|
89
|
+
* block boot. A missing engine is a `warn` (features will fail to start until
|
|
90
|
+
* it is reachable), not a fatal error.
|
|
91
|
+
*
|
|
92
|
+
* `opts.token` (the same `CAMUNDA_TOKEN` the engine client uses) is sent as a
|
|
93
|
+
* bearer credential so a secured cluster does not answer the probe with a
|
|
94
|
+
* misleading 401/403 while the real client is correctly configured.
|
|
95
|
+
*/
|
|
96
|
+
export async function announceEngine(
|
|
97
|
+
addr: EngineAddress,
|
|
98
|
+
log: PreflightLog,
|
|
99
|
+
opts: { token?: string; fetchImpl?: typeof fetch } = {},
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
102
|
+
log.info(`Engine address: ${addr.restAddress} (from ${addr.source})`);
|
|
103
|
+
const url = `${addr.restAddress.replace(/\/+$/, "")}/topology`;
|
|
104
|
+
const headers: Record<string, string> = { accept: "application/json" };
|
|
105
|
+
if (opts.token) headers.authorization = `Bearer ${opts.token}`;
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(3000) });
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
if (res.status === 401 || res.status === 403) {
|
|
110
|
+
// The engine is reachable but rejected the probe's credentials — the
|
|
111
|
+
// real client may still work (it authenticates independently), so this
|
|
112
|
+
// is an auth hint, not an "unreachable" warning.
|
|
113
|
+
log.warn(
|
|
114
|
+
`Engine preflight: ${url} returned HTTP ${res.status} (authentication). ` +
|
|
115
|
+
`If the engine requires a token, set CAMUNDA_TOKEN — the engine client uses it independently.`,
|
|
116
|
+
);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
log.warn(
|
|
120
|
+
`Engine preflight: ${url} returned HTTP ${res.status}. ` +
|
|
121
|
+
`Check CAMUNDA_REST_ADDRESS / NANOBPMN_BASE_URL — features will fail to start until the engine is reachable.`,
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const body: TopologyProbe = await res.json();
|
|
126
|
+
log.info(`Engine: ${describeEngine(body)} at ${addr.restAddress}.`);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
129
|
+
log.warn(
|
|
130
|
+
`Engine preflight: could not reach ${url} (${reason}). ` +
|
|
131
|
+
`Check CAMUNDA_REST_ADDRESS / NANOBPMN_BASE_URL — features will fail to start until the engine is reachable.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -212,3 +212,36 @@ test("a control-flow arm with a blank question opens nothing so gw-escalated re-
|
|
|
212
212
|
assertEquals(inserts.escalations.length, 0, "no dead escalation is fabricated");
|
|
213
213
|
assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is never flipped to escalated");
|
|
214
214
|
});
|
|
215
|
+
|
|
216
|
+
// The scope-integrity arm (persist-escalation-blockedcomments) binds the escalation to the reviewed
|
|
217
|
+
// commit (issue #395): it stamps `head_sha` and marks `scope_block` so the converge-gate can honour
|
|
218
|
+
// a same-HEAD human answer as an override instead of re-deriving the block and re-escalating forever.
|
|
219
|
+
test("persist-escalation binds a scope-integrity escalation to the reviewed HEAD (head_sha + scope_block)", async () => {
|
|
220
|
+
const { app, inserts } = fakeApp();
|
|
221
|
+
const job = {
|
|
222
|
+
variables: {
|
|
223
|
+
prKey: "o/r#5",
|
|
224
|
+
round: 2,
|
|
225
|
+
status: "blocked",
|
|
226
|
+
question: "Scope integrity blocked: ...",
|
|
227
|
+
recordRound: false,
|
|
228
|
+
headSha: "HEAD1",
|
|
229
|
+
scopeBlock: true,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
await handler(job as any, app as any);
|
|
233
|
+
assertEquals(inserts.escalations.length, 1);
|
|
234
|
+
assertEquals((inserts.escalations[0] as any).head_sha, "HEAD1", "the escalation carries the reviewed commit");
|
|
235
|
+
assertEquals((inserts.escalations[0] as any).scope_block, 1, "flagged as a scope-integrity block");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Every other escalation arm (agent verdict, no-progress, max-rounds, stalled) omits the scope
|
|
239
|
+
// binding: head_sha stays absent and scope_block defaults to 0, so the override door opens ONLY for
|
|
240
|
+
// the block a human can actually answer.
|
|
241
|
+
test("persist-escalation: a non-scope escalation records no HEAD binding and scope_block 0", async () => {
|
|
242
|
+
const { app, inserts } = fakeApp();
|
|
243
|
+
const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds" } };
|
|
244
|
+
await handler(job as any, app as any);
|
|
245
|
+
assertEquals((inserts.escalations[0] as any).head_sha, undefined, "no reviewed HEAD to bind");
|
|
246
|
+
assertEquals((inserts.escalations[0] as any).scope_block, 0, "not a scope-integrity block");
|
|
247
|
+
});
|
package/app/scopeGuard.test.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
findClosingKeywordRefs,
|
|
15
15
|
hasDeferralMarker,
|
|
16
16
|
hasFollowupIssueRef,
|
|
17
|
+
isScopeOverridden,
|
|
17
18
|
} from "./scopeGuard.ts";
|
|
18
19
|
|
|
19
20
|
// ── The canonical router ────────────────────────────────────────────────────
|
|
@@ -145,3 +146,40 @@ test("hasFollowupIssueRef: only an explicit tracking marker + issue ref counts",
|
|
|
145
146
|
"Follow-up marker with a full issue URL",
|
|
146
147
|
);
|
|
147
148
|
});
|
|
149
|
+
|
|
150
|
+
// ── The human-override door (#395) ──────────────────────────────────────────
|
|
151
|
+
// The scope-integrity gate re-derives `scopeBlocked` from the PR body every round, so answering
|
|
152
|
+
// its escalation used to re-block identically (an infinite loop). An answer bound to the SAME
|
|
153
|
+
// reviewed HEAD is now honoured as an explicit override; a different HEAD (a new push) is not.
|
|
154
|
+
|
|
155
|
+
test("isScopeOverridden: an answer bound to the same HEAD overrides the block", () => {
|
|
156
|
+
assert(
|
|
157
|
+
isScopeOverridden("abc123", { escalationId: 7, headSha: "abc123", answer: "Full delivery — keep Closes." }),
|
|
158
|
+
"same-HEAD answered escalation is an override",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("isScopeOverridden: an answer for a DIFFERENT HEAD does not override (a new push re-opens)", () => {
|
|
163
|
+
assert(
|
|
164
|
+
!isScopeOverridden("newHEAD", { escalationId: 7, headSha: "oldHEAD", answer: "Full delivery." }),
|
|
165
|
+
"an override never carries across a new push",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("isScopeOverridden: no recorded answer is never an override", () => {
|
|
170
|
+
assertEquals(isScopeOverridden("abc123", null), false);
|
|
171
|
+
assertEquals(isScopeOverridden("abc123", undefined), false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("isScopeOverridden: a missing/blank HEAD on either side fails closed (no override)", () => {
|
|
175
|
+
assertEquals(isScopeOverridden(null, { headSha: "abc123", answer: "x" }), false, "unreadable current HEAD");
|
|
176
|
+
assertEquals(isScopeOverridden("", { headSha: "abc123", answer: "x" }), false, "blank current HEAD");
|
|
177
|
+
assertEquals(isScopeOverridden("abc123", { headSha: null, answer: "x" }), false, "unrecorded escalation HEAD");
|
|
178
|
+
assertEquals(isScopeOverridden("abc123", { headSha: " ", answer: "x" }), false, "blank escalation HEAD");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("isScopeOverridden: the answer text is not parsed for intent — presence at the HEAD is the signal", () => {
|
|
182
|
+
// On an unchanged HEAD, the operator completing the escalation IS the explicit approval: had they
|
|
183
|
+
// wanted a real split, the servicing agent would have pushed a fix, moving the HEAD.
|
|
184
|
+
assert(isScopeOverridden("abc123", { headSha: "abc123", answer: null }), "a null answer at the HEAD still overrides");
|
|
185
|
+
});
|
package/app/scopeGuard.ts
CHANGED
|
@@ -129,3 +129,37 @@ export function evaluateScopeGuard(input: ScopeGuardInput): ScopeGuardResult {
|
|
|
129
129
|
scopeBlockReason: `Scope integrity blocked: ${reasons.join("; ")}.`,
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
|
+
|
|
133
|
+
// A recorded human answer to a scope-integrity escalation, bound to the PR HEAD it was raised
|
|
134
|
+
// against (issue #395). This is the override door the deterministic scope gate lacked: without it,
|
|
135
|
+
// the gate re-derives `scopeBlocked` from the PR body every round and re-escalates the identical
|
|
136
|
+
// question, so a legitimate human override ("this fully delivers the issue — keep the closing
|
|
137
|
+
// keyword") is unresolvable through the escalation the loop itself opens (infinite loop).
|
|
138
|
+
export interface ScopeEscalationAnswer {
|
|
139
|
+
/** The escalation row id, for the audit trail. */
|
|
140
|
+
escalationId?: number;
|
|
141
|
+
/** The PR HEAD sha this scope escalation was raised against (`escalations.head_sha`). */
|
|
142
|
+
headSha: string | null | undefined;
|
|
143
|
+
/** The operator's recorded answer/rationale (`escalations.answer`), surfaced in the audit. */
|
|
144
|
+
answer: string | null | undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Decide whether a recorded human answer overrides the scope-integrity block for the commit
|
|
148
|
+
* currently under review. The override is honoured ONLY when the human answered a scope-integrity
|
|
149
|
+
* escalation that was raised against the SAME HEAD sha now being checked — binding the override to
|
|
150
|
+
* the reviewed commit so a later push (a different HEAD) re-opens the gate instead of silently
|
|
151
|
+
* carrying the override forward. Pure and total: a missing/blank current HEAD or a missing/blank
|
|
152
|
+
* recorded HEAD never matches, so an unverifiable HEAD fails closed (no override) rather than
|
|
153
|
+
* waving the gate through. The answer TEXT is not parsed for intent: on an unchanged HEAD the human
|
|
154
|
+
* completing the escalation IS the explicit approval (had they wanted a real fix, the servicing
|
|
155
|
+
* agent would have pushed a new commit, moving the HEAD and side-stepping this override). */
|
|
156
|
+
export function isScopeOverridden(
|
|
157
|
+
currentHeadSha: string | null | undefined,
|
|
158
|
+
answered: ScopeEscalationAnswer | null | undefined,
|
|
159
|
+
): boolean {
|
|
160
|
+
if (!answered) return false;
|
|
161
|
+
const current = typeof currentHeadSha === "string" ? currentHeadSha.trim() : "";
|
|
162
|
+
const recorded = typeof answered.headSha === "string" ? answered.headSha.trim() : "";
|
|
163
|
+
if (current === "" || recorded === "") return false;
|
|
164
|
+
return current === recorded;
|
|
165
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- Bind a scope-integrity escalation to the reviewed commit so a human answer can override it
|
|
2
|
+
-- (issue #395). The review-convergence scope-integrity gate (`workers/converge-gate`) raises a
|
|
3
|
+
-- human question ("this partial delivery closes a broader-scoped parent") but then re-derives the
|
|
4
|
+
-- block from scratch off the PR body every round, ignoring the recorded `answer` — so answering
|
|
5
|
+
-- the escalation re-enters the loop, the gate re-blocks identically, and the operator is trapped in
|
|
6
|
+
-- an infinite escalation with no human-override door. The only escape was mangling the PR body into
|
|
7
|
+
-- a non-closing ref, i.e. changing the PR to what the machine wants rather than answering it.
|
|
8
|
+
--
|
|
9
|
+
-- The fix gives the gate a real override door: an escalation now records the PR HEAD sha it was
|
|
10
|
+
-- raised against (`head_sha`) and whether it was a scope-integrity block (`scope_block`). When the
|
|
11
|
+
-- gate would re-block on scope, it consults the answered escalation for THIS PR at the SAME HEAD:
|
|
12
|
+
-- a human answer bound to the reviewed commit is honoured as an explicit override (audited), and
|
|
13
|
+
-- the gate is satisfied. Binding to the HEAD sha is deliberate — a later push (a new HEAD)
|
|
14
|
+
-- legitimately re-opens the gate rather than silently carrying the override forward, and if the
|
|
15
|
+
-- human instead asked for a real split the agent pushes a fix (new HEAD) so the stale override
|
|
16
|
+
-- never applies. This categorically kills the infinite-escalation loop on an unchanged HEAD.
|
|
17
|
+
--
|
|
18
|
+
-- Both columns are nullable/defaulted (expand phase, additive). Only the scope-integrity arm
|
|
19
|
+
-- (`persist-escalation-blockedcomments`) populates them; every other escalation arm leaves them
|
|
20
|
+
-- NULL/0 and is unaffected. Numbered after the current highest prefix (055); the runner wraps each
|
|
21
|
+
-- file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
22
|
+
ALTER TABLE escalations ADD COLUMN head_sha TEXT;
|
|
23
|
+
ALTER TABLE escalations ADD COLUMN scope_block INTEGER NOT NULL DEFAULT 0;
|
package/main.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { Server } from "node:http";
|
|
21
21
|
import { createNanoSdkEngineClient, runFromEnv, selectHost } from "@nanobpm/urban";
|
|
22
22
|
import { type AgenticChannelHandle, mountAgenticChannel } from "./app/agentic/channel.ts";
|
|
23
|
+
import { announceEngine, resolveEngineAddress } from "./app/enginePreflight.ts";
|
|
23
24
|
import { MAX_ROUNDS, pollOnce } from "./app/service.ts";
|
|
24
25
|
import { envVar } from "./app/version.ts";
|
|
25
26
|
|
|
@@ -29,12 +30,18 @@ const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? "";
|
|
|
29
30
|
|
|
30
31
|
const host = selectHost();
|
|
31
32
|
|
|
32
|
-
// One engine client, shared by the runtime (surfaces/actions/workers) and the poller.
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
// One engine client, shared by the runtime (surfaces/actions/workers) and the poller. The address
|
|
34
|
+
// resolution (CAMUNDA_REST_ADDRESS wins, else NANOBPMN_BASE_URL + /v2, else localhost:8080) is the
|
|
35
|
+
// canonical `resolveEngineAddress`, shared with the demand reader. A non-rejecting startup preflight
|
|
36
|
+
// then echoes the resolved address and announces which engine answered (nano-workforce#391) so a
|
|
37
|
+
// misconfigured address is obvious at boot rather than as a cryptic mid-run engine error.
|
|
38
|
+
const engineAddress = resolveEngineAddress();
|
|
39
|
+
await announceEngine(engineAddress, {
|
|
40
|
+
info: (msg) => host.log("info", msg),
|
|
41
|
+
warn: (msg) => host.log("warn", msg),
|
|
42
|
+
}, { token: process.env.CAMUNDA_TOKEN });
|
|
36
43
|
const engine = await createNanoSdkEngineClient({
|
|
37
|
-
restAddress,
|
|
44
|
+
restAddress: engineAddress.restAddress,
|
|
38
45
|
token: process.env.CAMUNDA_TOKEN,
|
|
39
46
|
transport: process.env.CAMUNDA_TRANSPORT ?? "auto",
|
|
40
47
|
log: host.log,
|
|
@@ -96,7 +103,7 @@ let shuttingDown = false;
|
|
|
96
103
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
97
104
|
async function pollLoop(): Promise<void> {
|
|
98
105
|
try {
|
|
99
|
-
if (app.data) await pollOnce(app.data, engine, GITHUB_TOKEN, { restAddress, token: process.env.CAMUNDA_TOKEN });
|
|
106
|
+
if (app.data) await pollOnce(app.data, engine, GITHUB_TOKEN, { restAddress: engineAddress.restAddress, token: process.env.CAMUNDA_TOKEN });
|
|
100
107
|
} catch (err) {
|
|
101
108
|
console.error("poll error:", err);
|
|
102
109
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.115.0",
|
|
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",
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
<nano:shape id="PrConvergeGateOut" name="Converge gate — result">
|
|
51
51
|
<nano:extend name="convergeBlocked" type="boolean" />
|
|
52
52
|
<nano:extend name="convergeBlockReason" type="string" optional="true" />
|
|
53
|
+
<nano:extend name="headSha" type="string" optional="true" />
|
|
54
|
+
<nano:extend name="scopeBlocked" type="boolean" optional="true" />
|
|
53
55
|
</nano:shape>
|
|
54
56
|
<nano:shape id="EscalationIn" name="Record escalation — input">
|
|
55
57
|
<nano:extend name="prKey" type="string" />
|
|
@@ -62,6 +64,8 @@
|
|
|
62
64
|
<nano:extend name="prNumber" type="integer" optional="true" />
|
|
63
65
|
<nano:extend name="prUrl" type="string" optional="true" />
|
|
64
66
|
<nano:extend name="abandonUrl" type="string" optional="true" />
|
|
67
|
+
<nano:extend name="headSha" type="string" optional="true" />
|
|
68
|
+
<nano:extend name="scopeBlock" type="boolean" optional="true" />
|
|
65
69
|
</nano:shape>
|
|
66
70
|
<nano:shape id="EscalationOut" name="Record escalation — result">
|
|
67
71
|
<nano:extend name="escalationId" type="integer" optional="true" />
|
|
@@ -299,6 +303,8 @@
|
|
|
299
303
|
<zeebe:input source="="blocked"" target="status" />
|
|
300
304
|
<zeebe:input source="=false" target="recordRound" />
|
|
301
305
|
<zeebe:input source="=convergeBlockReason" target="question" />
|
|
306
|
+
<zeebe:input source="=headSha" target="headSha" />
|
|
307
|
+
<zeebe:input source="=scopeBlocked" target="scopeBlock" />
|
|
302
308
|
</zeebe:ioMapping>
|
|
303
309
|
</bpmn:extensionElements>
|
|
304
310
|
<bpmn:incoming>f_convergeBlocked</bpmn:incoming>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// pr.converge-gate — the human-override door for the scope-integrity block (issue #395).
|
|
2
|
+
//
|
|
3
|
+
// The scope-integrity gate re-derives `scopeBlocked` from the PR body every converged round. Before
|
|
4
|
+
// this fix, answering its escalation re-entered the loop, the gate re-blocked identically, and the
|
|
5
|
+
// operator was trapped in an infinite escalation (a fresh escalationId each cycle) — the only escape
|
|
6
|
+
// was mangling the PR body into a non-closing ref. These tests pin the override door: an escalation
|
|
7
|
+
// answer bound to the SAME reviewed HEAD satisfies the gate (audited), a different HEAD (a new push)
|
|
8
|
+
// re-opens it, and an unreadable HEAD keeps the block (fail closed).
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assert, assertEquals } from "#test-assert";
|
|
11
|
+
import { noopLog } from "../../test/log.ts";
|
|
12
|
+
import { makeHandler } from "./worker.ts";
|
|
13
|
+
|
|
14
|
+
// A PR body that trips the scope-integrity guard: it defers scope (`## Scope`) yet closes a
|
|
15
|
+
// broader-scoped parent (`Closes #631`) and links no filed follow-up.
|
|
16
|
+
const SCOPE_BLOCKING_BODY =
|
|
17
|
+
"Delivers the first half.\n\n## Scope\nThe embedded tools remain the deferred refinement.\n\nCloses #631";
|
|
18
|
+
|
|
19
|
+
// biome-ignore lint/suspicious/noExplicitAny: tiny in-memory app double, mirrors persist-escalation.test
|
|
20
|
+
function fakeApp(escalations: Record<string, unknown>[]): any {
|
|
21
|
+
const stores: Record<string, Record<string, unknown>[]> = { escalations };
|
|
22
|
+
return {
|
|
23
|
+
stores,
|
|
24
|
+
data: {
|
|
25
|
+
table(name: string, key: string) {
|
|
26
|
+
const store = (stores[name] ??= []);
|
|
27
|
+
return {
|
|
28
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
29
|
+
find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
log: noopLog(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function deps(overrides: {
|
|
38
|
+
headSha?: string | null;
|
|
39
|
+
prBody?: string;
|
|
40
|
+
headThrows?: boolean;
|
|
41
|
+
}) {
|
|
42
|
+
const headSha = "headSha" in overrides ? (overrides.headSha ?? null) : "HEAD1";
|
|
43
|
+
return {
|
|
44
|
+
readThreads: () => Promise.resolve([]),
|
|
45
|
+
readReviewBody: () => Promise.resolve(""),
|
|
46
|
+
readPrBody: () => Promise.resolve(overrides.prBody ?? SCOPE_BLOCKING_BODY),
|
|
47
|
+
readHeadSha: () => (overrides.headThrows ? Promise.reject(new Error("gh down")) : Promise.resolve(headSha)),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const job = { variables: { prKey: "o/r#5", repo: "o/r", prNumber: 5 } } as never;
|
|
52
|
+
|
|
53
|
+
test("scope blocks with no answered escalation → blocked, and surfaces the reviewed HEAD to bind the escalation", async () => {
|
|
54
|
+
const app = fakeApp([]);
|
|
55
|
+
const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
|
|
56
|
+
assertEquals(out.convergeBlocked, true);
|
|
57
|
+
assertEquals(out.scopeBlocked, true);
|
|
58
|
+
assertEquals(out.headSha, "HEAD1", "the reviewed HEAD is returned so persist-escalation can bind it");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("scope blocks but a human answered the escalation for the SAME HEAD → override honoured (loop broken)", async () => {
|
|
62
|
+
const app = fakeApp([
|
|
63
|
+
{ id: 7, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "HEAD1", answer: "Full delivery — keep Closes." },
|
|
64
|
+
]);
|
|
65
|
+
const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
|
|
66
|
+
assertEquals(out.convergeBlocked, false, "the same-HEAD human answer satisfies the scope gate");
|
|
67
|
+
assertEquals(out.convergeBlockReason, "");
|
|
68
|
+
// A cleared scope block routes to finalize, so the block-only binding fields are not emitted.
|
|
69
|
+
assertEquals(out.scopeBlocked, undefined);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("scope blocks and the answer was for a DIFFERENT HEAD (a new push) → still blocked", async () => {
|
|
73
|
+
const app = fakeApp([
|
|
74
|
+
{ id: 7, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "OLDHEAD", answer: "Full delivery." },
|
|
75
|
+
]);
|
|
76
|
+
const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
|
|
77
|
+
assertEquals(out.convergeBlocked, true, "a stale override never carries across a new push");
|
|
78
|
+
assertEquals(out.scopeBlocked, true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("scope blocks and an answered NON-scope escalation sits at the same HEAD → not an override", async () => {
|
|
82
|
+
const app = fakeApp([
|
|
83
|
+
{ id: 7, pr_key: "o/r#5", status: "answered", scope_block: 0, head_sha: "HEAD1", answer: "unrelated" },
|
|
84
|
+
]);
|
|
85
|
+
const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
|
|
86
|
+
assertEquals(out.convergeBlocked, true, "only a scope-integrity escalation opens the scope override door");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("scope blocks but the reviewed HEAD is unreadable → keep the block (fail closed)", async () => {
|
|
90
|
+
const app = fakeApp([
|
|
91
|
+
{ id: 7, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "HEAD1", answer: "override" },
|
|
92
|
+
]);
|
|
93
|
+
const nullHead = (await makeHandler(deps({ headSha: null }))(job, app)) as Record<string, unknown>;
|
|
94
|
+
assertEquals(nullHead.convergeBlocked, true, "cannot verify an override against an unknown HEAD");
|
|
95
|
+
const throwHead = (await makeHandler(deps({ headThrows: true }))(job, app)) as Record<string, unknown>;
|
|
96
|
+
assertEquals(throwHead.convergeBlocked, true, "a HEAD read error keeps the block");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("scope passes → not blocked, and no override lookup is needed", async () => {
|
|
100
|
+
const app = fakeApp([]);
|
|
101
|
+
const out = (await makeHandler(deps({ prBody: "Implements the whole thing.\n\nCloses #631" }))(job, app)) as Record<
|
|
102
|
+
string,
|
|
103
|
+
unknown
|
|
104
|
+
>;
|
|
105
|
+
assertEquals(out.convergeBlocked, false);
|
|
106
|
+
assertEquals(out.scopeBlocked, undefined);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("newest answered scope escalation wins when a re-escalation was answered again at the same HEAD", async () => {
|
|
110
|
+
const app = fakeApp([
|
|
111
|
+
{ id: 7, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "HEAD1", answer: "first" },
|
|
112
|
+
{ id: 9, pr_key: "o/r#5", status: "answered", scope_block: 1, head_sha: "HEAD1", answer: "latest" },
|
|
113
|
+
]);
|
|
114
|
+
const out = (await makeHandler(deps({ headSha: "HEAD1" }))(job, app)) as Record<string, unknown>;
|
|
115
|
+
assertEquals(out.convergeBlocked, false, "a re-answered override at the unchanged HEAD is honoured");
|
|
116
|
+
});
|
|
@@ -19,6 +19,15 @@
|
|
|
19
19
|
// This is the enforcement backstop for the Magikcraft/nano-bpm#631 → PR #863 (`Closes #631`, `##
|
|
20
20
|
// Scope` deferral, no follow-up → re-filed by hand as #872) failure class. See app/scopeGuard.ts.
|
|
21
21
|
//
|
|
22
|
+
// The scope-integrity block also carries a HUMAN-OVERRIDE door (#395): before it re-blocks, it
|
|
23
|
+
// reads the commit now under review and consults the `escalations` answer bound to that SAME HEAD.
|
|
24
|
+
// An operator who answered the scope question for this exact commit ("this fully delivers the issue
|
|
25
|
+
// — keep the closing keyword") has explicitly overridden it, so the gate honours that answer
|
|
26
|
+
// (audited) instead of re-deriving `scopeBlocked` from the PR body and re-escalating the identical
|
|
27
|
+
// question forever. Binding to the HEAD sha keeps the override from carrying across a new push, and
|
|
28
|
+
// (via `PrConvergeGateOut.headSha`/`scopeBlocked`) lets `persist-escalation-blockedcomments` stamp
|
|
29
|
+
// the escalation with the reviewed commit so the door can open on the next round.
|
|
30
|
+
//
|
|
22
31
|
// It FAILS CLOSED: if the live GitHub state cannot be read, it blocks (escalates) rather than
|
|
23
32
|
// letting an unverifiable "converged" through — the opposite of the no-progress guard, because a
|
|
24
33
|
// merge-gating check must escalate-on-uncertainty so #770 cannot recur.
|
|
@@ -26,13 +35,14 @@ import type { AppJobHandler } from "@nanobpm/urban";
|
|
|
26
35
|
import { type ConvergeGateResult, evaluateConvergeGate } from "../../app/convergeGate.ts";
|
|
27
36
|
import {
|
|
28
37
|
fetchLatestCopilotReviewBody,
|
|
38
|
+
fetchPrHead,
|
|
29
39
|
fetchPrMeta,
|
|
30
40
|
fetchReviewThreads,
|
|
31
41
|
parseAckedAdvisories,
|
|
32
42
|
parseSuppressedAdvisories,
|
|
33
43
|
type ReviewThread,
|
|
34
44
|
} from "../../app/github.ts";
|
|
35
|
-
import { evaluateScopeGuard } from "../../app/scopeGuard.ts";
|
|
45
|
+
import { evaluateScopeGuard, isScopeOverridden, type ScopeEscalationAnswer } from "../../app/scopeGuard.ts";
|
|
36
46
|
import { parsePr } from "../../app/service.ts";
|
|
37
47
|
import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
|
|
38
48
|
|
|
@@ -50,6 +60,9 @@ export type ReviewBodyReader = (repo: string, prNumber: number) => Promise<strin
|
|
|
50
60
|
// Reads the PR's own description body. `null` = no usable transport (unverifiable → fail closed);
|
|
51
61
|
// `""` = transport usable but the PR has an empty description (verified: nothing to scope-check).
|
|
52
62
|
export type PrBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
|
|
63
|
+
// Reads the PR's current HEAD sha (the commit under review). `null` = unreadable/no transport — the
|
|
64
|
+
// scope override cannot be verified or bound to a commit, so the gate keeps blocking (fail closed).
|
|
65
|
+
export type HeadShaReader = (repo: string, prNumber: number) => Promise<string | null>;
|
|
53
66
|
|
|
54
67
|
const defaultReadThreads: ThreadsReader = (repo, prNumber) =>
|
|
55
68
|
fetchReviewThreads(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
@@ -59,6 +72,10 @@ const defaultReadPrBody: PrBodyReader = async (repo, prNumber) => {
|
|
|
59
72
|
const meta = await fetchPrMeta(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
60
73
|
return meta ? meta.body : null;
|
|
61
74
|
};
|
|
75
|
+
const defaultReadHeadSha: HeadShaReader = async (repo, prNumber) => {
|
|
76
|
+
const head = await fetchPrHead(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
77
|
+
return head ? head.headSha : null;
|
|
78
|
+
};
|
|
62
79
|
|
|
63
80
|
const BLOCK_UNVERIFIABLE =
|
|
64
81
|
"Convergence blocked: could not verify the PR's review comments against GitHub. A human must confirm every Copilot review thread is resolved and every suppressed advisory acknowledged before this PR converges (reply to resume the loop).";
|
|
@@ -66,14 +83,56 @@ const BLOCK_UNVERIFIABLE =
|
|
|
66
83
|
const BLOCK_UNVERIFIABLE_BODY =
|
|
67
84
|
"Convergence blocked: could not read the PR description from GitHub to verify scope integrity. A human must confirm this PR does not close a broader-scoped parent with an untracked deferred remainder before it converges (reply to resume the loop).";
|
|
68
85
|
|
|
86
|
+
// An `escalations` row as this worker reads it back when looking for a recorded human override.
|
|
87
|
+
interface EscalationRow extends Record<string, unknown> {
|
|
88
|
+
id: number;
|
|
89
|
+
head_sha: string | null;
|
|
90
|
+
answer: string | null;
|
|
91
|
+
scope_block: number | boolean | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Find the newest ANSWERED scope-integrity escalation for this PR whose recorded HEAD matches the
|
|
95
|
+
// commit now under review (issue #395). This is the human-override door: `persist-escalation` binds
|
|
96
|
+
// a scope block to the HEAD it was raised against, `answer-escalation` marks the row `answered`, and
|
|
97
|
+
// here we honour that answer for the SAME HEAD so the gate stops re-deriving `scopeBlocked` from the
|
|
98
|
+
// PR body and re-escalating the identical question forever. Newest-first so a re-escalated-then-
|
|
99
|
+
// answered duplicate resolves to the operator's latest reply. Returns `null` on any read failure —
|
|
100
|
+
// the caller then keeps the block (fail closed), never fabricates an override.
|
|
101
|
+
async function findScopeOverride(
|
|
102
|
+
app: Parameters<AppJobHandler<In, Out>>[1],
|
|
103
|
+
prKey: string,
|
|
104
|
+
headSha: string,
|
|
105
|
+
): Promise<ScopeEscalationAnswer | null> {
|
|
106
|
+
try {
|
|
107
|
+
// `scope_block` is a first-class column (persist-escalation writes it as 0/1), so filter on it
|
|
108
|
+
// in the query rather than reading every answered escalation and filtering in memory — a PR with
|
|
109
|
+
// many answered non-scope escalations no longer loads them all just to discard them.
|
|
110
|
+
const rows = await app.data.table<EscalationRow>("escalations", "id").find({
|
|
111
|
+
pr_key: prKey,
|
|
112
|
+
status: "answered",
|
|
113
|
+
scope_block: 1,
|
|
114
|
+
});
|
|
115
|
+
const scoped = rows
|
|
116
|
+
.map((r) => ({ escalationId: Number(r.id), headSha: r.head_sha ?? null, answer: r.answer ?? null }))
|
|
117
|
+
.sort((a, b) => (b.escalationId ?? 0) - (a.escalationId ?? 0));
|
|
118
|
+
for (const candidate of scoped) {
|
|
119
|
+
if (isScopeOverridden(headSha, candidate)) return candidate;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
69
127
|
/** Build the handler with injectable GitHub readers. The default export binds the real readers;
|
|
70
128
|
* tests inject stubs. Fails CLOSED — any unreadable/errored state blocks convergence. */
|
|
71
129
|
export function makeHandler(deps: {
|
|
72
130
|
readThreads: ThreadsReader;
|
|
73
131
|
readReviewBody: ReviewBodyReader;
|
|
74
132
|
readPrBody: PrBodyReader;
|
|
133
|
+
readHeadSha: HeadShaReader;
|
|
75
134
|
}): AppJobHandler<In, Out> {
|
|
76
|
-
return async (job) => {
|
|
135
|
+
return async (job, app) => {
|
|
77
136
|
const { prKey, repo, prNumber } = job.variables;
|
|
78
137
|
// `parsePr` is total on any input (fails closed to `null` on a missing/non-string prKey), so
|
|
79
138
|
// pass it straight through — a malformed prKey degrades to the fail-closed target check below.
|
|
@@ -83,6 +142,9 @@ export function makeHandler(deps: {
|
|
|
83
142
|
if (!ghRepo || typeof ghNumber !== "number") {
|
|
84
143
|
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
|
|
85
144
|
}
|
|
145
|
+
// The canonical escalations key. Prefer the carried prKey; fall back to the parsed identity so
|
|
146
|
+
// the override lookup still keys off `owner/repo#N` when only repo/prNumber survived.
|
|
147
|
+
const escPrKey = typeof prKey === "string" && prKey !== "" ? prKey : `${ghRepo}#${ghNumber}`;
|
|
86
148
|
|
|
87
149
|
let result: ConvergeGateResult;
|
|
88
150
|
let scopeReason: string;
|
|
@@ -127,13 +189,58 @@ export function makeHandler(deps: {
|
|
|
127
189
|
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE_BODY };
|
|
128
190
|
}
|
|
129
191
|
|
|
192
|
+
// The human-override door for the scope-integrity block (issue #395). When the deterministic
|
|
193
|
+
// scope guard would re-block, read the commit now under review and consult the recorded
|
|
194
|
+
// escalation answer bound to that SAME HEAD: an operator who answered the scope question for
|
|
195
|
+
// this exact commit has explicitly overridden it ("this fully delivers the issue — keep the
|
|
196
|
+
// closing keyword"), so honour it (audited) instead of re-deriving the block from the body and
|
|
197
|
+
// re-escalating forever. Binding to the HEAD sha keeps the override from carrying across a new
|
|
198
|
+
// push (a different HEAD legitimately re-opens the gate); and if the human instead asked for a
|
|
199
|
+
// real split, the servicing agent pushes a fix — moving the HEAD so this stale override never
|
|
200
|
+
// fires. This is what turns the infinite escalation loop into a resolvable one.
|
|
201
|
+
let headSha: string | null = null;
|
|
202
|
+
let scopeBlocked = scopeReason !== "";
|
|
203
|
+
if (scopeBlocked) {
|
|
204
|
+
try {
|
|
205
|
+
headSha = await deps.readHeadSha(ghRepo, ghNumber);
|
|
206
|
+
} catch {
|
|
207
|
+
headSha = null;
|
|
208
|
+
}
|
|
209
|
+
if (headSha) {
|
|
210
|
+
const override = await findScopeOverride(app, escPrKey, headSha);
|
|
211
|
+
if (override) {
|
|
212
|
+
app.log.info("converge-gate: scope-integrity block overridden by human answer", {
|
|
213
|
+
prKey: escPrKey,
|
|
214
|
+
headSha,
|
|
215
|
+
escalationId: override.escalationId ?? null,
|
|
216
|
+
// The human answer is free-form operator input — never log it verbatim (it can carry
|
|
217
|
+
// sensitive content into application logs). Record only stable identifiers plus a
|
|
218
|
+
// minimal presence/length signal for debugging.
|
|
219
|
+
hasAnswer: override.answer != null && override.answer !== "",
|
|
220
|
+
answerLength: override.answer?.length ?? 0,
|
|
221
|
+
});
|
|
222
|
+
scopeReason = "";
|
|
223
|
+
scopeBlocked = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
130
228
|
// Both guards gate the same handoff to the merge loop: block if EITHER the review-comment gate
|
|
131
229
|
// or the scope-integrity gate blocks, joining their reasons so the human sees every cause.
|
|
132
230
|
const reason = [result.convergeBlockReason, scopeReason].filter((r) => r !== "").join(" ");
|
|
133
|
-
|
|
134
|
-
convergeBlocked: result.convergeBlocked ||
|
|
231
|
+
const out: Out = {
|
|
232
|
+
convergeBlocked: result.convergeBlocked || scopeBlocked,
|
|
135
233
|
convergeBlockReason: reason,
|
|
136
234
|
};
|
|
235
|
+
// Surface the reviewed HEAD and the scope-block flag ONLY when scope actually blocks — the
|
|
236
|
+
// `persist-escalation-blockedcomments` arm (which runs only on a blocked gate) binds the
|
|
237
|
+
// escalation to this commit with them, opening the override door on the next round. A clean
|
|
238
|
+
// converge keeps its original `{ convergeBlocked, convergeBlockReason }` shape.
|
|
239
|
+
if (scopeBlocked) {
|
|
240
|
+
out.scopeBlocked = true;
|
|
241
|
+
out.headSha = headSha ?? undefined;
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
137
244
|
};
|
|
138
245
|
}
|
|
139
246
|
|
|
@@ -141,5 +248,6 @@ const handler = makeHandler({
|
|
|
141
248
|
readThreads: defaultReadThreads,
|
|
142
249
|
readReviewBody: defaultReadReviewBody,
|
|
143
250
|
readPrBody: defaultReadPrBody,
|
|
251
|
+
readHeadSha: defaultReadHeadSha,
|
|
144
252
|
});
|
|
145
253
|
export default handler;
|
|
@@ -50,7 +50,7 @@ function workerOf(vars: Record<string, unknown>): string | undefined {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
const handler: AppJobHandler<In> = async (job, app) => {
|
|
53
|
-
const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl } = job.variables;
|
|
53
|
+
const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl, headSha, scopeBlock } = job.variables;
|
|
54
54
|
// `status` drives the escalation kind (control flow); a blank/absent status is an
|
|
55
55
|
// unclassified escalation -> a question needing input. `question` is returned as a
|
|
56
56
|
// process variable below so the downstream `wait-answer` userTask + `pr-escalation.form`
|
|
@@ -116,6 +116,13 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
116
116
|
worker,
|
|
117
117
|
status: "open",
|
|
118
118
|
asked_at: now,
|
|
119
|
+
// Bind a scope-integrity escalation to the reviewed commit (issue #395) so the converge-gate
|
|
120
|
+
// can honour a human answer as an override for THIS HEAD instead of re-deriving the block from
|
|
121
|
+
// the PR body and re-escalating forever. Only the scope-integrity arm passes these; every other
|
|
122
|
+
// arm leaves them absent (→ head_sha NULL, scope_block DEFAULT 0), so the override door opens
|
|
123
|
+
// exclusively for the block the human can actually answer.
|
|
124
|
+
head_sha: headSha,
|
|
125
|
+
scope_block: scopeBlock === true ? 1 : 0,
|
|
119
126
|
});
|
|
120
127
|
await app.data.table("pull_requests", "pr_key").update(prKey, {
|
|
121
128
|
status: "escalated",
|