@nanobpm/nano-workforce 0.32.0 → 0.32.1

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 CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.32.1](https://github.com/nanobpm/nano-workforce/compare/v0.32.0...v0.32.1) (2026-08-09)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **cancel:** reconcile terminated Epic/plan runs via instanceTracking ([#96](https://github.com/nanobpm/nano-workforce/issues/96)) ([3d0c5ba](https://github.com/nanobpm/nano-workforce/commit/3d0c5ba1b8bb9bd0c28432323b1867a7c86d1835))
7
+
1
8
  # [0.32.0](https://github.com/nanobpm/nano-workforce/compare/v0.31.0...v0.32.0) (2026-08-09)
2
9
 
3
10
 
@@ -0,0 +1,73 @@
1
+ // Guard for the `instanceTracking` manifest bindings (nano.app.json). The reconciler flips a row
2
+ // whose engine instance is TERMINATED only when the row is in one of `activeStatuses`. A status
3
+ // that is genuinely in-flight but missing from that list would leave an operator-terminated (or
4
+ // crashed) run stuck "active" in the UI — the exact drift Copilot flagged on #96. This ties the
5
+ // manifest to the code's single source of truth for "done" (TERMINAL_STATUSES / PLAN_TERMINAL_
6
+ // STATUSES) so the two can't diverge silently.
7
+ import { assert, assertEquals } from "jsr:@std/assert@1";
8
+ import { TERMINAL_STATUSES } from "./service.ts";
9
+ import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
10
+
11
+ interface Binding {
12
+ table: string;
13
+ statusField?: string;
14
+ activeStatuses?: string[];
15
+ onTerminated: { set: Record<string, unknown> };
16
+ }
17
+
18
+ async function bindings(): Promise<Binding[]> {
19
+ const manifest = JSON.parse(await Deno.readTextFile(new URL("../nano.app.json", import.meta.url)));
20
+ return manifest.instanceTracking as Binding[];
21
+ }
22
+
23
+ function bindingFor(all: Binding[], table: string): Binding {
24
+ const b = all.find((x) => x.table === table);
25
+ assert(b, `no instanceTracking binding for ${table}`);
26
+ return b;
27
+ }
28
+
29
+ Deno.test("instanceTracking: pull_requests activeStatuses excludes every terminal status", async () => {
30
+ const b = bindingFor(await bindings(), "pull_requests");
31
+ for (const terminal of TERMINAL_STATUSES) {
32
+ assert(
33
+ !b.activeStatuses?.includes(terminal),
34
+ `terminal status "${terminal}" must not be listed active (it would let the reconciler clobber a settled row)`,
35
+ );
36
+ }
37
+ });
38
+
39
+ // Every in-flight status the merge train keys off must be reconcilable. These are the states a
40
+ // pull_requests row can hold while a live engine instance still backs it (see app/service.ts merge
41
+ // poller: converging/waiting_review/escalated + the merge-stage waiting_deps/waiting_merge/
42
+ // waiting_lane/queued/merging). If a new one is added to the flow, add it here AND to the manifest.
43
+ Deno.test("instanceTracking: pull_requests activeStatuses covers every in-flight status", async () => {
44
+ const inFlight = [
45
+ "converging",
46
+ "waiting_review",
47
+ "escalated",
48
+ "waiting_deps",
49
+ "waiting_merge",
50
+ "waiting_lane",
51
+ "queued",
52
+ "merging",
53
+ ];
54
+ const b = bindingFor(await bindings(), "pull_requests");
55
+ for (const s of inFlight) {
56
+ assert(b.activeStatuses?.includes(s), `in-flight status "${s}" missing from activeStatuses`);
57
+ }
58
+ // No terminal status leaks into the in-flight universe we assert on.
59
+ for (const s of inFlight) assert(!TERMINAL_STATUSES.includes(s));
60
+ });
61
+
62
+ Deno.test("instanceTracking: plans activeStatuses excludes every terminal status", async () => {
63
+ const b = bindingFor(await bindings(), "plans");
64
+ for (const terminal of PLAN_TERMINAL_STATUSES) {
65
+ assert(!b.activeStatuses?.includes(terminal), `terminal status "${terminal}" must not be active`);
66
+ }
67
+ });
68
+
69
+ Deno.test("instanceTracking: plans activeStatuses covers every in-flight status", async () => {
70
+ const inFlight = ["planning", "dispatched"];
71
+ const b = bindingFor(await bindings(), "plans");
72
+ assertEquals([...(b.activeStatuses ?? [])].sort(), [...inFlight].sort());
73
+ });
@@ -6,7 +6,7 @@
6
6
  // loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
7
7
  // GitHub transport forced off so it is hermetic.
8
8
  import { assertEquals } from "jsr:@std/assert@1";
9
- import { pollIncidentsImpl, submitPr } from "./service.ts";
9
+ import { cancelRun, pollIncidentsImpl, submitPr } from "./service.ts";
10
10
 
11
11
  // deno-lint-ignore no-explicit-any
12
12
  function memTable(rows: any[], key: string) {
@@ -251,3 +251,77 @@ Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a mi
251
251
  assertEquals(row.incident_key, "INC-OLD");
252
252
  assertEquals(row.incident_message, "the first fault");
253
253
  });
