@nanobpm/nano-workforce 0.114.1 → 0.116.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 +94 -84
- package/app/enginePreflight.test.ts +122 -0
- package/app/enginePreflight.ts +134 -0
- package/app/persist-escalation.test.ts +0 -33
- package/db/migrations/057_drop_escalation_head_override.sql +16 -0
- package/e2e/convergence-escalation.e2e.ts +6 -0
- package/main.ts +13 -6
- package/nano.app.json +2 -1
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +177 -88
- package/resources/prompts/feature.md +15 -13
- package/resources/prompts/scope-classify.md +142 -0
- package/test/derivation-parity/derivation-parity.test.ts +2 -2
- package/test/derivation-parity/flows.ts +1 -1
- package/workers/converge-gate/worker.ts +14 -157
- package/workers/persist-escalation/worker.ts +1 -8
- package/app/scopeGuard.test.ts +0 -185
- package/app/scopeGuard.ts +0 -165
- package/workers/converge-gate/worker.test.ts +0 -116
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.116.0](https://github.com/nanobpm/nano-workforce/compare/v0.115.0...v0.116.0) (2026-08-20)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* replace deterministic scope regex with a scope-integrity agent classifier ([#403](https://github.com/nanobpm/nano-workforce/issues/403)) ([588b869](https://github.com/nanobpm/nano-workforce/commit/588b8699ea738ff0772f23d68bc051e8ff417d49)), closes [#395](https://github.com/nanobpm/nano-workforce/issues/395) [#395](https://github.com/nanobpm/nano-workforce/issues/395) [#395](https://github.com/nanobpm/nano-workforce/issues/395) [398/#399](https://github.com/nanobpm/nano-workforce/issues/399) [#395](https://github.com/nanobpm/nano-workforce/issues/395) [#395](https://github.com/nanobpm/nano-workforce/issues/395) [#395](https://github.com/nanobpm/nano-workforce/issues/395)
|
|
7
|
+
|
|
8
|
+
# [0.115.0](https://github.com/nanobpm/nano-workforce/compare/v0.114.1...v0.115.0) (2026-08-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **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)
|
|
14
|
+
|
|
1
15
|
## [0.114.1](https://github.com/nanobpm/nano-workforce/compare/v0.114.0...v0.114.1) (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
|
@@ -217,15 +217,9 @@ test("pickLatestCopilotReviewBody: FAILS CLOSED (null) when the reviews read was
|
|
|
217
217
|
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
|
-
readPrBody?: (repo: string, n: number) => Promise<string | null>;
|
|
221
|
-
readHeadSha?: (repo: string, n: number) => Promise<string | null>;
|
|
222
220
|
}) {
|
|
223
221
|
const { makeHandler } = await import("../workers/converge-gate/worker.ts");
|
|
224
|
-
|
|
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 });
|
|
222
|
+
return makeHandler(deps);
|
|
229
223
|
}
|
|
230
224
|
|
|
231
225
|
test("converge-gate: a clean PR is allowed to converge", async () => {
|
|
@@ -348,82 +342,6 @@ test("converge-gate: resolves repo/prNumber from the prKey when the vars are abs
|
|
|
348
342
|
assertEquals(seen, ["o/r", 7]);
|
|
349
343
|
});
|
|
350
344
|
|
|
351
|
-
// ── The scope-integrity guard through the worker (#313) ─────────────────────
|
|
352
|
-
|
|
353
|
-
test("converge-gate: a partial delivery that Closes a broader-scoped parent blocks convergence", async () => {
|
|
354
|
-
const handler = await makeUnderTest({
|
|
355
|
-
readThreads: async () => [],
|
|
356
|
-
readReviewBody: async () => "",
|
|
357
|
-
readPrBody: async () =>
|
|
358
|
-
"Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools remain the deferred refinement.\n\nCloses #631",
|
|
359
|
-
});
|
|
360
|
-
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
361
|
-
assertEquals(out.convergeBlocked, true);
|
|
362
|
-
assertStringIncludes(out.convergeBlockReason ?? "", "Scope integrity blocked");
|
|
363
|
-
assertStringIncludes(out.convergeBlockReason ?? "", "#631");
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
test("converge-gate: a deferral with a filed follow-up issue and a non-closing ref converges", async () => {
|
|
367
|
-
const handler = await makeUnderTest({
|
|
368
|
-
readThreads: async () => [],
|
|
369
|
-
readReviewBody: async () => "",
|
|
370
|
-
readPrBody: async () =>
|
|
371
|
-
"Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools are deferred.\nTracked-in: #872\n\nRefs #631",
|
|
372
|
-
});
|
|
373
|
-
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
374
|
-
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
test("converge-gate: a full-scope Closes PR with no deferral prose converges", async () => {
|
|
378
|
-
const handler = await makeUnderTest({
|
|
379
|
-
readThreads: async () => [],
|
|
380
|
-
readReviewBody: async () => "",
|
|
381
|
-
readPrBody: async () => "Implements the feature end to end.\n\nCloses #313",
|
|
382
|
-
});
|
|
383
|
-
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
384
|
-
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
test("converge-gate: a scope block and a comment block are reported together", async () => {
|
|
388
|
-
const handler = await makeUnderTest({
|
|
389
|
-
readThreads: async () => [{ isResolved: false, path: "a.ts", bodies: ["please fix"] }],
|
|
390
|
-
readReviewBody: async () => "",
|
|
391
|
-
readPrBody: async () => "Ships one half.\n\nDeferred: the rest.\n\nCloses #631",
|
|
392
|
-
});
|
|
393
|
-
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
394
|
-
assertEquals(out.convergeBlocked, true);
|
|
395
|
-
assertStringIncludes(out.convergeBlockReason ?? "", "unresolved review thread");
|
|
396
|
-
assertStringIncludes(out.convergeBlockReason ?? "", "Scope integrity blocked");
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
test("converge-gate: FAILS CLOSED when the PR-body read returns null (no transport)", async () => {
|
|
400
|
-
const handler = await makeUnderTest({
|
|
401
|
-
readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
|
|
402
|
-
readReviewBody: async () => "",
|
|
403
|
-
readPrBody: async () => null,
|
|
404
|
-
});
|
|
405
|
-
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
406
|
-
assertEquals(out.convergeBlocked, true);
|
|
407
|
-
assertStringIncludes(out.convergeBlockReason ?? "", "could not read the PR description");
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
test("converge-gate: FAILS CLOSED with the SCOPE reason when the PR-body read throws", async () => {
|
|
411
|
-
// A transport failure while reading/parsing the PR body is a scope-integrity read failure, not a
|
|
412
|
-
// review-comment verification failure: it must surface BLOCK_UNVERIFIABLE_BODY, not the generic
|
|
413
|
-
// review-comment BLOCK_UNVERIFIABLE — otherwise the human escalation is pointed at review threads
|
|
414
|
-
// when the real problem is the PR description could not be read.
|
|
415
|
-
const handler = await makeUnderTest({
|
|
416
|
-
readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
|
|
417
|
-
readReviewBody: async () => "",
|
|
418
|
-
readPrBody: async () => {
|
|
419
|
-
throw new Error("boom");
|
|
420
|
-
},
|
|
421
|
-
});
|
|
422
|
-
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
423
|
-
assertEquals(out.convergeBlocked, true);
|
|
424
|
-
assertStringIncludes(out.convergeBlockReason ?? "", "could not read the PR description");
|
|
425
|
-
});
|
|
426
|
-
|
|
427
345
|
// ── Structural guard over the committed BPMN (no engine) ─────────────────────
|
|
428
346
|
|
|
429
347
|
const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
@@ -460,16 +378,108 @@ test("gw-converge-gate blocks on an explicit convergeBlocked = true condition",
|
|
|
460
378
|
assertStringIncludes(f, "convergeBlocked = true");
|
|
461
379
|
});
|
|
462
380
|
|
|
463
|
-
test("gw-converge-gate default arm
|
|
381
|
+
test("gw-converge-gate default arm routes to the scope classifier (not straight to finalize)", () => {
|
|
464
382
|
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-converge-gate"[^>]*>/);
|
|
465
383
|
assert(gw, "gw-converge-gate gateway missing");
|
|
466
384
|
assertStringIncludes(gw[0], 'default="f_convergeOk"');
|
|
467
385
|
const ok = flowElement("f_convergeOk");
|
|
468
386
|
assert(ok, "f_convergeOk flow missing");
|
|
387
|
+
assertStringIncludes(ok, 'targetRef="classify-scope"');
|
|
388
|
+
assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// ── The scope classifier (agent task) replaces the deterministic scope regex ──
|
|
392
|
+
|
|
393
|
+
test("classify-scope is an agent task servicing senior:scope-classify with a linked prompt", () => {
|
|
394
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="classify-scope"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
395
|
+
assert(task, "classify-scope service task missing");
|
|
396
|
+
assertStringIncludes(task[0], 'type="senior:scope-classify"');
|
|
397
|
+
assertStringIncludes(task[0], 'resourceId="scope-classify.md"');
|
|
398
|
+
assertStringIncludes(task[0], 'linkName="prompt"');
|
|
399
|
+
// After honouring (or ignoring) the human's scope answer, it clears the one-shot
|
|
400
|
+
// scopeAnswer so a later round does not re-honour a stale decision.
|
|
401
|
+
assertStringIncludes(task[0], 'source="=null" target="scopeAnswer"');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// ── The scope-answer plumbing (#395 loop-defect fix) ─────────────────────────
|
|
405
|
+
// review-round clears `answer` on every round, so a human's scope answer cannot reach
|
|
406
|
+
// the downstream classify-scope via `answer`. A dedicated `scopeAnswer` variable, gated
|
|
407
|
+
// by a `scopePending` marker, carries the decision across the review round without being
|
|
408
|
+
// confused with answers to other escalation kinds.
|
|
409
|
+
|
|
410
|
+
test("PrScopeClassifyIn feeds the classifier the surviving scopeAnswer, not the cleared answer", () => {
|
|
411
|
+
const shape = flat.match(/<nano:shape\b[^>]*\bid="PrScopeClassifyIn"[^>]*>.*?<\/nano:shape>/);
|
|
412
|
+
assert(shape, "PrScopeClassifyIn envelope missing");
|
|
413
|
+
assertStringIncludes(shape[0], 'name="scopeAnswer"');
|
|
414
|
+
assert(!/name="answer"/.test(shape[0]), "classifier must read scopeAnswer, not the shared answer");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("PrScopeClassifyOut.scopeBlockReason is required (always emitted, empty when not blocked)", () => {
|
|
418
|
+
const shape = flat.match(/<nano:shape\b[^>]*\bid="PrScopeClassifyOut"[^>]*>.*?<\/nano:shape>/);
|
|
419
|
+
assert(shape, "PrScopeClassifyOut envelope missing");
|
|
420
|
+
assert(
|
|
421
|
+
/name="scopeBlockReason"(?![^>]*optional)/.test(shape[0]),
|
|
422
|
+
"scopeBlockReason must not be optional — the wire contract requires it always present",
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("persist-escalation-scope marks the open escalation as scope-kind (scopePending = true)", () => {
|
|
427
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="persist-escalation-scope"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
428
|
+
assert(task, "persist-escalation-scope task missing");
|
|
429
|
+
assertStringIncludes(task[0], 'source="=true" target="scopePending"');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("record-answer captures a scope answer into scopeAnswer only while scopePending", () => {
|
|
433
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="record-answer"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
434
|
+
assert(task, "record-answer task missing");
|
|
435
|
+
// Capture is gated on scopePending so an answer to a *different* escalation is not
|
|
436
|
+
// mis-read as a scope override; otherwise scopeAnswer is preserved.
|
|
437
|
+
assertStringIncludes(
|
|
438
|
+
task[0],
|
|
439
|
+
'source="=(if scopePending = true then answer else scopeAnswer)" target="scopeAnswer"',
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("review-round resets the scopePending marker after record-answer has consumed it", () => {
|
|
444
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="review-round"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
445
|
+
assert(task, "review-round task missing");
|
|
446
|
+
assertStringIncludes(task[0], 'source="=false" target="scopePending"');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("classify-scope feeds gw-scope-gate, which blocks on scopeBlocked = true", () => {
|
|
450
|
+
const toGate = flowElement("f_toScopeGate");
|
|
451
|
+
assert(toGate, "f_toScopeGate flow missing");
|
|
452
|
+
assertStringIncludes(toGate, 'sourceRef="classify-scope"');
|
|
453
|
+
assertStringIncludes(toGate, 'targetRef="gw-scope-gate"');
|
|
454
|
+
const blocked = flowElement("f_scopeBlocked");
|
|
455
|
+
assert(blocked, "f_scopeBlocked flow missing");
|
|
456
|
+
assertStringIncludes(blocked, 'targetRef="persist-escalation-scope"');
|
|
457
|
+
assertStringIncludes(blocked, "scopeBlocked = true");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("gw-scope-gate default arm finalizes (scope ok → persist-converged)", () => {
|
|
461
|
+
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-scope-gate"[^>]*>/);
|
|
462
|
+
assert(gw, "gw-scope-gate gateway missing");
|
|
463
|
+
assertStringIncludes(gw[0], 'default="f_scopeOk"');
|
|
464
|
+
const ok = flowElement("f_scopeOk");
|
|
465
|
+
assert(ok, "f_scopeOk flow missing");
|
|
469
466
|
assertStringIncludes(ok, 'targetRef="persist-converged"');
|
|
470
467
|
assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
|
|
471
468
|
});
|
|
472
469
|
|
|
470
|
+
test("the scope escalation routes through gw-escalated with the classifier's specific reason", () => {
|
|
471
|
+
const f = flowElement("f_scopeEscGate");
|
|
472
|
+
assert(f, "f_scopeEscGate flow missing");
|
|
473
|
+
assertStringIncludes(f, 'sourceRef="persist-escalation-scope"');
|
|
474
|
+
assertStringIncludes(f, 'targetRef="gw-escalated"');
|
|
475
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="persist-escalation-scope"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
476
|
+
assert(task, "persist-escalation-scope task missing");
|
|
477
|
+
assertStringIncludes(task[0], 'type="pr.persist-escalation"');
|
|
478
|
+
assertStringIncludes(task[0], 'target="question"');
|
|
479
|
+
// The human sees the classifier's specific finding, not a generic boilerplate reason.
|
|
480
|
+
assertStringIncludes(task[0], "scopeBlockReason");
|
|
481
|
+
});
|
|
482
|
+
|
|
473
483
|
test("the blocked-comments escalation routes through gw-escalated toward an answerable wait-answer", () => {
|
|
474
484
|
// #333: previously this flowed UNCONDITIONALLY into wait-answer, so a blank convergeBlockReason
|
|
475
485
|
// (the question is mapped from that OPTIONAL variable) opened no escalation yet still parked a
|
|
@@ -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,36 +212,3 @@ 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
|
-
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Contract phase for the escalation scope-override columns (migration 056, issue #395).
|
|
2
|
+
--
|
|
3
|
+
-- Migration 056 added `escalations.head_sha` + `escalations.scope_block` to give the DETERMINISTIC
|
|
4
|
+
-- scope-integrity gate a human-override door: it bound a scope escalation to the reviewed HEAD so a
|
|
5
|
+
-- same-HEAD human answer could override the block instead of re-escalating forever. That whole
|
|
6
|
+
-- deterministic gate has since been replaced by the `senior:scope-classify` AGENT classifier, which
|
|
7
|
+
-- reads each closed issue's acceptance criteria and honours the recorded human `answer` directly —
|
|
8
|
+
-- so nothing writes or reads these two columns any more. They are dead schema (a drift surface with
|
|
9
|
+
-- no source of truth behind them), so drop them.
|
|
10
|
+
--
|
|
11
|
+
-- 056 is forward-only and immutable (its ledger row stays), so this is the standard expand→contract
|
|
12
|
+
-- follow-up rather than an edit to 056. Both drops are safe: the columns were nullable/defaulted and
|
|
13
|
+
-- have no remaining writer or reader. Numbered after the current highest prefix (056); the runner
|
|
14
|
+
-- wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
15
|
+
ALTER TABLE escalations DROP COLUMN scope_block;
|
|
16
|
+
ALTER TABLE escalations DROP COLUMN head_sha;
|
|
@@ -87,6 +87,12 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
|
|
|
87
87
|
capturedAnswer = (job.variables as Record<string, unknown>).answer;
|
|
88
88
|
return { status: "converged", summary: "resolved after the human answer" };
|
|
89
89
|
});
|
|
90
|
+
// `senior:scope-classify` is likewise an externalTaskType (no app worker): the converged round
|
|
91
|
+
// routes through it before finalizing. Stub it as "scope delivered" so the happy path reaches
|
|
92
|
+
// persist-converged rather than parking on an unserviced agent job.
|
|
93
|
+
await app.engine.registerWorker("senior:scope-classify", () => {
|
|
94
|
+
return { scopeBlocked: false, scopeBlockReason: "" };
|
|
95
|
+
});
|
|
90
96
|
});
|
|
91
97
|
|
|
92
98
|
after(async () => {
|