@nanobpm/nano-workforce 0.201.5 → 0.203.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +48 -0
- package/app/admittedStaging.test.ts +71 -0
- package/app/agentActivationLedger.ts +5 -0
- package/app/agentCompletion.test.ts +33 -0
- package/app/agentCompletion.ts +108 -2
- package/app/agentic/README.md +48 -0
- package/app/agentic/claim-registry.test.ts +61 -0
- package/app/agentic/claim-registry.ts +35 -1
- package/app/agentic/families/claim.family.test.ts +216 -0
- package/app/agentic/families/claim.family.ts +100 -4
- package/app/agentic/vocab/agent-sla-boundary.test.ts +6 -1
- package/app/contracts.ts +31 -0
- package/app/deliveryGraphDispatch.ts +6 -0
- package/app/deliveryGraphReadModel.test.ts +2 -2
- package/app/deliveryGraphRun.ts +18 -0
- package/app/deliveryRunner.ts +9 -1
- package/app/escalationSteward.test.ts +452 -0
- package/app/escalationSteward.ts +537 -0
- package/app/escalationStewardPoll.test.ts +123 -0
- package/app/feature.test.ts +16 -0
- package/app/feature.ts +11 -0
- package/app/featureDelivery.test.ts +23 -0
- package/app/mcpToolSurface.ts +17 -1
- package/app/plan.ts +49 -6
- package/app/planLowering.test.ts +26 -1
- package/app/planLowering.ts +5 -0
- package/app/promotionPoll.test.ts +19 -0
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.test.ts +36 -0
- package/app/service.ts +139 -5
- package/db/migrations/120_escalation_auto_resolutions.sql +229 -0
- package/db/migrations/121_delivery_graph_read_model_auto_resolve.sql +54 -0
- package/db/migrations/122_pull_requests_read_model_auto_resolve.sql +56 -0
- package/db/migrations/123_escalation_auto_resolutions_selected_option.sql +47 -0
- package/db/migrations/124_admitted_epics_auto_resolve.sql +21 -0
- package/docs/agent-guide.md +69 -30
- package/docs/agentic-cockpit.md +34 -0
- package/docs/mcp-runbook.md +2 -0
- package/e2e/escalation-steward.e2e.ts +211 -0
- package/nano.app.json +10 -1
- package/openapi.yaml +365 -0
- package/operations/dispatchDeliveryGraph.ts +6 -1
- package/operations/listActivePrs.test.ts +60 -1
- package/operations/listActivePrs.ts +3 -3
- package/operations/listAutoResolutions.ts +83 -0
- package/operations/reviewAutoResolution.test.ts +137 -0
- package/operations/reviewAutoResolution.ts +42 -0
- package/operations/startConvergenceLoop.ts +5 -1
- package/operations/startEpicSet.admission.integration.test.ts +2 -2
- package/operations/startEpicSet.ts +8 -2
- package/operations/startFeature.ts +5 -0
- package/operations/startPlanFanout.ts +4 -1
- package/package.json +1 -1
- package/pages/_nav.json +12 -0
- package/pages/auto-resolutions.page.json +191 -0
- package/pages/board.page.json +17 -0
- package/pages/cockpit.page.json +17 -0
- package/pages/delivery-graph-detail.page.json +17 -0
- package/pages/delivery-graphs/staged.mount.js +17 -5
- package/pages/delivery-graphs.page.json +17 -0
- package/pages/epic-detail.page.json +17 -0
- package/pages/epic.page.json +19 -1
- package/pages/feature.page.json +19 -1
- package/pages/home.page.json +22 -0
- package/pages/lineage.page.json +17 -0
- package/pages/mcp.page.json +17 -0
- package/pages/overview.page.json +17 -0
- package/pages/tasks.page.json +17 -0
- package/pages/velocity.page.json +17 -0
- package/resources/processes/escalation-steward.bpmn +162 -0
- package/resources/prompts/escalation-steward.md +87 -0
- package/test/delivery-graphs-staged-embed.test.ts +13 -0
- package/test/derivation-parity/derivation-parity.test.ts +1 -0
- package/test/derivation-parity/flows.ts +12 -0
- package/workers/converge-feature/worker.ts +6 -1
- package/workers/delivery-connector/worker.test.ts +72 -1
- package/workers/delivery-connector/worker.ts +53 -2
- package/workers/escalation-steward-collect/worker.test.ts +180 -0
- package/workers/escalation-steward-collect/worker.ts +99 -0
- package/workers/escalation-steward-record/worker.test.ts +740 -0
- package/workers/escalation-steward-record/worker.ts +428 -0
- package/workers/record-wave/worker.ts +5 -1
|
@@ -126,6 +126,19 @@ test("#460/#511/#569: Dispatch is the operator's launch — posts the digest to
|
|
|
126
126
|
assert(!/approvalToken/.test(MOUNT_JS), "staged.mount.js must NOT carry the removed replayable approvalToken");
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
test("#889: the dispatch confirmation offers an escalation-steward opt-in and posts autoResolveEscalations", () => {
|
|
130
|
+
// Issue #889 §1 requires the opt-in on EVERY UI start pathway. The delivery-graph dispatch is one:
|
|
131
|
+
// the inline Dispatch confirmation must carry an "Automatically resolve escalations" checkbox, and
|
|
132
|
+
// the POST body must include `autoResolveEscalations: true` when the operator checks it (the dispatch
|
|
133
|
+
// door already accepts it — operations/dispatchDeliveryGraph.ts). Without the control a UI user can
|
|
134
|
+
// never opt a dispatched graph in.
|
|
135
|
+
assert(/data-dispatch-autoresolve=/.test(MOUNT_JS), "staged.mount.js must render an escalation-steward opt-in checkbox in the dispatch confirmation");
|
|
136
|
+
assert(/Automatically resolve escalations/.test(MOUNT_JS), "the opt-in control must be labelled for the operator");
|
|
137
|
+
// The checkbox state is tracked on the pending confirmation and included in the POST payload.
|
|
138
|
+
assert(/autoResolveEscalations/.test(MOUNT_JS), "staged.mount.js must thread autoResolveEscalations into the dispatch payload");
|
|
139
|
+
assert(/pending\.autoResolveEscalations/.test(MOUNT_JS), "the opt-in checkbox state is tracked on the pending dispatch confirmation");
|
|
140
|
+
});
|
|
141
|
+
|
|
129
142
|
test("#520/#569: Dismiss is the operator's discard — posts the digest to the dismiss door, behind an in-DOM confirm, and launches nothing", () => {
|
|
130
143
|
assertModuleAnchored("dismissUrl", "app/api/actions/delivery-graph/dismiss");
|
|
131
144
|
assert(/data-dismiss=/.test(MOUNT_JS), "staged.mount.js must render a per-row Dismiss affordance carrying the digest");
|
|
@@ -273,4 +273,16 @@ export const PORTS: readonly PortEntry[] = [
|
|
|
273
273
|
model: "human-escalation",
|
|
274
274
|
blockedReason: `${MULTI_START_END_BLOCK} (human-escalation: 1 start, 3 ends)`,
|
|
275
275
|
},
|
|
276
|
+
{
|
|
277
|
+
model: "escalation-steward",
|
|
278
|
+
blockedReason:
|
|
279
|
+
"blocked (agent-task marker + gateway merge): single top-level start/end, but its " +
|
|
280
|
+
"`senior:escalation-steward` task carries the <zeebe:agentDefinition agentType=\"external\" /> " +
|
|
281
|
+
"marker issue #745 requires on every prompt-bearing senior:* task (a marker the published " +
|
|
282
|
+
"@nanobpm/workflow compiler cannot emit — the class-3 block), AND its `any eligible?` " +
|
|
283
|
+
"exclusive gateway is a two-way split whose `eligible`/`none` out-edges re-converge on the " +
|
|
284
|
+
"shared `done` end event (an explicit-join topology the structured-only builder cannot emit " +
|
|
285
|
+
"without inserting its own gateway). Awaits an agent-marker option on task() and an " +
|
|
286
|
+
"arbitrary-graph / explicit-join builder upstream in @nanobpm/workflow (nano-ide).",
|
|
287
|
+
},
|
|
276
288
|
];
|
|
@@ -47,12 +47,17 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
|
|
|
47
47
|
pr_key: parsed.prKey,
|
|
48
48
|
updated_at: new Date().toISOString(),
|
|
49
49
|
});
|
|
50
|
-
|
|
50
|
+
// Propagate the feature run's escalation-steward opt-in (issue #889) to the convergence loop:
|
|
51
|
+
// read the durable flag off the `feature_runs` row (the source of truth) and hand it to `submitPr`.
|
|
52
|
+
const featureRow = await featureRuns(app.data).get(featureKey);
|
|
53
|
+
const autoResolve = featureRow?.auto_resolve_escalations === 1;
|
|
54
|
+
await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, !autoMerge, featureKey, humanApproval, autoResolve);
|
|
51
55
|
app.log.info("converge-feature: enrolled PR into convergence loop", {
|
|
52
56
|
featureKey,
|
|
53
57
|
prKey: parsed.prKey,
|
|
54
58
|
convergeOnly: !autoMerge,
|
|
55
59
|
humanApproval,
|
|
60
|
+
autoResolveEscalations: autoResolve,
|
|
56
61
|
});
|
|
57
62
|
return {};
|
|
58
63
|
};
|
|
@@ -6,7 +6,7 @@ import { test } from "node:test";
|
|
|
6
6
|
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
7
7
|
import { PROCESS_ID } from "../../app/service.ts";
|
|
8
8
|
import { withTrackingViews } from "../../test/trackingViews.ts";
|
|
9
|
-
import handler, { readConnectorInput, readConvergeInput, safeStringify } from "./worker.ts";
|
|
9
|
+
import handler, { readConnectorInput, readConvergeInput, safeStringify, setLaunchStampSleep } from "./worker.ts";
|
|
10
10
|
|
|
11
11
|
function memTable(rows: Record<string, unknown>[], key: string) {
|
|
12
12
|
return {
|
|
@@ -41,6 +41,7 @@ function fakeApp() {
|
|
|
41
41
|
escalations: { rows: [], key: "id" },
|
|
42
42
|
pr_dependencies: { rows: [], key: "pr_key" },
|
|
43
43
|
delivery_connector_dispatches: { rows: [], key: "id" },
|
|
44
|
+
delivery_graph_runs: { rows: [], key: "run_key" },
|
|
44
45
|
};
|
|
45
46
|
const created: { processDefinitionId?: string; variables?: Record<string, unknown> }[] = [];
|
|
46
47
|
let nextId = 1;
|
|
@@ -317,3 +318,73 @@ test("handler: a misconfigured converge connector (no parseable pr) fails CLOSED
|
|
|
317
318
|
assertEquals(stores.delivery_connector_dispatches.rows.length, 0, "no junk ledger row is claimed");
|
|
318
319
|
});
|
|
319
320
|
});
|
|
321
|
+
|
|
322
|
+
test("handler: the escalation-steward opt-in is resolved from the DURABLE delivery_graph_runs row, NOT the clobberable process variable (issue #889 review)", async () => {
|
|
323
|
+
await withGithubOff(async () => {
|
|
324
|
+
const { app, stores, created } = fakeApp();
|
|
325
|
+
// The run row for THIS process instance OPTED IN (auto_resolve_escalations = 1), keyed by
|
|
326
|
+
// `process_key` = the connector job's processInstanceKey.
|
|
327
|
+
stores.delivery_graph_runs.rows.push({ run_key: "run-1", process_key: "PI-graph", auto_resolve_escalations: 1, status: "running" });
|
|
328
|
+
// ...but an upstream agent completion CLOBBERED the inherited process variable to `false`. Before
|
|
329
|
+
// the fix the connector read `job.variables.autoResolveEscalations === true` → false, enrolling the
|
|
330
|
+
// PR as opted-OUT and contradicting the durable record. The durable row must win.
|
|
331
|
+
await handler(
|
|
332
|
+
{ variables: { target: "converge-merge", payload: { pr: "owner/repo#21" }, autoResolveEscalations: false }, processInstanceKey: "PI-graph", elementId: "n2" } as never,
|
|
333
|
+
app,
|
|
334
|
+
);
|
|
335
|
+
assertEquals(created.length, 1, "the PR was enrolled");
|
|
336
|
+
assertEquals(created[0]?.variables?.autoResolveEscalations, true, "the durable run row's opt-in wins over the clobbered process variable");
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("handler: the escalation-steward opt-in FAILS CLOSED when no delivery_graph_runs row matches this process instance", async () => {
|
|
341
|
+
await withGithubOff(async () => {
|
|
342
|
+
const { app, created } = fakeApp();
|
|
343
|
+
// No delivery_graph_runs row for PI-graph (and the process variable claims opted-IN). With no
|
|
344
|
+
// durable record the connector must NOT trust the clobberable variable — fail closed to opted-OUT.
|
|
345
|
+
await handler(
|
|
346
|
+
{ variables: { target: "converge-merge", payload: { pr: "owner/repo#22" }, autoResolveEscalations: true }, processInstanceKey: "PI-graph", elementId: "n2" } as never,
|
|
347
|
+
app,
|
|
348
|
+
);
|
|
349
|
+
assertEquals(created.length, 1, "the PR was still enrolled");
|
|
350
|
+
assertEquals(created[0]?.variables?.autoResolveEscalations, false, "no durable run row ⇒ fail closed to opted-out, never trust the process variable");
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("handler: a launch-in-flight run row (process_key not yet stamped) is retried, NOT converted to opted-out (issue #889 review — launch race)", async () => {
|
|
355
|
+
await withGithubOff(async () => {
|
|
356
|
+
const { app, stores, created } = fakeApp();
|
|
357
|
+
// THE LAUNCH RACE: `dispatchDeliveryGraphRun` claims the run row with `process_key = NULL`, starts
|
|
358
|
+
// the engine instance, and only THEN stamps the returned key. This connector job activated BEFORE
|
|
359
|
+
// that post-launch write, so the row is present (opted IN) but its `process_key` is still NULL. A
|
|
360
|
+
// single read keyed on `process_key` finds NO match → false → permanently enrolls the opted-in PR
|
|
361
|
+
// as opted OUT. The lookup must treat a NULL process_key as TRANSIENT and re-poll until the stamp
|
|
362
|
+
// lands. DETERMINISTIC (AGENTS.md — no real-timer race): the inter-poll sleep is overridden to a
|
|
363
|
+
// test-controlled signal that stamps `process_key` after a fixed number of polls (poll 1 and 2 see
|
|
364
|
+
// the launch-in-flight NULL, poll 3 sees the stamp) — no wall-clock timer, so the outcome cannot
|
|
365
|
+
// depend on scheduler load.
|
|
366
|
+
const row: Record<string, unknown> = { run_key: "run-1", process_key: null, auto_resolve_escalations: 1, status: "running" };
|
|
367
|
+
stores.delivery_graph_runs.rows.push(row);
|
|
368
|
+
let polls = 0;
|
|
369
|
+
setLaunchStampSleep(() => {
|
|
370
|
+
polls += 1;
|
|
371
|
+
if (polls === 2) row.process_key = "PI-graph"; // the dispatch's stamp lands after the 2nd poll
|
|
372
|
+
return Promise.resolve();
|
|
373
|
+
});
|
|
374
|
+
try {
|
|
375
|
+
await handler(
|
|
376
|
+
{ variables: { target: "converge-merge", payload: { pr: "owner/repo#23" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never,
|
|
377
|
+
app,
|
|
378
|
+
);
|
|
379
|
+
} finally {
|
|
380
|
+
setLaunchStampSleep();
|
|
381
|
+
}
|
|
382
|
+
assert(polls >= 2, "the launch-in-flight row was re-polled across the launch window, not read once and converted to opted-out");
|
|
383
|
+
assertEquals(created.length, 1, "the PR was enrolled");
|
|
384
|
+
assertEquals(
|
|
385
|
+
created[0]?.variables?.autoResolveEscalations,
|
|
386
|
+
true,
|
|
387
|
+
"a launch-in-flight run row is retried until its process_key stamp lands — never read as opted-out",
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// envelope) so it is unit-testable without the engine and shares ONE implementation with any other
|
|
8
8
|
// caller; this worker is the thin engine adapter that resolves the effective dedupe key from the job's
|
|
9
9
|
// author-supplied `dedupeKey` or its stable engine identity (`processInstanceKey:elementId`).
|
|
10
|
-
import type { AppJobHandler } from "@nanobpm/urban";
|
|
10
|
+
import type { AppJobHandler, DataLayer } from "@nanobpm/urban";
|
|
11
11
|
import {
|
|
12
12
|
type BoundFact,
|
|
13
13
|
type ConnectorDispatchResult,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
isConvergeTarget,
|
|
18
18
|
resolveConvergePr,
|
|
19
19
|
} from "../../app/deliveryConnector.ts";
|
|
20
|
+
import { deliveryGraphRuns } from "../../app/deliveryGraphRun.ts";
|
|
20
21
|
import { isPrSettled, MAX_ROUNDS, type ParsedPr, parsePr, submitPr } from "../../app/service.ts";
|
|
21
22
|
|
|
22
23
|
// Typed off the compiled `connector` subProcess ioMapping (a RUNTIME-generated definition, so there is
|
|
@@ -34,6 +35,49 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
|
34
35
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** Resolve the escalation-steward opt-in for THIS delivery run from the DURABLE
|
|
39
|
+
* `delivery_graph_runs.auto_resolve_escalations` flag (keyed by the run's `process_key`), NOT the
|
|
40
|
+
* inherited `autoResolveEscalations` process variable (issue #889 review). The process variable is
|
|
41
|
+
* clobberable: an upstream agent node that completes with `autoResolveEscalations: false` in its
|
|
42
|
+
* output overwrites the run-root variable downstream, so reading it here would enroll the PR with the
|
|
43
|
+
* WRONG opt-in — contradicting the durable-source guarantee the steward's eligibility relies on. The
|
|
44
|
+
* run row is the record of truth. FAIL CLOSED: no matching run row (or a non-1 flag) ⇒ not opted in.
|
|
45
|
+
*
|
|
46
|
+
* LAUNCH RACE (issue #889 review): `dispatchDeliveryGraphRun` claims the run row with
|
|
47
|
+
* `process_key = NULL`, starts the engine instance, and only THEN stamps the returned key
|
|
48
|
+
* (`app/deliveryGraphDispatch.ts`). This connector job can activate BEFORE that post-launch write, so
|
|
49
|
+
* a single read keyed on `process_key` finds NO match and would convert the launch-in-flight row to a
|
|
50
|
+
* permanent opted-OUT enrollment. A NULL `process_key` is TRANSIENT (launch in flight), not proof of
|
|
51
|
+
* "no run" — so re-poll across the launch window until the stamp lands (or the budget runs out, after
|
|
52
|
+
* which we fail closed as before). */
|
|
53
|
+
const LAUNCH_STAMP_POLLS = 20;
|
|
54
|
+
const LAUNCH_STAMP_POLL_MS = 250;
|
|
55
|
+
/** The scheduler the poll loop sleeps on between launch-stamp reads. Overridable in tests via
|
|
56
|
+
* {@link setLaunchStampSleep} so a regression drives the retry by a deterministic READ COUNT (the fake
|
|
57
|
+
* table stamps `process_key` after N polls) rather than racing real wall-clock timers — AGENTS.md
|
|
58
|
+
* forbids a test whose outcome depends on scheduler load. */
|
|
59
|
+
let launchStampSleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
60
|
+
/** Test-only seam: override the inter-poll sleep. Pass no argument to restore the real timer. */
|
|
61
|
+
export function setLaunchStampSleep(sleep?: (ms: number) => Promise<void>): void {
|
|
62
|
+
launchStampSleep = sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
63
|
+
}
|
|
64
|
+
async function resolveAutoResolveEscalations(data: DataLayer, processInstanceKey: string | null): Promise<boolean> {
|
|
65
|
+
if (!processInstanceKey) return false;
|
|
66
|
+
const runs = deliveryGraphRuns(data);
|
|
67
|
+
for (let attempt = 0; attempt < LAUNCH_STAMP_POLLS; attempt++) {
|
|
68
|
+
const rows = await runs.all();
|
|
69
|
+
const run = rows.find((r) => r.process_key != null && String(r.process_key) === String(processInstanceKey));
|
|
70
|
+
if (run != null) return run.auto_resolve_escalations === 1;
|
|
71
|
+
// No row carries this process_key yet. If a launch-in-flight row (NULL process_key) is still
|
|
72
|
+
// waiting for its stamp, re-poll; otherwise there is genuinely no run for this instance — fail
|
|
73
|
+
// closed without burning the remaining budget.
|
|
74
|
+
const launchInFlight = rows.some((r) => r.process_key == null && r.status === "running");
|
|
75
|
+
if (!launchInFlight) return false;
|
|
76
|
+
if (attempt < LAUNCH_STAMP_POLLS - 1) await launchStampSleep(LAUNCH_STAMP_POLL_MS);
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
37
81
|
/** Validate + normalise the untyped job variables into the shape the connector surface accepts. Job
|
|
38
82
|
* variables are untyped at runtime, so a misconfigured node (or a hand-seeded instance) could hand us a
|
|
39
83
|
* blank `target`, a scalar `payload`, or a non-array `boundFacts`. Fail CLOSED on a blank `target` (a
|
|
@@ -167,12 +211,19 @@ const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) =>
|
|
|
167
211
|
});
|
|
168
212
|
return { detail: `${converge.parsed.prKey} already terminal — enrollment skipped (at-most-once resume-safe)` };
|
|
169
213
|
}
|
|
170
|
-
|
|
214
|
+
// Propagate the delivery run's escalation-steward opt-in (issue #889) to the enrolled PR.
|
|
215
|
+
// Resolve it from the DURABLE `delivery_graph_runs` row (keyed by this job's process
|
|
216
|
+
// instance), NOT the inherited `autoResolveEscalations` process variable — an upstream agent
|
|
217
|
+
// completion can clobber that variable, so the durable row is the only trustworthy source
|
|
218
|
+
// (issue #889 review). Fails closed (false) when no run row matches.
|
|
219
|
+
const autoResolve = await resolveAutoResolveEscalations(app.data, job.processInstanceKey != null ? String(job.processInstanceKey) : null);
|
|
220
|
+
await submitPr(app.data, app.engine, converge.parsed, converge.dependsOn, MAX_ROUNDS, converge.convergeOnly, dedupeKey, false, autoResolve);
|
|
171
221
|
app.log.info("delivery-connector: enrolled PR into convergence loop", {
|
|
172
222
|
target,
|
|
173
223
|
dedupeKey,
|
|
174
224
|
prKey: converge.parsed.prKey,
|
|
175
225
|
convergeOnly: converge.convergeOnly,
|
|
226
|
+
autoResolveEscalations: autoResolve,
|
|
176
227
|
});
|
|
177
228
|
return { detail: `enrolled ${converge.parsed.prKey} into convergence loop (convergeOnly=${converge.convergeOnly})` };
|
|
178
229
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Unit coverage for the pr.escalation-steward-collect worker (issue #889) — the steward tick's first
|
|
2
|
+
// step. Drives the handler against the real data layer + a searchProcessInstances stub and asserts:
|
|
3
|
+
// • only OPTED-IN, past-min-age, answerable, unclaimed escalations are collected + claimed;
|
|
4
|
+
// • an escalation on non-opted-in work is NEVER collected (the core safety invariant);
|
|
5
|
+
// • the overlap guard: a still-ACTIVE prior steward instance makes the tick a no-op.
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assertEquals } from "#test-assert";
|
|
11
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
12
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
13
|
+
import { escalationAutoResolutions } from "../../app/escalationSteward.ts";
|
|
14
|
+
import handler from "./worker.ts";
|
|
15
|
+
|
|
16
|
+
const APP_ROOT = resolve(import.meta.dirname, "..", "..");
|
|
17
|
+
|
|
18
|
+
async function withApp(
|
|
19
|
+
fn: (data: DataLayer, run: (vars?: Record<string, unknown>, activeSteward?: string[]) => Promise<{ eligibleCount: number; claimed: string[]; skippedOverlap: boolean }>) => Promise<void>,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-steward-col-"));
|
|
22
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
23
|
+
try {
|
|
24
|
+
const run = async (vars: Record<string, unknown> = {}, activeSteward: string[] = []) => {
|
|
25
|
+
const engine = {
|
|
26
|
+
searchProcessInstances: (filter?: { processDefinitionId?: string; state?: string }) => {
|
|
27
|
+
// Report the listed keys as ACTIVE steward instances (the overlap probe).
|
|
28
|
+
const list = filter?.processDefinitionId === "escalation-steward"
|
|
29
|
+
? activeSteward.map((k) => ({ processInstanceKey: k, state: "ACTIVE" }))
|
|
30
|
+
: [];
|
|
31
|
+
return Promise.resolve(list);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const job = { jobKey: "J-1", processInstanceKey: "PI-THIS", variables: vars };
|
|
35
|
+
return (await handler(job as never, { data: app.db, engine: engine as never, log: { info: () => {}, warn: () => {}, error: () => {} } } as never)) as {
|
|
36
|
+
eligibleCount: number;
|
|
37
|
+
claimed: string[];
|
|
38
|
+
skippedOverlap: boolean;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
await fn(app.db, run);
|
|
42
|
+
} finally {
|
|
43
|
+
await app.stop?.();
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Seed an open user_tasks row + its owning subject (with the given opt-in flag). */
|
|
49
|
+
async function seedEscalation(
|
|
50
|
+
data: DataLayer,
|
|
51
|
+
opts: { userTaskKey: string; processKey: string; optedIn: boolean; elementId?: string; ageMinutes?: number; subjectKey?: string; formKey?: string },
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const ts = new Date().toISOString();
|
|
54
|
+
const created = new Date(Date.now() - (opts.ageMinutes ?? 10) * 60 * 1000).toISOString();
|
|
55
|
+
await data.table("pull_requests", "pr_key").insert({
|
|
56
|
+
pr_key: opts.subjectKey ?? "o/r#1", repo: "o/r", number: 1, url: "u", status: "converging", current_round: 0,
|
|
57
|
+
process_key: opts.processKey, auto_resolve_escalations: opts.optedIn ? 1 : 0, created_at: ts, updated_at: ts,
|
|
58
|
+
});
|
|
59
|
+
await data.table("user_tasks", "user_task_key").insert({
|
|
60
|
+
user_task_key: opts.userTaskKey, element_id: opts.elementId ?? "wait-answer", kind_label: "PR review",
|
|
61
|
+
subject_type: "pr", subject_key: opts.subjectKey ?? "o/r#1", subject_title: "t", subject_url: "u",
|
|
62
|
+
question: "q", process_key: opts.processKey, form_key: opts.formKey ?? "pr-escalation", created_at: created, updated_at: ts,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("collect worker: collects + claims an opted-in, past-min-age, answerable escalation", async () => {
|
|
67
|
+
await withApp(async (data, run) => {
|
|
68
|
+
await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: true });
|
|
69
|
+
const out = await run();
|
|
70
|
+
assertEquals(out.skippedOverlap, false);
|
|
71
|
+
assertEquals(out.eligibleCount, 1);
|
|
72
|
+
assertEquals(out.claimed.length, 1);
|
|
73
|
+
// The claim is recorded (a `claimed` placeholder row) so a racing tick is fenced out.
|
|
74
|
+
const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
|
|
75
|
+
assertEquals(rows.length, 1);
|
|
76
|
+
assertEquals(rows[0].outcome, "claimed");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("collect worker: an escalation on NON-opted-in work is never collected (safety invariant)", async () => {
|
|
81
|
+
await withApp(async (data, run) => {
|
|
82
|
+
await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: false });
|
|
83
|
+
const out = await run();
|
|
84
|
+
assertEquals(out.eligibleCount, 0, "non-opted-in work is never touched");
|
|
85
|
+
assertEquals(out.claimed.length, 0);
|
|
86
|
+
const rows = await escalationAutoResolutions(data).all();
|
|
87
|
+
assertEquals(rows.length, 0, "no claim row is written");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("collect worker: a human-only kind (merge-approval) is never collected even when opted in", async () => {
|
|
92
|
+
await withApp(async (data, run) => {
|
|
93
|
+
await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: true, elementId: "merge-approval" });
|
|
94
|
+
const out = await run();
|
|
95
|
+
assertEquals(out.eligibleCount, 0, "a human-only kind is never steward-collected");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("collect worker: a delivery-human CREDENTIAL/manual task is never collected even when opted in", async () => {
|
|
100
|
+
// A dynamic delivery-human task whose resolved form is the manual OTP/OIDC credential publish form
|
|
101
|
+
// must fail closed — the agent completer does NOT validate dynamic delivery-human forms, so a
|
|
102
|
+
// mis-prompted steward could otherwise blind-complete a credential task (issue #889 review).
|
|
103
|
+
for (const formKey of ["delivery-human-publish", "delivery-human-ack", "no-such-form"]) {
|
|
104
|
+
await withApp(async (data, run) => {
|
|
105
|
+
await seedEscalation(data, { userTaskKey: "UT-DG", processKey: "PI-PR", optedIn: true, elementId: "delivery-human-task__publish", formKey });
|
|
106
|
+
const out = await run();
|
|
107
|
+
assertEquals(out.eligibleCount, 0, `delivery-human + ${formKey} is never steward-collected`);
|
|
108
|
+
assertEquals((await escalationAutoResolutions(data).all()).length, 0, "no claim row is written");
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("collect worker: a delivery-human task on a steward-safe form IS collected when opted in", async () => {
|
|
114
|
+
await withApp(async (data, run) => {
|
|
115
|
+
await seedEscalation(data, { userTaskKey: "UT-DG", processKey: "PI-PR", optedIn: true, elementId: "delivery-human-task__step", formKey: "delivery-human-generic" });
|
|
116
|
+
const out = await run();
|
|
117
|
+
assertEquals(out.eligibleCount, 1, "delivery-human + generic (typed-value) form is steward-answerable");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("collect worker: a too-fresh escalation (under min age) is left for a human already answering", async () => {
|
|
122
|
+
await withApp(async (data, run) => {
|
|
123
|
+
await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: true, ageMinutes: 0 });
|
|
124
|
+
const out = await run({ minAgeMs: 300_000 });
|
|
125
|
+
assertEquals(out.eligibleCount, 0, "a just-opened escalation is not yet eligible");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("collect worker: an explicit process-key opt-OUT is trusted, never overridden by a colliding opted-in subject key in another table", async () => {
|
|
130
|
+
await withApp(async (data, run) => {
|
|
131
|
+
// The PR's OWN run opted OUT (auto_resolve=0). Its escalation parks with process_key=PI-PR,
|
|
132
|
+
// subject_type="pr", subject_key="o/r#1".
|
|
133
|
+
await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: false });
|
|
134
|
+
// A DIFFERENT subject table has a row whose run_key COLLIDES with the PR's subject_key but opted IN.
|
|
135
|
+
const ts = new Date().toISOString();
|
|
136
|
+
await data.table("delivery_graph_runs", "run_key").insert({
|
|
137
|
+
run_key: "o/r#1", process_key: "PI-DELIVERY", digest: "d", status: "running",
|
|
138
|
+
auto_resolve_escalations: 1, created_at: ts, updated_at: ts,
|
|
139
|
+
});
|
|
140
|
+
const out = await run();
|
|
141
|
+
// Old bug: the un-scoped subject fallback matched the colliding delivery row (opted in) and OR-ed
|
|
142
|
+
// PAST the PR's explicit opt-OUT, collecting a task the PR owner opted OUT of. Fix: a direct opt-OUT
|
|
143
|
+
// is trusted (only an ABSENT direct lookup falls back) AND the fallback is compound-keyed by
|
|
144
|
+
// subject_type, so a cross-table key collision can never make a non-opted-in task eligible.
|
|
145
|
+
assertEquals(out.eligibleCount, 0, "an explicit opt-OUT is never overridden by a colliding opted-in subject key");
|
|
146
|
+
assertEquals(out.claimed.length, 0);
|
|
147
|
+
const rows = await escalationAutoResolutions(data).all();
|
|
148
|
+
assertEquals(rows.length, 0, "no claim row is written for an opted-out subject");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("collect worker: the overlap guard makes a tick a no-op while a prior steward run is ACTIVE", async () => {
|
|
153
|
+
await withApp(async (data, run) => {
|
|
154
|
+
await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: true });
|
|
155
|
+
const out = await run({}, ["PI-PRIOR"]);
|
|
156
|
+
assertEquals(out.skippedOverlap, true, "a still-active prior run skips this tick");
|
|
157
|
+
assertEquals(out.eligibleCount, 0);
|
|
158
|
+
assertEquals(out.claimed.length, 0);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("collect worker: an escalation the steward ALREADY abstained/resolved is never re-claimed (no repeated abstentions)", async () => {
|
|
163
|
+
await withApp(async (data, run) => {
|
|
164
|
+
const { recordResolution } = await import("../../app/escalationSteward.ts");
|
|
165
|
+
await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: true });
|
|
166
|
+
// First tick claims + abstains (the task stays OPEN — an abstention does not complete it).
|
|
167
|
+
const first = await run({});
|
|
168
|
+
assertEquals(first.claimed.length, 1, "the first tick claims the eligible escalation");
|
|
169
|
+
await recordResolution(data, "UT-1", { outcome: "abstained", rationale: "unclear — needs a human" });
|
|
170
|
+
// The NEXT tick must NOT re-claim the same `user_task_key`: the partial claim fence no longer
|
|
171
|
+
// covers the finalised row, so without the final-record exclusion the steward would abstain the
|
|
172
|
+
// same open task every tick forever (issue #889 review).
|
|
173
|
+
const second = await run({});
|
|
174
|
+
assertEquals(second.claimed.length, 0, "an already-abstained task is never re-claimed");
|
|
175
|
+
assertEquals(second.eligibleCount, 0);
|
|
176
|
+
const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
|
|
177
|
+
assertEquals(rows.length, 1, "exactly ONE durable row — no repeated abstention records");
|
|
178
|
+
assertEquals(rows[0].outcome, "abstained");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// pr.escalation-steward-collect — the steward tick's first step (issue #889). Collects the OPEN
|
|
2
|
+
// escalation user tasks that are ELIGIBLE for auto-resolution and claims them for THIS steward run,
|
|
3
|
+
// so the agent dispatched next has a bounded, non-overlapping work set.
|
|
4
|
+
//
|
|
5
|
+
// Eligibility (app/escalationSteward.ts, the ONE derivation):
|
|
6
|
+
// • the escalation's owning work OPTED IN (`auto_resolve_escalations` on its subject row, resolved
|
|
7
|
+
// via the task's process/root instance key);
|
|
8
|
+
// • the kind is steward-answerable (never a human-only / irreversible / credential kind);
|
|
9
|
+
// • the task is past the minimum age (a human already answering wins);
|
|
10
|
+
// • it is not already claimed by a live steward run (the claim/lease fence).
|
|
11
|
+
//
|
|
12
|
+
// Overlap guard: if the PREVIOUS steward instance is still ACTIVE, this tick claims nothing and
|
|
13
|
+
// reports `skippedOverlap` — the process's gateway then routes straight to done (a no-op tick), so
|
|
14
|
+
// two steward runs never double-answer.
|
|
15
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
16
|
+
import {
|
|
17
|
+
claimEscalations,
|
|
18
|
+
DEFAULT_MIN_AGE_MS,
|
|
19
|
+
ESCALATION_STEWARD_PROCESS_ID,
|
|
20
|
+
finalRecordedTaskKeys,
|
|
21
|
+
isEligibleEscalation,
|
|
22
|
+
liveClaimedTaskKeys,
|
|
23
|
+
optInByProcessKey,
|
|
24
|
+
optInBySubjectKey,
|
|
25
|
+
} from "../../app/escalationSteward.ts";
|
|
26
|
+
import { type UserTaskRow, userTasks } from "../../app/userTasks.ts";
|
|
27
|
+
|
|
28
|
+
interface In extends Record<string, unknown> {
|
|
29
|
+
stewardInstanceKey?: string;
|
|
30
|
+
minAgeMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Out extends Record<string, unknown> {
|
|
34
|
+
eligibleCount: number;
|
|
35
|
+
/** The claimed escalations, serialised for the agent's `claimedTasks` input (JSON). */
|
|
36
|
+
claimed: string[];
|
|
37
|
+
skippedOverlap: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
41
|
+
const stewardKey = job.processInstanceKey != null ? String(job.processInstanceKey) : `steward-${Date.now()}`;
|
|
42
|
+
const minAgeMs = typeof job.variables.minAgeMs === "number" && job.variables.minAgeMs >= 0 ? job.variables.minAgeMs : DEFAULT_MIN_AGE_MS;
|
|
43
|
+
|
|
44
|
+
// Overlap guard: a still-active prior steward instance means this tick is a no-op.
|
|
45
|
+
const active = await app.engine.searchProcessInstances({ processDefinitionId: ESCALATION_STEWARD_PROCESS_ID, state: "ACTIVE" });
|
|
46
|
+
const prior = active.filter((i) => String(i.processInstanceKey) !== stewardKey);
|
|
47
|
+
if (prior.length > 0) {
|
|
48
|
+
app.log.info("escalation-steward collect: prior run still active — no-op tick", { active: prior.length });
|
|
49
|
+
return { eligibleCount: 0, claimed: [], skippedOverlap: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const open = await userTasks(app.data).all();
|
|
53
|
+
const optInByProc = await optInByProcessKey(app.data);
|
|
54
|
+
const optInBySubject = await optInBySubjectKey(app.data);
|
|
55
|
+
// The claim fence's partial UNIQUE index only fences an OPEN claim; once a row is finalised
|
|
56
|
+
// (`resolved`/`abstained`) it leaves that index, and because an abstention does NOT complete the
|
|
57
|
+
// task, the same `user_task_key` would be re-claimed every tick (issue #889 review). Union the
|
|
58
|
+
// already-final keys into the exclusion set so a task the steward already answered/abstained is
|
|
59
|
+
// never re-claimed (a genuinely NEW escalation gets a NEW engine key, so this never blocks one).
|
|
60
|
+
const claimedKeys = await liveClaimedTaskKeys(app.data);
|
|
61
|
+
for (const k of await finalRecordedTaskKeys(app.data)) claimedKeys.add(k);
|
|
62
|
+
const at = Date.now();
|
|
63
|
+
|
|
64
|
+
// Resolve each task's opt-in from its owning subject. A top-level escalation's `process_key` IS the
|
|
65
|
+
// subject's instance; an escalation parked INSIDE a callActivity child cell (the shared
|
|
66
|
+
// `human-escalation`/`implement-cell`) instead carries the CHILD's `process_key` (no subject row
|
|
67
|
+
// tracks it) but the PARENT run's `subject_key` (pollUserTasks correlates it via the root instance).
|
|
68
|
+
// So check the direct `process_key` first; only when that lookup is ABSENT (no subject row keyed on
|
|
69
|
+
// this process instance at all) fall back to the correlated `<subject_type>:<subject_key>`. A direct
|
|
70
|
+
// lookup that returned `false` is an explicit opt-OUT and MUST be trusted — never OR-ed past with a
|
|
71
|
+
// subject-key fallback that could flip it back to eligible (issue #889 review). The subject fallback
|
|
72
|
+
// is COMPOUND-keyed by `subject_type` so a `plan_key`/`pr_key`/`run_key` colliding across tables
|
|
73
|
+
// can't cross-match. A task with no tracked subject either way is NOT opted in (fail-closed).
|
|
74
|
+
const eligible: UserTaskRow[] = [];
|
|
75
|
+
for (const row of open) {
|
|
76
|
+
const procKey = row.process_key ?? "";
|
|
77
|
+
const directOptIn = procKey !== "" ? optInByProc.get(procKey) : undefined;
|
|
78
|
+
const optedIn =
|
|
79
|
+
directOptIn !== undefined
|
|
80
|
+
? directOptIn
|
|
81
|
+
: row.subject_key != null && row.subject_key !== "" &&
|
|
82
|
+
optInBySubject.get(`${row.subject_type}:${row.subject_key}`) === true;
|
|
83
|
+
if (isEligibleEscalation(row, { optedIn, minAgeMs, claimedUserTaskKeys: claimedKeys, at })) {
|
|
84
|
+
eligible.push(row);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const claimed = await claimEscalations(app.data, eligible, stewardKey);
|
|
89
|
+
app.log.info("escalation-steward collect", { open: open.length, eligible: eligible.length, claimed: claimed.length });
|
|
90
|
+
// Serialise the claimed rows for the agent (the message/job envelope is scalar-only for lists, so
|
|
91
|
+
// carry each as a compact JSON string the agent + record step parse).
|
|
92
|
+
return {
|
|
93
|
+
eligibleCount: claimed.length,
|
|
94
|
+
claimed: claimed.map((r) => JSON.stringify(r)),
|
|
95
|
+
skippedOverlap: false,
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export default handler;
|