@nanobpm/nano-workforce 0.208.0 → 0.208.2

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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.208.2](https://github.com/nanobpm/nano-workforce/compare/v0.208.1...v0.208.2) (2026-11-10)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **status:** batch the /status merge-policy engine read into one call ([#937](https://github.com/nanobpm/nano-workforce/issues/937)) ([a9778e1](https://github.com/nanobpm/nano-workforce/commit/a9778e1c7db7205fe0b3b5dfebc597468c4e66f4)), closes [issue-#916](https://github.com/nanobpm/issue-/issues/916) [#916](https://github.com/nanobpm/nano-workforce/issues/916) [#936](https://github.com/nanobpm/nano-workforce/issues/936)
6
+
7
+ ## [0.208.1](https://github.com/nanobpm/nano-workforce/compare/v0.208.0...v0.208.1) (2026-11-10)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **deps:** update dependency @nanobpm/agentic to ^0.16.0 ([#942](https://github.com/nanobpm/nano-workforce/issues/942)) ([9080010](https://github.com/nanobpm/nano-workforce/commit/9080010a73e54c922991be86b54d3f76a1b264e8))
12
+
1
13
  ## [0.208.0](https://github.com/nanobpm/nano-workforce/compare/v0.207.0...v0.208.0) (2026-11-10)
2
14
 
3
15
  ### Features
@@ -53,16 +53,22 @@ function memData(stores: Stores) {
53
53
  } as any;
54
54
  }
55
55
 
56
- /** A fake engine whose `searchVariables` serves the given per-instance variables. */
56
+ /** A fake engine whose `searchVariables` serves the given per-instance variables. A name-less,
57
+ * key-less query (`{}` — the batched /status read, issue #936) returns EVERY variable across all
58
+ * instances, each row carrying its owning `processInstanceKey`, exactly as the real engine does. */
57
59
  function fakeEngine(varsByInstance: Record<string, Record<string, unknown>>) {
58
60
  return {
59
- searchVariables: (q: { processInstanceKey?: string; name?: string }) => {
60
- const vars = varsByInstance[String(q.processInstanceKey)] ?? {};
61
- return Promise.resolve(
62
- Object.entries(vars)
63
- .filter(([name]) => q.name == null || name === q.name)
64
- .map(([name, v]) => ({ name, value: JSON.stringify(v) })),
65
- );
61
+ searchVariables: (q: { processInstanceKey?: string; scopeKey?: string; name?: string }) => {
62
+ const rows: { name: string; value: string; processInstanceKey: string; scopeKey: string }[] = [];
63
+ for (const [piKey, vars] of Object.entries(varsByInstance)) {
64
+ if (q.processInstanceKey != null && piKey !== String(q.processInstanceKey)) continue;
65
+ if (q.scopeKey != null && piKey !== String(q.scopeKey)) continue;
66
+ for (const [name, v] of Object.entries(vars)) {
67
+ if (q.name != null && name !== q.name) continue;
68
+ rows.push({ name, value: JSON.stringify(v), processInstanceKey: piKey, scopeKey: piKey });
69
+ }
70
+ }
71
+ return Promise.resolve(rows);
66
72
  },
67
73
  } as any;
68
74
  }
@@ -167,3 +173,52 @@ test("merge policy: NANO_PR_AUTO_MERGE=0 blocks a requested merge as env, never
167
173
  else process.env["NANO_PR_AUTO_MERGE"] = prev;
168
174
  }
169
175
  });
176
+
177
+ function activeStores() {
178
+ return {
179
+ pull_requests: { rows: [prRow({})], key: "pr_key" },
180
+ user_tasks: { rows: [], key: "id" },
181
+ } satisfies Stores;
182
+ }
183
+
184
+ type Row = { name: string; value: string; processInstanceKey: string; scopeKey: string };
185
+ const row = (name: string, v: unknown): Row => ({ name, value: JSON.stringify(v), processInstanceKey: "PI-1", scopeKey: "PI-1" });
186
+
187
+ test("merge policy: an unusable autoMerge row still probes a page-cut convergeOnly (PR #937 review)", async () => {
188
+ // Batch: autoMerge present but null (unusable), convergeOnly cut from its page; the root probe finds it.
189
+ const engine = {
190
+ searchVariables: (q: { processInstanceKey?: string; name?: string }) => {
191
+ if (q.processInstanceKey == null) {
192
+ if (q.name === "autoMerge") return Promise.resolve([row("autoMerge", null)]);
193
+ if (q.name === "humanApproval") return Promise.resolve([row("humanApproval", false)]);
194
+ return Promise.resolve([]);
195
+ }
196
+ return Promise.resolve(q.name === "convergeOnly" ? [row("convergeOnly", true)] : []);
197
+ },
198
+ } as any;
199
+ const [pr] = await activePrs(memData(activeStores()), engine);
200
+ assertEquals(pr.merge.requested, false, "the legacy convergeOnly:true must be honoured when autoMerge is unusable");
201
+ });
202
+
203
+ test("merge policy: a delayed successful probe cannot resurrect a policy after a sibling probe failed (PR #937 review)", async () => {
204
+ // Batch cuts every name. PI-1: autoMerge probe rejects early, humanApproval:true resolves late.
205
+ // PI-2 (another PR) has a slower probe, so the projection is still pending when PI-1's late probe lands.
206
+ const stores = {
207
+ pull_requests: {
208
+ rows: [prRow({}), prRow({ pr_key: "owner/repo#2", number: 2, process_key: "PI-2" })],
209
+ key: "pr_key",
210
+ },
211
+ user_tasks: { rows: [], key: "id" },
212
+ } satisfies Stores;
213
+ const engine = {
214
+ searchVariables: (q: { processInstanceKey?: string; name?: string }) => {
215
+ if (q.processInstanceKey == null) return Promise.resolve([]);
216
+ if (q.processInstanceKey === "PI-2") return new Promise<Row[]>((r) => setTimeout(() => r([]), 60));
217
+ if (q.name === "autoMerge") return Promise.reject(new Error("boom"));
218
+ return new Promise<Row[]>((r) => setTimeout(() => r([row(q.name ?? "", true)]), 20));
219
+ },
220
+ } as any;
221
+ const prs = await activePrs(memData(stores), engine);
222
+ const pr = prs.find((p: { prKey?: string; pr_key?: string; number?: number }) => p.number === 1);
223
+ assertEquals(pr?.merge, { requested: true, enabled: true, effective: true, blockedBy: null }, "failed probe → default, not a half-read policy");
224
+ });
@@ -1358,13 +1358,15 @@ function captureRoot() {
1358
1358
  open: () => memOpen(stores),
1359
1359
  } as any;
