@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.
@@ -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;
@@ -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 after the cancel attempt. `gone` ⇒ the engine has no record of the key (already cleaned up / never existed).
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 immediate reconcile recorded the terminal source into the canonical instance-state projection (so the PR's derived tracking status flips to `abandoned` at once), else 0.
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 only on a non-terminal failure — the reason the cancel did not take.
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); the run may still be live.
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: result.reconciled,
139
+ reconciled,
61
140
  ...(result.error !== undefined ? { error: result.error } : {}),
62
141
  };
63
- if (result.ok) {
64
- app.log.info("cancelInstance: instance terminated", {
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 { status: 200, body: responseBody };
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
- // A !ok result means the engine did NOT stop the instance (the cancel was not committed); the run
72
- // may still be live, so surface 502 — the same non-committed-cancel signal the built-in page
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
- error: result.error,
163
+ reconciled,
78
164
  });
79
- return { status: 502, body: responseBody };
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("H6: with the correlation family mounted, jobKeys populate, stream repoints, and correlations are reported", async () => {
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, ["6494"], "the correlation registry feeds the jobKeys seam");
180
- assertEquals(w.stream, "job:6494", "the drill stream repoints at the live job's 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");