@nanobpm/nano-workforce 0.176.0 → 0.176.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 +6 -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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.176.1](https://github.com/nanobpm/nano-workforce/compare/v0.176.0...v0.176.1) (2026-09-02)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **cancel:** reconcile feature_runs in the app cancel door with a truthful result ([#712](https://github.com/nanobpm/nano-workforce/issues/712)) ([810979b](https://github.com/nanobpm/nano-workforce/commit/810979b3708a247b2ab4ee068c211abd5478363a)), closes [#667](https://github.com/nanobpm/nano-workforce/issues/667) [#705](https://github.com/nanobpm/nano-workforce/issues/705)
|
|
6
|
+
|
|
1
7
|
## [0.176.0](https://github.com/nanobpm/nano-workforce/compare/v0.175.2...v0.176.0) (2026-09-02)
|
|
2
8
|
|
|
3
9
|
### Features
|
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
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.176.
|
|
3
|
+
"version": "0.176.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",
|