1360
1360
  let captured: unknown;
1361
+ let vars: Record<string, unknown> | undefined;
1361
1362
  const engine = {
1362
1363
  createInstance: (req: { variables?: Record<string, unknown> }) => {
1363
1364
  captured = req.variables?.rootRequestKey;
1365
+ vars = req.variables;
1364
1366
  return Promise.resolve({ processInstanceKey: "PI-1" });
1365
1367
  },
1366
1368
  } as any;
1367
- return { data, engine, stores, get: () => captured };
1369
+ return { data, engine, stores, get: () => captured, vars: () => vars };
1368
1370
  }
1369
1371
 
1370
1372
  test("submitPr persists root_request_key and threads it onto the convergence instance", async () => {
@@ -1447,6 +1449,21 @@ test("startMerge reads root_request_key off the PR row onto the merge instance",
1447
1449
  });
1448
1450
  });
1449
1451
 
1452
+ test("startMerge seeds the merge-policy triple so /status needs no per-instance probes (PR #937)", async () => {
1453
+ await withGithubOff(async () => {
1454
+ const { data, engine, vars } = captureRoot();
1455
+ await startMerge(data, engine, {
1456
+ repo: "owner/repo",
1457
+ number: 9,
1458
+ url: "https://github.com/owner/repo/pull/9",
1459
+ prKey: "owner/repo#9",
1460
+ round: 1,
1461
+ });
1462
+ const seeded = vars() ?? {};
1463
+ assertEquals([seeded.autoMerge, seeded.convergeOnly, seeded.humanApproval], [true, false, false]);
1464
+ });
1465
+ });
1466
+
1450
1467
  // The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
1451
1468
  // emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
1452
1469
  // checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
