@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/SPEC.md +48 -0
  3. package/app/admittedStaging.test.ts +71 -0
  4. package/app/agentActivationLedger.ts +5 -0
  5. package/app/agentCompletion.test.ts +33 -0
  6. package/app/agentCompletion.ts +108 -2
  7. package/app/agentic/vocab/agent-sla-boundary.test.ts +6 -1
  8. package/app/contracts.ts +23 -0
  9. package/app/deliveryGraphDispatch.ts +6 -0
  10. package/app/deliveryGraphReadModel.test.ts +2 -2
  11. package/app/deliveryGraphRun.ts +18 -0
  12. package/app/deliveryRunner.ts +9 -1
  13. package/app/escalationSteward.test.ts +452 -0
  14. package/app/escalationSteward.ts +537 -0
  15. package/app/escalationStewardPoll.test.ts +123 -0
  16. package/app/feature.test.ts +16 -0
  17. package/app/feature.ts +11 -0
  18. package/app/featureDelivery.test.ts +23 -0
  19. package/app/mcpToolSurface.ts +17 -1
  20. package/app/plan.ts +49 -6
  21. package/app/planLowering.test.ts +26 -1
  22. package/app/planLowering.ts +5 -0
  23. package/app/promotionPoll.test.ts +19 -0
  24. package/app/pullRequestReadModel.test.ts +1 -1
  25. package/app/service.test.ts +36 -0
  26. package/app/service.ts +139 -5
  27. package/db/migrations/120_escalation_auto_resolutions.sql +229 -0
  28. package/db/migrations/121_delivery_graph_read_model_auto_resolve.sql +54 -0
  29. package/db/migrations/122_pull_requests_read_model_auto_resolve.sql +56 -0
  30. package/db/migrations/123_escalation_auto_resolutions_selected_option.sql +47 -0
  31. package/db/migrations/124_admitted_epics_auto_resolve.sql +21 -0
  32. package/docs/agent-guide.md +69 -30
  33. package/docs/mcp-runbook.md +2 -0
  34. package/e2e/escalation-steward.e2e.ts +211 -0
  35. package/nano.app.json +10 -1
  36. package/openapi.yaml +365 -0
  37. package/operations/dispatchDeliveryGraph.ts +6 -1
  38. package/operations/listActivePrs.test.ts +60 -1
  39. package/operations/listActivePrs.ts +3 -3
  40. package/operations/listAutoResolutions.ts +83 -0
  41. package/operations/reviewAutoResolution.test.ts +137 -0
  42. package/operations/reviewAutoResolution.ts +42 -0
  43. package/operations/startConvergenceLoop.ts +5 -1
  44. package/operations/startEpicSet.admission.integration.test.ts +2 -2
  45. package/operations/startEpicSet.ts +8 -2
  46. package/operations/startFeature.ts +5 -0
  47. package/operations/startPlanFanout.ts +4 -1
  48. package/package.json +1 -1
  49. package/pages/_nav.json +12 -0
  50. package/pages/auto-resolutions.page.json +191 -0
  51. package/pages/board.page.json +17 -0
  52. package/pages/cockpit.page.json +17 -0
  53. package/pages/delivery-graph-detail.page.json +17 -0
  54. package/pages/delivery-graphs/staged.mount.js +17 -5
  55. package/pages/delivery-graphs.page.json +17 -0
  56. package/pages/epic-detail.page.json +17 -0
  57. package/pages/epic.page.json +19 -1
  58. package/pages/feature.page.json +19 -1
  59. package/pages/home.page.json +22 -0
  60. package/pages/lineage.page.json +17 -0
  61. package/pages/mcp.page.json +17 -0
  62. package/pages/overview.page.json +17 -0
  63. package/pages/tasks.page.json +17 -0
  64. package/pages/velocity.page.json +17 -0
  65. package/resources/processes/escalation-steward.bpmn +162 -0
  66. package/resources/prompts/escalation-steward.md +87 -0
  67. package/test/delivery-graphs-staged-embed.test.ts +13 -0
  68. package/test/derivation-parity/derivation-parity.test.ts +1 -0
  69. package/test/derivation-parity/flows.ts +12 -0
  70. package/workers/converge-feature/worker.ts +6 -1
  71. package/workers/delivery-connector/worker.test.ts +72 -1
  72. package/workers/delivery-connector/worker.ts +53 -2
  73. package/workers/escalation-steward-collect/worker.test.ts +180 -0
  74. package/workers/escalation-steward-collect/worker.ts +99 -0
  75. package/workers/escalation-steward-record/worker.test.ts +740 -0
  76. package/workers/escalation-steward-record/worker.ts +428 -0
  77. 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": {