@nanobpm/nano-workforce 0.155.0 → 0.156.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.
- package/CHANGELOG.md +6 -0
- package/app/deliveryDispatch.test.ts +248 -0
- package/app/deliveryDispatch.ts +153 -0
- package/nano.app.json +14 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.156.0](https://github.com/nanobpm/nano-workforce/compare/v0.155.0...v0.156.0) (2026-08-29)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **delivery-units:** single (kind, instanceId) dispatch door on delivery_units (ADR 0006 S3) ([#602](https://github.com/nanobpm/nano-workforce/issues/602)) ([f736b4b](https://github.com/nanobpm/nano-workforce/commit/f736b4bfaf838b4b4b000b0b84d76330a05edf7c)), closes [#590](https://github.com/nanobpm/nano-workforce/issues/590)
|
|
6
|
+
|
|
1
7
|
## [0.155.0](https://github.com/nanobpm/nano-workforce/compare/v0.154.0...v0.155.0) (2026-08-29)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Coverage for ADR 0006 slice S3 (#590) — the SINGLE dispatch door (`app/deliveryDispatch.ts`) and
|
|
2
|
+
// its one `delivery_units` `instanceTracking` binding, which collapse the three per-representation
|
|
3
|
+
// `senior:*` dispatch paths onto the aggregate.
|
|
4
|
+
//
|
|
5
|
+
// Proves:
|
|
6
|
+
// 1. VERB PARITY — the door's kind → `senior:*` target map uses the SAME job-type names the
|
|
7
|
+
// pre-collapse BPMN models dispatch (`senior:feature`, `senior:plan`). Collapsing the doors never
|
|
8
|
+
// renames a dispatch target; every mapped verb is a real prompt-bearing agent task in the
|
|
9
|
+
// deployed models.
|
|
10
|
+
// 2. KEY IDENTITY — `(kind, instanceId)` names exactly the S2 `unit_id` the identity helpers build,
|
|
11
|
+
// so the two-arg door key IS the aggregate key (no per-representation key builder survives).
|
|
12
|
+
// 3. GATE PARITY — the one re-dispatch gate matches the S1/S2 `isDeliveryUnitSettled` /
|
|
13
|
+
// `dispatchStatusForDelivery` short-circuit for EVERY canonical status: only `requested`(pending)
|
|
14
|
+
// dispatches; every live/parked/terminal state short-circuits — the exact rule each pre-collapse
|
|
15
|
+
// launcher re-implemented.
|
|
16
|
+
// 4. BINDING — the single `delivery_units` binding drives the door: it is the ONLY new binding, keys
|
|
17
|
+
// on `dispatch_status`, lists no `settled` state active, and provisions its `__tracking` VIEW
|
|
18
|
+
// against a REAL migrated DB.
|
|
19
|
+
import { DatabaseSync } from "node:sqlite";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { test } from "node:test";
|
|
22
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
23
|
+
import { applyMigrationSet, readMigrationSetFromDisk } from "#test-migrations";
|
|
24
|
+
import { assert, assertEquals } from "#test-assert";
|
|
25
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
26
|
+
import {
|
|
27
|
+
DELIVERY_UNITS_TABLE,
|
|
28
|
+
DISPATCH_JOB_TYPE_BY_KIND,
|
|
29
|
+
deliveryUnitKey,
|
|
30
|
+
dispatchGate,
|
|
31
|
+
dispatchJobTypeForKind,
|
|
32
|
+
resolveDeliveryDispatch,
|
|
33
|
+
} from "./deliveryDispatch.ts";
|
|
34
|
+
import {
|
|
35
|
+
DELIVERY_UNIT_KINDS,
|
|
36
|
+
deliveryGraphUnitId,
|
|
37
|
+
dispatchStatusForDelivery,
|
|
38
|
+
epicUnitId,
|
|
39
|
+
featureUnitId,
|
|
40
|
+
planTaskUnitId,
|
|
41
|
+
} from "./deliveryUnit.ts";
|
|
42
|
+
import { DELIVERY_UNIT_STATUSES, type DeliveryUnitStatus, isDeliveryUnitSettled } from "./deliveryUnitStatus.ts";
|
|
43
|
+
import { promptBearingTaskTypes } from "./agentic/vocab/job-types.ts";
|
|
44
|
+
|
|
45
|
+
interface Binding {
|
|
46
|
+
table: string;
|
|
47
|
+
keyField?: string;
|
|
48
|
+
statusField?: string;
|
|
49
|
+
activeStatuses?: string[];
|
|
50
|
+
onTerminated: { set: Record<string, unknown> };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function manifestBindings(): Binding[] {
|
|
54
|
+
const manifest = JSON.parse(readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"));
|
|
55
|
+
return manifest.instanceTracking as Binding[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The deployed process models — scanned for their prompt-bearing agent tasks, the real dispatch
|
|
59
|
+
// corpus the door's verbs must already exist in.
|
|
60
|
+
const MODEL_FILES = [
|
|
61
|
+
"feature.bpmn",
|
|
62
|
+
"plan-fanout.bpmn",
|
|
63
|
+
"convergence-loop.bpmn",
|
|
64
|
+
"merge-loop.bpmn",
|
|
65
|
+
"retro.bpmn",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function deployedAgentJobTypes(): Set<string> {
|
|
69
|
+
const types = new Set<string>();
|
|
70
|
+
for (const file of MODEL_FILES) {
|
|
71
|
+
const xml = readFileSync(new URL(`../resources/processes/${file}`, import.meta.url), "utf8");
|
|
72
|
+
for (const t of promptBearingTaskTypes(xml)) types.add(t);
|
|
73
|
+
}
|
|
74
|
+
return types;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("VERB PARITY: every mapped dispatch verb is a real senior:* agent task in the deployed models", () => {
|
|
78
|
+
const deployed = deployedAgentJobTypes();
|
|
79
|
+
for (const kind of DELIVERY_UNIT_KINDS) {
|
|
80
|
+
const verb = DISPATCH_JOB_TYPE_BY_KIND[kind];
|
|
81
|
+
if (verb === null) continue; // delivery-graph is runner-launched, no single verb
|
|
82
|
+
assert(verb.startsWith("senior:"), `${kind} must dispatch a senior:* verb, got ${verb}`);
|
|
83
|
+
assert(deployed.has(verb), `${kind} dispatches ${verb}, which must be a deployed agent task`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("VERB PARITY: the implementation kinds keep the pre-collapse senior:feature target", () => {
|
|
88
|
+
// feature.bpmn's implement task and plan-fanout.bpmn's per-slice implement task both dispatch
|
|
89
|
+
// senior:feature today — the door preserves that for the single-issue implementation kinds.
|
|
90
|
+
assertEquals(dispatchJobTypeForKind("feature"), "senior:feature");
|
|
91
|
+
assertEquals(dispatchJobTypeForKind("plan-task"), "senior:feature");
|
|
92
|
+
assertEquals(dispatchJobTypeForKind("bugfix"), "senior:feature");
|
|
93
|
+
assertEquals(dispatchJobTypeForKind("chore"), "senior:feature");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("VERB PARITY: an epic dispatches the pre-collapse senior:plan decomposition target", () => {
|
|
97
|
+
assertEquals(dispatchJobTypeForKind("epic"), "senior:plan");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("VERB PARITY: a delivery-graph unit has no single agent verb (runner-launched)", () => {
|
|
101
|
+
assertEquals(dispatchJobTypeForKind("delivery-graph"), null);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("VERB PARITY: the map covers exactly the closed kind enum (no drift)", () => {
|
|
105
|
+
assertEquals(Object.keys(DISPATCH_JOB_TYPE_BY_KIND).sort(), [...DELIVERY_UNIT_KINDS].sort());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("KEY IDENTITY: (kind, instanceId) names exactly the S2 unit_id the identity helpers build", () => {
|
|
109
|
+
assertEquals(deliveryUnitKey("feature", "owner/repo#42"), featureUnitId("owner/repo#42"));
|
|
110
|
+
assertEquals(deliveryUnitKey("epic", "plan-key-1"), epicUnitId("plan-key-1"));
|
|
111
|
+
assertEquals(deliveryUnitKey("plan-task", "plan-key-1#3"), planTaskUnitId("plan-key-1", 3));
|
|
112
|
+
assertEquals(deliveryUnitKey("delivery-graph", "run-key-1"), deliveryGraphUnitId("run-key-1"));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("GATE PARITY: the door dispatches iff pending, and matches isDeliveryUnitSettled for every status", () => {
|
|
116
|
+
for (const status of DELIVERY_UNIT_STATUSES as readonly DeliveryUnitStatus[]) {
|
|
117
|
+
const dispatchStatus = dispatchStatusForDelivery(status);
|
|
118
|
+
const gate = dispatchGate(dispatchStatus);
|
|
119
|
+
// Only the pre-dispatch canonical `requested` (⇒ pending) launches a fresh executor.
|
|
120
|
+
assertEquals(gate.dispatch, status === "requested", `dispatch decision for canonical ${status}`);
|
|
121
|
+
if (isDeliveryUnitSettled(status)) {
|
|
122
|
+
assertEquals(gate.reason, "settled", `${status} is settled-for-re-dispatch ⇒ short-circuit`);
|
|
123
|
+
} else if (status === "requested") {
|
|
124
|
+
assertEquals(gate.reason, "pending");
|
|
125
|
+
} else {
|
|
126
|
+
assertEquals(gate.reason, "in-flight", `${status} has a live executor ⇒ at-most-once skip`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("GATE PARITY: a unit the aggregate never recorded short-circuits as unknown-unit", () => {
|
|
132
|
+
assertEquals(dispatchGate(null), { dispatch: false, reason: "unknown-unit" });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("BINDING: exactly one new delivery_units binding drives the door", () => {
|
|
136
|
+
const du = manifestBindings().filter((b) => b.table === DELIVERY_UNITS_TABLE);
|
|
137
|
+
assertEquals(du.length, 1, "there must be exactly one delivery_units instanceTracking binding");
|
|
138
|
+
const b = du[0];
|
|
139
|
+
assertEquals(b.keyField, "process_key");
|
|
140
|
+
assertEquals(b.statusField, "dispatch_status");
|
|
141
|
+
assertEquals(b.onTerminated.set, { dispatch_status: "settled" });
|
|
142
|
+
// A settled unit is terminal/resting — listing it active would let the reconciler clobber it.
|
|
143
|
+
assert(!b.activeStatuses?.includes("settled"), "settled must not be an active dispatch status");
|
|
144
|
+
// `pending` is NOT instance-tracked: it has no engine instance yet, and some pending rows (e.g.
|
|
145
|
+
// kind="plan-task") carry a NULL process_key, so a process_key-keyed reconciler would treat them as
|
|
146
|
+
// "vanished" and wrongly apply onTerminated. Only the instance-backed `dispatched` is tracked —
|
|
147
|
+
// mirroring the delivery_graph_runs binding's invariant.
|
|
148
|
+
assert(!b.activeStatuses?.includes("pending"), "pending must not be an active dispatch status");
|
|
149
|
+
assertEquals(b.activeStatuses, ["dispatched"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("BINDING: the delivery_units__tracking VIEW provisions against the migrated schema", () => {
|
|
153
|
+
const db = new DatabaseSync(":memory:");
|
|
154
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
155
|
+
applyMigrationSet(db, readMigrationSetFromDisk());
|
|
156
|
+
// The base aggregate exists with the door's status column; the derived tracking VIEW the binding
|
|
157
|
+
// provisions (delivery_units__tracking) is created by urban at gen/deploy time, not migration time,
|
|
158
|
+
// so here we assert the base columns the binding names are present and typed for the door to read.
|
|
159
|
+
const cols = db.prepare("PRAGMA table_info(delivery_units)").all() as { name: string }[];
|
|
160
|
+
const names = new Set(cols.map((c) => c.name));
|
|
161
|
+
assert(names.has("process_key"), "keyField process_key must exist on delivery_units");
|
|
162
|
+
assert(names.has("dispatch_status"), "statusField dispatch_status must exist on delivery_units");
|
|
163
|
+
db.close();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// A fake DataLayer whose `delivery_units` store is keyed on `unit_id` and whose `delivery_units__tracking`
|
|
167
|
+
// derived VIEW is served by `withTrackingViews` (projecting `derived_status := seeded ?? base.dispatch_status`,
|
|
168
|
+
// exactly the ADR-0065 fall-through the real runtime computes). This lets a test seed a base row whose
|
|
169
|
+
// `derived_status` DIVERGES from its base `dispatch_status` — the terminated-executor case the door's
|
|
170
|
+
// derived-view read exists to fold in — without a live engine.
|
|
171
|
+
function memData(): DataLayer {
|
|
172
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only fake over dynamic row shapes.
|
|
173
|
+
const stores: Record<string, any[]> = {};
|
|
174
|
+
function tbl(name: string, pk = "unit_id") {
|
|
175
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only dynamic row store.
|
|
176
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
177
|
+
return {
|
|
178
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only dynamic row.
|
|
179
|
+
async insert(row: any) {
|
|
180
|
+
rows.push({ ...row });
|
|
181
|
+
return row[pk];
|
|
182
|
+
},
|
|
183
|
+
async get(key: unknown) {
|
|
184
|
+
return rows.find((r) => r[pk] === key);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function seedUnit(
|
|
192
|
+
data: DataLayer,
|
|
193
|
+
unit_id: string,
|
|
194
|
+
dispatch_status: string | null,
|
|
195
|
+
derived_status?: string,
|
|
196
|
+
) {
|
|
197
|
+
const row: Record<string, unknown> = { unit_id, dispatch_status };
|
|
198
|
+
if (derived_status !== undefined) row.derived_status = derived_status;
|
|
199
|
+
await data.table(DELIVERY_UNITS_TABLE, "unit_id").insert(row);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test("DOOR: resolveDeliveryDispatch dispatches a pending unit off the derived VIEW", async () => {
|
|
203
|
+
const data = memData();
|
|
204
|
+
await seedUnit(data, deliveryUnitKey("feature", "owner/repo#7"), "pending");
|
|
205
|
+
const decision = await resolveDeliveryDispatch(data, "feature", "owner/repo#7");
|
|
206
|
+
assertEquals(decision, {
|
|
207
|
+
unitId: "feature:owner/repo#7",
|
|
208
|
+
kind: "feature",
|
|
209
|
+
dispatch: true,
|
|
210
|
+
jobType: "senior:feature",
|
|
211
|
+
reason: "pending",
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("DOOR: resolveDeliveryDispatch skips a still-dispatched unit as in-flight (at-most-once)", async () => {
|
|
216
|
+
const data = memData();
|
|
217
|
+
await seedUnit(data, deliveryUnitKey("plan-task", "plan-1#3"), "dispatched");
|
|
218
|
+
const decision = await resolveDeliveryDispatch(data, "plan-task", "plan-1#3");
|
|
219
|
+
assertEquals(decision.dispatch, false);
|
|
220
|
+
assertEquals(decision.reason, "in-flight");
|
|
221
|
+
assertEquals(decision.jobType, null);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("DOOR: resolveDeliveryDispatch reads derived_status, so a terminated executor is settled not stranded dispatched", async () => {
|
|
225
|
+
// The base row still reads `dispatched` (the worker-owned transient the reconciler no longer
|
|
226
|
+
// overwrites), but the executor terminated out-of-band so the __tracking VIEW's `derived_status`
|
|
227
|
+
// is `settled` (the binding's onTerminated edge). Reading the base column would strand the unit as
|
|
228
|
+
// `in-flight`; the door MUST read `derived_status` and report `settled`. This is the regression the
|
|
229
|
+
// Copilot review flagged — a guard against reading the base table/statusField instead of the view.
|
|
230
|
+
const data = memData();
|
|
231
|
+
await seedUnit(data, deliveryUnitKey("feature", "owner/repo#9"), "dispatched", "settled");
|
|
232
|
+
const decision = await resolveDeliveryDispatch(data, "feature", "owner/repo#9");
|
|
233
|
+
assertEquals(decision.reason, "settled", "derived terminal status must win over the base dispatched");
|
|
234
|
+
assertEquals(decision.dispatch, false);
|
|
235
|
+
assertEquals(decision.jobType, null);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("DOOR: resolveDeliveryDispatch refuses to launch a unit the aggregate never recorded", async () => {
|
|
239
|
+
const data = memData();
|
|
240
|
+
const decision = await resolveDeliveryDispatch(data, "feature", "owner/repo#404");
|
|
241
|
+
assertEquals(decision, {
|
|
242
|
+
unitId: "feature:owner/repo#404",
|
|
243
|
+
kind: "feature",
|
|
244
|
+
dispatch: false,
|
|
245
|
+
jobType: null,
|
|
246
|
+
reason: "unknown-unit",
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// ADR 0006 slice S3 (#590) — the SINGLE dispatch door, keyed on `(kind, instanceId)`.
|
|
2
|
+
//
|
|
3
|
+
// Before S3 the fleet had THREE parallel dispatch paths — one per delivery-unit REPRESENTATION
|
|
4
|
+
// (`feature` via feature.bpmn, `epic`/`plan-task` via plan-fanout.bpmn, `delivery-graph` via the
|
|
5
|
+
// engine-native runner) — each choosing its own `senior:*` implementation job and each gating
|
|
6
|
+
// re-dispatch off its own bespoke status union. ADR 0006 §2 collapses those onto ONE door that reads
|
|
7
|
+
// the aggregate's `delivery_units.dispatch_status` and dispatches on the two universal facts a unit
|
|
8
|
+
// carries — its `kind` and its `instanceId` — instead of three representation-specific launchers.
|
|
9
|
+
//
|
|
10
|
+
// What this door owns:
|
|
11
|
+
// 1. The `(kind, instanceId) → unit_id` key derivation (the aggregate's universal identity; the
|
|
12
|
+
// unit_id IS `<kind>:<instanceId>`, so the door's two args ARE the delivery unit's key).
|
|
13
|
+
// 2. The kind → STABLE `senior:*` dispatch-target verb map. Per #464 ("What survives" #3) the
|
|
14
|
+
// `senior:*` names are DISPATCH TARGETS, not implementations — they stay the stable verbs the
|
|
15
|
+
// deployed fleet already answers (`senior:feature`, `senior:plan`), so collapsing the doors never
|
|
16
|
+
// renames a job type.
|
|
17
|
+
// 3. The single re-dispatch gate, read straight off `dispatch_status` (the S2 lifecycle derived from
|
|
18
|
+
// the S1 canonical union): dispatch ONLY when `pending`; a `dispatched` unit has a live executor
|
|
19
|
+
// (at-most-once) and a `settled` unit already reached a terminal/resting outcome — both
|
|
20
|
+
// short-circuit. This is exactly the `isDeliveryUnitSettled` re-dispatch semantics S1/S2 defined,
|
|
21
|
+
// so the one door matches every pre-collapse launcher's short-circuit without re-deriving it.
|
|
22
|
+
//
|
|
23
|
+
// The active/tracking half is wired in `nano.app.json`: a single `delivery_units` `instanceTracking`
|
|
24
|
+
// binding (keyField `process_key`, statusField `dispatch_status`) is ADDED as the SOURCE the door is
|
|
25
|
+
// driven by — `deliveryUnitActiveDispatchStatuses()` reads that one binding, so the door and the
|
|
26
|
+
// framework reconciler can never drift on "what counts as in-flight". The legacy per-representation
|
|
27
|
+
// bindings (`feature_runs`, `plans`, `delivery_graph_runs`, …) are retained in this slice and retired
|
|
28
|
+
// onto this single binding in the later contract phase, not by this diff.
|
|
29
|
+
//
|
|
30
|
+
// Only `dispatched` is instance-tracked (an executor/engine instance backs it): a `pending` unit has
|
|
31
|
+
// no instance yet — and some `pending` rows (e.g. `kind="plan-task"`) carry a NULL `process_key` — so
|
|
32
|
+
// tracking `pending` would make the `process_key`-keyed reconciler treat those rows as "vanished" and
|
|
33
|
+
// wrongly apply `onTerminated`. `settled` is terminal/resting. This mirrors the `delivery_graph_runs`
|
|
34
|
+
// binding's invariant (only instance-backed statuses are instance-tracked).
|
|
35
|
+
|
|
36
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
37
|
+
import type { DeliveryUnitDispatchStatus, DeliveryUnitKind } from "./deliveryUnit.ts";
|
|
38
|
+
import { activeStatusesFor, derivedTrackingTable } from "./instanceTracking.ts";
|
|
39
|
+
|
|
40
|
+
/** The base table the single dispatch door is keyed on — the S2 aggregate. */
|
|
41
|
+
export const DELIVERY_UNITS_TABLE = "delivery_units";
|
|
42
|
+
|
|
43
|
+
/** Narrow a raw `derived_status` string to the closed dispatch-status domain (no `as`). Any value
|
|
44
|
+
* outside the domain — including a missing row / NULL — resolves to `null` (⇒ `unknown-unit`). */
|
|
45
|
+
function asDispatchStatus(value: string | null | undefined): DeliveryUnitDispatchStatus | null {
|
|
46
|
+
return value === "pending" || value === "dispatched" || value === "settled" ? value : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The stable `senior:*` dispatch-target verb each delivery-unit KIND dispatches to — the collapse of
|
|
51
|
+
* the per-representation launchers onto one map (#464 "What survives" #3). The verbs are unchanged
|
|
52
|
+
* from the pre-collapse models (`app/deliveryDispatch.test.ts` pins them against the deployed BPMN):
|
|
53
|
+
* - `feature` / `plan-task` — a single-issue implementation ⇒ `senior:feature` (feature.bpmn's
|
|
54
|
+
* implement task and plan-fanout.bpmn's per-slice implement task both dispatch this today).
|
|
55
|
+
* - `epic` — decomposed into a wave of slices by the planner ⇒ `senior:plan` (plan-fanout.bpmn's
|
|
56
|
+
* decomposition task).
|
|
57
|
+
* - `bugfix` / `chore` — reserved §2 implementation units with no legacy table yet; they implement an
|
|
58
|
+
* issue like a feature ⇒ `senior:feature`.
|
|
59
|
+
* - `delivery-graph` — dispatched by the engine-native runner (`app/deliveryRunner.ts`), NOT a single
|
|
60
|
+
* agent verb: every node in the graph carries its OWN `jobType`, so the unit has no single dispatch
|
|
61
|
+
* target. `null` records that the runner, not this verb map, launches a delivery-graph unit.
|
|
62
|
+
*/
|
|
63
|
+
export const DISPATCH_JOB_TYPE_BY_KIND: Readonly<Record<DeliveryUnitKind, string | null>> = {
|
|
64
|
+
feature: "senior:feature",
|
|
65
|
+
"plan-task": "senior:feature",
|
|
66
|
+
epic: "senior:plan",
|
|
67
|
+
bugfix: "senior:feature",
|
|
68
|
+
chore: "senior:feature",
|
|
69
|
+
"delivery-graph": null,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The dispatch-target verb for a kind, or `null` for a runner-launched (`delivery-graph`) unit. */
|
|
73
|
+
export function dispatchJobTypeForKind(kind: DeliveryUnitKind): string | null {
|
|
74
|
+
return DISPATCH_JOB_TYPE_BY_KIND[kind];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The universal `unit_id` a `(kind, instanceId)` pair names — `<kind>:<instanceId>`. The S2 identity
|
|
79
|
+
* helpers (`featureUnitId` = `feature:<key>`, `epicUnitId` = `epic:<key>`, `planTaskUnitId` =
|
|
80
|
+
* `plan-task:<key>#<idx>`, `deliveryGraphUnitId` = `delivery-graph:<runKey>`) are all exactly this
|
|
81
|
+
* shape, so the door's two args ARE the aggregate key — no per-representation key builder survives.
|
|
82
|
+
*/
|
|
83
|
+
export function deliveryUnitKey(kind: DeliveryUnitKind, instanceId: string): string {
|
|
84
|
+
return `${kind}:${instanceId}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The non-`settled` dispatch statuses the single `delivery_units` binding declares in-flight, read
|
|
88
|
+
* from nano.app.json (the one source of truth). The door never hard-codes this set — it derives from
|
|
89
|
+
* the same binding the framework reconciler polls, so "in-flight" can't drift between the two. */
|
|
90
|
+
export function deliveryUnitActiveDispatchStatuses(): readonly string[] {
|
|
91
|
+
return activeStatusesFor(DELIVERY_UNITS_TABLE);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Why the single door did or did not dispatch — a closed reason set the caller can log/route on. */
|
|
95
|
+
export type DispatchReason = "pending" | "in-flight" | "settled" | "unknown-unit";
|
|
96
|
+
|
|
97
|
+
/** The single dispatch door's decision for one `(kind, instanceId)`. `dispatch` is the gate; `jobType`
|
|
98
|
+
* is the stable `senior:*` target when a fresh dispatch is due (and the kind has an agent verb). */
|
|
99
|
+
export interface DispatchDecision {
|
|
100
|
+
unitId: string;
|
|
101
|
+
kind: DeliveryUnitKind;
|
|
102
|
+
dispatch: boolean;
|
|
103
|
+
jobType: string | null;
|
|
104
|
+
reason: DispatchReason;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The single re-dispatch gate, applied to a `dispatch_status`. This is the ONE short-circuit rule the
|
|
109
|
+
* three pre-collapse launchers each re-implemented against their own union:
|
|
110
|
+
* - `pending` ⇒ dispatch (created, no executor yet — the canonical `requested`).
|
|
111
|
+
* - `dispatched` ⇒ skip, a live executor already holds the unit (at-most-once).
|
|
112
|
+
* - `settled` ⇒ skip, the prior run reached a terminal / live-PR resting outcome
|
|
113
|
+
* (`isDeliveryUnitSettled`) — re-dispatch short-circuits onto it.
|
|
114
|
+
* - missing row ⇒ skip (`unknown-unit`): the door refuses to launch a unit the aggregate never saw
|
|
115
|
+
* rather than dispatch blind.
|
|
116
|
+
*/
|
|
117
|
+
export function dispatchGate(status: DeliveryUnitDispatchStatus | null): { dispatch: boolean; reason: DispatchReason } {
|
|
118
|
+
if (status === null) return { dispatch: false, reason: "unknown-unit" };
|
|
119
|
+
if (status === "pending") return { dispatch: true, reason: "pending" };
|
|
120
|
+
if (status === "dispatched") return { dispatch: false, reason: "in-flight" };
|
|
121
|
+
return { dispatch: false, reason: "settled" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the single dispatch door for a `(kind, instanceId)`: read the unit's EFFECTIVE dispatch
|
|
126
|
+
* status off the ADR-0065 `delivery_units__tracking` VIEW's `derived_status` (NOT the base
|
|
127
|
+
* `dispatch_status`, which the reconciler no longer writes) and apply {@link dispatchGate}, returning
|
|
128
|
+
* the stable `senior:*` target verb when a fresh dispatch is due. Reading `derived_status` folds in the
|
|
129
|
+
* binding's `onTerminated` edge, so a unit whose executor terminated out-of-band is seen `settled` and
|
|
130
|
+
* re-dispatchable rather than stranded `dispatched`. A row the aggregate never recorded resolves to
|
|
131
|
+
* `unknown-unit` (no dispatch) — the door never launches blind.
|
|
132
|
+
*/
|
|
133
|
+
export async function resolveDeliveryDispatch(
|
|
134
|
+
data: DataLayer,
|
|
135
|
+
kind: DeliveryUnitKind,
|
|
136
|
+
instanceId: string,
|
|
137
|
+
): Promise<DispatchDecision> {
|
|
138
|
+
const unitId = deliveryUnitKey(kind, instanceId);
|
|
139
|
+
const view = derivedTrackingTable<{ unit_id: string; derived_status: string | null }>(
|
|
140
|
+
data,
|
|
141
|
+
DELIVERY_UNITS_TABLE,
|
|
142
|
+
"unit_id",
|
|
143
|
+
);
|
|
144
|
+
const unit = await view.get(unitId);
|
|
145
|
+
const { dispatch, reason } = dispatchGate(asDispatchStatus(unit?.derived_status));
|
|
146
|
+
return {
|
|
147
|
+
unitId,
|
|
148
|
+
kind,
|
|
149
|
+
dispatch,
|
|
150
|
+
jobType: dispatch ? dispatchJobTypeForKind(kind) : null,
|
|
151
|
+
reason,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/nano.app.json
CHANGED
|
@@ -95,6 +95,20 @@
|
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
97
|
"pollMs": 5000
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"table": "delivery_units",
|
|
101
|
+
"keyField": "process_key",
|
|
102
|
+
"statusField": "dispatch_status",
|
|
103
|
+
"activeStatuses": [
|
|
104
|
+
"dispatched"
|
|
105
|
+
],
|
|
106
|
+
"onTerminated": {
|
|
107
|
+
"set": {
|
|
108
|
+
"dispatch_status": "settled"
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"pollMs": 5000
|
|
98
112
|
}
|
|
99
113
|
],
|
|
100
114
|
"workers": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.156.0",
|
|
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",
|