254
+
255
+
256
+ // Bug: the Epic cancel button (Nano Workforce UI) POSTs the plan row's `process_key` to
257
+ // /app/actions/cancel → cancelRun. cancelRun only knows the `pull_requests` table, so for a plan
258
+ // instance it terminated the engine instance but returned `not_found` (a 404 the UI surfaces as an
259
+ // error), and never reconciled the `plans` row — so "cancel didn't cancel the epic". The instance
260
+ // IS torn down; the declarative instanceTracking reconciler flips the plans row. cancelRun must
261
+ // therefore report success for a raw instance key it terminated.
262
+ Deno.test("cancelRun terminates a non-PR (Epic/plan) instance and reports success", async () => {
263
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
264
+ pull_requests: { rows: [], key: "pr_key" }, // no PR tracks this key — it's a plan instance
265
+ };
266
+ const data = {
267
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
268
+ // deno-lint-ignore no-explicit-any
269
+ } as any;
270
+ const cancelled: string[] = [];
271
+ const engine = {
272
+ // deno-lint-ignore no-explicit-any
273
+ cancelInstance: (input: any) => {
274
+ cancelled.push(String(input.processInstanceKey));
275
+ return Promise.resolve();
276
+ },
277
+ // deno-lint-ignore no-explicit-any
278
+ } as any;
279
+
280
+ const r = await cancelRun(data, engine, { processInstanceKey: "PI-EPIC-1" });
281
+
282
+ assertEquals(cancelled, ["PI-EPIC-1"]); // the engine instance was terminated …
283
+ assertEquals(r.ok, true); // … and cancel is reported successful (no misleading 404).
284
+ });
285
+
286
+ // The tracked-PR path must still flip the row abandoned SYNCHRONOUSLY: app/abandon.ts derives the
287
+ // agent-abort signal straight off pull_requests.status, so a deferred (reconciler-only) write would
288
+ // widen the check-then-push window a side-effecting agent races against.
289
+ Deno.test("cancelRun flips a tracked PR to abandoned immediately and clears the escalation pointer", async () => {
290
+ const PR_KEY = "owner/repo#7";
291
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
292
+ pull_requests: {
293
+ rows: [{
294
+ pr_key: PR_KEY,
295
+ repo: "owner/repo",
296
+ number: 7,
297
+ status: "escalated",
298
+ process_key: "PI-PR-7",
299
+ open_escalation_id: 3,
300
+ open_escalation_question: "why?",
301
+ }],
302
+ key: "pr_key",
303
+ },
304
+ };
305
+ const data = {
306
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
307
+ // deno-lint-ignore no-explicit-any
308
+ } as any;
309
+ const cancelled: string[] = [];
310
+ const engine = {
311
+ // deno-lint-ignore no-explicit-any
312
+ cancelInstance: (input: any) => {
313
+ cancelled.push(String(input.processInstanceKey));
314
+ return Promise.resolve();
315
+ },
316
+ // deno-lint-ignore no-explicit-any
317
+ } as any;
318
+
319
+ const r = await cancelRun(data, engine, { prKey: PR_KEY });
320
+
321
+ assertEquals(r.ok, true);
322
+ assertEquals(cancelled, ["PI-PR-7"]);
323
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
324
+ assertEquals(pr.status, "abandoned");
325
+ assertEquals(pr.open_escalation_id, null);
326
+ assertEquals(pr.open_escalation_question, null);
327
+ });
package/app/service.ts CHANGED
@@ -388,11 +388,21 @@ export interface CancelSelector {
388
388
  prKey?: string;
389
389
  }
