@nanobpm/nano-workforce 0.208.1 → 0.208.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.
@@ -104,6 +104,10 @@ test("submitPr re-opens a derive-only-terminated PR (base 'converging', derived
104
104
  status: "converging", // base transient FROZEN — reconciler no longer writes the terminal
105
105
  derived_status: "abandoned", // ADR-0065 derive-only terminal
106
106
  current_round: 3,
107
+ // A PRIOR run's operator Dismiss + provenance must not survive the reopen (PR #944 review).
108
+ acknowledged_at: "2024-01-05",
109
+ termination_reason: "operator cancel",
110
+ termination_process_key: "PI-1",
107
111
  }],
108
112
  key: "pr_key",
109
113
  },
@@ -126,24 +130,31 @@ test("submitPr re-opens a derive-only-terminated PR (base 'converging', derived
126
130
  assertEquals(pr.status, "converging");
127
131
  assertEquals(pr.current_round, 1); // re-opened for a fresh run
128
132
  assertEquals(pr.process_key, "PI-9");
133
+ assertEquals(pr.acknowledged_at, null, "a prior run's Dismiss never acknowledges this run's orphaning");
134
+ assertEquals(pr.termination_reason, null);
135
+ assertEquals(pr.termination_process_key, null);
129
136
  });
130
137
  });
131
138
 
