@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 +7 -0
- package/app/instance-tracking.test.ts +73 -0
- package/app/service.test.ts +75 -1
- package/app/service.ts +23 -5
- package/deno.json +1 -1
- package/deno.lock +7 -7
- package/nano.app.json +37 -0
- package/package.json +2 -2
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
|
+
});
|
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 { 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
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
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
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.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.
|
|
71
|
-
"integrity": "sha512
|
|
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.
|
|
86
|
-
"integrity": "sha512-
|
|
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.
|
|
1760
|
+
"npm:@nanobpm/urban@0.29"
|
|
1761
1761
|
],
|
|
1762
1762
|
"packageJson": {
|
|
1763
1763
|
"dependencies": [
|
|
1764
|
-
"npm:@nanobpm/urban@0.
|
|
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.
|
|
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.
|
|
43
|
+
"@nanobpm/urban": "^0.29.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@semantic-release/changelog": "^6.0.3",
|