@nanobpm/nano-workforce 0.202.0 → 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 +6 -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/vocab/agent-sla-boundary.test.ts +6 -1
- package/app/contracts.ts +23 -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/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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// End-to-end proof for the escalation-steward orchestration (issue #889 review — the "Previously
|
|
2
|
+
// missed" advisory). The steward's collect/record handlers are unit-tested directly, but the BPMN
|
|
3
|
+
// model that wires them (`resources/processes/escalation-steward.bpmn`) had no engine-level behavior
|
|
4
|
+
// test: a wrong `claimed`→`claimedTasks` ioMapping, an inverted `eligibleCount > 0` gateway condition,
|
|
5
|
+
// or a mis-registered `pr.escalation-steward-*` / `senior:escalation-steward` task would pass every
|
|
6
|
+
// handler unit test while the deployed process mis-routes. This boots the whole Urban app in-process
|
|
7
|
+
// against the WASM engine via `bootTestApp` and drives the deployed process end-to-end:
|
|
8
|
+
//
|
|
9
|
+
// • NO-ELIGIBLE branch — a steward tick with no opted-in escalation takes the gateway's `default`
|
|
10
|
+
// (`gw_any -> done`), dispatching NEITHER the agent nor the record worker;
|
|
11
|
+
// • ELIGIBLE branch — a real review-loop escalation (a convergence loop parked on `wait-answer`,
|
|
12
|
+
// opted in via `autoResolveEscalations`) is claimed by `pr.escalation-steward-collect`, handed to
|
|
13
|
+
// the external `senior:escalation-steward` agent (stubbed), whose `resolved` decision is finalised
|
|
14
|
+
// by `pr.escalation-steward-record` through the canonical attributed door — completing the parked
|
|
15
|
+
// `wait-answer` and writing a `resolved` `escalation_auto_resolutions` row.
|
|
16
|
+
//
|
|
17
|
+
// The falsifiable core: the agent stub CAPTURES its `claimedTasks` input (proving the
|
|
18
|
+
// `claimed`→`claimedTasks` ioMapping), the record worker only runs on the eligible branch (proving the
|
|
19
|
+
// gateway condition), and the parked escalation's completion + final record prove the
|
|
20
|
+
// `resolutions`→record hand-off. Network isolation mirrors the sibling convergence e2e (GitHub
|
|
21
|
+
// transport forced to `token` with no token, so best-effort GitHub reads short-circuit).
|
|
22
|
+
//
|
|
23
|
+
// Run with `npm run e2e` (a dedicated node:test invocation, kept out of the fast unit `npm test`).
|
|
24
|
+
|
|
25
|
+
import assert from "node:assert/strict";
|
|
26
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { dirname, join, resolve } from "node:path";
|
|
29
|
+
import { after, before, describe, test } from "node:test";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
32
|
+
import { ESCALATION_STEWARD_PROCESS_ID } from "../app/escalationSteward.ts";
|
|
33
|
+
import { pollUserTasks } from "../app/service.ts";
|
|
34
|
+
|
|
35
|
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
36
|
+
const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-steward-e2e-"));
|
|
37
|
+
|
|
38
|
+
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
39
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
40
|
+
GITHUB_TOKEN: "",
|
|
41
|
+
};
|
|
42
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
43
|
+
|
|
44
|
+
const HARNESS_ENV = {
|
|
45
|
+
NANO_APP_DB_URL: `file:${join(DB_DIR, "app.db")}`,
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
interface TakenFlow {
|
|
49
|
+
from: string;
|
|
50
|
+
to: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function takenFlows(app: TestApp): string[] {
|
|
54
|
+
const snapshot = app.snapshot();
|
|
55
|
+
const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
|
|
56
|
+
return flows
|
|
57
|
+
.filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
|
|
58
|
+
.map((f) => `${f.from}->${f.to}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Start one steward tick and settle it. `engine.createInstance` is the same door the production
|
|
62
|
+
* poller (`pollEscalationSteward`) uses; driving it directly keeps the tick deterministic (no
|
|
63
|
+
* dependence on the 30-min interval / the testkit's virtual clock). */
|
|
64
|
+
async function runStewardTick(app: TestApp): Promise<void> {
|
|
65
|
+
await app.engine.createInstance({ processDefinitionId: ESCALATION_STEWARD_PROCESS_ID, variables: { minAgeMs: 0 } });
|
|
66
|
+
await app.settle();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("escalation-steward orchestration (engine-level)", () => {
|
|
70
|
+
let app: TestApp;
|
|
71
|
+
// The steward agent stub's captured state across activations.
|
|
72
|
+
let agentCalls = 0;
|
|
73
|
+
let capturedClaimedTasks: unknown;
|
|
74
|
+
// The review agent stub's activation counter (stateful: escalate first, converge on resume).
|
|
75
|
+
let reviewCalls = 0;
|
|
76
|
+
|
|
77
|
+
before(async () => {
|
|
78
|
+
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
79
|
+
savedEnv.set(k, process.env[k]);
|
|
80
|
+
process.env[k] = v;
|
|
81
|
+
}
|
|
82
|
+
app = await bootTestApp(APP_ROOT, { env: HARNESS_ENV });
|
|
83
|
+
|
|
84
|
+
// `senior:pr-review` is an externalTaskType (no app worker), so register a STATEFUL stub (mirrors
|
|
85
|
+
// `e2e/convergence-escalation.e2e.ts`): round 1 escalates (needs_input + question) so the loop
|
|
86
|
+
// parks on the native `wait-answer` userTask; the resumed round (after the steward completes
|
|
87
|
+
// `wait-answer` and the BPMN loops record-answer → capture-head → review-round) CONVERGES. A
|
|
88
|
+
// stateless always-needs_input stub would re-park a fresh `wait-answer` on resume, contradicting
|
|
89
|
+
// both the stub's own "resumed round converges" contract and the eligible branch's final
|
|
90
|
+
// "no wait-answer remains" assertion.
|
|
91
|
+
await app.engine.registerWorker("senior:pr-review", () => {
|
|
92
|
+
reviewCalls += 1;
|
|
93
|
+
if (reviewCalls === 1) {
|
|
94
|
+
return { status: "needs_input", summary: "need a human decision", question: "Which retry cap?" };
|
|
95
|
+
}
|
|
96
|
+
return { status: "converged", summary: "resolved after the steward's answer" };
|
|
97
|
+
});
|
|
98
|
+
// `senior:scope-classify` is likewise external — stub "scope delivered" so a converged round
|
|
99
|
+
// finalizes rather than parking on an unserviced agent job.
|
|
100
|
+
await app.engine.registerWorker("senior:scope-classify", () => ({ scopeBlocked: false, scopeBlockReason: "" }));
|
|
101
|
+
// The converge-gate fails closed without a live GitHub transport (sealed here); stub it clean so a
|
|
102
|
+
// resumed round finalizes rather than parking on a second escalation.
|
|
103
|
+
await app.engine.registerWorker("pr.converge-gate", () => ({ convergeBlocked: false, convergeBlockReason: "" }));
|
|
104
|
+
|
|
105
|
+
// `senior:escalation-steward` is the external AGENT the steward process dispatches. Stub it to
|
|
106
|
+
// capture its `claimedTasks` input (proving the `claimed`→`claimedTasks` ioMapping) and return a
|
|
107
|
+
// `resolved` decision for each claimed task, so the record worker finalises it. The decision's
|
|
108
|
+
// `variables` carry the escalation form's `answer` field (the `wait-answer`/`pr-escalation`
|
|
109
|
+
// contract), and the rationale+evidence satisfy the record worker's evidence-before-choosing guard.
|
|
110
|
+
await app.engine.registerWorker("senior:escalation-steward", (job) => {
|
|
111
|
+
agentCalls += 1;
|
|
112
|
+
const claimed = (job.variables as Record<string, unknown>).claimedTasks;
|
|
113
|
+
capturedClaimedTasks = claimed;
|
|
114
|
+
const keys = (Array.isArray(claimed) ? claimed : [])
|
|
115
|
+
.map((entry) => {
|
|
116
|
+
try {
|
|
117
|
+
const row = JSON.parse(String(entry)) as { user_task_key?: string };
|
|
118
|
+
return typeof row.user_task_key === "string" ? row.user_task_key : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
.filter((k): k is string => k != null);
|
|
124
|
+
return {
|
|
125
|
+
resolutions: JSON.stringify(
|
|
126
|
+
keys.map((userTaskKey) => ({
|
|
127
|
+
userTaskKey,
|
|
128
|
+
outcome: "resolved",
|
|
129
|
+
variables: { answer: "Cap the retries at 5 and proceed." },
|
|
130
|
+
rationale: "the retry cap is the safe, reversible choice",
|
|
131
|
+
evidence: "git ls-remote showed no branch; the prior round made no progress",
|
|
132
|
+
})),
|
|
133
|
+
),
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// `pr.escalation-steward-collect` / `pr.escalation-steward-record` are APP workers — the app
|
|
138
|
+
// registers their real handlers on boot, so the eligible branch exercises the genuine record path
|
|
139
|
+
// (the canonical attributed door + the durable `escalation_auto_resolutions` write). No stub needed;
|
|
140
|
+
// the record worker having run is proven by its durable side effect (a `resolved` record) and the
|
|
141
|
+
// `record->done` flow.
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
after(async () => {
|
|
145
|
+
await app?.stop();
|
|
146
|
+
for (const [k, v] of savedEnv) {
|
|
147
|
+
if (v === undefined) delete process.env[k];
|
|
148
|
+
else process.env[k] = v;
|
|
149
|
+
}
|
|
150
|
+
rmSync(DB_DIR, { recursive: true, force: true });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("no-eligible branch: a tick with no opted-in escalation takes the gateway default and dispatches nothing", async () => {
|
|
154
|
+
const agentBefore = agentCalls;
|
|
155
|
+
await runStewardTick(app);
|
|
156
|
+
const flows = takenFlows(app);
|
|
157
|
+
assert.ok(flows.includes("gw_any->done"), `the no-eligible tick took the gateway default (flows: ${flows.join(", ")})`);
|
|
158
|
+
assert.ok(!flows.includes("gw_any->steward-agent"), "the agent was NOT dispatched on the no-eligible branch");
|
|
159
|
+
assert.ok(!flows.includes("steward-agent->record"), "the record worker was NOT reached on the no-eligible branch");
|
|
160
|
+
assert.equal(agentCalls, agentBefore, "the steward agent did not run");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("eligible branch: a parked escalation is claimed, dispatched to the agent, and resolved by the record worker", async () => {
|
|
164
|
+
const api = app.api;
|
|
165
|
+
assert.ok(api, "the OpenAPI driver is available");
|
|
166
|
+
|
|
167
|
+
// Opt this loop's escalations into the steward and drive a review round to park on `wait-answer`.
|
|
168
|
+
const prKey = "acme/widgets#88";
|
|
169
|
+
const started = await api.call<{ prKey: string }>("startConvergenceLoop", {
|
|
170
|
+
body: { pr: prKey, convergeOnly: true, autoResolveEscalations: true },
|
|
171
|
+
});
|
|
172
|
+
assert.equal(started.status, 202, "start returns 202 Accepted");
|
|
173
|
+
await app.settle();
|
|
174
|
+
|
|
175
|
+
// Project the parked `wait-answer` escalation into the `user_tasks` read model the collect worker
|
|
176
|
+
// scans (the projection is part of the imperative poll loop the testkit does not run).
|
|
177
|
+
await pollUserTasks(app.db, app.engine);
|
|
178
|
+
const openTasks = await app.db.table<{ user_task_key: string; element_id: string }>("user_tasks", "user_task_key").all();
|
|
179
|
+
assert.ok(openTasks.some((t) => t.element_id === "wait-answer"), "the review-loop escalation is projected as an open user task");
|
|
180
|
+
|
|
181
|
+
const agentBefore = agentCalls;
|
|
182
|
+
await runStewardTick(app);
|
|
183
|
+
|
|
184
|
+
// The gateway routed to the agent + record branch.
|
|
185
|
+
const flows = takenFlows(app);
|
|
186
|
+
assert.ok(flows.includes("gw_any->steward-agent"), `the eligible tick dispatched the agent (flows: ${flows.join(", ")})`);
|
|
187
|
+
assert.ok(flows.includes("steward-agent->record") && flows.includes("record->done"), "the agent's resolutions reached the record worker and the tick completed");
|
|
188
|
+
assert.equal(agentCalls, agentBefore + 1, "the steward agent ran once for the eligible tick");
|
|
189
|
+
|
|
190
|
+
// The `claimed`→`claimedTasks` ioMapping carried the claimed escalation to the agent.
|
|
191
|
+
const claimed = Array.isArray(capturedClaimedTasks) ? capturedClaimedTasks : [];
|
|
192
|
+
assert.ok(claimed.length >= 1, "the agent received at least one claimed task");
|
|
193
|
+
const claimedRows = claimed.map((e) => JSON.parse(String(e)) as { user_task_key?: string; element_id?: string });
|
|
194
|
+
assert.ok(claimedRows.some((r) => r.element_id === "wait-answer"), "the claimed set includes the parked wait-answer escalation");
|
|
195
|
+
|
|
196
|
+
// The record worker completed the parked escalation through the canonical door and wrote a
|
|
197
|
+
// `resolved` record — the audit row is final.
|
|
198
|
+
const records = await app.db
|
|
199
|
+
.table<{ user_task_key: string; outcome: string; selected_option: string | null }>("escalation_auto_resolutions", "id")
|
|
200
|
+
.all();
|
|
201
|
+
assert.ok(
|
|
202
|
+
records.some((r) => r.outcome === "resolved"),
|
|
203
|
+
`a resolved auto-resolution record was written (records: ${JSON.stringify(records.map((r) => r.outcome))})`,
|
|
204
|
+
);
|
|
205
|
+
// Re-project the read model (as the durable poller would) so the completed task's row reconciles
|
|
206
|
+
// away before asserting the escalation is no longer open.
|
|
207
|
+
await pollUserTasks(app.db, app.engine);
|
|
208
|
+
const stillOpen = await app.db.table<{ user_task_key: string; element_id: string }>("user_tasks", "user_task_key").all();
|
|
209
|
+
assert.ok(!stillOpen.some((t) => t.element_id === "wait-answer"), "the steward's resolution completed the parked escalation");
|
|
210
|
+
});
|
|
211
|
+
});
|
package/nano.app.json
CHANGED
|
@@ -243,6 +243,14 @@
|
|
|
243
243
|
{
|
|
244
244
|
"taskType": "pr.reconcile-implement",
|
|
245
245
|
"handler": "workers/reconcile-implement/worker.ts"
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"taskType": "pr.escalation-steward-collect",
|
|
249
|
+
"handler": "workers/escalation-steward-collect/worker.ts"
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"taskType": "pr.escalation-steward-record",
|
|
253
|
+
"handler": "workers/escalation-steward-record/worker.ts"
|
|
246
254
|
}
|
|
247
255
|
],
|
|
248
256
|
"externalTaskTypes": [
|
|
@@ -256,7 +264,8 @@
|
|
|
256
264
|
"senior:retro",
|
|
257
265
|
"senior:conformance",
|
|
258
266
|
"senior:scope-classify",
|
|
259
|
-
"senior:adversarial-review"
|
|
267
|
+
"senior:adversarial-review",
|
|
268
|
+
"senior:escalation-steward"
|
|
260
269
|
],
|
|
261
270
|
"surfaces": {
|
|
262
271
|
"taskInbox": {
|