@nanobpm/nano-workforce 0.33.0 → 0.33.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.33.1](https://github.com/nanobpm/nano-workforce/compare/v0.33.0...v0.33.1) (2026-08-09)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * adopt Urban's built-in reconcile-aware cancel primitive ([#100](https://github.com/nanobpm/nano-workforce/issues/100)) ([e1aed94](https://github.com/nanobpm/nano-workforce/commit/e1aed94c29fc1ebd8ec0f5ca15d73e6ee723d276)), closes [nanobpm/nano-ide#144](https://github.com/nanobpm/nano-ide/issues/144)
7
+
1
8
  # [0.33.0](https://github.com/nanobpm/nano-workforce/compare/v0.32.2...v0.33.0) (2026-08-09)
2
9
 
3
10
 
package/SPEC.md CHANGED
@@ -267,12 +267,15 @@ carry app-specific business logic:
267
267
  | method | route | purpose |
268
268
  |---|---|---|
269
269
  | `POST` | `/app/actions/start/convergence-loop` | parse the PR ref → create the aggregate + start the process |
270
- | `POST` | `/app/actions/cancel` | cancel the engine instance + mark the PR `abandoned` |
271
270
  | `POST` | `/app/actions/message` (`escalation-answered`) | answer an open escalation → publish `escalation-answered` |
272
271
  | `POST` | `/hooks/submit` | webhook submit (shared-secret auth) → start the process |
273
272
 
274
273
  Everything else (`GET /`, `GET /app/pages/*`, `GET /app/data/*`, the renderer) is
275
- served by the runtime. `deno task purge` wipes and re-migrates the app db (used
274
+ served by the runtime including `POST /app/actions/cancel`, which is Urban's
275
+ built-in reconcile-aware cancel primitive (there is **no** local handler in this
276
+ repo): it terminates the engine instance, verifies the termination, and flips the
277
+ tracked row to `abandoned` via the `instanceTracking` `onTerminated.set` patch.
278
+ `deno task purge` wipes and re-migrates the app db (used
276
279
  when the engine data is purged, to keep app state and engine state consistent).
277
280
 
278
281
  ## 9. Prompt delivery — model-authored template headers
@@ -7,7 +7,7 @@
7
7
  // unknown token is a 404 (never leaks which PRs exist).
8
8
  //
9
9
  // GET → { prKey, status, abandoned } — `abandoned` is derived from `pull_requests.status`,
10
- // which `cancelRun` sets to 'abandoned' on cancel. `true` ⇒ the agent must stop.
10
+ // which Urban's cancel primitive sets to 'abandoned' on cancel. `true` ⇒ the agent must stop.
11
11
  import type { ActionHandler } from "@nanobpm/urban";
12
12
  import { abandonStatusForToken } from "../app/abandon.ts";
13
13
 
package/actions/status.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // GET /app/status — list the PRs currently in flight (every tracked PR not converged/abandoned).
2
2
  // A read-only projection over the app datasource so an operator or an external automation
3
- // harness can see active work — and grab a prKey to cancel — without opening the DB or the UI.
3
+ // harness can see active work — and grab a `processKey` to cancel — without opening the DB or the UI.
4
4
  //
5
5
  // Optional shared-secret guard, mirroring /hooks/submit: when NANO_PR_WEBHOOK_SECRET is set,
6
6
  // callers must present it via the x-hook-secret header. Unset → open (unchanged default). The
package/app/abandon.ts CHANGED
@@ -11,7 +11,8 @@
11
11
  // - CAPABILITY URL. The per-PR token IS the credential; the agent curls the exact URL it was
12
12
  // handed in its prompt. An unknown token is a 404 (never leaks which PRs exist).
13
13
  // - DERIVED, not a separate marker. `abandoned` is read straight off `pull_requests.status`,
14
- // which `cancelRun` (app/service.ts) already sets to 'abandoned' on cancel. No new state to
14
+ // which Urban's cancel primitive sets to 'abandoned' on cancel (via the `instanceTracking`
15
+ // `onTerminated.set` patch, applied the instant the instance terminates). No new state to
15
16
  // keep in sync.
16
17
  // - ADVISORY. Like the blackboard, this never hard-locks; it narrows an unavoidable
17
18
  // check-then-push (TOCTOU) window to near-zero. Job fencing in the harness (issue #76 layer 2)
@@ -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 { cancelRun, pollIncidentsImpl, submitPr } from "./service.ts";
9
+ import { pollIncidentsImpl, submitPr } from "./service.ts";
10
10
 
11
11
  // deno-lint-ignore no-explicit-any
12
12
  function memTable(rows: any[], key: string) {
@@ -252,76 +252,3 @@ Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a mi
252
252
  assertEquals(row.incident_message, "the first fault");
253
253
  });
254
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
@@ -438,70 +438,6 @@ export async function answerEscalation(
438
438
  return { ok: true, escalationId: open.id };
439
439
  }
440
440
 
441
- /** How a caller identifies the run to cancel: by its engine `processInstanceKey` or, more
442
- * ergonomically, by the `prKey` the status endpoint reports. The cancel action rejects a
443
- * request that supplies both, so exactly one selector reaches here. */
444
- export interface CancelSelector {
445
- processInstanceKey?: string;
446
- prKey?: string;
447
- }
448
-
449
- /** Cancel a run and mark its read-model row abandoned. Two paths converge here:
450
- *
451
- * - **Tracked PR** (a `pull_requests` row): the engine instance is terminated (which emits no
452
- * completion event — no worker runs) and this function flips the PR row to `abandoned`
453
- * *synchronously*. The immediacy matters: the agent-abort capability (`app/abandon.ts`) derives
454
- * `abandoned` straight off `pull_requests.status`, so a deferred write would widen the
455
- * check-then-push window a side-effecting agent races against.
456
- * - **Any other instance** (e.g. the cancel button on an Epic/plan row, which POSTs the row's
457
- * `process_key`): the instance is terminated here, and the declarative `instanceTracking`
458
- * reconciler (`nano.app.json`) flips the owning row (`plans`) to abandoned on its next poll.
459
- * That same reconciler is also the safety net for terminations that never reach this function
460
- * at all — an operator terminating the instance directly, or a crash.
461
- *
462
- * Accepts either selector; a PR already in a terminal state is left untouched so a stale cancel
463
- * can't overwrite a `converged` outcome with `abandoned`. */
464
- export async function cancelRun(data: DataLayer, engine: EngineClient, selector: CancelSelector) {
465
- const { processInstanceKey, prKey } = selector;
466
- const table = prs(data);
467
- const pr = prKey
468
- ? await table.get(prKey)
469
- : processInstanceKey
470
- ? (await table.find({ process_key: processInstanceKey }))[0]
471
- : undefined;
472
- if (pr && TERMINAL_STATUSES.includes(pr.status)) {
473
- return { ok: false, kind: "terminal", reason: `PR already ${pr.status}`, prKey: pr.pr_key };
474
- }
475
- const instanceKey = pr?.process_key ?? processInstanceKey ?? null;
476
- if (instanceKey) {
477
- try {
478
- await engine.cancelInstance({ processInstanceKey: instanceKey });
479
- } catch (err) {
480
- // The instance may already be gone (converged/cancelled) — still reconcile the app row so
481
- // a stale "converging" PR can't linger in the UI.
482
- console.warn(`[cancel] engine cancel for ${instanceKey}: ${err}`);
483
- }
484
- }
485
- if (pr) {
486
- await table.update(pr.pr_key, {
487
- status: "abandoned",
488
- updated_at: now(),
489
- open_escalation_id: null,
490
- open_escalation_question: null,
491
- });
492
- return { ok: true, prKey: pr.pr_key };
493
- }
494
- // No tracked PR for this key. If we were handed a raw instance key (e.g. the cancel button
495
- // on an Epic/plan row, which POSTs the row's `process_key`), the instance has still been
496
- // terminated above — the declarative `instanceTracking` reconciler (nano.app.json) flips the
497
- // owning row (`plans`) to abandoned on its next poll. Report success so the UI does not
498
- // surface a misleading 404 for a cancel that actually took effect.
499
- if (instanceKey) {
500
- return { ok: true, processInstanceKey: instanceKey };
501
- }
502
- return { ok: false, kind: "not_found", reason: "no PR for that selector" };
503
- }
504
-
505
441
  /** A PR currently in flight, as reported by the status endpoint. */
506
442
  export interface ActivePr {
507
443
  prKey: string;
package/deno.json CHANGED
@@ -6,7 +6,7 @@
6
6
  ]
7
7
  },