132
- // #503 / #497 phantom: `activePrs` filters on `derived_status`, so a derive-only-terminated PR drops
133
- // off the Convergence tab instead of showing "active" indefinitely.
134
- test("activePrs excludes a derive-only-terminated PR and keeps a genuinely active one", async () => {
139
+ // #503 / #497 phantom: `activePrs` classifies on `derived_status`, so a derive-only-terminated PR never
140
+ // reads as its frozen in-flight base status. Issue #943: if NO reason was recorded for the termination
141
+ // (an orphan) it is surfaced as `orphaned` rather than silently dropped; a recorded reason or an
142
+ // operator Dismiss (`acknowledged_at`) excludes it.
143
+ test("activePrs never reports a derive-only-terminated PR as active — orphaned if unexplained, excluded if explained/dismissed", async () => {
135
144
  const stores: Stores = {
136
145
  pull_requests: {
137
146
  rows: [
138
147
  { pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", status: "converging", derived_status: "abandoned", current_round: 2, updated_at: "2024-01-02" },
139
148
  { pr_key: "o/r#2", repo: "o/r", number: 2, url: "u2", status: "converging", derived_status: "converging", current_round: 1, updated_at: "2024-01-01" },
149
+ { pr_key: "o/r#3", repo: "o/r", number: 3, url: "u3", status: "converging", derived_status: "abandoned", process_key: "PI-3", termination_reason: "operator cancel", termination_process_key: "PI-3", current_round: 1, updated_at: "2024-01-03" },
150
+ { pr_key: "o/r#4", repo: "o/r", number: 4, url: "u4", status: "converging", derived_status: "abandoned", acknowledged_at: "2024-01-05", current_round: 1, updated_at: "2024-01-04" },
140
151
  ],
141
152
  key: "pr_key",
142
153
  },
143
154
  escalations: { rows: [], key: "id" },
144
155
  };
145
156
  const active = await activePrs(memData(stores));
146
- assertEquals(active.map((p) => p.prKey), ["o/r#2"]);
157
+ assertEquals(active.map((p) => [p.prKey, p.status]), [["o/r#1", "orphaned"], ["o/r#2", "converging"]]);
147
158
  });
148
159
 
149
160
  // #503: `pollIncidentsImpl` classifies a dead instance on `derived_status`, so a derive-only-terminated
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.208.1",
3
- "gitSha": "9080010a73e54c922991be86b54d3f76a1b264e8",
4
- "gitTag": "v0.208.1",
5
- "builtAt": "2026-11-10T07:05:28.202Z"
2
+ "version": "0.208.3",
3
+ "gitSha": "ff42e2a6fa77fd780949822adf3d4f2b9caefc38",
4
+ "gitTag": "v0.208.3",
5
+ "builtAt": "2026-11-11T02:35:58.462Z"
6
6
  }
@@ -0,0 +1,17 @@
1
+ -- Loop-termination provenance (issue #943). A merge loop was terminated in-app (the waiting-merge →
2
+ -- convergence hand-off) with no successor and no escalation, and nothing on the row said why — the PR
3
+ -- silently dropped off `/status`. Every APP-INITIATED cancel of a PR's convergence/merge loop now
4
+ -- stamps a one-line reason here (via `terminatePrLoop` in app/service.ts), naming the outcome it
5
+ -- led to (a successor convergence instance, or the PR's terminal GitHub state). NULL when the current
6
+ -- run was never cancelled by the app. `/status` surfaces it, and an `orphaned` overlay flags a PR
7
+ -- whose loop ended with NO recorded reason, no successor and no escalation.
8
+ ALTER TABLE pull_requests ADD COLUMN termination_reason TEXT;
9
+
10
+ -- The instance the reason describes (PR #944 review): provenance is bound to the CANCELLED instance and
11
+ -- only explains a termination while it equals the row's `process_key`, so a predecessor's reason can
12
+ -- never mask the later orphaning of a successor.
13
+ ALTER TABLE pull_requests ADD COLUMN termination_process_key TEXT;
14
+
15
+ -- A handed-off predecessor merge loop whose retirement has not yet succeeded (PR #944 review): durable
16
+ -- so the poller retries it every pass instead of dropping the key. NULL when nothing is pending.
17
+ ALTER TABLE pull_requests ADD COLUMN retire_pending_key TEXT;
@@ -0,0 +1,48 @@
1
+ -- Re-create the `pull_requests_read_model` VIEW so it re-exports the new base column (the VIEW must pass
2
+ -- through EVERY base `pull_requests` column — app/pullRequestReadModel.test.ts DRIFT GUARD). A mechanical
3
+ -- re-emission of 128 with `termination_reason` added; the derived columns are emitted VERBATIM from
4
+ -- app/pullRequestReadModel.ts and are unchanged. No BEGIN/COMMIT (the runner wraps each file). Numbered after 130.
5
+
6
+ DROP VIEW IF EXISTS pull_requests_read_model;
7
+
8
+ CREATE VIEW pull_requests_read_model AS
9
+ SELECT
10
+ pr.pr_key AS pr_key,
11
+ pr.repo AS repo,
12
+ pr.number AS number,
13
+ pr.url AS url,
14
+ pr.title AS title,
15
+ COALESCE(pr.derived_status, pr.status) AS status,
16
+ pr.current_round AS current_round,
17
+ pr.process_key AS process_key,
18
+ pr.waiting_since AS waiting_since,
19
+ pr.last_review_id AS last_review_id,
20
+ pr.outcome AS outcome,
21
+ pr.created_at AS created_at,
22
+ pr.updated_at AS updated_at,
23
+ pr.converged_at AS converged_at,
24
+ pr.merged_at AS merged_at,
25
+ pr.active_worker AS active_worker,
26
+ pr.lease_until AS lease_until,
27
+ pr.last_nudge_at AS last_nudge_at,
28
+ pr.fresh_head_run_head AS fresh_head_run_head,
29
+ pr.abandon_token AS abandon_token,
30
+ pr.incident_key AS incident_key,
31
+ pr.incident_type AS incident_type,
32
+ pr.incident_since AS incident_since,
33
+ pr.incident_message AS incident_message,
34
+ pr.last_round_head AS last_round_head,
35
+ pr.last_progress_job_key AS last_progress_job_key,
36
+ pr.last_progress_result AS last_progress_result,
37
+ pr.last_progress_agent_watermark AS last_progress_agent_watermark,
38
+ pr.churn_escalated_round AS churn_escalated_round,
39
+ pr.root_request_key AS root_request_key,
40
+ pr.epic_phase_label AS epic_phase_label,
41
+ pr.acknowledged_at AS acknowledged_at,
42
+ pr.auto_resolve_escalations AS auto_resolve_escalations,
43
+ pr.termination_reason AS termination_reason,
44
+ pr.termination_process_key AS termination_process_key,
45
+ pr.retire_pending_key AS retire_pending_key,
46
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
47
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
48
+ FROM pull_requests__tracking pr;
package/openapi.yaml CHANGED
@@ -94,7 +94,15 @@ components:
94
94
  - autoResolveEscalations
95
95
  - merge
96
96
  - incident
97
+ - terminationReason
97
98
  properties:
99
+ terminationReason:
100
+ type: string
101
+ nullable: true
102
+ description: >-
103
+ Why the app (or an operator) last cancelled this PR's current loop instance (issue #943),
104
+ naming the successor/outcome; null when never. An `orphaned` status means the loop ended
105
+ with no recorded reason, successor, or escalation.
98
106
  prKey:
99
107
  type: string
100
108
  repo:
@@ -4357,6 +4365,51 @@ paths:
4357
4365
  application/json:
4358
4366
  schema:
4359
4367
  $ref: "#/components/schemas/ErrorBody"
4368
+ /actions/cancel-pr:
4369
+ post:
4370
+ operationId: cancelPrRun
4371
+ # x-mcp exclusion: the page-only twin of `cancelInstance` (the per-row Cancel button). Agents use
4372
+ # the guarded `/actions/cancel` door.
4373
+ x-mcp:
4374
+ exclude: true
4375
+ summary: "Page-facing record-consistent cancel (issue #943): identical to `cancelInstance` (shared implementation, records termination provenance) but unguarded, because the declarative page action cannot attach x-hook-secret (see operations/cancelPrRun.ts)."
4376
+ requestBody:
4377
+ required: true
4378
+ content:
4379
+ application/json:
4380
+ schema:
4381
+ $ref: "#/components/schemas/CancelInstanceIntent"
4382
+ responses:
4383
+ "200":
4384
+ description: The instance was terminated; its record derives/flips to `abandoned` and drops out of `listActivePrs`.
4385
+ content:
4386
+ application/json:
4387
+ schema:
4388
+ $ref: "#/components/schemas/CancelInstanceResult"
4389
+ "400":
4390
+ description: Missing/invalid `processInstanceKey`.
4391
+ content:
4392
+ application/json:
4393
+ schema:
4394
+ $ref: "#/components/schemas/ErrorBody"
4395
+ "404":
4396
+ description: "No tracked record owns the key (a clean no-op — the engine is never touched). Body is a CancelInstanceResult with `ok:false`, `state:\"gone\"`, `reconciled:0`."
4397
+ content:
4398
+ application/json:
4399
+ schema:
4400
+ $ref: "#/components/schemas/CancelInstanceResult"
4401
+ "502":
4402
+ description: "The cancel did not fully take: EITHER the engine did NOT stop the instance (the cancel was not committed; the run may still be live), OR the engine stopped it but the tracked record did not settle to a terminal edge (`ok:false`, `reconciled:0`) — a wedged record the caller must not read as done."
4403
+ content:
4404
+ application/json:
4405
+ schema:
4406
+ $ref: "#/components/schemas/CancelInstanceResult"
4407
+ "503":
4408
+ description: The app has no data source configured, so the cancel cannot be reconciled into the record.
4409
+ content:
4410
+ application/json:
4411
+ schema:
4412
+ $ref: "#/components/schemas/ErrorBody"
4360
4413
  /actions/incident/retry:
4361
4414
  post:
4362
4415
  operationId: retryIncident
@@ -293,3 +293,12 @@ test("record routing also covers PR/plan aggregates (a resolved pull_requests ke
293
293
  assertEquals(res.body.ok, true);
294
294
  assertEquals(res.body.reconciled, 1);
295
295
  });
296
+
297
+ test("PR#944: operator-cancel provenance is recorded even when the record fails to reconcile (502)", async () => {
298
+ const rows: any[] = [{ pr_key: "o/r#4", process_key: "400", status: "converging" }];
299
+ const { app } = makeRecordApp({ pull_requests: { rows, key: "pr_key" } }, { readBackState: "TERMINATED" });
300
+ const res = await call(app, { processInstanceKey: "400" });
301
+ assertEquals(res.status, 502, "the unreconciled record still surfaces as 502");
302
+ assertEquals(rows[0].termination_process_key, "400", "provenance is stamped once the engine confirms termination");
303
+ assert(typeof rows[0].termination_reason === "string" && rows[0].termination_reason.length > 0);
304
+ });
@@ -37,9 +37,12 @@
37
37
  // door, when NANO_PR_WEBHOOK_SECRET is set callers must present it via the x-hook-secret header —
38
38
  // mirroring `reconcileEngineState`/`agentCompleteEscalation`.
39
39
 
40
+ import type { AppApi } from "@nanobpm/urban";
40
41
  import { cancelInstanceReconciling } from "@nanobpm/urban";
41
42
  import { engineBackedBindings, resolveTrackedInstance } from "../app/instanceTracking.ts";
43
+ import { recordOperatorCancelProvenance } from "../app/service.ts";
42
44
  import { envVar } from "../app/version.ts";
45
+ import type { CancelInstanceResponse } from "../nano-generated/api-io.d.ts";
43
46
  import { defineOperation } from "../nano-generated/operations.ts";
44
47
 
45
48
  const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
@@ -49,6 +52,17 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
49
52
  app.log.warn("cancelInstance rejected: missing/invalid shared secret");
50
53
  return { status: 401, body: { error: "unauthorized" } };
51
54
  }
55
+ return cancelTrackedRun(app, body, "/actions/cancel");
56
+ });
57
+
58
+ /** The ONE record-consistent cancel implementation shared by every cancel door (this guarded API/MCP
59
+ * door and the unguarded page door `/actions/cancel-pr`, operations/cancelPrRun.ts), so both stamp
60
+ * termination provenance (issue #943, PR #944 review) and neither can drift. Auth stays per door. */
61
+ export async function cancelTrackedRun(
62
+ app: AppApi,
63
+ body: unknown,
64
+ door: string,
65
+ ): Promise<{ status: number; body: CancelInstanceResponse }> {
52
66
  if (!app.data) {
53
67
  app.log.warn("cancelInstance: no data source configured — cannot reconcile the record");
54
68
  return { status: 503, body: { error: "no data source configured" } };
@@ -58,7 +72,7 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
58
72
  // so a numeric JSON value would already have lost precision before it reached us — accepting it
59
73
  // (and coercing it back to a string) would silently cancel the wrong instance and also contradicts
60
74
  // the OpenAPI `string` contract. Reject a non-string with a 400 rather than coerce.
61
- const raw = body && typeof body === "object" ? body.processInstanceKey : undefined;
75
+ const raw = body && typeof body === "object" && "processInstanceKey" in body ? body.processInstanceKey : undefined;
62
76
  const processInstanceKey = typeof raw === "string" ? raw.trim() : "";
63
77
  if (!processInstanceKey) {
64
78
  return { status: 400, body: { error: "processInstanceKey is required and must be a string" } };
@@ -124,6 +138,17 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
124
138
  // delta lies. Re-read the resolved record and count it reconciled only when its ADR-0065
125
139
  // `derived_status` has LEFT its `activeStatuses` (become terminal → resubmittable). When we cannot
126
140
  // read the record (no default source), trust the primitive's own count.
141
+ // Termination provenance (issue #943): a deliberate operator cancel of a PR loop is NOT an orphan —
142
+ // stamp the reason (as soon as the ENGINE confirms termination, before
143
+ // the record re-read — a 502 on an unreconciled record must not lose it; PR #944 review) on the owning PR row so `/status` does not flag it `orphaned`. Best-effort.
144
+ if (canResolveRecord) {
145
+ try {
146
+ await recordOperatorCancelProvenance(app.data, processInstanceKey, door);
147
+ } catch (err) {
148
+ app.log.warn("cancelInstance: could not record termination reason", { processInstanceKey, error: String(err) });
149
+ }
150
+ }
151
+
127
152
  let reconciled = result.reconciled;
128
153
  let recordReconciled = true;
129
154
  if (canResolveRecord) {
@@ -163,4 +188,5 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
163
188
  reconciled,
164
189
  });
165
190
  return { status: 200, body: responseBody };
166
- });
191
+ }
192
+
@@ -0,0 +1,16 @@
1
+ // POST /app/api/actions/cancel-pr → operationId `cancelPrRun` (issue #943, PR #944 review).
2
+ //
3
+ // The page-facing twin of `cancelInstance` for the per-row Cancel button. The runtime's built-in
4
+ // `/app/actions/cancel` page action bypassed the app's termination-provenance write, so a deliberate
5
+ // UI cancel left an in-flight base status with no reason and `/status` reported it `orphaned`. Both
6
+ // doors now route through ONE implementation (`cancelTrackedRun`), which stamps provenance.
7
+ //
8
+ // Auth posture is preserved: like the built-in page action it replaces (and `retryIncident`), this
9
+ // door is INTENTIONALLY UNGUARDED by NANO_PR_WEBHOOK_SECRET — the `@nanobpm/urban` page runtime cannot
10
+ // attach x-hook-secret, so a guard would 401 every button press on a secured deployment. The guarded
11
+ // `/actions/cancel` stays the API/MCP door; this one is excluded from MCP (`x-mcp`).
12
+
13
+ import { defineOperation } from "../nano-generated/operations.ts";
14
+ import { cancelTrackedRun } from "./cancelInstance.ts";
15
+
16
+ export default defineOperation("cancelPrRun", async ({ body }, app) => cancelTrackedRun(app, body, "/actions/cancel-pr"));
@@ -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.1",
3
+ "version": "0.208.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",
@@ -247,7 +247,7 @@
247
247
  "confirm": "Cancel this PR's review run?",
248
248
  "showWhenField": "process_key",
249
249
  "action": {
250
- "path": "/app/actions/cancel",
250
+ "path": "/app/api/actions/cancel-pr",
251
251
  "body": {
252
252
  "processInstanceKey": "{{row.process_key}}"
253
253
  }