@nanobpm/nano-workforce 0.186.1 → 0.186.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +14 -3
- package/app/consoleRedirect.test.ts +127 -0
- package/app/consoleRedirect.ts +122 -0
- package/app/contracts.ts +8 -1
- package/app/mergeLoopBehaviour.test.ts +90 -0
- package/app/mergeableWait.test.ts +42 -1
- package/app/mergeableWait.ts +23 -0
- package/app/service.ts +15 -1
- package/main.ts +21 -0
- package/package.json +1 -1
- package/resources/processes/merge-loop.bpmn +291 -235
- package/workers/merge-stall-probe/worker.ts +10 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.186.3](https://github.com/nanobpm/nano-workforce/compare/v0.186.2...v0.186.3) (2026-09-12)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **app:** redirect standalone /console routes to the engine console ([#780](https://github.com/nanobpm/nano-workforce/issues/780)) ([103e967](https://github.com/nanobpm/nano-workforce/commit/103e967b95191bb04239b5c6a2195913e50b8b9b)), closes [#771](https://github.com/nanobpm/nano-workforce/issues/771)
|
|
6
|
+
|
|
7
|
+
## [0.186.2](https://github.com/nanobpm/nano-workforce/compare/v0.186.1...v0.186.2) (2026-09-11)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **merge-loop:** re-poll a 'waiting' mergeability verdict instead of escalating ([#775](https://github.com/nanobpm/nano-workforce/issues/775)) ([4812fe9](https://github.com/nanobpm/nano-workforce/commit/4812fe9689088f21e41ef50ba8f1dbec2093fb23)), closes [#774](https://github.com/nanobpm/nano-workforce/issues/774) [#774](https://github.com/nanobpm/nano-workforce/issues/774) [#774](https://github.com/nanobpm/nano-workforce/issues/774)
|
|
12
|
+
|
|
1
13
|
## [0.186.1](https://github.com/nanobpm/nano-workforce/compare/v0.186.0...v0.186.1) (2026-09-11)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/SPEC.md
CHANGED
|
@@ -216,7 +216,7 @@ Consequences the prompt (`resources/prompts/review-round.md`) encodes:
|
|
|
216
216
|
| `pr-submitted` | — (start) | submit route/webhook | `{repo, prNumber, prUrl, prKey}` |
|
|
217
217
|
| `readiness-ready` | `prKey` | **poller** | `{ready, detail?}` (ADR 0001 §2 wait-gate; the review-ready wait, re-expressed on the ReadinessProbe gate — #259) |
|
|
218
218
|
| `deps-cleared` | `prKey` | **poller** (merge) | — (all `Depends-on` PRs merged) |
|
|
219
|
-
| `merge-ready` | `prKey` | **poller** (merge) | `{mergeState}` (`ready` \| `conflict` \| `blocked`); when `blocked`, also `{failingChecks, failingChecksList}` for the `senior:fix-ci` branch |
|
|
219
|
+
| `merge-ready` | `prKey` | **poller** (merge) | `{mergeState}` (`ready` \| `conflict` \| `blocked` \| `draft`); when `blocked`, also `{failingChecks, failingChecksList}` for the `senior:fix-ci` branch. A transient `UNKNOWN`/`waiting` verdict is NOT published — the poller keeps polling (fast self-heal); the merge loop's `gw-mergeable` sees `waiting` only via the dead-poller stall-probe re-derivation, where it re-polls (bounded, #774) instead of escalating |
|
|
220
220
|
| `merge-landed` | `prKey` | **poller** (merge) | — (queued PR merged, or merged out-of-band) |
|
|
221
221
|
|
|
222
222
|
The `escalation-answered` message was retired (#256): the merge-loop escalation is
|
|
@@ -466,10 +466,21 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
|
|
|
466
466
|
time** by a `fix-ci`/`rebase` agent (`status: "waiting-on-pr"`, above). `merge-loop`
|
|
467
467
|
parks at *wait: deps merged*; the poller checks each dependency (own tracked row
|
|
468
468
|
first, else GitHub `merged` state) and publishes `deps-cleared` once all have landed.
|
|
469
|
-
- **Mergeability** — the poller classifies GitHub's `mergeStateStatus
|
|
469
|
+
- **Mergeability** — the poller classifies GitHub's `mergeStateStatus`, but a
|
|
470
|
+
first-class `isDraft` guard runs *first* (`classifyMergeability`, `app/github.ts`):
|
|
471
|
+
a draft PR is `draft` regardless of `mergeStateStatus`. Otherwise, by status:
|
|
470
472
|
`CLEAN`/`HAS_HOOKS`/`UNSTABLE`/`BEHIND` → `ready`; `DIRTY` → `conflict`;
|
|
471
473
|
`BLOCKED` → `blocked` if a required check is failing, else keep waiting;
|
|
472
|
-
`
|
|
474
|
+
`UNKNOWN`/empty (and any other status, including `DRAFT`) → keep polling (GitHub
|
|
475
|
+
still computing). It
|
|
476
|
+
publishes `merge-ready {mergeState}` for the settled verdicts. When the live
|
|
477
|
+
poller is dead the `wait-mergeable-timeout` backstop's stall-probe re-derives
|
|
478
|
+
mergeability; a still-unsettled `UNKNOWN`/`waiting` verdict then routes to a
|
|
479
|
+
**bounded re-poll** (`wait-mergeable-repoll`, `NANO_PR_MERGEABLE_REPOLL_INTERVAL`)
|
|
480
|
+
rather than escalating (bounded by `NANO_PR_MAX_MERGE_STALL_ROUNDS`), and an
|
|
481
|
+
unclassified/default verdict routes to auto-rebase (`gw-rebase`, bounded by its
|
|
482
|
+
own `NANO_PR_MAX_REBASE_ROUNDS` budget); once either budget is exhausted the loop
|
|
483
|
+
escalates to a human (#774).
|
|
473
484
|
- **Merge** — `pr.merge` attempts the merge (`NANO_PR_MERGE_METHOD`, default
|
|
474
485
|
`squash`). GitHub auto-enqueues on merge-queue-required branches → the process
|
|
475
486
|
waits for `merge-landed` (poller detects the landed PR). Every attempt is
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Tests for app/consoleRedirect.ts — the standalone `/console` → engine-console redirect (issue
|
|
2
|
+
// #771). Covers the pure match/target derivation, origin resolution from NANOBPMN_BASE_URL, and the
|
|
3
|
+
// live listener-rewiring mount over a real node:http server (redirect fires for `/console/*`,
|
|
4
|
+
// passthrough for everything else, teardown restores the original handler).
|
|
5
|
+
import { createServer, type Server } from "node:http";
|
|
6
|
+
import type { AddressInfo } from "node:net";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assert, assertEquals } from "#test-assert";
|
|
9
|
+
import {
|
|
10
|
+
consoleRedirectLocation,
|
|
11
|
+
mountConsoleRedirect,
|
|
12
|
+
resolveConsoleOrigin,
|
|
13
|
+
} from "./consoleRedirect.ts";
|
|
14
|
+
|
|
15
|
+
test("redirects a bare /console path to the console origin", () => {
|
|
16
|
+
assertEquals(consoleRedirectLocation("/console", "http://localhost:8080"), "http://localhost:8080/console");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("preserves the full path and query under /console/", () => {
|
|
20
|
+
assertEquals(
|
|
21
|
+
consoleRedirectLocation("/console/app-view/Foo?tab=bar&x=1", "http://localhost:8080"),
|
|
22
|
+
"http://localhost:8080/console/app-view/Foo?tab=bar&x=1",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("leaves /app/* and root routes unchanged (null)", () => {
|
|
27
|
+
assertEquals(consoleRedirectLocation("/app/api/agent", "http://localhost:8080"), null);
|
|
28
|
+
assertEquals(consoleRedirectLocation("/", "http://localhost:8080"), null);
|
|
29
|
+
assertEquals(consoleRedirectLocation("/agentic", "http://localhost:8080"), null);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("does not match a sibling route that merely starts with 'console'", () => {
|
|
33
|
+
assertEquals(consoleRedirectLocation("/console-x", "http://localhost:8080"), null);
|
|
34
|
+
assertEquals(consoleRedirectLocation("/consolexyz/app", "http://localhost:8080"), null);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("matches /console immediately followed by a query", () => {
|
|
38
|
+
assertEquals(consoleRedirectLocation("/console?next=1", "http://localhost:8080"), "http://localhost:8080/console?next=1");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("normalises a trailing slash on the console origin", () => {
|
|
42
|
+
assertEquals(consoleRedirectLocation("/console/x", "http://localhost:8080/"), "http://localhost:8080/console/x");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("handles an absent url", () => {
|
|
46
|
+
assertEquals(consoleRedirectLocation(undefined, "http://localhost:8080"), null);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("resolveConsoleOrigin gives CAMUNDA_REST_ADDRESS precedence over NANOBPMN_BASE_URL", () => {
|
|
50
|
+
const read = (name: string): string | null =>
|
|
51
|
+
name === "CAMUNDA_REST_ADDRESS"
|
|
52
|
+
? "http://engine.example:8080/v2"
|
|
53
|
+
: name === "NANOBPMN_BASE_URL"
|
|
54
|
+
? "http://localhost:9999"
|
|
55
|
+
: null;
|
|
56
|
+
assertEquals(resolveConsoleOrigin(read), "http://engine.example:8080");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("resolveConsoleOrigin uses NANOBPMN_BASE_URL when CAMUNDA_REST_ADDRESS is unset", () => {
|
|
60
|
+
const read = (name: string): string | null =>
|
|
61
|
+
name === "NANOBPMN_BASE_URL" ? "https://engine.example.com:9000" : null;
|
|
62
|
+
assertEquals(resolveConsoleOrigin(read), "https://engine.example.com:9000");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("resolveConsoleOrigin derives the origin from NANOBPMN_BASE_URL", () => {
|
|
66
|
+
const read = (name: string): string | null =>
|
|
67
|
+
name === "NANOBPMN_BASE_URL" ? "https://engine.example.com:9000" : null;
|
|
68
|
+
assertEquals(resolveConsoleOrigin(read), "https://engine.example.com:9000");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("resolveConsoleOrigin strips any path from the base", () => {
|
|
72
|
+
assertEquals(resolveConsoleOrigin(() => "https://engine.example.com/v2/"), "https://engine.example.com");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("resolveConsoleOrigin defaults to localhost:8080 when unset", () => {
|
|
76
|
+
assertEquals(resolveConsoleOrigin(() => null), "http://localhost:8080");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("resolveConsoleOrigin falls back to the default on an unparseable value", () => {
|
|
80
|
+
assertEquals(resolveConsoleOrigin(() => "not a url"), "http://localhost:8080");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** Spin up a real server with an app handler, mount the redirect, and return a fetch helper. */
|
|
84
|
+
async function withServer(
|
|
85
|
+
run: (base: string, teardown: () => void) => Promise<void>,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const server: Server = createServer((_req, res) => {
|
|
88
|
+
res.statusCode = 200;
|
|
89
|
+
res.setHeader("content-type", "text/plain");
|
|
90
|
+
res.end("app-handled");
|
|
91
|
+
});
|
|
92
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
93
|
+
const teardown = mountConsoleRedirect(server, "http://console.example:8080");
|
|
94
|
+
const { port } = server.address() as AddressInfo;
|
|
95
|
+
try {
|
|
96
|
+
await run(`http://127.0.0.1:${port}`, teardown);
|
|
97
|
+
} finally {
|
|
98
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test("mount: /console/* is answered with a 302 to the console origin, not the app handler", async () => {
|
|
103
|
+
await withServer(async (base) => {
|
|
104
|
+
const res = await fetch(`${base}/console/app-view/Workforce?tab=x`, { redirect: "manual" });
|
|
105
|
+
assertEquals(res.status, 302);
|
|
106
|
+
assertEquals(res.headers.get("location"), "http://console.example:8080/console/app-view/Workforce?tab=x");
|
|
107
|
+
// 302 body is empty — the app handler must not have run.
|
|
108
|
+
assertEquals(await res.text(), "");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("mount: a non-console request is delegated to the app handler unchanged", async () => {
|
|
113
|
+
await withServer(async (base) => {
|
|
114
|
+
const res = await fetch(`${base}/app/api/agent`, { redirect: "manual" });
|
|
115
|
+
assertEquals(res.status, 200);
|
|
116
|
+
assertEquals(await res.text(), "app-handled");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("mount: teardown restores the original app handler for /console too", async () => {
|
|
121
|
+
await withServer(async (base, teardown) => {
|
|
122
|
+
teardown();
|
|
123
|
+
const res = await fetch(`${base}/console/x`, { redirect: "manual" });
|
|
124
|
+
assertEquals(res.status, 200);
|
|
125
|
+
assert((await res.text()) === "app-handled");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Standalone `/console` → engine-console redirect (issue #771).
|
|
2
|
+
//
|
|
3
|
+
// When Nano Workforce runs **embedded** behind the nano console, the console origin serves
|
|
4
|
+
// `/console/*` (its app-view host, `/console/app-view/Workforce/…`) and the reverse proxy strips the
|
|
5
|
+
// `/console/app-view/Workforce` prefix before the app's own HTTP server ever sees the request — so
|
|
6
|
+
// the app only ever handles `/app/*`, `/agentic`, and its page routes at the root.
|
|
7
|
+
//
|
|
8
|
+
// Run **standalone** (the default `npm start` on a bare port, no console proxy in front), links the
|
|
9
|
+
// UI emits under `/console` — the engine console's own origin — hit this app's port instead, where
|
|
10
|
+
// nothing serves them: the runtime answers a bare 503 because `/console/*` is not a registered
|
|
11
|
+
// app-view route. This is a narrow Node request redirect that rewrites those `/console/*` requests to
|
|
12
|
+
// the engine console origin, preserving the full path + query, and leaves every other route
|
|
13
|
+
// (`/app/*`, `/agentic`, the Workforce page routes at the root) untouched — it only ever fires for a
|
|
14
|
+
// path that is exactly `/console` or under `/console/`.
|
|
15
|
+
//
|
|
16
|
+
// The redirect is derived, not configured twice: the engine console shares the engine's origin, so
|
|
17
|
+
// the target origin comes from the CANONICAL engine-address resolution (`resolveEngineAddress`,
|
|
18
|
+
// `app/enginePreflight.ts`) — an explicit `CAMUNDA_REST_ADDRESS` wins over `NANOBPMN_BASE_URL`
|
|
19
|
+
// (default `http://localhost:8080`), the same precedence and single source of truth the engine
|
|
20
|
+
// client uses, so the redirect target can never drift from the engine the app actually talks to.
|
|
21
|
+
// Mounting it when embedded is harmless: the proxy owns `/console` there, so the app's server never
|
|
22
|
+
// sees such a path.
|
|
23
|
+
|
|
24
|
+
import type { IncomingMessage, RequestListener, Server, ServerResponse } from "node:http";
|
|
25
|
+
import { resolveEngineAddress } from "./enginePreflight.ts";
|
|
26
|
+
import { envVar } from "./version.ts";
|
|
27
|
+
|
|
28
|
+
/** A minimal logging surface (structurally a subset of `Logger`) the mount uses at boot. */
|
|
29
|
+
export interface ConsoleRedirectLog {
|
|
30
|
+
info(msg: string): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The `Location` a `/console`-prefixed request should be redirected to, or `null` when the request
|
|
35
|
+
* is NOT a console route (and so must be left for the app's own handlers).
|
|
36
|
+
*
|
|
37
|
+
* Matches ONLY a path that is exactly `/console` or begins with `/console/` — a sibling route such
|
|
38
|
+
* as `/console-x` or `/app/console` is deliberately NOT matched, keeping the redirect narrow. The
|
|
39
|
+
* full original request target (path + query) is preserved by appending it verbatim to the console
|
|
40
|
+
* origin: because the request path already carries the `/console` prefix, the result is
|
|
41
|
+
* `<consoleOrigin>/console/<rest>?<query>`.
|
|
42
|
+
*
|
|
43
|
+
* @param url the raw request target (`req.url`), e.g. `/console/app-view/Foo?tab=bar`.
|
|
44
|
+
* @param consoleOrigin the engine console origin (scheme + authority, no trailing slash), e.g.
|
|
45
|
+
* `http://localhost:8080`.
|
|
46
|
+
*/
|
|
47
|
+
export function consoleRedirectLocation(
|
|
48
|
+
url: string | undefined,
|
|
49
|
+
consoleOrigin: string,
|
|
50
|
+
): string | null {
|
|
51
|
+
if (!url) return null;
|
|
52
|
+
const queryAt = url.indexOf("?");
|
|
53
|
+
const path = queryAt === -1 ? url : url.slice(0, queryAt);
|
|
54
|
+
if (path === "/console" || path.startsWith("/console/")) {
|
|
55
|
+
return `${consoleOrigin.replace(/\/+$/, "")}${url}`;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the engine console origin (scheme + authority) from the CANONICAL engine-address
|
|
62
|
+
* resolution (`resolveEngineAddress`, `app/enginePreflight.ts`). The engine console is served on the
|
|
63
|
+
* engine's own origin, so this reuses the single engine-base source of truth — including its
|
|
64
|
+
* precedence — rather than reading one input directly. That means an explicit `CAMUNDA_REST_ADDRESS`
|
|
65
|
+
* (already the `/v2` REST address) wins over `NANOBPMN_BASE_URL`, exactly as the engine client
|
|
66
|
+
* resolves it; the redirect target can never drift from the engine the app actually talks to. Only
|
|
67
|
+
* the origin is meaningful for the redirect, so any path/query on the resolved address (e.g. the
|
|
68
|
+
* `/v2` suffix) is discarded. Falls back to the localhost default when the resolved value is
|
|
69
|
+
* unparseable.
|
|
70
|
+
*
|
|
71
|
+
* `read` is injectable so resolution is testable without mutating `process.env`.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveConsoleOrigin(read: (name: string) => string | null = envVar): string {
|
|
74
|
+
const { restAddress } = resolveEngineAddress(read);
|
|
75
|
+
try {
|
|
76
|
+
return new URL(restAddress).origin;
|
|
77
|
+
} catch {
|
|
78
|
+
return "http://localhost:8080";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Prepend a `/console` → engine-console redirect in front of `server`'s existing request handling.
|
|
84
|
+
*
|
|
85
|
+
* The runtime has already attached the app's own `request` listener(s) by the time this runs, so we
|
|
86
|
+
* capture them, remove them, and install a single wrapper that either answers a `/console/*` request
|
|
87
|
+
* with a `302` (preserving path + query) or delegates to every captured listener unchanged. This
|
|
88
|
+
* keeps the app's own routing (control API, pages, agentic upgrade) intact for every non-console
|
|
89
|
+
* request while giving the redirect first refusal on the console prefix.
|
|
90
|
+
*
|
|
91
|
+
* Returns a teardown that restores the original listener set.
|
|
92
|
+
*/
|
|
93
|
+
export function mountConsoleRedirect(
|
|
94
|
+
server: Server,
|
|
95
|
+
consoleOrigin: string,
|
|
96
|
+
log?: ConsoleRedirectLog,
|
|
97
|
+
): () => void {
|
|
98
|
+
// `Server#listeners` is typed `Function[]` by Node; narrow to the request-listener signature so we
|
|
99
|
+
// can re-invoke and later restore them.
|
|
100
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for Node's untyped listeners()
|
|
101
|
+
const existing = server.listeners("request") as RequestListener[];
|
|
102
|
+
server.removeAllListeners("request");
|
|
103
|
+
|
|
104
|
+
const handler = (req: IncomingMessage, res: ServerResponse): void => {
|
|
105
|
+
const location = consoleRedirectLocation(req.url, consoleOrigin);
|
|
106
|
+
if (location !== null) {
|
|
107
|
+
res.statusCode = 302;
|
|
108
|
+
res.setHeader("Location", location);
|
|
109
|
+
res.end();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const listener of existing) listener.call(server, req, res);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
server.on("request", handler);
|
|
116
|
+
log?.info(`console redirect mounted: /console/* → ${consoleOrigin}/console/* (standalone links)`);
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
server.removeListener("request", handler);
|
|
120
|
+
for (const listener of existing) server.on("request", listener);
|
|
121
|
+
};
|
|
122
|
+
}
|
package/app/contracts.ts
CHANGED
|
@@ -137,7 +137,7 @@ export const ENV_CONTRACTS = {
|
|
|
137
137
|
name: "NANO_PR_MAX_MERGE_STALL_ROUNDS",
|
|
138
138
|
owner: "app/service.ts",
|
|
139
139
|
semantics:
|
|
140
|
-
"Maximum mergeable-wait-timeout stall-probe re-derivations (dead-poller backstop, #636) before escalating; 0 escalates on the first stall.",
|
|
140
|
+
"Maximum mergeable-wait-timeout stall-probe re-derivations (dead-poller backstop, #636) AND `waiting`-verdict re-poll probes (#774) before escalating; 0 escalates on the first stall.",
|
|
141
141
|
default: "3",
|
|
142
142
|
},
|
|
143
143
|
NANO_PR_REVIEW_WAIT_TIMEOUT: {
|
|
@@ -166,6 +166,13 @@ export const ENV_CONTRACTS = {
|
|
|
166
166
|
semantics:
|
|
167
167
|
"How long the merge loop waits for the poller's `merge-ready` before the stall-probe timer arm fires (dead-poller backstop, #636; FEEL/ISO-8601 duration).",
|
|
168
168
|
},
|
|
169
|
+
NANO_PR_MERGEABLE_REPOLL_INTERVAL: {
|
|
170
|
+
category: "env",
|
|
171
|
+
name: "NANO_PR_MERGEABLE_REPOLL_INTERVAL",
|
|
172
|
+
owner: "app/service.ts",
|
|
173
|
+
semantics:
|
|
174
|
+
"How long the merge loop waits before re-deriving mergeability when `gw-mergeable` sees an async-`UNKNOWN` `\"waiting\"` verdict, instead of escalating to a human (#774; bounded by `NANO_PR_MAX_MERGE_STALL_ROUNDS`; FEEL/ISO-8601 duration).",
|
|
175
|
+
},
|
|
169
176
|
NANO_PR_AUTO_MERGE: {
|
|
170
177
|
category: "env",
|
|
171
178
|
name: "NANO_PR_AUTO_MERGE",
|
|
@@ -43,6 +43,7 @@ const MODEL = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
|
43
43
|
const AGENT_SLA_MS = 30 * 60 * 1000; // matches the PT30M we start instances with
|
|
44
44
|
const LANDED_WAIT_MS = 30 * 60 * 1000; // matches the PT30M landedWaitTimeout we start instances with
|
|
45
45
|
const MERGEABLE_WAIT_MS = 30 * 60 * 1000; // matches the PT30M mergeableWaitTimeout we start instances with
|
|
46
|
+
const MERGEABLE_REPOLL_MS = 2 * 60 * 1000; // matches the PT2M mergeableRepollInterval we start instances with
|
|
46
47
|
|
|
47
48
|
type Output = Record<string, unknown>;
|
|
48
49
|
type Responder = Output | Output[] | ((job: { variables: Record<string, unknown> }) => Output);
|
|
@@ -85,6 +86,7 @@ const DEFAULT_VARS: Record<string, unknown> = {
|
|
|
85
86
|
agentSlaTimeout: "PT30M",
|
|
86
87
|
landedWaitTimeout: "PT30M",
|
|
87
88
|
mergeableWaitTimeout: "PT30M",
|
|
89
|
+
mergeableRepollInterval: "PT2M",
|
|
88
90
|
mergeStallRounds: 0,
|
|
89
91
|
mergeStallMax: 3,
|
|
90
92
|
abandonBrief: null,
|
|
@@ -349,6 +351,94 @@ test("the stall-round cap escalates to a human instead of looping forever (#636)
|
|
|
349
351
|
assert(!completedElementIds(engine).has("mark-merged"), "an exhausted stall must not mark-merged");
|
|
350
352
|
});
|
|
351
353
|
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Async-UNKNOWN "waiting" verdict → bounded re-poll, NOT a human (issue #774)
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// GitHub returns `mergeable=UNKNOWN` transiently while it computes mergeability in the background,
|
|
358
|
+
// which `classifyMergeability` maps to `"waiting"`. Before the fix, a `"waiting"` verdict at
|
|
359
|
+
// `gw-mergeable` hit the DEFAULT arm and escalated straight to the `wait-merge-answer` human task —
|
|
360
|
+
// so a PR whose real state was still settling (e.g. to `"conflict"`, which would auto-fire
|
|
361
|
+
// `senior:rebase`) paged a human instead of self-healing. The fix routes `"waiting"` to a bounded
|
|
362
|
+
// re-poll (`wait-mergeable-repoll` timer → `merge-stall-probe` → the existing `gw-mergeable` arms),
|
|
363
|
+
// bounded by the shared `mergeStallMax` budget, and routes the true DEFAULT to auto-rebase.
|
|
364
|
+
|
|
365
|
+
test("a 'waiting' (async UNKNOWN) verdict re-polls instead of escalating to a human (#774)", async () => {
|
|
366
|
+
// RED (before the fix): `waiting` → DEFAULT → `merge-esc-conflict` → `wait-merge-answer`. GREEN:
|
|
367
|
+
// `waiting` parks on the re-poll timer; when it fires, `merge-stall-probe` re-derives `ready` and
|
|
368
|
+
// the PR merges — no human ever touched.
|
|
369
|
+
const engine = await boot({
|
|
370
|
+
responses: {
|
|
371
|
+
"pr.merge-stall-probe": { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
|
|
372
|
+
"pr.merge": { mergeStatus: "merged" },
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
376
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "waiting" } });
|
|
377
|
+
// Parks on the bounded re-poll timer — NOT on a human task, NOT escalated.
|
|
378
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable-repoll");
|
|
379
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "a waiting verdict must NOT escalate to a human");
|
|
380
|
+
const openTasks = await engine.searchUserTasks({});
|
|
381
|
+
assert(!openTasks.some((t) => t.elementId === "wait-merge-answer"), "a waiting verdict must not park a user task");
|
|
382
|
+
// The timer fires → the probe re-derives ground truth (`ready`) → the PR merges.
|
|
383
|
+
await engine.advanceTime(MERGEABLE_REPOLL_MS + 1);
|
|
384
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
385
|
+
.hasCompleted()
|
|
386
|
+
.hasNoIncident()
|
|
387
|
+
.hasCompletedElements("wait-mergeable-repoll", "merge-stall-probe", "attempt-merge", "mark-merged");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("a 'waiting' verdict that settles to conflict on re-poll reaches the rebase arm (#774)", async () => {
|
|
391
|
+
// The incident (Magikcraft/nano-bpm#1166): the PR was really CONFLICTING, but GitHub reported
|
|
392
|
+
// UNKNOWN transiently. The re-poll must let it settle to `conflict` → `senior:rebase`, the arm that
|
|
393
|
+
// would have fixed it, rather than paging a human on the transient UNKNOWN.
|
|
394
|
+
const engine = await boot({
|
|
395
|
+
responses: {
|
|
396
|
+
"pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
|
|
397
|
+
"senior:rebase": { status: "rebased" },
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
401
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "waiting" } });
|
|
402
|
+
await engine.advanceTime(MERGEABLE_REPOLL_MS + 1);
|
|
403
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
404
|
+
.isActive()
|
|
405
|
+
.hasCompletedElements("wait-mergeable-repoll", "merge-stall-probe", "rebase")
|
|
406
|
+
.hasVariable("rebaseRound", 1);
|
|
407
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "settling to conflict must rebase, not escalate");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("the 'waiting' re-poll is bounded — exhausting mergeStallMax escalates to a human (#774)", async () => {
|
|
411
|
+
// A permanently-UNKNOWN PR cannot re-poll forever: the re-poll shares the `mergeStallMax` budget.
|
|
412
|
+
// `mergeStallMax: 0` — escalate on the first re-poll. The probe runs once, `gw-merge-stall` finds
|
|
413
|
+
// the budget exhausted, and routes to the human `wait-merge-answer` (last resort, not first).
|
|
414
|
+
const engine = await boot({
|
|
415
|
+
vars: { mergeStallMax: 0 },
|
|
416
|
+
responses: {
|
|
417
|
+
"pr.merge-stall-probe": { mergeState: "waiting", failingChecks: 0, failingChecksList: "" },
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
421
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "waiting" } });
|
|
422
|
+
assert(!completedElementIds(engine).has("merge-esc-conflict"), "must re-poll before ever escalating");
|
|
423
|
+
await engine.advanceTime(MERGEABLE_REPOLL_MS + 1);
|
|
424
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
425
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("wait-mergeable-repoll", "merge-stall-probe", "merge-esc-conflict");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("an unclassified verdict routes to auto-rebase (DEFAULT), not to a human (#774)", async () => {
|
|
429
|
+
// The DEFAULT arm is a last-resort self-heal, not a page: an unexpected/garbage `mergeState` routes
|
|
430
|
+
// to the bounded rebase agent (which reconciles from ground truth), never straight to a human.
|
|
431
|
+
const engine = await boot({ responses: { "senior:rebase": { status: "rebased" } } });
|
|
432
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
433
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "bogus" } });
|
|
434
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
435
|
+
.isActive()
|
|
436
|
+
.hasCompletedElements("rebase")
|
|
437
|
+
.hasVariable("rebaseRound", 1);
|
|
438
|
+
const openTasks = await engine.searchUserTasks({});
|
|
439
|
+
assert(!openTasks.some((t) => t.elementId === "wait-merge-answer"), "the default arm must not page a human");
|
|
440
|
+
});
|
|
441
|
+
|
|
352
442
|
test("an evicted queued merge re-arms the poller rather than completing", async () => {
|
|
353
443
|
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
|
|
354
444
|
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { test } from "node:test";
|
|
9
|
-
import { DEFAULT_MERGEABLE_WAIT_TIMEOUT, mergeableWaitTimeout } from "./mergeableWait.ts";
|
|
9
|
+
import { DEFAULT_MERGEABLE_REPOLL_INTERVAL, DEFAULT_MERGEABLE_WAIT_TIMEOUT, mergeableRepollInterval, mergeableWaitTimeout } from "./mergeableWait.ts";
|
|
10
|
+
import { isoDurationToMs } from "./reviewWait.ts";
|
|
10
11
|
|
|
11
12
|
test("mergeableWaitTimeout: blank / absent / malformed → default", () => {
|
|
12
13
|
assert.equal(mergeableWaitTimeout(undefined), DEFAULT_MERGEABLE_WAIT_TIMEOUT);
|
|
@@ -37,3 +38,43 @@ test("the default is itself a well-formed ISO-8601 duration (never an uninterpre
|
|
|
37
38
|
assert.notEqual(DEFAULT_MERGEABLE_WAIT_TIMEOUT, sentinel);
|
|
38
39
|
assert.equal(mergeableWaitTimeout(DEFAULT_MERGEABLE_WAIT_TIMEOUT, sentinel), DEFAULT_MERGEABLE_WAIT_TIMEOUT);
|
|
39
40
|
});
|
|
41
|
+
|
|
42
|
+
// --- Mergeability re-poll interval (issue #774) -----------------------------------------------
|
|
43
|
+
// Baked into every merge-loop instance's `mergeableRepollInterval` process variable and evaluated by
|
|
44
|
+
// the `wait-mergeable-repoll` timer catch, which bounds the `"waiting"` (async UNKNOWN) verdict's
|
|
45
|
+
// re-poll before it re-derives mergeability from ground truth — so a malformed operator env must
|
|
46
|
+
// never deploy an uninterpretable `<bpmn:timeDuration>`.
|
|
47
|
+
|
|
48
|
+
test("mergeableRepollInterval: blank / absent / malformed → default", () => {
|
|
49
|
+
assert.equal(mergeableRepollInterval(undefined), DEFAULT_MERGEABLE_REPOLL_INTERVAL);
|
|
50
|
+
assert.equal(mergeableRepollInterval(""), DEFAULT_MERGEABLE_REPOLL_INTERVAL);
|
|
51
|
+
assert.equal(mergeableRepollInterval(" "), DEFAULT_MERGEABLE_REPOLL_INTERVAL);
|
|
52
|
+
assert.equal(mergeableRepollInterval("2m"), DEFAULT_MERGEABLE_REPOLL_INTERVAL); // missing leading P/T
|
|
53
|
+
assert.equal(mergeableRepollInterval("garbage"), DEFAULT_MERGEABLE_REPOLL_INTERVAL);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("mergeableRepollInterval: a valid ISO-8601 duration is honoured and upper-cased", () => {
|
|
57
|
+
assert.equal(mergeableRepollInterval("PT2M"), "PT2M");
|
|
58
|
+
assert.equal(mergeableRepollInterval("pt90s"), "PT90S");
|
|
59
|
+
assert.equal(mergeableRepollInterval(" pt5m "), "PT5M");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("mergeableRepollInterval: the re-poll interval is shorter than the poller-death backstop", () => {
|
|
63
|
+
// The re-poll (GitHub settling UNKNOWN, ~seconds) must fire far sooner than the 30-minute
|
|
64
|
+
// dead-poller backstop, so a transient UNKNOWN self-heals briskly rather than parking for tens of
|
|
65
|
+
// minutes per round.
|
|
66
|
+
assert.equal(DEFAULT_MERGEABLE_REPOLL_INTERVAL, "PT2M");
|
|
67
|
+
// `notEqual` only proves the values differ — a future `PT1H` would pass despite being LONGER than
|
|
68
|
+
// the backstop. Assert the actual ordering invariant on parsed durations via the canonical helper.
|
|
69
|
+
assert.ok(
|
|
70
|
+
isoDurationToMs(DEFAULT_MERGEABLE_REPOLL_INTERVAL, "PT0S") <
|
|
71
|
+
isoDurationToMs(DEFAULT_MERGEABLE_WAIT_TIMEOUT, "PT0S"),
|
|
72
|
+
"the re-poll interval must be strictly shorter than the poller-death backstop",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("mergeableRepollInterval: the default is itself a well-formed ISO-8601 duration", () => {
|
|
77
|
+
const sentinel = "PT1S";
|
|
78
|
+
assert.notEqual(DEFAULT_MERGEABLE_REPOLL_INTERVAL, sentinel);
|
|
79
|
+
assert.equal(mergeableRepollInterval(DEFAULT_MERGEABLE_REPOLL_INTERVAL, sentinel), DEFAULT_MERGEABLE_REPOLL_INTERVAL);
|
|
80
|
+
});
|
package/app/mergeableWait.ts
CHANGED
|
@@ -36,3 +36,26 @@ export function mergeableWaitTimeout(
|
|
|
36
36
|
): string {
|
|
37
37
|
return isoDuration(raw, def);
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/** Default mergeability re-poll interval (ISO-8601 duration): how long the merge loop waits before
|
|
41
|
+
* re-deriving mergeability from ground truth when `gw-mergeable` sees an async-`UNKNOWN` `"waiting"`
|
|
42
|
+
* verdict (issue #774). GitHub returns `mergeable=UNKNOWN` transiently while it computes mergeability
|
|
43
|
+
* in the background; a `"waiting"` verdict must therefore route to a *bounded re-poll* — not straight
|
|
44
|
+
* to a human — so the loop self-heals once GitHub settles (to `"conflict"` → `senior:rebase`, etc.).
|
|
45
|
+
* Deliberately SHORT (unlike the 30-minute {@link DEFAULT_MERGEABLE_WAIT_TIMEOUT} poller-death
|
|
46
|
+
* backstop): GitHub usually settles UNKNOWN within seconds, so re-poll briskly rather than parking
|
|
47
|
+
* the PR for tens of minutes per round. The re-poll is bounded by the shared `mergeStallMax` budget
|
|
48
|
+
* (`NANO_PR_MAX_MERGE_STALL_ROUNDS`): once exhausted, `gw-merge-stall` escalates to a human. */
|
|
49
|
+
export const DEFAULT_MERGEABLE_REPOLL_INTERVAL = "PT2M";
|
|
50
|
+
|
|
51
|
+
/** Validate the operator-supplied mergeability re-poll interval (env
|
|
52
|
+
* `NANO_PR_MERGEABLE_REPOLL_INTERVAL`, ISO-8601 duration), falling back to
|
|
53
|
+
* {@link DEFAULT_MERGEABLE_REPOLL_INTERVAL} when absent, blank, or malformed — a bad env value must
|
|
54
|
+
* never deploy an uninterpretable timer expression. Derives its validation from the single canonical
|
|
55
|
+
* {@link isoDuration}. */
|
|
56
|
+
export function mergeableRepollInterval(
|
|
57
|
+
raw: string | undefined,
|
|
58
|
+
def: string = DEFAULT_MERGEABLE_REPOLL_INTERVAL,
|
|
59
|
+
): string {
|
|
60
|
+
return isoDuration(raw, def);
|
|
61
|
+
}
|
package/app/service.ts
CHANGED
|
@@ -53,7 +53,7 @@ import {
|
|
|
53
53
|
} from "./github.ts";
|
|
54
54
|
import { activeStatusesFor, derivedTrackingTable } from "./instanceTracking.ts";
|
|
55
55
|
import { pollLineage } from "./lineage.ts";
|
|
56
|
-
import { mergeableWaitTimeout } from "./mergeableWait.ts";
|
|
56
|
+
import { mergeableRepollInterval, mergeableWaitTimeout } from "./mergeableWait.ts";
|
|
57
57
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
58
58
|
import { mergeLandedWaitTimeout } from "./mergeLandedWait.ts";
|
|
59
59
|
import {
|
|
@@ -206,6 +206,19 @@ export const MERGEABLE_WAIT_TIMEOUT = mergeableWaitTimeout(
|
|
|
206
206
|
process.env.NANO_PR_MERGEABLE_WAIT_TIMEOUT,
|
|
207
207
|
);
|
|
208
208
|
|
|
209
|
+
/** How long the merge loop waits before re-deriving mergeability from ground truth when
|
|
210
|
+
* `gw-mergeable` sees an async-`UNKNOWN` `"waiting"` verdict (issue #774). Seeded as the
|
|
211
|
+
* `mergeableRepollInterval` process variable at merge start and evaluated by the merge-loop's
|
|
212
|
+
* `wait-mergeable-repoll` timer catch. A `"waiting"` verdict means GitHub is still computing
|
|
213
|
+
* mergeability in the background — so route it to a *bounded re-poll* (this timer → `merge-stall-probe`
|
|
214
|
+
* → the existing `gw-mergeable` arms), not to a human. The re-poll shares the `mergeStallMax` budget
|
|
215
|
+
* (`NANO_PR_MAX_MERGE_STALL_ROUNDS`) with the dead-poller stall backstop; once exhausted,
|
|
216
|
+
* `gw-merge-stall` escalates. ISO-8601 duration; a malformed `NANO_PR_MERGEABLE_REPOLL_INTERVAL`
|
|
217
|
+
* falls back to the default so an uninterpretable timer is never deployed. */
|
|
218
|
+
export const MERGEABLE_REPOLL_INTERVAL = mergeableRepollInterval(
|
|
219
|
+
process.env.NANO_PR_MERGEABLE_REPOLL_INTERVAL,
|
|
220
|
+
);
|
|
221
|
+
|
|
209
222
|
/** Cooldown (ms) between the poller's automatic Copilot re-request nudges for a single waiting PR.
|
|
210
223
|
* Copilot dismisses re-requests, so the poller retries — but not on every tick; this throttles it
|
|
211
224
|
* to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
|
|
@@ -678,6 +691,7 @@ export async function startMerge(
|
|
|
678
691
|
agentSlaTimeout: AGENT_SLA_TIMEOUT,
|
|
679
692
|
landedWaitTimeout: MERGE_LANDED_WAIT_TIMEOUT,
|
|
680
693
|
mergeableWaitTimeout: MERGEABLE_WAIT_TIMEOUT,
|
|
694
|
+
mergeableRepollInterval: MERGEABLE_REPOLL_INTERVAL,
|
|
681
695
|
mergeStallRounds: 0,
|
|
682
696
|
mergeStallMax: MAX_MERGE_STALL_ROUNDS,
|
|
683
697
|
// Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
|
package/main.ts
CHANGED
|
@@ -21,6 +21,7 @@ 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
23
|
import { makeElementInstanceResolver } from "./app/agentic/element-instance.ts";
|
|
24
|
+
import { mountConsoleRedirect, resolveConsoleOrigin } from "./app/consoleRedirect.ts";
|
|
24
25
|
import { announceEngine, resolveEngineAddress } from "./app/enginePreflight.ts";
|
|
25
26
|
import { runEngineReconcile } from "./app/reconcile.ts";
|
|
26
27
|
import { MAX_ROUNDS, pollOnce } from "./app/service.ts";
|
|
@@ -103,6 +104,20 @@ if (httpServer instanceof Server) {
|
|
|
103
104
|
app.log.warn("agentic channel not mounted: app.httpServer is not a node:http Server on this host");
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
// Standalone `/console` redirect (issue #771). Running behind the nano console, the console origin
|
|
108
|
+
// serves `/console/*` and the reverse proxy strips its app-view prefix before the app ever sees a
|
|
109
|
+
// request — but STANDALONE, the UI's `/console` links land on this app's own port, where nothing
|
|
110
|
+
// serves them, so the runtime answers a bare 503. Mount a narrow request redirect that rewrites
|
|
111
|
+
// `/console/*` to the engine console origin (derived from the CANONICAL engine-address resolution,
|
|
112
|
+
// `resolveEngineAddress`, so an explicit `CAMUNDA_REST_ADDRESS` wins over `NANOBPMN_BASE_URL`),
|
|
113
|
+
// preserving path + query, and leaves `/app/*`, `/agentic`, and the Workforce page routes untouched.
|
|
114
|
+
// Mounted last so it captures every request listener the runtime + agentic channel attached, and
|
|
115
|
+
// delegates each non-console request to them unchanged.
|
|
116
|
+
let consoleRedirectTeardown: (() => void) | undefined;
|
|
117
|
+
if (httpServer instanceof Server) {
|
|
118
|
+
consoleRedirectTeardown = mountConsoleRedirect(httpServer, resolveConsoleOrigin(), app.log);
|
|
119
|
+
}
|
|
120
|
+
|
|
106
121
|
// Engine-reset reconciliation (issues #622, #630). On boot, compare the engine's incarnation epoch
|
|
107
122
|
// against the last-seen value; on a REGRESSION (the engine was reset/restored/rewound and re-minted
|
|
108
123
|
// its keys, Magikcraft/nano-bpm#1065) drive every dangling engine-backed inflight row to the defined
|
|
@@ -153,6 +168,12 @@ async function drainAndExit(): Promise<void> {
|
|
|
153
168
|
if (shuttingDown) return;
|
|
154
169
|
shuttingDown = true;
|
|
155
170
|
if (pollTimer) clearTimeout(pollTimer);
|
|
171
|
+
// Restore the original request listener set (drops the /console redirect wrapper).
|
|
172
|
+
if (consoleRedirectTeardown) {
|
|
173
|
+
try {
|
|
174
|
+
consoleRedirectTeardown();
|
|
175
|
+
} catch { /* best-effort redirect teardown */ }
|
|
176
|
+
}
|
|
156
177
|
// Tear the agentic families + hub down (releases the WS clients) before the app stops its HTTP
|
|
157
178
|
// server, which the channel shares.
|
|
158
179
|
if (agentic) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.186.
|
|
3
|
+
"version": "0.186.3",
|
|
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",
|