@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.
- package/CHANGELOG.md +12 -0
- package/app/loopTermination.test.ts +412 -0
- package/app/mcpExclusions.test.ts +1 -1
- package/app/mergeBlockedThreads.test.ts +62 -2
- package/app/mergeStatus.test.ts +63 -8
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.test.ts +18 -1
- package/app/service.ts +435 -63
- package/app/terminalReaderBehaviour.test.ts +15 -4
- package/build-info.json +4 -4
- package/db/migrations/130_pull_requests_termination_reason.sql +17 -0
- package/db/migrations/131_pull_requests_read_model_termination_reason.sql +48 -0
- package/openapi.yaml +53 -0
- package/operations/cancelInstance.test.ts +9 -0
- package/operations/cancelInstance.ts +28 -2
- package/operations/cancelPrRun.ts +16 -0
- package/operations/listActivePrs.test.ts +289 -0
- package/operations/listActivePrs.ts +20 -0
- package/package.json +1 -1
- package/pages/home.page.json +1 -1
|
@@ -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`
|
|
133
|
-
//
|
|
134
|
-
|
|
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.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.208.
|
|
5
|
-
"builtAt": "2026-11-
|
|
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.
|
|
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",
|
package/pages/home.page.json
CHANGED