390
390
 
391
- /** Cancel a PR's running convergence instance and mark it abandoned. Terminating the engine
392
- * instance emits no completion event (no worker runs), so the app-tier flips the PR's status
393
- * here the same place ADR 0040 puts app-owned rest state. Accepts either selector; a PR
394
- * already in a terminal state is left untouched so a stale cancel can't overwrite a `converged`
395
- * outcome with `abandoned`. */
391
+ /** Cancel a run and mark its read-model row abandoned. Two paths converge here:
392
+ *
393
+ * - **Tracked PR** (a `pull_requests` row): the engine instance is terminated (which emits no
394
+ * completion event no worker runs) and this function flips the PR row to `abandoned`
395
+ * *synchronously*. The immediacy matters: the agent-abort capability (`app/abandon.ts`) derives
396
+ * `abandoned` straight off `pull_requests.status`, so a deferred write would widen the
397
+ * check-then-push window a side-effecting agent races against.
398
+ * - **Any other instance** (e.g. the cancel button on an Epic/plan row, which POSTs the row's
399
+ * `process_key`): the instance is terminated here, and the declarative `instanceTracking`
400
+ * reconciler (`nano.app.json`) flips the owning row (`plans`) to abandoned on its next poll.
401
+ * That same reconciler is also the safety net for terminations that never reach this function
402
+ * at all — an operator terminating the instance directly, or a crash.
403
+ *
404
+ * Accepts either selector; a PR already in a terminal state is left untouched so a stale cancel
405
+ * can't overwrite a `converged` outcome with `abandoned`. */
396
406
  export async function cancelRun(data: DataLayer, engine: EngineClient, selector: CancelSelector) {
397
407
  const { processInstanceKey, prKey } = selector;
398
408
  const table = prs(data);
@@ -423,6 +433,14 @@ export async function cancelRun(data: DataLayer, engine: EngineClient, selector:
423
433
  });
424
434
  return { ok: true, prKey: pr.pr_key };
425
435
  }
436
+ // No tracked PR for this key. If we were handed a raw instance key (e.g. the cancel button
437
+ // on an Epic/plan row, which POSTs the row's `process_key`), the instance has still been
438
+ // terminated above — the declarative `instanceTracking` reconciler (nano.app.json) flips the
439
+ // owning row (`plans`) to abandoned on its next poll. Report success so the UI does not
440
+ // surface a misleading 404 for a cancel that actually took effect.
441
+ if (instanceKey) {
442
+ return { ok: true, processInstanceKey: instanceKey };
443
+ }
426
444
  return { ok: false, kind: "not_found", reason: "no PR for that selector" };
427
445
  }
428
446
 
package/deno.json CHANGED
@@ -6,7 +6,7 @@
6
6
  ]
7
7
  },
8
8
  "imports": {
9
- "@nanobpm/urban": "npm:@nanobpm/urban@^0.28.0"
9
+ "@nanobpm/urban": "npm:@nanobpm/urban@^0.29.0"
10
10
  },
