@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 +7 -0
- package/SPEC.md +5 -2
- package/actions/abandon.ts +1 -1
- package/actions/status.ts +1 -1
- package/app/abandon.ts +2 -1
- package/app/service.test.ts +1 -74
- package/app/service.ts +0 -64
- package/deno.json +1 -1
- package/deno.lock +5 -5
- package/nano.app.json +4 -5
- package/package.json +2 -2
- package/actions/cancel.ts +0 -29
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
|
|
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
|
package/actions/abandon.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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)
|
package/app/service.test.ts
CHANGED
|
@@ -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 {
|
|
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
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.
|
|
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.
|
|
86
|
-
"integrity": "sha512-
|
|
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.
|
|
1760
|
+
"npm:@nanobpm/urban@0.30"
|
|
1761
1761
|
],
|
|
1762
1762
|
"packageJson": {
|
|
1763
1763
|
"dependencies": [
|
|
1764
|
-
"npm:@nanobpm/urban@0.
|
|
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": [
|
|
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.
|
|
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.
|
|
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;
|