package/app/service.ts CHANGED
@@ -1091,6 +1091,12 @@ export async function startMerge(
1091
1091
  reviewRequest: null,
1092
1092
  // Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
1093
1093
  rootRequestKey,
1094
+ // Merge policy (issue #936): a merge loop only starts for a requested, approved merge. Seeding
1095
+ // the policy triple keeps it in /status's batched reads (no per-instance probes). Inert here —
1096
+ // the merge-loop process reads none of them.
1097
+ autoMerge: true,
1098
+ convergeOnly: false,
1099
+ humanApproval: false,
1094
1100
  abandonUrl: abUrl,
1095
1101
  abandonBrief: renderAbandonBrief(abUrl),
1096
1102
  // Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
@@ -1230,15 +1236,21 @@ export async function activePrs(
1230
1236
  openEscByPr.set(t.subject_key, toOpenEscalation(t));
1231
1237
  }
1232
1238
  // Merge intent (issue #916): read the loop's `autoMerge` / `convergeOnly` / `humanApproval`
1233
- // variables per PR so the effective merge policy is visible without opening the engine. One
1234
- // best-effort read per PR; a failure (engine unreachable, instance gone) degrades to the
1235
- // historical default (merge requested) rather than failing the whole status read.
1239
+ // variables per PR so the effective merge policy is visible without opening the engine.
1240
+ //
1241
+ // A CONSTANT number of batched engine reads per /status call (issue #936) — never a per-row
1242
+ // fan-out. The pre-#936 shape issued 3 `searchVariables` round-trips PER active PR (12 calls at 4
1243
+ // rows ≈ 13 s; concurrent callers stacked until the console proxy 502'd). See
1244
+ // `readMergePolicyVars` for the completeness contract (single-page seam, root scope only, one
1245
+ // per-name root probe only for a page cut or legacy instance). Best-effort: a failed batched read degrades every
1246
+ // PR to the historical default (merge requested); a failed per-instance fallback degrades only
1247
+ // that PR.
1236
1248
  const mergeByPr = new Map<string, MergePolicy>();
1237
- await Promise.all(
1238
- active.map(async (p) => {
1239
- mergeByPr.set(p.pr_key, await mergePolicyFor(p.process_key ?? null, engine));
1240
- }),
1241
- );
1249
+ const activeKeys = active.map((p) => p.process_key).filter((k): k is string => k != null && k !== "");
1250
+ const varsByInstance = await readMergePolicyVars(engine, activeKeys);
1251
+ for (const p of active) {
1252
+ mergeByPr.set(p.pr_key, mergePolicyFromVars(varsByInstance?.get(p.process_key ?? "")));
1253
+ }
1242
1254
  return active.map((p) => {
1243
1255
  // Issue #931: an ACTIVE engine incident parked on the loop's instance must be VISIBLE — the row
1244
1256
  // kept reading `converging` for hours while nothing ran. Overlay the derived `incident` status
@@ -1284,44 +1296,134 @@ function mergePolicyFrom(requested: boolean, humanApproval: boolean): MergePolic
1284
1296
  return { requested, enabled, effective, blockedBy };
1285
1297
  }
1286
1298
 
1287
- /** Read one boolean process variable off a live instance; undefined when absent/unreadable. */
1288
- async function readBoolVar(
1289
- engine: Pick<EngineClient, "searchVariables">,
1290
- processKey: string,
1291
- name: string,
1292
- ): Promise<boolean | undefined> {
1293
- const [row] = await engine.searchVariables({ processInstanceKey: processKey, name });
1294
- if (!row) return undefined;
1299
+ /** The merge-policy variable triple for one instance: the values of `autoMerge`, `convergeOnly`,
1300
+ * and `humanApproval`, each `undefined` when the instance does not carry it (or it is not a
1301
+ * boolean). */
1302
+ type MergePolicyVars = {
1303
+ autoMerge: boolean | undefined;
1304
+ convergeOnly: boolean | undefined;
1305
+ humanApproval: boolean | undefined;
1306
+ };
1307
+
1308
+ function emptyMergePolicyVars(): MergePolicyVars {
1309
+ return { autoMerge: undefined, convergeOnly: undefined, humanApproval: undefined };
1310
+ }
1311
+
1312
+ function parseBoolValue(value: string): boolean | undefined {
1295
1313
  try {
1296
- const v: unknown = JSON.parse(row.value);
1314
+ const v: unknown = JSON.parse(value);
1297
1315
  return typeof v === "boolean" ? v : undefined;
1298
1316
  } catch {
1299
1317
  return undefined;
1300
1318
  }
1301
1319
  }
1302
1320
 
1303
- /** Derive a PR's effective merge policy from its loop's process variables (issue #916). The
1304
- * canonical positive `autoMerge` wins; the legacy inverted `convergeOnly` is the fallback for
1305
- * in-flight loops seeded before `autoMerge` existed. Best-effort: any read failure degrades to the
1306
- * historical default (merge requested) rather than failing the status read. */
1307
- async function mergePolicyFor(
1308
- processKey: string | null,
1321
+ const MERGE_POLICY_NAMES = ["autoMerge", "convergeOnly", "humanApproval"] as const;
1322
+ type MergePolicyName = (typeof MERGE_POLICY_NAMES)[number];
1323
+
1324
+ function isMergePolicyName(name: string): name is MergePolicyName {
1325
+ return MERGE_POLICY_NAMES.some((n) => n === name);
1326
+ }
1327
+
1328
+ /** Batched merge-policy read (issue #936): ONE name-filtered engine-wide read per policy variable
1329
+ * (3 concurrent calls, independent of row count), bucketed by owning instance.
1330
+ *
1331
+ * Completeness: the engine seam returns a single page with no cursor, so an engine-wide read can
1332
+ * silently omit rows, and the three name pages truncate independently. Completeness is therefore
1333
+ * tracked per (instance, name): every name an active instance is missing from is re-probed with a
1334
+ * name-filtered root read (<=1 row, cannot be truncated). Every loop seeds all three names at
1335
+ * creation (submitPr for the review loop, startMerge for the merge loop), so the steady state is a
1336
+ * constant 3 calls; probes are paid only for genuine page cuts or legacy instances.
1337
+ *
1338
+ * Scope: only the PROCESS-ROOT variable (`scopeKey === processInstanceKey`) is the loop's policy; a
1339
+ * same-named child-scope variable is ignored so it can never overwrite the root value.
1340
+ *
1341
+ * Failure isolation: a failed BATCH read rejects the whole projection (its page is partial, so
1342
+ * absence can no longer be distinguished from a page cut) and the caller degrades every row to
1343
+ * the default. A failed TARGETED read degrades only its own instance (its partial entry is dropped,
1344
+ * so it projects the default, never a half-read policy) — the policies already
1345
+ * fetched for every other PR are preserved (a rejected fallback must not discard a sibling's
1346
+ * `autoMerge:false` or `humanApproval:true` and silently un-block its merge).
1347
+ *
1348
+ * Skips the engine entirely when no active row carries a process key. Returns `undefined` when no
1349
+ * engine was handed in or a batched read fails — the caller degrades every row to the default. */
1350
+ async function readMergePolicyVars(
1309
1351
  engine: Pick<EngineClient, "searchVariables"> | undefined,
1310
- ): Promise<MergePolicy> {
1311
- if (processKey == null || !engine) return mergePolicyDefault();
1352
+ activeKeys: readonly string[],
1353
+ ): Promise<Map<string, MergePolicyVars> | undefined> {
1354
+ if (!engine || activeKeys.length === 0) return undefined;
1355
+ const wanted = new Set(activeKeys);
1312
1356
  try {
1313
- const [autoMerge, convergeOnly, humanApproval] = await Promise.all([
1314
- readBoolVar(engine, processKey, "autoMerge"),
1315
- readBoolVar(engine, processKey, "convergeOnly"),
1316
- readBoolVar(engine, processKey, "humanApproval"),
1317
- ]);
1318
- const requested = autoMerge ?? (convergeOnly === undefined ? true : !convergeOnly);
1319
- return mergePolicyFrom(requested, humanApproval === true);
1357
+ const byInstance = new Map<string, MergePolicyVars>();
1358
+ const seen = new Map<string, Set<MergePolicyName>>();
1359
+ const record = (row: { name: string; processInstanceKey: string; scopeKey: string; value: string }) => {
1360
+ const pk = String(row.processInstanceKey);
1361
+ if (!wanted.has(pk) || String(row.scopeKey) !== pk) return;
1362
+ if (!isMergePolicyName(row.name)) return;
1363
+ const vars = byInstance.get(pk) ?? emptyMergePolicyVars();
1364
+ vars[row.name] = parseBoolValue(row.value);
1365
+ byInstance.set(pk, vars);
1366
+ const names = seen.get(pk) ?? new Set<MergePolicyName>();
1367
+ names.add(row.name);
1368
+ seen.set(pk, names);
1369
+ };
1370
+ await Promise.all(
1371
+ MERGE_POLICY_NAMES.map(async (name) => {
1372
+ for (const row of await engine.searchVariables({ name })) if (row.name === name) record(row);
1373
+ }),
1374
+ );
1375
+ // Completeness is tracked per (instance, NAME), not per instance: the three name pages truncate
1376
+ // independently, so an instance present in one page (e.g. `autoMerge`) may still have been cut
1377
+ // from another (e.g. `humanApproval`). Each name an instance is missing from is re-probed with a
1378
+ // name-filtered ROOT read — a <=1-row result that cannot be truncated, so it proves absence.
1379
+ // `convergeOnly` is skipped once a usable boolean `autoMerge` is known (the canonical intent wins). Every loop
1380
+ // seeds all three names at creation (submitPr; startMerge seeds the merge loop's policy too), so
1381
+ // the steady state is exactly 3 calls; probes are only paid for page cuts and legacy instances.
1382
+ await Promise.all(
1383
+ [...wanted].map(async (pk) => {
1384
+ const got = seen.get(pk);
1385
+ const probe = MERGE_POLICY_NAMES.filter(
1386
+ (name) =>
1387
+ !got?.has(name) &&
1388
+ !(name === "convergeOnly" && byInstance.get(pk)?.autoMerge !== undefined),
1389
+ );
1390
+ if (probe.length === 0) return;
1391
+ try {
1392
+ // Collect ALL probe results first and record only once every probe succeeded: recording
1393
+ // inside each probe would let a slower successful sibling re-create the entry the catch
1394
+ // below deleted after an earlier rejection, exposing a half-read policy.
1395
+ const results = await Promise.all(
1396
+ probe.map(async (name) =>
1397
+ (await engine.searchVariables({ processInstanceKey: pk, scopeKey: pk, name })).filter(
1398
+ (row) => row.name === name,
1399
+ ),
1400
+ ),
1401
+ );
1402
+ for (const rows of results) for (const row of rows) record(row);
1403
+ } catch {
1404
+ // Degrade ONLY this instance — and fully: drop any partial entry the batch recorded so
1405
+ // the caller projects the default for it, never a half-read policy.
1406
+ byInstance.delete(pk);
1407
+ }
1408
+ }),
1409
+ );
1410
+ return byInstance;
1320
1411
  } catch {
1321
- return mergePolicyDefault();
1412
+ return undefined;
1322
1413
  }
1323
1414
  }
1324
1415
 
1416
+ /** Derive a PR's effective merge policy from an already-read variable triple (issue #916). The
1417
+ * canonical positive `autoMerge` wins; the legacy inverted `convergeOnly` is the fallback for
1418
+ * in-flight loops seeded before `autoMerge` existed; an instance carrying neither (or absent from
1419
+ * the batch — mid-handoff row, instance gone) degrades to the historical default (merge
1420
+ * requested) rather than failing the status read. */
1421
+ function mergePolicyFromVars(vars: MergePolicyVars | undefined): MergePolicy {
1422
+ if (!vars) return mergePolicyDefault();
1423
+ const requested = vars.autoMerge ?? (vars.convergeOnly === undefined ? true : !vars.convergeOnly);
1424
+ return mergePolicyFrom(requested, vars.humanApproval === true);
1425
+ }
1426
+
1325
1427
  /** A delivery-graph run currently in flight, as reported by the status endpoint (issue #889 §1 —
1326
1428
  * "Show it in `/status` for each PR or delivery"). Mirrors {@link ActivePr} for the delivery side. */
1327
1429
  export interface ActiveDelivery {
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.208.0",
3
- "gitSha": "0e2d50e8610beb5e939cd3a143d28e62d690f272",
4
- "gitTag": "v0.208.0",
5
- "builtAt": "2026-11-10T03:09:52.732Z"
2
+ "version": "0.208.2",
3
+ "gitSha": "a9778e1c7db7205fe0b3b5dfebc597468c4e66f4",
4
+ "gitTag": "v0.208.2",
5
+ "builtAt": "2026-11-10T20:28:50.098Z"
6
6
  }
@@ -189,3 +189,292 @@ test("a delivery run with no opt-in row flag surfaces autoResolveEscalations fal
189
189
  assertEquals(res.body.deliveries[0].openEscalation, null);
190
190
  assertEquals(res.body.deliveries[0].processKey, null, "a launch-in-flight run has no stamped process_key yet");
191
191
  });
192
+
193
+ // ── Issue #936: /status latency + concurrency regression guard ────────────────────────────────
194
+ // The defect: `activePrs` fanned the merge-policy read out per row — 3 `searchVariables` engine
195
+ // round-trips PER active PR — so 4 rows cost 12 sequential-ish upstream calls (~13 s) and K
196
+ // concurrent callers stacked (~50 s → the console proxy 502'd). The fix batches the engine read:
197
+ // ONE name-less `searchVariables({})` per /status serve, bucketed in memory. These tests pin the
198
+ // contract against a fake engine with a REALISTIC per-call delay: the engine-call count must stay
199
+ // at one per serve regardless of row count, and K concurrent serves must overlap (wall time ≈ one
200
+ // upstream round-trip, not K ×).
201
+
202
+ /** A fake engine that serves the batched variable read after a realistic upstream delay, counting
203
+ * calls and tracking peak in-flight concurrency. */
204
+ function slowEngine(varsByInstance: Record<string, Record<string, unknown>>, delayMs: number) {
205
+ let calls = 0;
206
+ let inFlight = 0;
207
+ let maxInFlight = 0;
208
+ return {
209
+ calls: () => calls,
210
+ maxInFlight: () => maxInFlight,
211
+ searchVariables: async (q: { processInstanceKey?: string; scopeKey?: string; name?: string }) => {
212
+ calls += 1;
213
+ inFlight += 1;
214
+ maxInFlight = Math.max(maxInFlight, inFlight);
215
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
216
+ inFlight -= 1;
217
+ const rows: { name: string; value: string; processInstanceKey: string; scopeKey: string }[] = [];
218
+ for (const [piKey, vars] of Object.entries(varsByInstance)) {
219
+ if (q.processInstanceKey != null && piKey !== String(q.processInstanceKey)) continue;
220
+ if (q.scopeKey != null && piKey !== String(q.scopeKey)) continue;
221
+ for (const [name, v] of Object.entries(vars)) {
222
+ if (q.name != null && name !== q.name) continue;
223
+ rows.push({ name, value: JSON.stringify(v), processInstanceKey: piKey, scopeKey: piKey });
224
+ }
225
+ }
226
+ return rows;
227
+ },
228
+ } as any;
229
+ }
230
+
231
+ function prRow936(n: number) {
232
+ return {
233
+ pr_key: `o/r#${n}`,
234
+ repo: "o/r",
235
+ number: n,
236
+ url: `https://github.com/o/r/pull/${n}`,
237
+ title: `PR ${n}`,
238
+ status: "converging",
239
+ current_round: 1,
240
+ process_key: `PI-${n}`,
241
+ updated_at: `2026-01-${String(n).padStart(2, "0")}`,
242
+ };
243
+ }
244
+
245
+ test("issue #936: N active rows cost a constant 3 engine variable reads, not 3N (the 13 s fan-out)", async () => {
246
+ const N = 8;
247
+ const rows = Array.from({ length: N }, (_, i) => prRow936(i + 1));
248
+ const vars: Record<string, Record<string, unknown>> = {};
249
+ for (let i = 1; i <= N; i++) vars[`PI-${i}`] = { autoMerge: true, convergeOnly: false, humanApproval: false };
250
+ const engine = slowEngine(vars, 25);
251
+ const app = { ...memApp(rows), engine } as AppApi;
252
+ const started = Date.now();
253
+ const res = (await handler(input(), app)) as any;
254
+ const elapsed = Date.now() - started;
255
+ assertEquals(res.status, 200);
256
+ assertEquals(res.body.count, N);
257
+ assertEquals(
258
+ engine.calls(),
259
+ 3,
260
+ "the merge-policy projection batches to ONE read per policy name (3) per serve — a per-row fan-out would show 3N calls",
261
+ );
262
+ assert(
263
+ elapsed < 25 * N,
264
+ `one batched ~25 ms upstream read serves ${N} rows (took ${elapsed} ms); the per-row fan-out this regresses to would take ≥ ${25 * N} ms`,
265
+ );
266
+ // The batch still projects the per-row policy correctly.
267
+ for (const pr of res.body.prs) {
268
+ assertEquals(pr.merge, { requested: true, enabled: true, effective: true, blockedBy: null });
269
+ }
270
+ });
271
+
272
+ test("issue #936: K concurrent /status callers overlap instead of queueing behind each other", async () => {
273
+ const K = 4;
274
+ const rows = [prRow936(1), prRow936(2)];
275
+ const vars = {
276
+ "PI-1": { autoMerge: true, convergeOnly: false, humanApproval: false },
277
+ "PI-2": { autoMerge: false, convergeOnly: true, humanApproval: false },
278
+ };
279
+ // A realistic slow upstream: each engine read takes 100 ms. Serialized callers would stack to
280
+ // K × 100 ms (the measured 35–50 s at production scale); overlapped callers finish in ~100 ms.
281
+ const engine = slowEngine(vars, 100);
282
+ const app = { ...memApp(rows), engine } as AppApi;
283
+ const started = Date.now();
284
+ const results = await Promise.all(Array.from({ length: K }, () => handler(input(), app)));
285
+ const elapsed = Date.now() - started;
286
+ for (const res of results) {
287
+ assertEquals((res as any).status, 200);
288
+ assertEquals((res as any).body.count, 2);
289
+ }
290
+ assertEquals(engine.calls(), 3 * K, "each serve issues its own batched reads (no cross-request cache serving stale policy)");
291
+ assertEquals(
292
+ engine.maxInFlight(),
293
+ 3 * K,
294
+ "all K engine reads were in flight at once — the serves overlap rather than serialise",
295
+ );
296
+ assert(
297
+ elapsed < K * 100,
298
+ `${K} concurrent serves over a 100 ms upstream finished in ${elapsed} ms (< ${K * 100} ms) — no caller queued behind another`,
299
+ );
300
+ // Mixed intents still project per row under the batched read.
301
+ const body = (results[0] as any).body;
302
+ assertEquals(body.prs.find((p: any) => p.prKey === "o/r#1").merge.requested, true);
303
+ assertEquals(body.prs.find((p: any) => p.prKey === "o/r#2").merge.requested, false);
304
+ });
305
+
306
+ // ── PR #937 review: completeness, root scope, idle skip ──────────────────────────────────────
307
+
308
+ /** A fake engine with explicit rows (incl. child scopes) whose engine-wide reads are cut to one
309
+ * `pageSize` page — the production seam returns a single page with no cursor. */
310
+ function pagedEngine(
311
+ rows: { name: string; value: string; processInstanceKey: string; scopeKey: string }[],
312
+ pageSize: number,
313
+ ) {
314
+ const queries: { processInstanceKey?: string; scopeKey?: string; name?: string }[] = [];
315
+ return {
316
+ queries,
317
+ searchVariables: async (q: { processInstanceKey?: string; scopeKey?: string; name?: string }) => {
318
+ queries.push(q);
319
+ return rows
320
+ .filter(
321
+ (r) =>
322
+ (q.processInstanceKey == null || r.processInstanceKey === q.processInstanceKey) &&
323
+ (q.scopeKey == null || r.scopeKey === q.scopeKey) &&
324
+ (q.name == null || r.name === q.name),
325
+ )
326
+ .slice(0, pageSize);
327
+ },
328
+ } as any;
329
+ }
330
+
331
+ function v(pk: string, name: string, value: unknown, scopeKey = pk) {
332
+ return { name, value: JSON.stringify(value), processInstanceKey: pk, scopeKey };
333
+ }
334
+
335
+ test("PR #937: an active PR's policy cut off the first engine page is still read (targeted fallback)", async () => {
336
+ const rows = [];
337
+ // 50 unrelated (completed/other) instances fill the single page ahead of the active PR.
338
+ for (let i = 0; i < 50; i++) {
339
+ for (const n of ["autoMerge", "convergeOnly", "humanApproval"]) rows.push(v(`OTHER-${i}`, n, true));
340
+ }
341
+ rows.push(v("PI-1", "autoMerge", false), v("PI-1", "convergeOnly", true), v("PI-1", "humanApproval", true));
342
+ const engine = pagedEngine(rows, 10);
343
+ const res = (await handler(input(), { ...memApp([prRow936(1)]), engine } as AppApi)) as any;
344
+ assertEquals(res.body.prs[0].merge.requested, false, "autoMerge:false beyond page 1 must not degrade to requested");
345
+ const withApproval = pagedEngine(
346
+ [...rows.filter((r) => r.processInstanceKey !== "PI-1"), v("PI-1", "autoMerge", true), v("PI-1", "humanApproval", true)],
347
+ 10,
348
+ );
349
+ const res2 = (await handler(input(), { ...memApp([prRow936(1)]), engine: withApproval } as AppApi)) as any;
350
+ assertEquals(res2.body.prs[0].merge.blockedBy != null, true, "the human-approval blocker beyond page 1 is not dropped");
351
+ });
352
+
353
+ for (const order of ["root-first", "child-first"] as const) {
354
+ test(`PR #937: child-scoped policy vars never override the root policy (${order})`, async () => {
355
+ const root = [v("PI-1", "autoMerge", false), v("PI-1", "humanApproval", true)];
356
+ const child = [v("PI-1", "autoMerge", true, "EI-9"), v("PI-1", "humanApproval", false, "EI-9")];
357
+ const engine = pagedEngine(order === "root-first" ? [...root, ...child] : [...child, ...root], 100);
358
+ const res = (await handler(input(), { ...memApp([prRow936(1)]), engine } as AppApi)) as any;
359
+ assertEquals(res.body.prs[0].merge.requested, false);
360
+ const engine2 = pagedEngine(
361
+ order === "root-first"
362
+ ? [v("PI-1", "autoMerge", true), v("PI-1", "humanApproval", true), ...child]
363
+ : [...child, v("PI-1", "autoMerge", true), v("PI-1", "humanApproval", true)],
364
+ 100,
365
+ );
366
+ const res2 = (await handler(input(), { ...memApp([prRow936(1)]), engine: engine2 } as AppApi)) as any;
367
+ assertEquals(res2.body.prs[0].merge.blockedBy != null, true, "a child humanApproval:false cannot hide the root gate");
368
+ });
369
+ }
370
+
371
+ test("PR #937: no engine read when no active row has a process key", async () => {
372
+ const engine = pagedEngine([], 100);
373
+ await handler(input(), { ...memApp([]), engine } as AppApi);
374
+ await handler(input(), { ...memApp([{ ...prRow936(1), process_key: null }]), engine } as AppApi);
375
+ assertEquals(engine.queries.length, 0);
376
+ });
377
+
378
+ // ── PR #937 round-3 review: bounded fallback fan-out, per-instance failure isolation ─────────
379
+
380
+ test("PR #937 r4: populated merge-loop roots cost a constant 3 reads (startMerge seeds the policy)", async () => {
381
+ // Round-3 defect: merge-loop roots carry non-policy vars, so the name-less fallback was non-empty
382
+ // and each merging PR paid 1 + 3 probes (19 calls at N=4). startMerge now seeds the policy triple,
383
+ // so populated merge-loop roots are fully represented in the batch and need no probes.
384
+ const N = 4;
385
+ const rows = Array.from({ length: N }, (_, i) => prRow936(i + 1));
386
+ const vars = [];
387
+ for (let i = 1; i <= N; i++) {
388
+ const pk = `PI-${i}`;
389
+ vars.push(v(pk, "repo", "o/r"), v(pk, "prNumber", i), v(pk, "ciFixRound", 0));
390
+ vars.push(v(pk, "autoMerge", true), v(pk, "convergeOnly", false), v(pk, "humanApproval", false));
391
+ }
392
+ const engine = pagedEngine(vars, 100);
393
+ const res = (await handler(input(), { ...memApp(rows), engine } as AppApi)) as any;
394
+ assertEquals(res.body.count, N);
395
+ assertEquals(engine.queries.length, 3, "populated merge-loop roots need no per-instance probes");
396
+ });
397
+
398
+ test("PR #937 r4: a name cut from ONE page is re-probed even when the instance appears in another", async () => {
399
+ // Round-3 defect: an instance present in the autoMerge page was treated as complete, so a
400
+ // humanApproval:true cut from its own page was dropped and /status reported an unblocked merge.
401
+ const rows = [v("PI-1", "autoMerge", true), v("PI-1", "convergeOnly", false)];
402
+ for (let i = 0; i < 20; i++) rows.push(v(`OTHER-${i}`, "humanApproval", false));
403
+ rows.push(v("PI-1", "humanApproval", true));
404
+ const engine = pagedEngine(rows, 10);
405
+ const res = (await handler(input(), { ...memApp([prRow936(1)]), engine } as AppApi)) as any;
406
+ assertEquals(res.body.prs[0].merge.blockedBy, "human-approval", "the cut humanApproval gate is recovered");
407
+ const targeted = engine.queries.filter((q: any) => q.processInstanceKey === "PI-1");
408
+ assertEquals(targeted.length, 1, "only the missing name is probed");
409
+ assertEquals(targeted[0].name, "humanApproval");
410
+ assertEquals(targeted[0].scopeKey, "PI-1", "the probe is confined to the process-root scope");
411
+ });
412
+
413
+ test("PR #937 r4: a failed probe after a partial batch drops the partial entry (default, not half-read)", async () => {
414
+ const failing = {
415
+ searchVariables: async (q: { processInstanceKey?: string; name?: string }) => {
416
+ if (q.processInstanceKey === "PI-1") throw new Error("engine read timed out");
417
+ if (q.name === "humanApproval") return [v("PI-1", "humanApproval", true)];
418
+ return [];
419
+ },
420
+ } as any;
421
+ const res = (await handler(input(), { ...memApp([prRow936(1)]), engine: failing } as AppApi)) as any;
422
+ assertEquals(res.status, 200);
423
+ assertEquals(res.body.prs[0].merge, { requested: true, enabled: true, effective: true, blockedBy: null });
424
+ });
425
+
426
+ test("PR #937 r4: a cut-off instance is recovered with name-filtered (untruncatable) root probes", async () => {
427
+ const rows = [];
428
+ for (let i = 0; i < 50; i++) {
429
+ for (const n of ["autoMerge", "convergeOnly", "humanApproval"]) rows.push(v(`OTHER-${i}`, n, true));
430
+ }
431
+ rows.push(v("PI-1", "autoMerge", true), v("PI-1", "convergeOnly", false));
432
+ rows.push(v("PI-2", "autoMerge", true), v("PI-2", "convergeOnly", false), v("PI-2", "humanApproval", true));
433
+ const engine = pagedEngine(rows, 10);
434
+ const res = (await handler(input(), { ...memApp([prRow936(1), prRow936(2)]), engine } as AppApi)) as any;
435
+ const pr2 = res.body.prs.find((p: any) => p.prKey === "o/r#2");
436
+ assertEquals(pr2.merge.requested, true);
437
+ assertEquals(pr2.merge.blockedBy, "human-approval");
438
+ for (const q of engine.queries.filter((q: any) => q.processInstanceKey != null)) {
439
+ assert(q.name != null, "every fallback read is name-filtered (<=1 row, cannot be truncated)");
440
+ assertEquals(q.scopeKey, q.processInstanceKey);
441
+ }
442
+ });
443
+
444
+ test("PR #937 r3: one failed targeted read degrades only its own instance, not every other PR's policy", async () => {
445
+ // Round-2 defect: a rejected targeted read propagated to the OUTER catch, so the whole batch
446
+ // read was discarded and PI-1's already-fetched policy silently degraded to "merge requested,
447
+ // no approval blocker". Each instance's fallback must fail independently. PI-1 requests the
448
+ // merge AND carries an outstanding approval gate; PI-2's fallback rejects.
449
+ const failing = {
450
+ searchVariables: async (q: { processInstanceKey?: string; scopeKey?: string; name?: string }) => {
451
+ if (q.processInstanceKey === "PI-2") throw new Error("engine read timed out");
452
+ if (q.name === "autoMerge") return [v("PI-1", "autoMerge", true)];
453
+ if (q.name === "humanApproval") return [v("PI-1", "humanApproval", true)];
454
+ return [];
455
+ },
456
+ } as any;
457
+ const res = (await handler(input(), { ...memApp([prRow936(1), prRow936(2)]), engine: failing } as AppApi)) as any;
458
+ assertEquals(res.status, 200);
459
+ const pr1 = res.body.prs.find((p: any) => p.prKey === "o/r#1");
460
+ assertEquals(pr1.merge.requested, true, "PI-1's fetched merge intent survives PI-2's failed fallback");
461
+ assertEquals(pr1.merge.blockedBy, "human-approval", "PI-1's fetched approval blocker survives PI-2's failed fallback");
462
+ const pr2 = res.body.prs.find((p: any) => p.prKey === "o/r#2");
463
+ assertEquals(pr2.merge.requested, true, "the failed instance degrades to the historical default (merge requested)");
464
+ assertEquals(pr2.merge.blockedBy, null, "the failed instance shows no approval blocker it could not verify");
465
+ });
466
+
467
+ test("PR #937 r3: a failed BATCH read still degrades every row to the default (no partial page)", async () => {
468
+ // The batched name reads are the completeness backbone — if one rejects, its page is partial
469
+ // and absence can no longer be distinguished from a page cut, so the whole projection degrades.
470
+ const failing = {
471
+ searchVariables: async (q: { name?: string }) => {
472
+ if (q.name === "humanApproval") throw new Error("engine read timed out");
473
+ if (q.name === "autoMerge") return [v("PI-1", "autoMerge", false)];
474
+ return [];
475
+ },
476
+ } as any;
477
+ const res = (await handler(input(), { ...memApp([prRow936(1)]), engine: failing } as AppApi)) as any;
478
+ assertEquals(res.status, 200);
479
+ assertEquals(res.body.prs[0].merge, { requested: true, enabled: true, effective: true, blockedBy: null });
480
+ });
@@ -20,9 +20,29 @@ export default defineOperation("listActivePrs", async ({ req }, app) => {
20
20
  app.log.warn("listActivePrs rejected: missing/invalid shared secret");
21
21
  return { status: 401, body: { error: "unauthorized" } };
22
22
  }
23
+ // Per-phase timing (issue #936): /status regressed to ~13 s (and serialised concurrent callers
24
+ // into proxy 502s) on an unprofiled per-row engine fan-out. The merge-policy engine read is now
25
+ // ONE batched call inside `activePrs`, and these phase timings make any future regression
26
+ // attributable from the logs instead of rediscovered by hand. Debug level: /status is a hot
27
+ // polling endpoint, so this must not spam info.
28
+ const t0 = Date.now();
23
29
  const [prs, deliveries] = await Promise.all([activePrs(app.data, app.engine), activeDeliveries(app.data)]);
30
+ const readMs = Date.now() - t0;
24
31
  // The running app's identity, derived from the SAME app/version.ts getter /version uses (never
25
32
  // re-read), so the endpoint everyone polls answers "is the fix live?" in one call (issue #925).
26
33
  const { version, gitSha } = buildVersionInfo();
34
+ app.log.debug("listActivePrs served", {
35
+ ms: readMs,
36
+ prRows: prs.length,
37
+ deliveryRows: deliveries.length,
38
+ });
39
+ if (readMs > STATUS_SLOW_MS) {
40
+ app.log.warn("listActivePrs slow", { ms: readMs, prRows: prs.length, deliveryRows: deliveries.length });
41
+ }
27
42
  return { status: 200, body: { count: prs.length, prs, deliveries, version: { version, gitSha } } };
28
43
  });
44
+
45
+ /** The wall-clock budget over which a /status serve is logged as slow (issue #936). The endpoint
46
+ * targets well under 1 s for a handful of rows; anything past this is a regression signal an
47
+ * operator can grep for. */
48
+ const STATUS_SLOW_MS = 1_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.208.0",
3
+ "version": "0.208.2",
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",
@@ -65,7 +65,7 @@
65
65
  "lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
66
66
  },
67
67
  "dependencies": {
68
- "@nanobpm/agentic": "^0.15.0",
68
+ "@nanobpm/agentic": "^0.16.0",
69
69
  "@nanobpm/urban": "^0.98.0",
70
70
  "bpmn-auto-layout": "^2.0.0-alpha.2"
71
71
  },