11
11
  "tasks": {
12
12
  "start": "deno run --allow-net --allow-read --allow-write --allow-run=gh --allow-env main.ts",
package/deno.lock CHANGED
@@ -3,7 +3,7 @@
3
3
  "specifiers": {
4
4
  "jsr:@std/assert@1": "1.0.19",
5
5
  "jsr:@std/internal@^1.0.12": "1.0.14",
6
- "npm:@nanobpm/urban@0.28": "0.28.0",
6
+ "npm:@nanobpm/urban@0.29": "0.29.0",
7
7
  "npm:@semantic-release/changelog@^6.0.3": "6.0.3_semantic-release@24.2.9__typescript@5.9.3_typescript@5.9.3",
8
8
  "npm:@semantic-release/git@^10.0.1": "10.0.1_semantic-release@24.2.9__typescript@5.9.3_typescript@5.9.3",
9
9
  "npm:@semantic-release/npm@^13.1.5": "13.1.5_semantic-release@24.2.9__typescript@5.9.3",
@@ -67,8 +67,8 @@
67
67
  "@colors/colors@1.5.0": {
68
68
  "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="
69
69
  },
70
- "@nanobpm/nano-app-schema@0.3.0": {
71
- "integrity": "sha512-bz7wqYsfetcSMYIMW2e1rmbE2k844TCtXWwBd+uVV/s3iRsi09Ff0WMaBEZWuvBPopwHhD2JATCNCXdEBULjfA==",
70
+ "@nanobpm/nano-app-schema@0.4.0": {
71
+ "integrity": "sha512-/1rXzXVDVglqjNoqrxJ0FjBUKqej3q8R+/3xXXU3cdwRO9QKwDkd3/2K+I0gS1bAiqNI8SWZiH9+pkKiT7I8wQ==",
72
72
  "dependencies": [
73
73
  "bpmn-moddle@9.0.4",
74
74
  "dmn-moddle",
@@ -82,8 +82,8 @@
82
82
  "ws"
83
83
  ]
84
84
  },
85
- "@nanobpm/urban@0.28.0": {
86
- "integrity": "sha512-rhzgOV+Vb1CPuNoVHVX3OtxjHqzSp2gJIV2pi3EBi245ZjDRN0gSoPGd/MEnJdufxgWwDKuvhyljRDHE8T957w==",
85
+ "@nanobpm/urban@0.29.0": {
86
+ "integrity": "sha512-79CMeYpfnPmKSF2x7zRJ6ZcmAimOknCxySFXvg2z0JtS/pTGlUvkzZy87xp5jHB88LZdKsUIuvVnN5hnZKdS+g==",
87
87
  "dependencies": [
88
88
  "@nanobpm/nano-app-schema",
89
89
  "@nanobpm/nano-sdk",
@@ -1757,11 +1757,11 @@
1757
1757
  },
1758
1758
  "workspace": {
1759
1759
  "dependencies": [
1760
- "npm:@nanobpm/urban@0.28"
1760
+ "npm:@nanobpm/urban@0.29"
1761
1761
  ],
1762
1762
  "packageJson": {
1763
1763
  "dependencies": [
1764
- "npm:@nanobpm/urban@0.28",
1764
+ "npm:@nanobpm/urban@0.29",
1765
1765
  "npm:@semantic-release/changelog@^6.0.3",
1766
1766
  "npm:@semantic-release/git@^10.0.1",
1767
1767
  "npm:@semantic-release/npm@^13.1.5",
package/nano.app.json CHANGED
@@ -22,6 +22,43 @@
22
22
  }
23
23
  }
24
24
  },
25
+ "instanceTracking": [
26
+ {
27
+ "table": "pull_requests",
28
+ "keyField": "process_key",
29
+ "statusField": "status",
30
+ "activeStatuses": [
31
+ "converging",
32
+ "waiting_review",
33
+ "escalated",
34
+ "waiting_deps",
35
+ "waiting_merge",
36
+ "waiting_lane",
37
+ "queued",
38
+ "merging"
39
+ ],
40
+ "onTerminated": {
41
+ "set": {
42
+ "status": "abandoned",
43
+ "open_escalation_id": null,
44
+ "open_escalation_question": null
45
+ }
46
+ },
47
+ "pollMs": 5000
48
+ },
49
+ {
50
+ "table": "plans",
51
+ "keyField": "process_key",
52
+ "statusField": "status",
53
+ "activeStatuses": ["planning", "dispatched"],
54
+ "onTerminated": {
55
+ "set": {
56
+ "status": "abandoned"
57
+ }
58
+ },
59
+ "pollMs": 5000
60
+ }
61
+ ],
25
62
  "workers": [
26
63
  {
27
64
  "taskType": "pr.persist-round",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
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",
@@ -40,7 +40,7 @@
40
40
  "test": "deno test -A"
41
41
  },
42
42
  "dependencies": {
43
- "@nanobpm/urban": "^0.28.0"
43
+ "@nanobpm/urban": "^0.29.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@semantic-release/changelog": "^6.0.3",