@nanobpm/nano-workforce 0.199.0 → 0.200.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +23 -1
- package/app/contracts.ts +24 -0
- package/app/convergeAdversarial.test.ts +294 -0
- package/app/deliveryGraphCompiler.ts +9 -3
- package/app/layoutOffThread.test.ts +208 -0
- package/app/layoutOffThread.ts +182 -0
- package/app/layoutWorker.ts +34 -0
- package/app/roundProgress.test.ts +7 -1
- package/app/roundResultDefault.test.ts +24 -2
- package/app/service.test.ts +178 -1
- package/app/service.ts +70 -11
- package/nano.app.json +2 -1
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +291 -163
- package/resources/prompts/adversarial-review.md +116 -0
- package/resources/prompts/review-round.md +15 -1
- package/test/derivation-parity/derivation-parity.test.ts +2 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// app/layoutOffThread.ts — the ONE canonical off-main-thread bridge to the CPU-bound BPMN autolayout
|
|
2
|
+
// (`layoutBpmn` → `bpmn-auto-layout`), issue #854.
|
|
3
|
+
//
|
|
4
|
+
// WHY: `layoutBpmn` is superlinear in node/edge count and, run inline on the event loop, blocked the
|
|
5
|
+
// whole app (no HTTP, no poll passes) for the entire layout — ~52s on merlin, >4min under load on a
|
|
6
|
+
// 54-node delivery graph, a hard lock-up the operator had to kill mid-launch (#852). This module runs
|
|
7
|
+
// the layout in a `node:worker_threads` worker so the main loop keeps ticking, and BOUNDS it with a
|
|
8
|
+
// timeout that TERMINATES the worker on expiry so a pathological graph fails the launch CLEANLY
|
|
9
|
+
// instead of hanging forever.
|
|
10
|
+
//
|
|
11
|
+
// `layoutDeliveryDiagram` (app/deliveryGraphCompiler.ts) — the single layout entry point BOTH the
|
|
12
|
+
// dispatch path (`dispatchDeliveryGraphRun` → `compileDeliveryGraph`) and the preview path
|
|
13
|
+
// (`previewProposalBpmn` → `compileDeliveryGraph`) funnel through — calls `layoutBpmnOffThread` here,
|
|
14
|
+
// so there is exactly ONE place the autolayout runs and exactly ONE timeout/termination policy.
|
|
15
|
+
|
|
16
|
+
import { Worker } from "node:worker_threads";
|
|
17
|
+
import { readEnvOr } from "./contracts.ts";
|
|
18
|
+
|
|
19
|
+
/** Node's `setTimeout` delay ceiling (a signed 32-bit ms value). A delay above this silently wraps to
|
|
20
|
+
* 1ms (emitting `TimeoutOverflowWarning`), so an over-large override would abort layouts IMMEDIATELY
|
|
21
|
+
* instead of extending the bound — a value that large is treated as garbage and degrades to the
|
|
22
|
+
* registered default. */
|
|
23
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
24
|
+
|
|
25
|
+
/** The registered default, resolved once from the schema (empty env ⇒ registry `default`) so the
|
|
26
|
+
* invalid-override fallback and the timeout message stay single-sourced — not a literal `300000`
|
|
27
|
+
* duplicated here that would drift from `ENV_CONTRACTS`. Mirrors `cloneTimeoutMs` in
|
|
28
|
+
* `app/repoEnvelope.ts`. */
|
|
29
|
+
const envDefaultLayoutTimeoutMs = Number(readEnvOr("NANO_DELIVERY_LAYOUT_TIMEOUT_MS", "300000", {}));
|
|
30
|
+
|
|
31
|
+
/** Resolve the layout timeout (ms) from `NANO_DELIVERY_LAYOUT_TIMEOUT_MS` (registered default 300000
|
|
32
|
+
* = 5 min), degrading to the registered default for any value that is non-positive, garbage, or above
|
|
33
|
+
* Node's timer ceiling ({@link MAX_TIMER_MS} — an overflow would wrap to 1ms and abort every layout).
|
|
34
|
+
* Read per call so an operator can retune it without a restart; the layout is rare (dispatch/preview),
|
|
35
|
+
* so the lookup cost is irrelevant. */
|
|
36
|
+
export function layoutTimeoutMs(env: Record<string, string | undefined> = process.env): number {
|
|
37
|
+
const raw = Number(readEnvOr("NANO_DELIVERY_LAYOUT_TIMEOUT_MS", "300000", env));
|
|
38
|
+
return Number.isFinite(raw) && raw > 0 && raw <= MAX_TIMER_MS ? raw : envDefaultLayoutTimeoutMs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A sanity ceiling on the concurrency bound: a value this large is effectively unbounded and is
|
|
42
|
+
* treated as garbage (degrading to the registered default) rather than honoured — the whole point of
|
|
43
|
+
* the bound is to keep concurrent CPU-bound layouts SMALL. */
|
|
44
|
+
const MAX_LAYOUT_CONCURRENCY = 1024;
|
|
45
|
+
|
|
46
|
+
/** The registered default concurrency, resolved once from the schema (empty env ⇒ registry `default`)
|
|
47
|
+
* so the invalid-override fallback stays single-sourced — not a literal duplicated here that would
|
|
48
|
+
* drift from `ENV_CONTRACTS`. Mirrors {@link envDefaultLayoutTimeoutMs}. */
|
|
49
|
+
const envDefaultLayoutMaxConcurrency = Number(readEnvOr("NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY", "2", {}));
|
|
50
|
+
|
|
51
|
+
/** Resolve the max number of concurrent layout workers from `NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY`
|
|
52
|
+
* (registered default 2), degrading to the registered default for any value that is non-integer,
|
|
53
|
+
* non-positive, garbage, or above {@link MAX_LAYOUT_CONCURRENCY}. Mirrors {@link layoutTimeoutMs}'s
|
|
54
|
+
* fail-safe parsing so a bad override can never make the bound 0/∞ (either of which defeats it). */
|
|
55
|
+
export function layoutMaxConcurrency(env: Record<string, string | undefined> = process.env): number {
|
|
56
|
+
const raw = Number(readEnvOr("NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY", "2", env));
|
|
57
|
+
return Number.isInteger(raw) && raw > 0 && raw <= MAX_LAYOUT_CONCURRENCY ? raw : envDefaultLayoutMaxConcurrency;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A minimal FIFO counting semaphore: at most `max` holders run concurrently, the rest queue in
|
|
61
|
+
* arrival order. Bounds how many CPU-bound layout workers exist at once so a burst of overlapping
|
|
62
|
+
* preview/dispatch layouts cannot spawn an UNBOUNDED number of superlinear workers and saturate
|
|
63
|
+
* CPU/memory — the very starvation this off-thread move exists to prevent (issue #854). Each
|
|
64
|
+
* `acquire()` resolves with an idempotent `release` that hands the freed slot to the next waiter. */
|
|
65
|
+
export class Semaphore {
|
|
66
|
+
private active = 0;
|
|
67
|
+
private readonly waiters: Array<() => void> = [];
|
|
68
|
+
private readonly max: number;
|
|
69
|
+
|
|
70
|
+
constructor(max: number) {
|
|
71
|
+
if (!Number.isInteger(max) || max < 1) {
|
|
72
|
+
throw new Error(`Semaphore: max must be a positive integer, got ${String(max)}`);
|
|
73
|
+
}
|
|
74
|
+
this.max = max;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Wait (FIFO) for a slot, then return a one-shot `release`. Always `release()` in a `finally` so a
|
|
78
|
+
* rejecting holder cannot leak its slot and wedge every queued layout behind it. */
|
|
79
|
+
async acquire(): Promise<() => void> {
|
|
80
|
+
await new Promise<void>((resolve) => {
|
|
81
|
+
if (this.active < this.max) {
|
|
82
|
+
this.active++;
|
|
83
|
+
resolve();
|
|
84
|
+
} else {
|
|
85
|
+
this.waiters.push(() => {
|
|
86
|
+
this.active++;
|
|
87
|
+
resolve();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
let released = false;
|
|
92
|
+
return () => {
|
|
93
|
+
if (released) return; // idempotent: a double release must not free a slot it never held
|
|
94
|
+
released = true;
|
|
95
|
+
this.active--;
|
|
96
|
+
this.waiters.shift()?.();
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The ONE shared gate bounding concurrent layout workers, sized lazily on first use from
|
|
102
|
+
* {@link layoutMaxConcurrency}. A singleton so EVERY `layoutBpmnOffThread` call — across both the
|
|
103
|
+
* dispatch and preview paths — contends for the same small pool of slots. */
|
|
104
|
+
let layoutGate: Semaphore | undefined;
|
|
105
|
+
function layoutConcurrencyGate(): Semaphore {
|
|
106
|
+
if (!layoutGate) layoutGate = new Semaphore(layoutMaxConcurrency());
|
|
107
|
+
return layoutGate;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The worker message shape: a laid-out XML payload on success, or a server-side error string. */
|
|
111
|
+
type LayoutWorkerMessage = { ok: true; laidOut: string } | { ok: false; error: string };
|
|
112
|
+
|
|
113
|
+
/** Run `layoutBpmn(semanticBpmn)` in a dedicated `worker_threads` worker, OFF the main event loop,
|
|
114
|
+
* bounded by `timeoutMs`. Resolves with the laid-out XML; rejects (and TERMINATES the worker — the
|
|
115
|
+
* bound that stops a runaway layout) on timeout, a worker error, an early exit, or a layout failure
|
|
116
|
+
* reported by the worker. The worker is always torn down before this resolves/rejects, so no isolate
|
|
117
|
+
* leaks across calls. At most {@link layoutMaxConcurrency} workers run at once (the shared
|
|
118
|
+
* {@link Semaphore} gate) — a burst of overlapping preview/dispatch layouts queues for a slot rather
|
|
119
|
+
* than spawning an unbounded number of superlinear CPU-bound workers that would saturate the host.
|
|
120
|
+
* The timeout covers only the ACTUAL layout (it starts after a slot is acquired), so queueing behind
|
|
121
|
+
* other layouts is never charged against a graph's bound.
|
|
122
|
+
*
|
|
123
|
+
* `spawnWorker` is an injectable seam (default: the real `layoutWorker.ts` worker) so a test can force
|
|
124
|
+
* a SYNCHRONOUS construction failure — `new Worker(...)` can throw (e.g. `ERR_WORKER_INIT_FAILED`
|
|
125
|
+
* under resource pressure) — and prove the slot is still released (see below). */
|
|
126
|
+
export async function layoutBpmnOffThread(
|
|
127
|
+
semanticBpmn: string,
|
|
128
|
+
timeoutMs: number = layoutTimeoutMs(),
|
|
129
|
+
spawnWorker: () => Worker = () =>
|
|
130
|
+
new Worker(new URL("./layoutWorker.ts", import.meta.url), { workerData: { semanticBpmn } }),
|
|
131
|
+
): Promise<string> {
|
|
132
|
+
// Acquire the slot, then wrap EVERYTHING — including worker construction — in the release `finally`.
|
|
133
|
+
// `new Worker(...)` can throw synchronously (e.g. `ERR_WORKER_INIT_FAILED` under resource pressure);
|
|
134
|
+
// if construction sat outside this guard, each such failure would permanently consume a slot and,
|
|
135
|
+
// after `max` failures, wedge every later layout waiting on the gate forever (issue #854 review).
|
|
136
|
+
const release = await layoutConcurrencyGate().acquire();
|
|
137
|
+
try {
|
|
138
|
+
const worker = spawnWorker();
|
|
139
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
140
|
+
try {
|
|
141
|
+
return await new Promise<string>((resolve, reject) => {
|
|
142
|
+
let settled = false;
|
|
143
|
+
const settle = (fn: () => void) => {
|
|
144
|
+
if (settled) return;
|
|
145
|
+
settled = true;
|
|
146
|
+
if (timer) clearTimeout(timer);
|
|
147
|
+
fn();
|
|
148
|
+
};
|
|
149
|
+
timer = setTimeout(() => {
|
|
150
|
+
settle(() =>
|
|
151
|
+
reject(
|
|
152
|
+
new Error(
|
|
153
|
+
`layoutDeliveryDiagram: BPMN autolayout exceeded its ${timeoutMs}ms bound and was aborted — the ` +
|
|
154
|
+
"delivery graph is too large/dense to lay out within the timeout (bpmn-auto-layout is superlinear in " +
|
|
155
|
+
"node/edge count, issue #854). The launch was failed cleanly rather than hung; retune " +
|
|
156
|
+
`\`NANO_DELIVERY_LAYOUT_TIMEOUT_MS\` (default ${envDefaultLayoutTimeoutMs}) or shrink the graph.`,
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
}, timeoutMs);
|
|
161
|
+
// A setTimeout in a hot dispatch path should not itself keep a draining process alive.
|
|
162
|
+
timer.unref?.();
|
|
163
|
+
worker.once("message", (msg: LayoutWorkerMessage) => {
|
|
164
|
+
settle(() => {
|
|
165
|
+
if (msg.ok) resolve(msg.laidOut);
|
|
166
|
+
else reject(new Error(msg.error));
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
worker.once("error", (err) => settle(() => reject(err)));
|
|
170
|
+
worker.once("exit", (code) => {
|
|
171
|
+
if (code === 0) return; // a clean exit after a delivered message is expected on teardown
|
|
172
|
+
settle(() => reject(new Error(`layoutDeliveryDiagram: layout worker exited early with code ${code}`)));
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
} finally {
|
|
176
|
+
if (timer) clearTimeout(timer);
|
|
177
|
+
await worker.terminate();
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
release(); // hand our slot to the next queued layout — ALWAYS, even on a construction throw/reject/terminate
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// app/layoutWorker.ts — the `node:worker_threads` entry that runs the CPU-bound BPMN autolayout
|
|
2
|
+
// (`layoutBpmn` → `bpmn-auto-layout`) OFF the app's main event loop (issue #854).
|
|
3
|
+
//
|
|
4
|
+
// `bpmn-auto-layout` is superlinear in node/edge count: on the 54-node merlin delivery graph it ran
|
|
5
|
+
// ~52s on merlin and >4min under real load, and because it ran SYNCHRONOUSLY on the event loop in
|
|
6
|
+
// `layoutDeliveryDiagram`, the whole app served no HTTP and ran no poll passes for that entire window
|
|
7
|
+
// — the operator saw a hard lock-up (#852). Hoisting the layout into this worker keeps the main loop
|
|
8
|
+
// free to serve pages/API/MCP and tick its poll loop while a large graph lays out; the parent bounds
|
|
9
|
+
// the work with a timeout and TERMINATES this worker on expiry (see `app/layoutOffThread.ts`), so a
|
|
10
|
+
// pathological graph fails the launch cleanly instead of hanging forever.
|
|
11
|
+
//
|
|
12
|
+
// Protocol: `workerData.semanticBpmn` carries the pre-layout semantic BPMN; we post back either the
|
|
13
|
+
// laid-out XML (a string) on success or `{ error }` on failure. We never `process.exit` — resolving
|
|
14
|
+
// the message lets the parent `terminate()` us.
|
|
15
|
+
|
|
16
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
17
|
+
import { layoutBpmn } from "@nanobpm/urban";
|
|
18
|
+
|
|
19
|
+
async function run(): Promise<void> {
|
|
20
|
+
const port = parentPort;
|
|
21
|
+
if (!port) throw new Error("layoutWorker: must be spawned as a worker_threads worker (no parentPort)");
|
|
22
|
+
const { semanticBpmn } = workerData ?? {};
|
|
23
|
+
if (typeof semanticBpmn !== "string") {
|
|
24
|
+
throw new Error("layoutWorker: workerData.semanticBpmn must be a string");
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const laidOut = await layoutBpmn(semanticBpmn);
|
|
28
|
+
port.postMessage({ ok: true, laidOut });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
port.postMessage({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
void run();
|
|
@@ -945,8 +945,14 @@ test("the round-cap guard is downstream of progress classification, so a husk au
|
|
|
945
945
|
const retry = flowElement("f_huskRetry");
|
|
946
946
|
assert(retry, "f_huskRetry flow missing");
|
|
947
947
|
assertStringIncludes(retry, 'targetRef="capture-head"');
|
|
948
|
-
//
|
|
948
|
+
// (#844: the addressed AND waiting arms detour through the bounded adversarial gate, whose arms all
|
|
949
|
+
// land on persist-round or loop to review-round — never gw-guard.)
|
|
949
950
|
for (const id of ["f_addressed", "f_waiting"]) {
|
|
951
|
+
const f = flowElement(id);
|
|
952
|
+
assert(f, `${id} flow missing`);
|
|
953
|
+
assertStringIncludes(f, 'targetRef="gw-adv-due"');
|
|
954
|
+
}
|
|
955
|
+
for (const id of ["f_advSkip", "f_advClean"]) {
|
|
950
956
|
const f = flowElement(id);
|
|
951
957
|
assert(f, `${id} flow missing`);
|
|
952
958
|
assertStringIncludes(f, 'targetRef="persist-round"');
|
|
@@ -77,14 +77,35 @@ test("escalation is an explicit needs_input/blocked arm gated on a non-blank que
|
|
|
77
77
|
test("the default (addressed) arm carries no condition and re-enters round processing", () => {
|
|
78
78
|
const addressed = flowElement("f_addressed");
|
|
79
79
|
assert(addressed, "f_addressed flow missing");
|
|
80
|
-
|
|
80
|
+
// #844: the addressed arm first passes the (bounded) local adversarial-review gate, whose
|
|
81
|
+
// unconditional defaults — budget spent / findings clean — both land on round processing.
|
|
82
|
+
assertStringIncludes(addressed, 'targetRef="gw-adv-due"');
|
|
81
83
|
// A default flow must have NO conditionExpression.
|
|
82
84
|
assert(
|
|
83
85
|
!/conditionExpression/.test(addressed),
|
|
84
86
|
"f_addressed is the default flow and must not carry a conditionExpression",
|
|
85
87
|
);
|
|
88
|
+
assertAdversarialDefaultsReenterRound();
|
|
86
89
|
});
|
|
87
90
|
|
|
91
|
+
/** The adversarial-review detour (#844) on the addressed arm must always drain to round
|
|
92
|
+
* processing: both gateways' DEFAULT arms are unconditional and target persist-round, and the only
|
|
93
|
+
* conditional arm loops back into review-round (bounded by advPass/advMax) — never escalation. */
|
|
94
|
+
function assertAdversarialDefaultsReenterRound() {
|
|
95
|
+
for (const [gw, dflt] of [["gw-adv-due", "f_advSkip"], ["gw-adv-result", "f_advClean"]]) {
|
|
96
|
+
const g = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${gw}"[^>]*>`));
|
|
97
|
+
assert(g, `${gw} gateway missing`);
|
|
98
|
+
assertStringIncludes(g[0], `default="${dflt}"`);
|
|
99
|
+
const f = flowElement(dflt);
|
|
100
|
+
assert(f, `${dflt} flow missing`);
|
|
101
|
+
assertStringIncludes(f, 'targetRef="persist-round"');
|
|
102
|
+
assert(!/conditionExpression/.test(f), `${dflt} is a default arm and must not carry a condition`);
|
|
103
|
+
}
|
|
104
|
+
const loop = flowElement("f_advFindings");
|
|
105
|
+
assert(loop, "f_advFindings flow missing");
|
|
106
|
+
assertStringIncludes(loop, 'targetRef="review-round"');
|
|
107
|
+
}
|
|
108
|
+
|
|
88
109
|
test("regression: an empty/unknown status no longer routes to persist-escalation", () => {
|
|
89
110
|
// Escalation is reachable ONLY via an explicit condition (the needs_input/blocked arm),
|
|
90
111
|
// never as a catch-all default. Enumerate EVERY sequenceFlow into `persist-escalation`
|
|
@@ -98,7 +119,8 @@ test("regression: an empty/unknown status no longer routes to persist-escalation
|
|
|
98
119
|
// The addressed default must land on round processing (persist-round → check-progress, which
|
|
99
120
|
// re-solicits the review after the round-cap gate downstream), not escalation.
|
|
100
121
|
const addressed = flowElement("f_addressed");
|
|
101
|
-
assert(addressed && /targetRef="
|
|
122
|
+
assert(addressed && /targetRef="gw-adv-due"/.test(addressed), "default arm must re-enter round processing");
|
|
123
|
+
assertAdversarialDefaultsReenterRound();
|
|
102
124
|
});
|
|
103
125
|
|
|
104
126
|
// The canonical router (app/roundResultDefault.ts) mirrors the gw-status routing above, with the
|
package/app/service.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { memDataFor } from "../test/worldDb.ts";
|
|
|
11
11
|
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
12
12
|
import { DurableResumeRegistry } from "./durableResume.ts";
|
|
13
13
|
import { WorldStore } from "./world/index.ts";
|
|
14
|
-
import { abandonClosedPr, isPrSettled, MAX_ACK_RETRIES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollReviews, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
14
|
+
import { abandonClosedPr, isPrSettled, MAX_ACK_RETRIES, MAX_ADVERSARIAL_PASSES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollJobActivationImpl, pollReviews, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
15
15
|
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
16
16
|
import type { DataLayer } from "@nanobpm/urban";
|
|
17
17
|
import { READINESS_READY_MESSAGE } from "./readiness.ts";
|
|
@@ -669,6 +669,168 @@ test("pollIncidents picks the oldest incident by creationTime, sorting a missing
|
|
|
669
669
|
});
|
|
670
670
|
|
|
671
671
|
|
|
672
|
+
// Red/green regression (nano-workforce#845 Copilot review): the convergence loop runs TWO external
|
|
673
|
+
// agent task types while the PR reads `converging` — `review-round` (`senior:pr-review`) and, since
|
|
674
|
+
// issue #844, `adversarial-review` (`senior:adversarial-review`). `pollJobActivation` originally
|
|
675
|
+
// searched only for `senior:pr-review`, so during an adversarial pass it found no matching job and
|
|
676
|
+
// cleared `active_worker`/`lease_until` — the Home/Overview grids reported "queued" while an agent
|
|
677
|
+
// was actively working. This drives the pass's reconciliation core against a stubbed
|
|
678
|
+
// `/v2/jobs/search`:
|
|
679
|
+
// 1. an activated `senior:adversarial-review` job surfaces its leasing worker (the defect),
|
|
680
|
+
// 2. the review-round → adversarial-review handoff keeps a live worker visible across the switch,
|
|
681
|
+
// 3. a non-agent job (an internal `pr.*` worker) is defensively excluded even if the wire `$in`
|
|
682
|
+
// filter is ignored,
|
|
683
|
+
// 4. a merely-created (unactivated) agent job — no `worker` yet — reads as "queued" (lease null).
|
|
684
|
+
function jobActivationFetch(byInstance: Record<string, unknown[]>, opts: { ignoreTypeFilter?: boolean } = {}) {
|
|
685
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
686
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
687
|
+
if (!u.endsWith("/jobs/search")) {
|
|
688
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
689
|
+
}
|
|
690
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
|
691
|
+
filter?: { processInstanceKey?: string; type?: string | { $in?: string[] } };
|
|
692
|
+
};
|
|
693
|
+
const all = (byInstance[body.filter?.processInstanceKey ?? ""] ?? []) as { type?: string }[];
|
|
694
|
+
// Honor the wire `type` filter (scalar `$eq` or `{ $in: [...] }`) the way the engine does, so the
|
|
695
|
+
// test reproduces the real defect: a single-type `senior:pr-review` filter returns ZERO items for
|
|
696
|
+
// an adversarial-pass job (the engine filters server-side), which is exactly what cleared the
|
|
697
|
+
// lease pre-fix. A stub that ignores `type` would pass against the old code and prove nothing.
|
|
698
|
+
// `ignoreTypeFilter` simulates an engine that IGNORES the `$in` filter, so the non-agent item
|
|
699
|
+
// reaches `pollJobActivationImpl` and the test exercises the defensive client-side `.includes`
|
|
700
|
+
// re-filter (service.ts) rather than the wire filter doing the work.
|
|
701
|
+
const t = body.filter?.type;
|
|
702
|
+
const wanted: string[] | null = typeof t === "string" ? [t] : Array.isArray(t?.$in) ? t.$in : null;
|
|
703
|
+
const items = opts.ignoreTypeFilter || wanted == null ? all : all.filter((j) => wanted.includes(j.type ?? ""));
|
|
704
|
+
return Promise.resolve(
|
|
705
|
+
new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
|
|
706
|
+
);
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function jobActivationData(rows: unknown[]) {
|
|
711
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
712
|
+
pull_requests: { rows, key: "pr_key" },
|
|
713
|
+
};
|
|
714
|
+
return {
|
|
715
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
716
|
+
open: () => memOpen(stores),
|
|
717
|
+
} as any;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
test("pollJobActivation surfaces an activated adversarial-review agent (not just senior:pr-review)", async () => {
|
|
721
|
+
const row = {
|
|
722
|
+
pr_key: "owner/repo#20",
|
|
723
|
+
status: "converging",
|
|
724
|
+
process_key: "PI-20",
|
|
725
|
+
active_worker: null as string | null,
|
|
726
|
+
lease_until: null as string | null,
|
|
727
|
+
updated_at: "t0",
|
|
728
|
+
};
|
|
729
|
+
const data = jobActivationData([row]);
|
|
730
|
+
const headers = { "content-type": "application/json" };
|
|
731
|
+
|
|
732
|
+
const prevFetch = globalThis.fetch;
|
|
733
|
+
// The PR is mid adversarial pass: the only open job is `senior:adversarial-review`, leased by a
|
|
734
|
+
// worker. Before the fix the pass filtered on `senior:pr-review` alone, found nothing, and left
|
|
735
|
+
// `active_worker` null — the grid showed "queued" while this agent was actively working.
|
|
736
|
+
globalThis.fetch = jobActivationFetch({
|
|
737
|
+
"PI-20": [
|
|
738
|
+
{ type: "senior:adversarial-review", worker: "agent-adv", deadline: "2024-01-01T00:15:00Z", state: "CREATED" },
|
|
739
|
+
],
|
|
740
|
+
}) as typeof fetch;
|
|
741
|
+
try {
|
|
742
|
+
await pollJobActivationImpl(data, "http://engine/v2", headers);
|
|
743
|
+
} finally {
|
|
744
|
+
globalThis.fetch = prevFetch;
|
|
745
|
+
}
|
|
746
|
+
assertEquals(row.active_worker, "agent-adv");
|
|
747
|
+
assertEquals(row.lease_until, "2024-01-01T00:15:00Z");
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("pollJobActivation keeps a live worker visible across the review-round ↔ adversarial handoff", async () => {
|
|
751
|
+
const row = {
|
|
752
|
+
pr_key: "owner/repo#21",
|
|
753
|
+
status: "converging",
|
|
754
|
+
process_key: "PI-21",
|
|
755
|
+
active_worker: null as string | null,
|
|
756
|
+
lease_until: null as string | null,
|
|
757
|
+
updated_at: "t0",
|
|
758
|
+
};
|
|
759
|
+
const data = jobActivationData([row]);
|
|
760
|
+
const headers = { "content-type": "application/json" };
|
|
761
|
+
|
|
762
|
+
const prevFetch = globalThis.fetch;
|
|
763
|
+
try {
|
|
764
|
+
// Leg 1: the review-round agent (`senior:pr-review`) is working.
|
|
765
|
+
globalThis.fetch = jobActivationFetch({
|
|
766
|
+
"PI-21": [
|
|
767
|
+
{ type: "senior:pr-review", worker: "agent-review", deadline: "2024-01-01T00:10:00Z", state: "CREATED" },
|
|
768
|
+
],
|
|
769
|
+
}) as typeof fetch;
|
|
770
|
+
await pollJobActivationImpl(data, "http://engine/v2", headers);
|
|
771
|
+
assertEquals(row.active_worker, "agent-review");
|
|
772
|
+
|
|
773
|
+
// Leg 2: the loop handed off to the adversarial pass (`senior:adversarial-review`) — same
|
|
774
|
+
// `converging` status, different job type. The grid must keep showing "agent working", not
|
|
775
|
+
// flash "queued".
|
|
776
|
+
globalThis.fetch = jobActivationFetch({
|
|
777
|
+
"PI-21": [
|
|
778
|
+
{ type: "senior:adversarial-review", worker: "agent-adv", deadline: "2024-01-01T00:20:00Z", state: "CREATED" },
|
|
779
|
+
],
|
|
780
|
+
}) as typeof fetch;
|
|
781
|
+
await pollJobActivationImpl(data, "http://engine/v2", headers);
|
|
782
|
+
assertEquals(row.active_worker, "agent-adv");
|
|
783
|
+
assertEquals(row.lease_until, "2024-01-01T00:20:00Z");
|
|
784
|
+
} finally {
|
|
785
|
+
globalThis.fetch = prevFetch;
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("pollJobActivation excludes a non-agent job and reports an unactivated agent job as queued", async () => {
|
|
790
|
+
const row = {
|
|
791
|
+
pr_key: "owner/repo#22",
|
|
792
|
+
status: "converging",
|
|
793
|
+
process_key: "PI-22",
|
|
794
|
+
active_worker: "stale-worker" as string | null,
|
|
795
|
+
lease_until: "2024-01-01T00:05:00Z" as string | null,
|
|
796
|
+
updated_at: "t0",
|
|
797
|
+
};
|
|
798
|
+
const data = jobActivationData([row]);
|
|
799
|
+
const headers = { "content-type": "application/json" };
|
|
800
|
+
|
|
801
|
+
const prevFetch = globalThis.fetch;
|
|
802
|
+
try {
|
|
803
|
+
// An internal `pr.*` worker job is open and "leased" — but it is not a convergence agent, so it
|
|
804
|
+
// must NOT keep `active_worker` lit even if the engine ignores the wire `$in` type filter. Pass
|
|
805
|
+
// `ignoreTypeFilter` so the mock returns the non-agent item UNFILTERED (an engine that ignores
|
|
806
|
+
// `$in`): the defensive client-side `.includes(j.type)` re-filter in `pollJobActivationImpl` is
|
|
807
|
+
// then what excludes it — so the assertion genuinely guards that branch (a wire-filtered stub
|
|
808
|
+
// would pass even if the defensive check were deleted).
|
|
809
|
+
globalThis.fetch = jobActivationFetch(
|
|
810
|
+
{
|
|
811
|
+
"PI-22": [
|
|
812
|
+
{ type: "pr.capture-head", worker: "host-internal", deadline: "2024-01-01T00:30:00Z", state: "CREATED" },
|
|
813
|
+
],
|
|
814
|
+
},
|
|
815
|
+
{ ignoreTypeFilter: true },
|
|
816
|
+
) as typeof fetch;
|
|
817
|
+
await pollJobActivationImpl(data, "http://engine/v2", headers);
|
|
818
|
+
assertEquals(row.active_worker, null);
|
|
819
|
+
assertEquals(row.lease_until, null);
|
|
820
|
+
|
|
821
|
+
// A merely-created (queued) agent job carries no worker yet → still "queued" (lease null).
|
|
822
|
+
globalThis.fetch = jobActivationFetch({
|
|
823
|
+
"PI-22": [{ type: "senior:adversarial-review", state: "CREATED" }],
|
|
824
|
+
}) as typeof fetch;
|
|
825
|
+
await pollJobActivationImpl(data, "http://engine/v2", headers);
|
|
826
|
+
assertEquals(row.active_worker, null);
|
|
827
|
+
assertEquals(row.lease_until, null);
|
|
828
|
+
} finally {
|
|
829
|
+
globalThis.fetch = prevFetch;
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
|
|
672
834
|
// Red/green regression (nano-workforce#102 review): the engine can hand back a numeric
|
|
673
835
|
// `processInstanceKey`, but the OpenAPI `SubmitResult.processKey` contract is `string | null`, so
|
|
674
836
|
// under api `validateResponses:"dev"` a raw number fails response validation. Stringifying at the
|
|
@@ -876,6 +1038,21 @@ test("submitPr seeds the #796 auto-ack budget onto the instance (ackRetryRound=0
|
|
|
876
1038
|
});
|
|
877
1039
|
});
|
|
878
1040
|
|
|
1041
|
+
test("submitPr seeds the #844 adversarial-review budget (advPass=0, advMax=MAX_ADVERSARIAL_PASSES)", async () => {
|
|
1042
|
+
await withGithubOff(async () => {
|
|
1043
|
+
const { data, engine, get } = captureVars();
|
|
1044
|
+
await submitPr(data, engine, {
|
|
1045
|
+
repo: "owner/repo",
|
|
1046
|
+
number: 14,
|
|
1047
|
+
url: "https://github.com/owner/repo/pull/14",
|
|
1048
|
+
prKey: "owner/repo#14",
|
|
1049
|
+
});
|
|
1050
|
+
const vars = get();
|
|
1051
|
+
assertEquals(vars?.advPass, 0);
|
|
1052
|
+
assertEquals(vars?.advMax, MAX_ADVERSARIAL_PASSES);
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
879
1056
|
test("submitPr seeds humanApproval (issue #826), pinned off for a converge-only run that never merges", async () => {
|
|
880
1057
|
await withGithubOff(async () => {
|
|
881
1058
|
const pr = (n: number) => ({ repo: "owner/repo", number: n, url: `https://github.com/owner/repo/pull/${n}`, prKey: `owner/repo#${n}` });
|
package/app/service.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
CONFORMANCE_ESCALATION_ELEMENT,
|
|
28
28
|
conformanceEscalationQuestion,
|
|
29
29
|
} from "./conformance.ts";
|
|
30
|
+
import { envContract, readEnvOr } from "./contracts.ts";
|
|
30
31
|
import { makeDefaultReadHead } from "./currentHead.ts";
|
|
31
32
|
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
32
33
|
import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
|
|
@@ -136,6 +137,18 @@ export const MERGE_PROCESS_ID = "merge-loop";
|
|
|
136
137
|
* in convergence-loop.bpmn). Deliberately NOT hosted here — an external harness services it; the
|
|
137
138
|
* activation poll keys off it to tell "agent working" from "queued". */
|
|
138
139
|
const REVIEW_JOB_TYPE = "senior:pr-review";
|
|
140
|
+
/** Job type of the local adversarial-review agent (the `adversarial-review` service task's
|
|
141
|
+
* `zeebe:taskDefinition` in convergence-loop.bpmn, issue #844). Like {@link REVIEW_JOB_TYPE} it is
|
|
142
|
+
* serviced by an external harness while the PR stays `converging`, so the activation poll must
|
|
143
|
+
* treat it as another live convergence agent — otherwise the grid reports "queued" for the whole
|
|
144
|
+
* adversarial pass even though an agent has leased the job. */
|
|
145
|
+
const ADVERSARIAL_JOB_TYPE = "senior:adversarial-review";
|
|
146
|
+
/** Every external-agent job type the convergence loop parks on while the PR reads `converging`.
|
|
147
|
+
* `pollJobActivation` reconciles `active_worker`/`lease_until` against ANY of these so the
|
|
148
|
+
* review-round ↔ adversarial-review handoff (same `converging` status, different job type) never
|
|
149
|
+
* flashes a phantom "queued". Typed `readonly string[]` (not `as const`) so `.includes(aString)`
|
|
150
|
+
* needs no `as` cast at the read site. */
|
|
151
|
+
const CONVERGENCE_AGENT_JOB_TYPES: readonly string[] = [REVIEW_JOB_TYPE, ADVERSARIAL_JOB_TYPE];
|
|
139
152
|
|
|
140
153
|
/** Default round cap before the loop escalates to a human. A per-submit override (submit form /
|
|
141
154
|
* webhook / start action) takes precedence; this env var sets the fleet-wide default. The cap
|
|
@@ -179,6 +192,18 @@ export const MAX_MERGE_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_MERGE_
|
|
|
179
192
|
* (allows 0 = disable, ceiling-capped). */
|
|
180
193
|
export const MAX_ACK_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_ACK_RETRIES, 2);
|
|
181
194
|
|
|
195
|
+
/** Local adversarial-review passes per convergence round (issue #844). An `addressed` round runs the
|
|
196
|
+
* `senior:adversarial-review` agent over its diff BEFORE the round parks on `waiting_review` (the only
|
|
197
|
+
* status the poller solicits a Copilot review for); non-blank findings re-dispatch `review-round`, up
|
|
198
|
+
* to this many passes per round. Default 1; `NANO_PR_MAX_ADVERSARIAL_PASSES=0` disables the stage.
|
|
199
|
+
* Reuses the CI-fix budget clamp (allows 0 = disable, ceiling-capped). The clamp fallback is
|
|
200
|
+
* derived from the registered `default` so the schema stays the single source of truth (a
|
|
201
|
+
* registry edit can't drift from a second literal here). */
|
|
202
|
+
export const MAX_ADVERSARIAL_PASSES = clampCiFixBudget(
|
|
203
|
+
readEnvOr("NANO_PR_MAX_ADVERSARIAL_PASSES"),
|
|
204
|
+
Number(envContract("NANO_PR_MAX_ADVERSARIAL_PASSES").default),
|
|
205
|
+
);
|
|
206
|
+
|
|
182
207
|
/** How many times the mergeable-wait timeout backstop (`merge-stall-probe`) will re-derive
|
|
183
208
|
* mergeability from ground truth and re-arm the merge stage before giving up and escalating to a
|
|
184
209
|
* human. Bounds the timer arm of the `gw-merge-wait` event-based gateway so a dead in-process poller
|
|
@@ -740,6 +765,10 @@ async function submitPrCritical(
|
|
|
740
765
|
// solely on unacked ones, before escalating to a human. `ackRetryRound` counts those passes.
|
|
741
766
|
ackRetryRound: 0,
|
|
742
767
|
ackRetryMax: MAX_ACK_RETRIES,
|
|
768
|
+
// Local adversarial review (issue #844): `advPass` counts this round's adversarial passes (reset
|
|
769
|
+
// by `capture-head` each round); `advMax` bounds them. 0 skips the stage.
|
|
770
|
+
advPass: 0,
|
|
771
|
+
advMax: MAX_ADVERSARIAL_PASSES,
|
|
743
772
|
// Lineage (issue #245): carry the origin identity onto the convergence instance so every
|
|
744
773
|
// descendant (and any message it correlates) is stitched back to the originating request.
|
|
745
774
|
// A human/webhook PR that is its own root carries its own `pr_key` (never NULL — see above).
|
|
@@ -1669,15 +1698,19 @@ interface JobSearchItem {
|
|
|
1669
1698
|
worker?: string;
|
|
1670
1699
|
deadline?: string | null;
|
|
1671
1700
|
state?: string;
|
|
1701
|
+
/** The job's `zeebe:taskDefinition` type — read to defensively re-filter the wire `$in` type
|
|
1702
|
+
* filter so a non-agent job can never masquerade as a leasing convergence agent. */
|
|
1703
|
+
type?: string;
|
|
1672
1704
|
}
|
|
1673
1705
|
|
|
1674
|
-
/** One job-activation poll pass. The `converging` status means the process is parked at
|
|
1675
|
-
*
|
|
1676
|
-
*
|
|
1677
|
-
*
|
|
1678
|
-
*
|
|
1679
|
-
*
|
|
1680
|
-
*
|
|
1706
|
+
/** One job-activation poll pass. The `converging` status means the process is parked at a
|
|
1707
|
+
* convergence-agent service task (`review-round`'s `senior:pr-review`, or `adversarial-review`'s
|
|
1708
|
+
* `senior:adversarial-review` — issue #844) with a job outstanding — but it does not say whether an
|
|
1709
|
+
* external agent has *activated* (leased) that job yet. This pass reads that off the engine's
|
|
1710
|
+
* Camunda-8 `/v2/jobs/search`: an activated job carries a leasing `worker` + a lock `deadline`; a
|
|
1711
|
+
* merely-created (queued) one carries neither. (The wire `state` can't tell them apart — Camunda's
|
|
1712
|
+
* JobStateEnum has no ACTIVATED value, so the engine projects Activated -> CREATED; the
|
|
1713
|
+
* `worker`/`deadline` fields are the compatible activation signal.)
|
|
1681
1714
|
*
|
|
1682
1715
|
* It writes `active_worker` + `lease_until` onto the PR row so the pages surface can show
|
|
1683
1716
|
* "agent working" vs "queued (awaiting an agent)", updating (and bumping `updated_at`) only on
|
|
@@ -1691,10 +1724,21 @@ async function pollJobActivation(
|
|
|
1691
1724
|
const base = restAddress.replace(/\/+$/, "");
|
|
1692
1725
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
1693
1726
|
if (engineToken) headers.authorization = `Bearer ${engineToken}`;
|
|
1727
|
+
await pollJobActivationImpl(data, base, headers);
|
|
1728
|
+
}
|
|
1694
1729
|
|
|
1730
|
+
/** Testable core of {@link pollJobActivation}: given the normalised `base` URL and prepared auth
|
|
1731
|
+
* `headers`, reconcile every `converging` PR row against the engine's open convergence-agent jobs.
|
|
1732
|
+
* Split out so tests can exercise the reconciliation with a stubbed `fetch` without re-deriving
|
|
1733
|
+
* transport wiring. */
|
|
1734
|
+
export async function pollJobActivationImpl(
|
|
1735
|
+
data: DataLayer,
|
|
1736
|
+
base: string,
|
|
1737
|
+
headers: Record<string, string>,
|
|
1738
|
+
) {
|
|
1695
1739
|
const all = await prsTracking(data).all();
|
|
1696
1740
|
for (const pr of all) {
|
|
1697
|
-
// Only a `converging` PR has a live
|
|
1741
|
+
// Only a `converging` PR has a live convergence-agent job. Any other status with a stale worker
|
|
1698
1742
|
// set (e.g. it just moved to `waiting_review`) gets cleared so the grid can't show a
|
|
1699
1743
|
// phantom "agent working". ADR-0065: classify on the DERIVED status, not the base transient —
|
|
1700
1744
|
// a PR whose instance was terminated out-of-band keeps its base `status` frozen at `converging`
|
|
@@ -1731,7 +1775,15 @@ async function pollJobActivation(
|
|
|
1731
1775
|
method: "POST",
|
|
1732
1776
|
headers,
|
|
1733
1777
|
body: JSON.stringify({
|
|
1734
|
-
filter: {
|
|
1778
|
+
filter: {
|
|
1779
|
+
// ANY convergence-agent job type: the loop moves between `review-round`
|
|
1780
|
+
// (`senior:pr-review`) and `adversarial-review` (`senior:adversarial-review`) without
|
|
1781
|
+
// leaving `converging`, so filtering on only one would clear the lease mid-handoff and
|
|
1782
|
+
// report "queued" while the other agent is actively working (issue #845 review).
|
|
1783
|
+
type: { $in: [...CONVERGENCE_AGENT_JOB_TYPES] },
|
|
1784
|
+
processInstanceKey: pr.process_key,
|
|
1785
|
+
state: "CREATED",
|
|
1786
|
+
},
|
|
1735
1787
|
page: { limit: 20 },
|
|
1736
1788
|
}),
|
|
1737
1789
|
});
|
|
@@ -1739,9 +1791,16 @@ async function pollJobActivation(
|
|
|
1739
1791
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
1740
1792
|
const body = (await res.json()) as { items?: JobSearchItem[] };
|
|
1741
1793
|
// An open job with a leasing worker means an agent has activated it. Prefer the one with
|
|
1742
|
-
// the latest deadline if several are open (there is normally at most one).
|
|
1794
|
+
// the latest deadline if several are open (there is normally at most one). Re-filter on the
|
|
1795
|
+
// job type defensively in case the wire `$in` filter is ignored, so a non-agent job (e.g. an
|
|
1796
|
+
// internal `pr.*` worker) can never masquerade as a leasing convergence agent.
|
|
1743
1797
|
const activated = (body.items ?? [])
|
|
1744
|
-
.filter(
|
|
1798
|
+
.filter(
|
|
1799
|
+
(j) =>
|
|
1800
|
+
typeof j.worker === "string" &&
|
|
1801
|
+
j.worker.length > 0 &&
|
|
1802
|
+
CONVERGENCE_AGENT_JOB_TYPES.includes(j.type ?? ""),
|
|
1803
|
+
)
|
|
1745
1804
|
.sort((a, b) => (a.deadline ?? "").localeCompare(b.deadline ?? ""))
|
|
1746
1805
|
.pop();
|
|
1747
1806
|
if (activated) {
|
package/nano.app.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.200.1",
|
|
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",
|