@nanobpm/nano-workforce 0.176.0 → 0.177.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 +12 -0
- package/app/agentic/claim-registry.test.ts +155 -0
- package/app/agentic/claim-registry.ts +194 -0
- package/app/agentic/families/claim.family.test.ts +196 -0
- package/app/agentic/families/claim.family.ts +148 -0
- package/app/instanceTracking.ts +43 -0
- package/openapi.yaml +11 -5
- package/operations/cancelInstance.test.ts +157 -0
- package/operations/cancelInstance.ts +99 -13
- package/operations/getAgenticSupply.test.ts +32 -3
- package/operations/getAgenticSupply.ts +24 -15
- package/package.json +2 -2
- package/test/agentic-e2e.test.ts +11 -3
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// nano-workforce — the agentic-channel `claim` / `release` job-ownership family (#713).
|
|
2
|
+
//
|
|
3
|
+
// H0 (#143) seam plug-in: ONE new file under `app/agentic/families/`, discovered by the loader's
|
|
4
|
+
// `*.family.ts` convention and mounted by the seam — it never edits `main.ts`, `drainAndExit`, or any
|
|
5
|
+
// shared boot line. It owns the `claim` (wire code 8) and `release` (wire code 9) message families —
|
|
6
|
+
// the explicit job-ownership frames nano-ide#542 appended to `@nanobpm/agentic` — attaching each
|
|
7
|
+
// handler through the hub's `registerFamilyHandler` seam (one family, one owning module), never a
|
|
8
|
+
// shared dispatch switch.
|
|
9
|
+
//
|
|
10
|
+
// What it gives the fleet: a first-class {@link ClaimRegistry} (`instance → set<jobKey>`) that becomes
|
|
11
|
+
// the AUTHORITATIVE source the supply snapshot reads for `jobKeys` — replacing the fragile
|
|
12
|
+
// relay-derived visibility. Each frame carries its OWNING `instance` EXPLICITLY, so attribution reads
|
|
13
|
+
// the frame, NOT the connection id (`conn.id`); that is what lets one per-host supervisor multiplex N
|
|
14
|
+
// distinct workers' ownership over a single connection. On a reconnect the supervisor re-`register`s
|
|
15
|
+
// every worker and re-`claim`s every active jobKey, and the (idempotent) claim handler rebuilds the
|
|
16
|
+
// registry from that resync.
|
|
17
|
+
//
|
|
18
|
+
// Liveness: a worker holding a claim reads "working" even with ZERO transcript — visibility no longer
|
|
19
|
+
// depends on terminal bytes landing/correlating. A bounded-memory maintenance tick reconciles the
|
|
20
|
+
// claim registry against the live presence set so a departed supervisor's claims are reclaimed (the
|
|
21
|
+
// `release` frame is the primary clear; this is the safety net for an unclean drop).
|
|
22
|
+
//
|
|
23
|
+
// Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
|
|
24
|
+
// is untouched; ADVISORY — the registry is a read-only visibility source and NEVER gates a BPMN
|
|
25
|
+
// sequence flow.
|
|
26
|
+
import type { HubConnection } from "@nanobpm/agentic/channel";
|
|
27
|
+
import { type Frame, validatePayload } from "@nanobpm/agentic/protocol";
|
|
28
|
+
import { ClaimRegistry, setCurrentClaimRegistry } from "../claim-registry.ts";
|
|
29
|
+
import type { AgenticContext, AgenticFamily } from "../registry.ts";
|
|
30
|
+
import { currentPresenceRegistry } from "./presence.family.ts";
|
|
31
|
+
|
|
32
|
+
/** The message-family names this module owns (the two ownership frames). */
|
|
33
|
+
export const CLAIM_FAMILY = "claim";
|
|
34
|
+
export const RELEASE_FAMILY = "release";
|
|
35
|
+
|
|
36
|
+
/** The reconcile tick runs at a third of the presence TTL — matching the presence-maintenance cadence. */
|
|
37
|
+
const SWEEP_DIVISOR = 3;
|
|
38
|
+
/** Fallback reconcile cadence when no presence registry is mounted to source a TTL. */
|
|
39
|
+
const DEFAULT_RECONCILE_MS = 10_000;
|
|
40
|
+
|
|
41
|
+
/** Read a string property from an unknown frame payload, or undefined when absent / non-string. */
|
|
42
|
+
function readString(value: unknown, key: string): string | undefined {
|
|
43
|
+
if (!value || typeof value !== "object") return undefined;
|
|
44
|
+
if (!Object.hasOwn(value, key)) return undefined;
|
|
45
|
+
const field = Object.getOwnPropertyDescriptor(value, key)?.value;
|
|
46
|
+
return typeof field === "string" ? field : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface MountState {
|
|
50
|
+
readonly registry: ClaimRegistry;
|
|
51
|
+
timer: ReturnType<typeof setTimeout> | undefined;
|
|
52
|
+
stopped: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let state: MountState | undefined;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The `claim` / `release` family module. `mount` installs a fresh {@link ClaimRegistry} as the
|
|
59
|
+
* process-wide singleton, attaches the two frame handlers via the S1 seam, and starts ONE bounded
|
|
60
|
+
* reconcile tick. `teardown` stops the tick, detaches nothing (the hub owns handler lifetime for the
|
|
61
|
+
* remount-guarded test path) and clears the singleton.
|
|
62
|
+
*/
|
|
63
|
+
export const family: AgenticFamily = {
|
|
64
|
+
name: CLAIM_FAMILY,
|
|
65
|
+
|
|
66
|
+
mount(ctx: AgenticContext): void {
|
|
67
|
+
const registry = new ClaimRegistry();
|
|
68
|
+
setCurrentClaimRegistry(registry);
|
|
69
|
+
|
|
70
|
+
// `claim` opens the ownership window; `release` closes it. Attribution reads the frame's EXPLICIT
|
|
71
|
+
// `instance` — never `conn.id` — so one connection can carry many instances' ownership frames. A
|
|
72
|
+
// malformed payload is rejected before it touches the registry (advisory: logged, connection
|
|
73
|
+
// kept). Both mutations are idempotent, matching the wire contract.
|
|
74
|
+
ctx.hub.registerFamilyHandler(CLAIM_FAMILY, (frame: Frame, _conn: HubConnection) => {
|
|
75
|
+
const result = validatePayload(CLAIM_FAMILY, frame.payload);
|
|
76
|
+
if (!result.ok) {
|
|
77
|
+
ctx.log.warn("agentic claim: malformed payload", { errors: result.errors.map((e) => e.code) });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const instance = readString(frame.payload, "instance");
|
|
81
|
+
const jobKey = readString(frame.payload, "jobKey");
|
|
82
|
+
if (!instance || !jobKey) return;
|
|
83
|
+
registry.claim(instance, jobKey);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
ctx.hub.registerFamilyHandler(RELEASE_FAMILY, (frame: Frame, _conn: HubConnection) => {
|
|
87
|
+
const result = validatePayload(RELEASE_FAMILY, frame.payload);
|
|
88
|
+
if (!result.ok) {
|
|
89
|
+
ctx.log.warn("agentic release: malformed payload", { errors: result.errors.map((e) => e.code) });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const instance = readString(frame.payload, "instance");
|
|
93
|
+
const jobKey = readString(frame.payload, "jobKey");
|
|
94
|
+
if (!instance || !jobKey) return;
|
|
95
|
+
registry.release(instance, jobKey);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Bounded-memory reconcile: drop claims whose owning instance no longer has a presence row (a
|
|
99
|
+
// dropped supervisor / aged-out worker). BOTH the drop-set AND the cadence are recomputed per
|
|
100
|
+
// tick from the live presence registry via a self-rescheduling timer, so the reconcile is truly
|
|
101
|
+
// independent of family mount order: whether presence mounts before or after this family, once it
|
|
102
|
+
// is present each tick reclaims absent instances' claims AND adjusts its cadence to the real TTL
|
|
103
|
+
// (a fixed-at-mount interval would stay pinned to the fallback cadence when claim mounts first).
|
|
104
|
+
// Advisory — a fault is logged, never thrown, and the tick never keeps the process alive on its
|
|
105
|
+
// own.
|
|
106
|
+
const reconcileMs = (): number => {
|
|
107
|
+
const presenceTtl = currentPresenceRegistry()?.ttlMs;
|
|
108
|
+
return Math.max(1, Math.floor((presenceTtl ?? DEFAULT_RECONCILE_MS) / SWEEP_DIVISOR));
|
|
109
|
+
};
|
|
110
|
+
const schedule = (): void => {
|
|
111
|
+
if (!state || state.stopped) return;
|
|
112
|
+
const timer = setTimeout(tick, reconcileMs());
|
|
113
|
+
timer.unref?.();
|
|
114
|
+
state.timer = timer;
|
|
115
|
+
};
|
|
116
|
+
const tick = () => {
|
|
117
|
+
try {
|
|
118
|
+
const presence = currentPresenceRegistry();
|
|
119
|
+
if (presence) {
|
|
120
|
+
const present = new Set(presence.registeredWorkers().map((w) => w.instance));
|
|
121
|
+
const released = registry.reconcile(present);
|
|
122
|
+
if (released.length > 0) {
|
|
123
|
+
ctx.log.info("agentic claim reconcile released absent instances", { released: released.length });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// else: no presence source → keep claims until one mounts (resync repopulates)
|
|
127
|
+
} catch (err) {
|
|
128
|
+
ctx.log.warn("agentic claim reconcile failed", { err: String(err) });
|
|
129
|
+
}
|
|
130
|
+
schedule();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
state = { registry, timer: undefined, stopped: false };
|
|
134
|
+
schedule();
|
|
135
|
+
ctx.log.info("agentic claim family mounted", { families: [CLAIM_FAMILY, RELEASE_FAMILY] });
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
teardown(): void {
|
|
139
|
+
if (state) {
|
|
140
|
+
state.stopped = true;
|
|
141
|
+
if (state.timer !== undefined) clearTimeout(state.timer);
|
|
142
|
+
}
|
|
143
|
+
state = undefined;
|
|
144
|
+
setCurrentClaimRegistry(undefined);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export default family;
|
package/app/instanceTracking.ts
CHANGED
|
@@ -112,3 +112,46 @@ export function derivedTrackingTable<T extends object>(
|
|
|
112
112
|
): Table<T> {
|
|
113
113
|
return data.table<T>(trackingTargetFor(table).view, pk);
|
|
114
114
|
}
|
|
115
|
+
|
|
116
|
+
/** A tracked record resolved from an engine process-instance key: which binding's base table owns it,
|
|
117
|
+
* its ADR-0065 `derived_status` (the terminal edge folded over the base transient), and whether that
|
|
118
|
+
* derived status is still ACTIVE (in the binding's `activeStatuses`). `active === false` means the
|
|
119
|
+
* record has LEFT Active — it is at a terminal edge (`abandoned`/`failed`/`reviewed`/…) and
|
|
120
|
+
* resubmittable. */
|
|
121
|
+
export interface ResolvedTrackedInstance {
|
|
122
|
+
table: string;
|
|
123
|
+
keyField: string;
|
|
124
|
+
derivedStatus: string;
|
|
125
|
+
active: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Resolve which tracked record (if any) an engine process-instance key belongs to, scanning every
|
|
129
|
+
* engine-backed binding and matching the key against each binding's `keyField` on its DERIVED
|
|
130
|
+
* tracking VIEW — so the answer reflects the ADR-0065 terminal edge, not the frozen base transient.
|
|
131
|
+
* This is the cancel door's record-type resolver (issue #705): it lets the door route a key to the
|
|
132
|
+
* correct aggregate (PR vs plan vs feature run vs …) and report a TRUTHFUL reconciled result, and
|
|
133
|
+
* distinguishes a key that maps to a tracked record (reconcile it) from one that maps to NONE (a
|
|
134
|
+
* clean no-op, never a silent success). Returns `undefined` when no binding has a row for the key.
|
|
135
|
+
* Reads only — the terminal transition itself is DERIVED (recorded through the shared
|
|
136
|
+
* `reconcileTerminatedKey` seam), never hand-written here. */
|
|
137
|
+
export async function resolveTrackedInstance(
|
|
138
|
+
data: DataLayer,
|
|
139
|
+
processInstanceKey: string,
|
|
140
|
+
): Promise<ResolvedTrackedInstance | undefined> {
|
|
141
|
+
for (const binding of engineBackedBindings()) {
|
|
142
|
+
const table = binding.table;
|
|
143
|
+
const keyField = keyFieldFor(table);
|
|
144
|
+
const row = await derivedTrackingTable<Record<string, unknown>>(data, table, keyField).findOne({
|
|
145
|
+
[keyField]: processInstanceKey,
|
|
146
|
+
});
|
|
147
|
+
if (!row) continue;
|
|
148
|
+
const statusColumn = trackingTargetFor(table).statusColumn;
|
|
149
|
+
const derivedStatus = String(row[statusColumn] ?? "");
|
|
150
|
+
// Classify against the manifest-enforced active set (throws on a binding whose `activeStatuses`
|
|
151
|
+
// is missing/empty) rather than a silent `?? []` — an empty fallback would misclassify EVERY
|
|
152
|
+
// resolved record as terminal (`active:false`) and let the cancel door report a false success.
|
|
153
|
+
const active = activeStatusesFor(table).some((s) => s === derivedStatus);
|
|
154
|
+
return { table, keyField, derivedStatus, active };
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
package/openapi.yaml
CHANGED
|
@@ -3188,20 +3188,20 @@ components:
|
|
|
3188
3188
|
properties:
|
|
3189
3189
|
ok:
|
|
3190
3190
|
type: boolean
|
|
3191
|
-
description: True when the engine confirmed the instance is terminated (the record is now — or derives as — `abandoned`). False ⇒ the engine did NOT stop the instance (surfaced as a 502).
|
|
3191
|
+
description: True when the engine confirmed the instance is terminated AND its tracked record reconciled to a terminal edge (the record is now — or derives as — `abandoned`). False ⇒ either the engine did NOT stop the instance, or it stopped it but the tracked record did not settle to a terminal edge (both surfaced as a 502), or no tracked record owns the key (404). NOTE — record reconciliation is only verified when the deployment has a readable default source; in a no-default-source deployment (e.g. `mount.data:false`) there is no derived view to reconcile against, so `ok:true` reflects ENGINE TERMINATION ONLY (the primitive-only path) and does not by itself guarantee a tracked record reconciled.
|
|
3192
3192
|
processInstanceKey:
|
|
3193
3193
|
type: string
|
|
3194
3194
|
description: The cancelled instance key, echoed back.
|
|
3195
3195
|
state:
|
|
3196
3196
|
type: string
|
|
3197
3197
|
enum: [ACTIVE, COMPLETED, TERMINATED, gone]
|
|
3198
|
-
description: The instance state read back from the engine
|
|
3198
|
+
description: The instance state after the cancel attempt. For a tracked key, this is read back from the engine — `ACTIVE`/`COMPLETED`/`TERMINATED` reflect that post-cancel engine read. NOTE — this readback can LAG — a committed cancel is acknowledged asynchronously, so the engine may still report `ACTIVE` for a short window even though the cancel has been accepted. Therefore `ok:true` can coincide with `state:ACTIVE` (see the "accepted cancel whose read model lags at ACTIVE is still trusted → 200 ok" case); do NOT treat `ok:true` + `state:ACTIVE` as contradictory — `ok`/`reconciled` are the authoritative signal that the cancel took, not `state`. `gone` ⇒ EITHER the engine has no record of the key (already cleaned up / never existed) OR no tracked record owns the key (the 404 no-op, in which case the engine is never contacted, so `gone` is not an engine read).
|
|
3199
3199
|
reconciled:
|
|
3200
3200
|
type: integer
|
|
3201
|
-
description: 1 when the
|
|
3201
|
+
description: 1 when the tracked record's derived status has left its active set (become terminal → the tracked PR/plan/feature-run row derives as `abandoned`; for a PR that additionally means it drops out of `listActivePrs`, which lists PRs only — plans and feature runs are not surfaced there), else 0. NOTE — with no readable default source there is no derived record to re-read, so this carries the primitive's own reconciliation count rather than a record-derived terminal edge.
|
|
3202
3202
|
error:
|
|
3203
3203
|
type: string
|
|
3204
|
-
description: Present
|
|
3204
|
+
description: Present on a non-terminal failure — the reason the cancel did not take, the tracked record was not reconciled, or no tracked record owns the key.
|
|
3205
3205
|
# ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
3206
3206
|
# MCP tool-schema convention (epic nano-workforce#605, S0 — the shared invariant every later slice
|
|
3207
3207
|
# inherits). The Urban runtime projects THIS document into MCP tools (ADR 0067 — zero MCP server
|
|
@@ -3284,8 +3284,14 @@ paths:
|
|
|
3284
3284
|
application/json:
|
|
3285
3285
|
schema:
|
|
3286
3286
|
$ref: "#/components/schemas/ErrorBody"
|
|
3287
|
+
"404":
|
|
3288
|
+
description: "No tracked record owns the key (a clean no-op — the engine is never touched). Body is a CancelInstanceResult with `ok:false`, `state:\"gone\"`, `reconciled:0`."
|
|
3289
|
+
content:
|
|
3290
|
+
application/json:
|
|
3291
|
+
schema:
|
|
3292
|
+
$ref: "#/components/schemas/CancelInstanceResult"
|
|
3287
3293
|
"502":
|
|
3288
|
-
description: The engine did NOT stop the instance (the cancel was not committed
|
|
3294
|
+
description: "The cancel did not fully take: EITHER the engine did NOT stop the instance (the cancel was not committed; the run may still be live), OR the engine stopped it but the tracked record did not settle to a terminal edge (`ok:false`, `reconciled:0`) — a wedged record the caller must not read as done."
|
|
3289
3295
|
content:
|
|
3290
3296
|
application/json:
|
|
3291
3297
|
schema:
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
import { test } from "node:test";
|
|
11
11
|
import { assert, assertEquals } from "#test-assert";
|
|
12
12
|
import type { AppApi } from "@nanobpm/urban";
|
|
13
|
+
import { memBlackboardSource } from "../test/blackboardDb.ts";
|
|
13
14
|
import { noopLog } from "../test/log.ts";
|
|
15
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
14
16
|
import handler from "./cancelInstance.ts";
|
|
15
17
|
|
|
16
18
|
interface FakeEngineOpts {
|
|
@@ -136,3 +138,158 @@ test("shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, a missing/wrong s
|
|
|
136
138
|
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
137
139
|
}
|
|
138
140
|
});
|
|
141
|
+
|
|
142
|
+
// --- Record-type routing + a truthful reconciled result (issue #705) ----------------------------
|
|
143
|
+
//
|
|
144
|
+
// #667 shipped this door reconciling the pull_requests / plans aggregates, but a `processInstanceKey`
|
|
145
|
+
// belonging to a FEATURE RUN reported `reconciled:0` and left the `feature_runs` row inconsistent.
|
|
146
|
+
// These tests drive the delegate against a data layer whose derived tracking VIEWs are served off
|
|
147
|
+
// in-memory base stores (`withTrackingViews`, exactly as the feature/PR domain tests) plus a REAL
|
|
148
|
+
// SQLite `source()` so the shared primitive's absent-safe projection feed has a handle to write. They
|
|
149
|
+
// pin the delegate's OWN additions: it resolves the record type across every engine-backed binding,
|
|
150
|
+
// 404s a key that maps to none, and reports `reconciled` off the record's derived terminal edge.
|
|
151
|
+
|
|
152
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only in-memory table over dynamic row shapes.
|
|
153
|
+
function memTable(rows: any[], key: string) {
|
|
154
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only predicate over dynamic row shapes.
|
|
155
|
+
const matches = (r: any, q: any) => Object.entries(q).every(([f, v]) => r[f] === v);
|
|
156
|
+
return {
|
|
157
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only.
|
|
158
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
159
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only.
|
|
160
|
+
find: (q: any = {}) => Promise.resolve(rows.filter((r) => matches(r, q))),
|
|
161
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only.
|
|
162
|
+
findOne: (q: any = {}) => Promise.resolve(rows.find((r) => matches(r, q)) ?? null),
|
|
163
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only.
|
|
164
|
+
insert: (r: any) => {
|
|
165
|
+
rows.push(r);
|
|
166
|
+
return Promise.resolve(r);
|
|
167
|
+
},
|
|
168
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only.
|
|
169
|
+
update: (k: any, patch: any) => {
|
|
170
|
+
const r = rows.find((x) => x[key] === k);
|
|
171
|
+
if (r) Object.assign(r, patch);
|
|
172
|
+
return Promise.resolve(r);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only store map over dynamic row shapes.
|
|
178
|
+
function makeRecordApp(
|
|
179
|
+
stores: Record<string, { rows: any[]; key: string }>,
|
|
180
|
+
opts: FakeEngineOpts = {},
|
|
181
|
+
): { app: AppApi; cancelCalls: string[] } {
|
|
182
|
+
const cancelCalls: string[] = [];
|
|
183
|
+
const engine = {
|
|
184
|
+
async cancelInstance({ processInstanceKey }: { processInstanceKey: string }) {
|
|
185
|
+
cancelCalls.push(processInstanceKey);
|
|
186
|
+
if (opts.cancelThrows) throw new Error("engine refused");
|
|
187
|
+
},
|
|
188
|
+
async searchProcessInstances() {
|
|
189
|
+
return opts.readBackState ? [{ state: opts.readBackState }] : [];
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
const bb = memBlackboardSource();
|
|
193
|
+
const data = {
|
|
194
|
+
hasDefaultSource: () => true,
|
|
195
|
+
source: bb.source,
|
|
196
|
+
table: withTrackingViews((name: string, key = "id") =>
|
|
197
|
+
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
198
|
+
),
|
|
199
|
+
};
|
|
200
|
+
const app = { engine, data, log: noopLog() } as unknown as AppApi;
|
|
201
|
+
return { app, cancelCalls };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
test("a key that maps to NO tracked record → 404 no-op and never touches the engine", async () => {
|
|
205
|
+
// The record-type resolver finds no binding row for this key: a clean 404, NOT a silent
|
|
206
|
+
// reconciled:0 success — and we never terminate an instance we don't track.
|
|
207
|
+
const { app, cancelCalls } = makeRecordApp({ feature_runs: { rows: [], key: "feature_key" } });
|
|
208
|
+
const res = await call(app, { processInstanceKey: "does-not-exist" });
|
|
209
|
+
assertEquals(res.status, 404);
|
|
210
|
+
assertEquals(res.body.ok, false);
|
|
211
|
+
assertEquals(res.body.state, "gone");
|
|
212
|
+
assertEquals(res.body.reconciled, 0);
|
|
213
|
+
assert(typeof res.body.error === "string" && res.body.error.length > 0, "carries a reason");
|
|
214
|
+
assertEquals(cancelCalls.length, 0, "an untracked key never reaches the engine");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("a feature-run key whose derived record is terminal → 200 ok reconciled:1 (resubmittable)", async () => {
|
|
218
|
+
// The live repro: the engine instance is already TERMINATED, so its `feature_runs` row derives to
|
|
219
|
+
// `abandoned` (base `status` frozen at its last transient — the ADR-0065 divergence, seeded here).
|
|
220
|
+
// The shared primitive writes nothing new (already terminal), yet the delegate must report the
|
|
221
|
+
// record's REAL terminal state, not the projection-write delta of 0.
|
|
222
|
+
const { app, cancelCalls } = makeRecordApp(
|
|
223
|
+
{
|
|
224
|
+
feature_runs: {
|
|
225
|
+
rows: [
|
|
226
|
+
{
|
|
227
|
+
feature_key: "Magikcraft/nano-bpm#1099",
|
|
228
|
+
process_key: "5654",
|
|
229
|
+
status: "running",
|
|
230
|
+
derived_status: "abandoned",
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
key: "feature_key",
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{ cancelThrows: true, readBackState: "TERMINATED" },
|
|
237
|
+
);
|
|
238
|
+
const res = await call(app, { processInstanceKey: "5654" });
|
|
239
|
+
assertEquals(res.status, 200);
|
|
240
|
+
assertEquals(res.body.ok, true);
|
|
241
|
+
assertEquals(res.body.reconciled, 1, "the lagging feature_runs record is reported reconciled");
|
|
242
|
+
assertEquals(cancelCalls, ["5654"], "the door still issued the idempotent engine cancel");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("a feature-run key committed-cancelled whose record went terminal → 200 ok reconciled:1", async () => {
|
|
246
|
+
const { app } = makeRecordApp(
|
|
247
|
+
{
|
|
248
|
+
feature_runs: {
|
|
249
|
+
rows: [{ feature_key: "o/r#7", process_key: "900", status: "running", derived_status: "abandoned" }],
|
|
250
|
+
key: "feature_key",
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{ readBackState: "TERMINATED" },
|
|
254
|
+
);
|
|
255
|
+
const res = await call(app, { processInstanceKey: "900" });
|
|
256
|
+
assertEquals(res.status, 200);
|
|
257
|
+
assertEquals(res.body.ok, true);
|
|
258
|
+
assertEquals(res.body.reconciled, 1);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("engine terminates but the record stays ACTIVE → 502 ok:false (not an unqualified success)", async () => {
|
|
262
|
+
// A resolved record whose derived edge did NOT leave `activeStatuses` (e.g. a projection write that
|
|
263
|
+
// failed) must not be reported as a clean cancel: reconciled:0 and a non-ok 502.
|
|
264
|
+
const { app } = makeRecordApp(
|
|
265
|
+
{
|
|
266
|
+
feature_runs: {
|
|
267
|
+
// No seeded derived_status ⇒ the VIEW folds `derived_status := status` = "running" (still active).
|
|
268
|
+
rows: [{ feature_key: "o/r#8", process_key: "901", status: "running" }],
|
|
269
|
+
key: "feature_key",
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{ readBackState: "TERMINATED" },
|
|
273
|
+
);
|
|
274
|
+
const res = await call(app, { processInstanceKey: "901" });
|
|
275
|
+
assertEquals(res.status, 502, "a terminated instance whose record didn't reconcile is not ok:true");
|
|
276
|
+
assertEquals(res.body.ok, false);
|
|
277
|
+
assertEquals(res.body.reconciled, 0);
|
|
278
|
+
assert(typeof res.body.error === "string" && res.body.error.length > 0, "explains the un-reconciled record");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("record routing also covers PR/plan aggregates (a resolved pull_requests key reconciles)", async () => {
|
|
282
|
+
const { app } = makeRecordApp(
|
|
283
|
+
{
|
|
284
|
+
pull_requests: {
|
|
285
|
+
rows: [{ pr_key: "o/r#3", process_key: "300", status: "converging", derived_status: "abandoned" }],
|
|
286
|
+
key: "pr_key",
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
{ readBackState: "TERMINATED" },
|
|
290
|
+
);
|
|
291
|
+
const res = await call(app, { processInstanceKey: "300" });
|
|
292
|
+
assertEquals(res.status, 200);
|
|
293
|
+
assertEquals(res.body.ok, true);
|
|
294
|
+
assertEquals(res.body.reconciled, 1);
|
|
295
|
+
});
|
|
@@ -16,13 +16,29 @@
|
|
|
16
16
|
// bindings come from the app's single accessor (`engineBackedBindings`), so the set can never drift
|
|
17
17
|
// from the reconciler's registry.
|
|
18
18
|
//
|
|
19
|
+
// Record-type routing + a TRUTHFUL result (issue #705). #667 shipped this door reconciling the
|
|
20
|
+
// pull_requests / plans aggregates, but a `processInstanceKey` belonging to a FEATURE RUN reported
|
|
21
|
+
// `reconciled:0` and left the `feature_runs` row inconsistent — the run stayed wedged and resubmit
|
|
22
|
+
// returned a green "Done". The reconcile itself is binding-agnostic (the shared primitive records the
|
|
23
|
+
// terminal fact into `urban_instance_state`, and EVERY binding's derived view — feature_runs included
|
|
24
|
+
// — folds it), so the gap was in what the door RESOLVED and REPORTED:
|
|
25
|
+
// - It resolves the record type from the key across ALL engine-backed bindings
|
|
26
|
+
// (`resolveTrackedInstance`) and requires a hit: a key that maps to NO tracked record is a clean
|
|
27
|
+
// 404 no-op, not a silent `reconciled:0` success (and we never terminate an instance we don't
|
|
28
|
+
// track).
|
|
29
|
+
// - `reconciled` reflects the REAL record transition — whether the resolved record's ADR-0065
|
|
30
|
+
// `derived_status` has left its `activeStatuses` (become terminal) — NOT the primitive's
|
|
31
|
+
// projection-write delta, which reports 0 for an ALREADY-terminated instance whose record is in
|
|
32
|
+
// fact (now) terminal. So the `reconciled:0`-on-already-TERMINATED case returns a flipped record.
|
|
33
|
+
// - Terminating a run whose record could NOT be reconciled is not an unqualified `ok:true`.
|
|
34
|
+
//
|
|
19
35
|
// The runtime validates the body against openapi.yaml (`processInstanceKey` required); the optional
|
|
20
36
|
// shared-secret guard stays HERE (the runtime does not enforce OpenAPI `security`): as a MUTATING
|
|
21
37
|
// door, when NANO_PR_WEBHOOK_SECRET is set callers must present it via the x-hook-secret header —
|
|
22
38
|
// mirroring `reconcileEngineState`/`agentCompleteEscalation`.
|
|
23
39
|
|
|
24
40
|
import { cancelInstanceReconciling } from "@nanobpm/urban";
|
|
25
|
-
import { engineBackedBindings } from "../app/instanceTracking.ts";
|
|
41
|
+
import { engineBackedBindings, resolveTrackedInstance } from "../app/instanceTracking.ts";
|
|
26
42
|
import { envVar } from "../app/version.ts";
|
|
27
43
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
28
44
|
|
|
@@ -48,33 +64,103 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
|
|
|
48
64
|
return { status: 400, body: { error: "processInstanceKey is required and must be a string" } };
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
// Resolve which tracked aggregate (PR / plan / feature run / …) this key belongs to BEFORE we touch
|
|
68
|
+
// the engine (issue #705). A key that maps to NO tracked record is a clean 404 no-op — we neither
|
|
69
|
+
// cancel an instance we don't track nor report a silent `reconciled:0` success. Only meaningful with
|
|
70
|
+
// a readable default source; without one (e.g. a `mount.data:false` deployment) there is no derived
|
|
71
|
+
// view to resolve against, so fall through to the primitive-only path below (behaviour unchanged).
|
|
72
|
+
const canResolveRecord = app.data.hasDefaultSource();
|
|
73
|
+
if (canResolveRecord) {
|
|
74
|
+
const record = await resolveTrackedInstance(app.data, processInstanceKey);
|
|
75
|
+
if (!record) {
|
|
76
|
+
app.log.warn("cancelInstance: no tracked record for processInstanceKey — no-op", {
|
|
77
|
+
processInstanceKey,
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
status: 404,
|
|
81
|
+
body: {
|
|
82
|
+
ok: false,
|
|
83
|
+
processInstanceKey,
|
|
84
|
+
// No tracked record owns this key, so from the door's aggregates the run is `gone`. Carry a
|
|
85
|
+
// `state` so the body is a full CancelInstanceResult (uniform shape for clients/projection).
|
|
86
|
+
state: "gone",
|
|
87
|
+
reconciled: 0,
|
|
88
|
+
error: "no tracked record for processInstanceKey",
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
51
94
|
const result = await cancelInstanceReconciling(
|
|
52
95
|
app,
|
|
53
96
|
[...engineBackedBindings()],
|
|
54
97
|
processInstanceKey,
|
|
55
98
|
);
|
|
99
|
+
|
|
100
|
+
// A !ok result means the engine did NOT stop the instance (the cancel was not committed); the run
|
|
101
|
+
// may still be live, so surface 502 — the same non-committed-cancel signal the built-in page
|
|
102
|
+
// action returns. Report it before we assert anything about the record (nothing was reconciled).
|
|
103
|
+
if (!result.ok) {
|
|
104
|
+
app.log.warn("cancelInstance: engine did not confirm termination", {
|
|
105
|
+
processInstanceKey,
|
|
106
|
+
state: result.state,
|
|
107
|
+
error: result.error,
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
status: 502,
|
|
111
|
+
body: {
|
|
112
|
+
ok: false,
|
|
113
|
+
processInstanceKey: result.processInstanceKey,
|
|
114
|
+
state: result.state,
|
|
115
|
+
reconciled: 0,
|
|
116
|
+
...(result.error !== undefined ? { error: result.error } : {}),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The engine confirmed termination. Report a TRUTHFUL `reconciled` off the RECORD's derived terminal
|
|
122
|
+
// edge, not the primitive's projection-write delta: an already-TERMINATED instance re-fed on this
|
|
123
|
+
// pass writes nothing new (`result.reconciled === 0`) yet its record IS (now) terminal, so the
|
|
124
|
+
// delta lies. Re-read the resolved record and count it reconciled only when its ADR-0065
|
|
125
|
+
// `derived_status` has LEFT its `activeStatuses` (become terminal → resubmittable). When we cannot
|
|
126
|
+
// read the record (no default source), trust the primitive's own count.
|
|
127
|
+
let reconciled = result.reconciled;
|
|
128
|
+
let recordReconciled = true;
|
|
129
|
+
if (canResolveRecord) {
|
|
130
|
+
const after = await resolveTrackedInstance(app.data, processInstanceKey);
|
|
131
|
+
recordReconciled = !!after && !after.active;
|
|
132
|
+
reconciled = recordReconciled ? 1 : 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
56
135
|
const responseBody = {
|
|
57
|
-
ok: result.ok,
|
|
136
|
+
ok: result.ok && recordReconciled,
|
|
58
137
|
processInstanceKey: result.processInstanceKey,
|
|
59
138
|
state: result.state,
|
|
60
|
-
reconciled
|
|
139
|
+
reconciled,
|
|
61
140
|
...(result.error !== undefined ? { error: result.error } : {}),
|
|
62
141
|
};
|
|
63
|
-
|
|
64
|
-
|
|
142
|
+
|
|
143
|
+
// The engine stopped the instance but the record did not settle to a terminal edge — terminating a
|
|
144
|
+
// run whose record could not be reconciled is not an unqualified `ok:true` (issue #705). Surface it
|
|
145
|
+
// as a 502 so the caller does not read a wedged record as "done".
|
|
146
|
+
if (!recordReconciled) {
|
|
147
|
+
app.log.warn("cancelInstance: instance terminated but record not reconciled", {
|
|
65
148
|
processInstanceKey,
|
|
66
149
|
state: result.state,
|
|
67
|
-
reconciled: result.reconciled,
|
|
68
150
|
});
|
|
69
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
status: 502,
|
|
153
|
+
body: {
|
|
154
|
+
...responseBody,
|
|
155
|
+
error: responseBody.error ?? "instance terminated but tracked record was not reconciled",
|
|
156
|
+
},
|
|
157
|
+
};
|
|
70
158
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// action returns.
|
|
74
|
-
app.log.warn("cancelInstance: engine did not confirm termination", {
|
|
159
|
+
|
|
160
|
+
app.log.info("cancelInstance: instance terminated", {
|
|
75
161
|
processInstanceKey,
|
|
76
162
|
state: result.state,
|
|
77
|
-
|
|
163
|
+
reconciled,
|
|
78
164
|
});
|
|
79
|
-
return { status:
|
|
165
|
+
return { status: 200, body: responseBody };
|
|
80
166
|
});
|
|
@@ -12,7 +12,9 @@ import { encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
|
|
|
12
12
|
import type { SqliteDb } from "@nanobpm/agentic/presence";
|
|
13
13
|
import type { AppApi, DataLayer } from "@nanobpm/urban";
|
|
14
14
|
import { assert, assertEquals } from "#test-assert";
|
|
15
|
+
import { currentClaimRegistry } from "../app/agentic/claim-registry.ts";
|
|
15
16
|
import { currentCorrelation } from "../app/agentic/correlation.ts";
|
|
17
|
+
import { family as claimFamily } from "../app/agentic/families/claim.family.ts";
|
|
16
18
|
import { family as correlationFamily } from "../app/agentic/families/correlation.family.ts";
|
|
17
19
|
import { family } from "../app/agentic/families/presence.family.ts";
|
|
18
20
|
import type { AgenticContext } from "../app/agentic/registry.ts";
|
|
@@ -154,7 +156,32 @@ test("shared-secret guard rejects a missing secret when configured", async () =>
|
|
|
154
156
|
}
|
|
155
157
|
});
|
|
156
158
|
|
|
157
|
-
test("
|
|
159
|
+
test("#713: a claim populates jobKeys and repoints the drill stream with ZERO transcript (claim is the visibility source)", async () => {
|
|
160
|
+
const hub = await mountPresence(memSqlite());
|
|
161
|
+
const ctx: AgenticContext = { hub, registry: hub.registry, transport: undefined as never, data: undefined, log: noopLog() };
|
|
162
|
+
claimFamily.mount(ctx);
|
|
163
|
+
const claims = currentClaimRegistry();
|
|
164
|
+
assert(claims !== undefined, "the claim family installs the singleton");
|
|
165
|
+
// An explicit claim — no relay produce frame, no correlation link, zero transcript.
|
|
166
|
+
claims.claim("wk-a", "8420");
|
|
167
|
+
try {
|
|
168
|
+
const res = (await handler(input(), app)) as {
|
|
169
|
+
status: number;
|
|
170
|
+
body: { workers: Array<Record<string, unknown>>; correlations: unknown[] };
|
|
171
|
+
};
|
|
172
|
+
assertEquals(res.status, 200);
|
|
173
|
+
const w = res.body.workers[0];
|
|
174
|
+
assertEquals(w.jobKeys, ["8420"], "the claim registry feeds the jobKeys seam");
|
|
175
|
+
assertEquals(w.stream, "job:8420", "the drill stream repoints at the claimed job, keyed by the claim");
|
|
176
|
+
assertEquals(res.body.correlations.length, 0, "no correlation context until a terminal lands (drill-in only)");
|
|
177
|
+
} finally {
|
|
178
|
+
claimFamily.teardown?.();
|
|
179
|
+
family.teardown?.();
|
|
180
|
+
await hub.close();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("#713: the correlation registry is demoted to drill-in context — a link alone no longer feeds jobKeys", async () => {
|
|
158
185
|
const hub = await mountPresence(memSqlite());
|
|
159
186
|
correlationFamily.mount({
|
|
160
187
|
hub,
|
|
@@ -165,6 +192,7 @@ test("H6: with the correlation family mounted, jobKeys populate, stream repoints
|
|
|
165
192
|
});
|
|
166
193
|
const correlation = currentCorrelation();
|
|
167
194
|
assert(correlation !== undefined, "the correlation family installs the singleton");
|
|
195
|
+
// A correlation link (drill-in context) WITHOUT a claim: visibility must NOT light up from it.
|
|
168
196
|
correlation.link("wk-a", "6494", { processInstanceKey: "4612", bpmnProcessId: "plan-fanout", elementId: "implement-task", planKey: "o/r#142" });
|
|
169
197
|
try {
|
|
170
198
|
const res = (await handler(input(), app)) as {
|
|
@@ -176,8 +204,9 @@ test("H6: with the correlation family mounted, jobKeys populate, stream repoints
|
|
|
176
204
|
};
|
|
177
205
|
assertEquals(res.status, 200);
|
|
178
206
|
const w = res.body.workers[0];
|
|
179
|
-
assertEquals(w.jobKeys, [
|
|
180
|
-
assertEquals(w.stream, "
|
|
207
|
+
assertEquals(w.jobKeys, [], "correlation alone no longer feeds jobKeys (relay demoted)");
|
|
208
|
+
assertEquals(w.stream, "wk-a", "the drill stream stays instance-keyed without a claim");
|
|
209
|
+
// The correlation context is still reported for drill-in.
|
|
181
210
|
assertEquals(res.body.correlations.length, 1);
|
|
182
211
|
const c = res.body.correlations[0];
|
|
183
212
|
assertEquals(c.jobKey, "6494");
|