8
8
  "imports": {
9
- "@nanobpm/urban": "npm:@nanobpm/urban@^0.29.0"
9
+ "@nanobpm/urban": "npm:@nanobpm/urban@^0.30.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.29": "0.29.0",
6
+ "npm:@nanobpm/urban@0.30": "0.30.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",
@@ -82,8 +82,8 @@
82
82
  "ws"
83
83
  ]
84
84
  },
85
- "@nanobpm/urban@0.29.0": {
86
- "integrity": "sha512-79CMeYpfnPmKSF2x7zRJ6ZcmAimOknCxySFXvg2z0JtS/pTGlUvkzZy87xp5jHB88LZdKsUIuvVnN5hnZKdS+g==",
85
+ "@nanobpm/urban@0.30.0": {
86
+ "integrity": "sha512-jaxVFwmWk/dLyA/Owgyons7tc8Yx0iJfotY7mN63hCQS7jenak9d/h3wQmevphF+JDq7E8Ovo+9Wq2K1CGy+yw==",
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.29"
1760
+ "npm:@nanobpm/urban@0.30"
1761
1761
  ],
1762
1762
  "packageJson": {
1763
1763
  "dependencies": [
1764
- "npm:@nanobpm/urban@0.29",
1764
+ "npm:@nanobpm/urban@0.30",
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
@@ -50,7 +50,10 @@
50
50
  "table": "plans",
51
51
  "keyField": "process_key",
52
52
  "statusField": "status",
53
- "activeStatuses": ["planning", "dispatched"],
53
+ "activeStatuses": [
54
+ "planning",
55
+ "dispatched"
56
+ ],
54
57
  "onTerminated": {
55
58
  "set": {
56
59
  "status": "abandoned"
@@ -145,10 +148,6 @@
145
148
  "path": "/app/actions/start/plan-fanout",
146
149
  "module": "actions/plan-start.ts"
147
150
  },
148
- {
149
- "path": "/app/actions/cancel",
150
- "module": "actions/cancel.ts"
151
- },
152
151
  {
153
152
  "path": "/app/status",
154
153
  "module": "actions/status.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.33.0",
3
+ "version": "0.33.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.29.0"
43
+ "@nanobpm/urban": "^0.30.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@semantic-release/changelog": "^6.0.3",
package/actions/cancel.ts DELETED
@@ -1,29 +0,0 @@
1
- // POST /app/actions/cancel — override the generic row-cancel action. Terminating the engine
2
- // instance emits no completion event, so reconcile the app row (status='abandoned', clear the
3
- // open escalation) here. Accepts either `processInstanceKey` or the `prKey` the status endpoint
4
- // reports, so a caller can cancel a run it discovered via GET /app/status.
5
- import type { ActionHandler } from "@nanobpm/urban";
6
- import { cancelRun } from "../app/service.ts";
7
-
8
- const str = (v: unknown): string | undefined => {
9
- if (v == null) return undefined;
10
- const s = String(v).trim();
11
- return s === "" ? undefined : s;
12
- };
13
-
14
- const handler: ActionHandler = async ({ body }, app) => {
15
- const b = (body ?? {}) as { processInstanceKey?: unknown; prKey?: unknown };
16
- const processInstanceKey = str(b.processInstanceKey);
17
- const prKey = str(b.prKey);
18
- if (!processInstanceKey && !prKey) {
19
- return { status: 400, body: { error: "processInstanceKey or prKey is required" } };
20
- }
21
- if (processInstanceKey && prKey) {
22
- return { status: 400, body: { error: "provide exactly one of processInstanceKey or prKey" } };
23
- }
24
- const r = await cancelRun(app.data, app.engine, { processInstanceKey, prKey });
25
- if (r.ok) return { status: 200, body: r };
26
- return { status: r.kind === "terminal" ? 409 : 404, body: r };
27
- };
28
-
29
- export default handler;