@nanobpm/nano-workforce 0.175.2 → 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 CHANGED
@@ -1,3 +1,15 @@
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
+
7
+ ## [0.176.0](https://github.com/nanobpm/nano-workforce/compare/v0.175.2...v0.176.0) (2026-09-02)
8
+
9
+ ### Features
10
+
11
+ * **agentic:** flush durable transcript on a worker phase:close event (fixes truncated tails) ([#711](https://github.com/nanobpm/nano-workforce/issues/711)) ([e02ff9b](https://github.com/nanobpm/nano-workforce/commit/e02ff9b4f8c2b7452dc40b0b8532736c5d418cc0)), closes [#710](https://github.com/nanobpm/nano-workforce/issues/710)
12
+
1
13
  ## [0.175.2](https://github.com/nanobpm/nano-workforce/compare/v0.175.1...v0.175.2) (2026-09-02)
2
14
 
3
15
  ### Bug Fixes
@@ -16,7 +16,13 @@ import { fileURLToPath } from "node:url";
16
16
  import { ConnectionRegistry } from "@nanobpm/agentic/channel";
17
17
  import type { Frame } from "@nanobpm/agentic/protocol";
18
18
  import { RELAY_FAMILY } from "@nanobpm/agentic/relay";
19
- import { encodeTranscriptEvent, type SqliteDb, TRANSCRIPT_SCHEMA_SQL } from "@nanobpm/agentic/transcript";
19
+ import {
20
+ encodeTranscriptEvent,
21
+ type SqliteDb,
22
+ TRANSCRIPT_EVENT_MARKER,
23
+ TRANSCRIPT_EVENT_VERSION,
24
+ TRANSCRIPT_SCHEMA_SQL,
25
+ } from "@nanobpm/agentic/transcript";
20
26
  import { assert, assertEquals, assertThrows } from "#test-assert";
21
27
  import { noopLog } from "../../../test/log.ts";
22
28
  import { CorrelationRegistry, jobStream } from "../correlation.ts";
@@ -551,6 +557,21 @@ test("#544 element-instance enrichment: an unresolved job (never parked) leaves
551
557
  const lifecycle = (stream: string, incarnation: number, phase: "open" | "completed" | "exited"): Frame =>
552
558
  produce(stream, incarnation, encodeTranscriptEvent({ kind: "lifecycle", phase, offset: 0 }));
553
559
 
560
+ /**
561
+ * A `produce` frame carrying the harness's job-end `phase:"close"` transcript lifecycle marker
562
+ * (#710 / jwulf/c8ctl-plugin-nano#150) — the closing twin of the `phase:"open"` RELAY_OPEN_CHUNK the
563
+ * harness emits at relay-session open. The typed `encodeTranscriptEvent` cannot express the `close`
564
+ * phase (the agentic `LifecycleEvent` union is `open|completed|exited`), so the on-wire envelope is
565
+ * built through the SAME marker/version constants the encoder stamps — mirroring exactly what the
566
+ * harness's untyped `encodeTranscriptEvent({ kind: "lifecycle", phase: "close" })` puts on the wire,
567
+ * without a forked wire shape. */
568
+ const closeMarker = JSON.stringify({
569
+ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION,
570
+ kind: "lifecycle",
571
+ phase: "close",
572
+ });
573
+ const close = (stream: string, incarnation: number): Frame => produce(stream, incarnation, closeMarker);
574
+
554
575
  test("#661 primary release: a terminal lifecycle event clears an idle-but-connected worker's finished job", () => {
555
576
  const registry = new ConnectionRegistry();
556
577
  const correlation = new CorrelationRegistry();
@@ -607,6 +628,79 @@ test("#661 primary release: an `exited` lifecycle also releases; a non-terminal
607
628
  service.teardown();
608
629
  });
609
630
 
631
+ test("#710 close release: a job-end `phase:close` marker flushes the durable transcript with ALL bytes (no truncated tail)", () => {
632
+ const registry = new ConnectionRegistry();
633
+ const correlation = new CorrelationRegistry();
634
+ const byConnection = new Map([["prod", "worker-A"]]);
635
+ const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
636
+ const p = connect("prod", registry);
637
+
638
+ // The worker relays N job frames on a live connection → linked as active. It then goes idle after the
639
+ // job WITHOUT a supersede (no next job) and WITHOUT a disconnect (the channel persists across jobs),
640
+ // so the ONLY job-end signal is the harness's drained `phase:close` marker.
641
+ const N = 4;
642
+ for (let i = 0; i < N; i++) hub.handler?.(produce(jobStream("k1"), 1, `job-1 line ${i}`), p.conn);
643
+ assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "the job is active while it runs");
644
+ assertEquals(service.transcriptOf(jobStream("k1")), undefined, "not flushed while the job runs");
645
+
646
+ // The job completes: the harness drains its outbound relay buffer and emits `phase:close` on the SAME
647
+ // live connection, then goes idle. Before #710 this was NOT a flush trigger — the durable transcript
648
+ // was snapshotted late (or not at all) by a later supersede/disconnect, truncating the tail. Now the
649
+ // close event flushes it deterministically at job-completion time.
650
+ hub.handler?.(close(jobStream("k1"), 1), p.conn);
651
+
652
+ assertEquals(correlation.jobKeysFor("worker-A"), [], "the finished job is released on the close marker");
653
+ assertEquals(correlation.count(), 0, "no phantom active job after close");
654
+
655
+ const meta = service.transcriptOf(jobStream("k1"));
656
+ assertEquals(meta?.status, "completed", "the job becomes a completed past session at close time");
657
+ // ALL N produced frames PLUS the close marker itself are in the durable transcript — no truncated
658
+ // tail (the close event rides the ring append before the release, like the #661 terminal path).
659
+ assertEquals(
660
+ service.reattach(jobStream("k1"), 0)?.entries.length,
661
+ N + 1,
662
+ "every relayed byte (and the close marker) is flushed — the tail is not truncated",
663
+ );
664
+ service.teardown();
665
+ });
666
+
667
+ test("#710 idempotent: a close then a duplicate close then a producer disconnect neither double-flushes nor throws", () => {
668
+ const registry = new ConnectionRegistry();
669
+ const correlation = new CorrelationRegistry();
670
+ const byConnection = new Map([["prod", "worker-A"]]);
671
+ const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
672
+ const p = connect("prod", registry);
673
+
674
+ hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
675
+ hub.handler?.(close(jobStream("k1"), 1), p.conn);
676
+ const afterClose = service.reattach(jobStream("k1"), 0)?.entries.length;
677
+ assertEquals(service.transcriptOf(jobStream("k1"))?.status, "completed", "flushed on the first close");
678
+ assertEquals(correlation.count(), 0, "correlation released on the first close");
679
+
680
+ // A late DUPLICATE close (a harness retry, or a redelivered frame) is a no-op — the completed stream
681
+ // is terminal, so it neither re-flushes nor throws.
682
+ hub.handler?.(close(jobStream("k1"), 1), p.conn);
683
+ assertEquals(
684
+ service.reattach(jobStream("k1"), 0)?.entries.length,
685
+ afterClose,
686
+ "a duplicate close does not append or re-flush",
687
+ );
688
+
689
+ // The producer later disconnects (the belt-and-suspenders fallback). #reconcile runs on the next
690
+ // inbound frame; the already-completed stream is skipped, so the fallback neither double-flushes nor
691
+ // throws — the additive close trigger composes cleanly with the disconnect fallback.
692
+ registry.remove("prod");
693
+ const q = connect("other", registry);
694
+ hub.handler?.(produce(jobStream("k2"), 1, "unrelated"), q.conn);
695
+ assertEquals(
696
+ service.reattach(jobStream("k1"), 0)?.entries.length,
697
+ afterClose,
698
+ "the disconnect fallback does not re-flush the already-closed stream",
699
+ );
700
+ assertEquals(service.transcriptOf(jobStream("k1"))?.status, "completed", "still exactly one completed past session");
701
+ service.teardown();
702
+ });
703
+
610
704
  test("#661 no-regression: an ordinary (non-lifecycle) chunk never clears a genuinely active job", () => {
611
705
  const registry = new ConnectionRegistry();
612
706
  const correlation = new CorrelationRegistry();
@@ -24,6 +24,8 @@ import type { ConnectionRegistry } from "@nanobpm/agentic/channel";
24
24
  import type { Frame } from "@nanobpm/agentic/protocol";
25
25
  import { RELAY_FAMILY, RelayHub, type RelayHubOptions } from "@nanobpm/agentic/relay";
26
26
  import {
27
+ CORE_TRANSCRIPT_VOCAB,
28
+ mergeTranscriptVocab,
27
29
  parseTranscriptEvent,
28
30
  type SqliteDb,
29
31
  type TranscriptLifecycle,
@@ -32,6 +34,7 @@ import {
32
34
  TranscriptStore,
33
35
  type TranscriptStoreOptions,
34
36
  type TranscriptStream,
37
+ type TranscriptVocab,
35
38
  } from "@nanobpm/agentic/transcript";
36
39
  import type { Logger } from "@nanobpm/urban";
37
40
  import { currentCorrelation, type JobContext, type JobCorrelation, jobKeyOfStream } from "../correlation.ts";
@@ -128,17 +131,50 @@ function readProp(value: unknown, key: string): unknown {
128
131
  return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
129
132
  }
130
133
 
134
+ /**
135
+ * The harness's job-end lifecycle phase (#710 / jwulf/c8ctl-plugin-nano#150): the closing twin of the
136
+ * `phase:"open"` `RELAY_OPEN_CHUNK` the harness emits at relay-session open. The harness emits it —
137
+ * through agentic's own `encodeTranscriptEvent`, never hand-rolled — right before `job.complete`/
138
+ * `job.fail`, once it has DRAINED its outbound relay buffer, so it is the deterministic "this job's
139
+ * bytes are all here, it is done" signal that supersede/disconnect only ever approximated.
140
+ */
141
+ const HARNESS_CLOSE_PHASE = "close";
142
+
143
+ /**
144
+ * The relay family's terminal-detection vocabulary: the ONE canonical transcript vocab, additively
145
+ * EXTENDED (via the sanctioned {@link mergeTranscriptVocab} extension point, never a forked parser) to
146
+ * ALSO classify the harness's job-end `phase:"close"` marker (#710). The agentic `LifecycleEvent`
147
+ * contract's phases are `open|completed|exited` — `close` is a fourth, job-scoped termination marker
148
+ * agentic 0.10.0 does not yet know, so the core `lifecycle` decoder drops it to a raw `stream-chunk`.
149
+ * This override recognizes it and maps it onto the contract's terminal `completed` phase, so
150
+ * {@link parseTranscriptEvent} classifies a close envelope as a terminal lifecycle WITHOUT a second
151
+ * wire shape. It is scoped to {@link isTerminalLifecycleChunk} (the correlation-release detector); the
152
+ * cockpit derive/read fold keeps using the CORE vocab, so the close chunk stays byte-faithful in the
153
+ * durable transcript and this remap never leaks into the derived view.
154
+ */
155
+ const RELAY_TERMINAL_VOCAB: TranscriptVocab = mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, {
156
+ lifecycle: (body, offset) => {
157
+ const phase = typeof body.phase === "string" ? body.phase : undefined;
158
+ if (phase === "open" || phase === "completed" || phase === "exited") return { kind: "lifecycle", offset, phase };
159
+ if (phase === HARNESS_CLOSE_PHASE) return { kind: "lifecycle", offset, phase: "completed" };
160
+ return undefined;
161
+ },
162
+ });
163
+
131
164
  /**
132
165
  * Decode a single relay chunk through the ONE canonical transcript parser and report whether it is a
133
- * TERMINAL `lifecycle` event (`phase` `completed`/`exited`) — the authoritative "job end" signal a
134
- * clean agent run emits on its transcript stream (#661). Anything else raw terminal bytes, a
135
- * non-envelope JSON value, a `phase: "open"` lifecycle, any other event kind — is not terminal.
136
- * Reusing {@link parseTranscriptEvent} keeps transcript-vocab knowledge out of the content-agnostic
137
- * relay ring and off any forked decoder (Derivation Over Duplication): the ring still sees opaque
138
- * bytes; only this narrow seam classifies them.
166
+ * TERMINAL `lifecycle` event — the authoritative "job end" signal that flushes the durable transcript
167
+ * and releases correlation. Two shapes are terminal: the agent run's own `phase` `completed`/`exited`
168
+ * (#661), and the harness's drained job-end `phase:"close"` marker (#710), which
169
+ * {@link RELAY_TERMINAL_VOCAB} decodes onto the contract's `completed` phase. Anything else — raw
170
+ * terminal bytes, a non-envelope JSON value, a `phase: "open"` lifecycle, any other event kind — is
171
+ * not terminal. Reusing {@link parseTranscriptEvent} (with the additively-extended vocab) keeps
172
+ * transcript-vocab knowledge out of the content-agnostic relay ring and off any forked decoder
173
+ * (Derivation Over Duplication): the ring still sees opaque bytes; only this narrow seam classifies
174
+ * them.
139
175
  */
140
176
  function isTerminalLifecycleChunk(chunk: string): boolean {
141
- const event = parseTranscriptEvent({ offset: 0, chunk });
177
+ const event = parseTranscriptEvent({ offset: 0, chunk }, RELAY_TERMINAL_VOCAB);
142
178
  return event.kind === "lifecycle" && (event.phase === "completed" || event.phase === "exited");
143
179
  }
144
180
 
@@ -516,8 +552,8 @@ export class RelayTranscriptService {
516
552
  }
517
553
 
518
554
  /** Handle one inbound `relay` frame: reconcile dead producers, observe ownership, delegate, then
519
- * release on a terminal `lifecycle` event (#661 — AFTER the hub appended the chunk, so the terminal
520
- * event itself is captured in the flushed transcript). */
555
+ * release on a terminal `lifecycle` event (#661/#710 — AFTER the hub appended the chunk, so the
556
+ * terminal event itself is captured in the flushed transcript). */
521
557
  #onFrame(frame: Frame, conn: RelayConnectionCtx): void {
522
558
  this.#reconcile();
523
559
  this.#observe(frame, conn);
@@ -526,17 +562,23 @@ export class RelayTranscriptService {
526
562
  }
527
563
 
528
564
  /**
529
- * Primary job-end release (#661): when a `produce` frame carries the terminal `lifecycle` event
530
- * (`phase` `completed`/`exited`), complete its stream so the worker's job⇄instance correlation is
531
- * released the moment the job ends even though the worker's relay connection stays open across jobs
532
- * (the disconnect/supersede release paths miss that idle-after-last-job tail, so an idle worker's
533
- * finished job would otherwise linger as a phantom active job on its supply row). Runs AFTER the hub
534
- * appends the chunk to the ring, so the terminal event is part of the flushed transcript. A narrow,
535
- * self-contained decode at the correlation seam that reuses the ONE canonical
536
- * {@link parseTranscriptEvent} the content-agnostic relay ring keeps treating chunks as opaque
537
- * bytes, and no transcript-vocab knowledge is forked into it. Non-terminal chunks (raw bytes, a
538
- * `phase: "open"` lifecycle, any other event kind) never complete a live stream, so a genuinely
539
- * active job is never cleared. Advisory never throws into the frame handler.
565
+ * Primary job-end release (#661, #710): when a `produce` frame carries a terminal `lifecycle` event
566
+ * the agent run's own `phase` `completed`/`exited` (#661), OR the harness's drained job-end
567
+ * `phase:"close"` marker (#710) complete its stream so the worker's job⇄instance correlation is
568
+ * released, and the durable "past session" transcript is flushed, the moment the job ends. This runs
569
+ * even though the worker's relay connection stays open across jobs (the disconnect/supersede release
570
+ * paths miss that idle-after-last-job tail, so an idle worker's finished job would otherwise linger as
571
+ * a phantom active job on its supply row, and its transcript would be snapshotted late → truncated
572
+ * tail). The `close` trigger is ADDITIVE supersede + disconnect remain as belt-and-suspenders
573
+ * fallbacks for a harness that predates the close event or a crash that never sends one — and the
574
+ * `state.completed` guard below makes a close-then-later-disconnect (or a duplicate close) an
575
+ * idempotent no-op. Runs AFTER the hub appends the chunk to the ring, so the terminal event is part
576
+ * of the flushed transcript. A narrow, self-contained decode at the correlation seam that reuses the
577
+ * ONE canonical {@link parseTranscriptEvent} ({@link isTerminalLifecycleChunk}) — the content-agnostic
578
+ * relay ring keeps treating chunks as opaque bytes, and no transcript-vocab knowledge is forked into
579
+ * it. Non-terminal chunks (raw bytes, a `phase: "open"` lifecycle, any other event kind) never
580
+ * complete a live stream, so a genuinely active job is never cleared. Advisory — never throws into
581
+ * the frame handler.
540
582
  */
541
583
  #observeTerminalLifecycle(frame: Frame): void {
542
584
  if (readProp(frame.payload, "op") !== "produce") return;
package/app/contracts.ts CHANGED
@@ -463,6 +463,14 @@ export const WIRE_CONTRACTS = {
463
463
  shape:
464
464
  '{ nwfTranscriptEvent: 1, kind: "permission", phase: "request", callId: string, policy: "escalate"|"yolo", options: [{ optionId: string, name: string, kind: "allow-once"|"allow-always"|"reject-once"|"reject-always" }, ...Array<{ optionId: string, name: string, kind: "allow-once"|"allow-always"|"reject-once"|"reject-always" }>], toolName?: string, title?: string, reason?: string } | { nwfTranscriptEvent: 1, kind: "permission", phase: "resolution", callId: string, optionId: string, allowed: boolean, by?: "operator"|"auto" }',
465
465
  },
466
+ "transcript.lifecycleClose": {
467
+ category: "wire",
468
+ name: "transcript.lifecycleClose",
469
+ owner: "app/agentic/families/relay.family.ts",
470
+ semantics:
471
+ "The harness's job-end `phase:\"close\"` transcript `lifecycle` event (issue #710, harness half jwulf/c8ctl-plugin-nano#150) — the closing twin of the `phase:\"open\"` RELAY_OPEN_CHUNK the harness emits at relay-session open. The harness emits it on the `job:<jobKey>` relay stream through agentic's own `encodeTranscriptEvent` (never hand-rolled), right before `job.complete`/`job.fail` and AFTER draining its outbound relay buffer, so it is the deterministic \"all this job's bytes are here, it is done\" signal. The app recognizes it in `isTerminalLifecycleChunk` (relay.family.ts) to `completeStream()` — flush the durable past-session transcript and release job⇄instance correlation — at job-completion time, fixing the truncated-tail defect where the flush waited for a supersede/disconnect. `close` is NOT a core agentic `LifecycleEvent` phase (the contract's phases are open|completed|exited); the app decodes it via an ADDITIVE `mergeTranscriptVocab` extension (`RELAY_TERMINAL_VOCAB`) that maps it onto the terminal `completed` phase — never a forked wire shape, and scoped to the relay job-end detector so the cockpit derive (CORE vocab) keeps the close chunk byte-faithful. The `close` trigger is ADDITIVE: supersede + disconnect remain as fallbacks and the `state.completed` guard keeps a close-then-disconnect (or duplicate close) idempotent. Consume this ONE marker — do not re-declare a synonym or a second job-end signal.",
472
+ shape: '{ nwfTranscriptEvent: 1, kind: "lifecycle", phase: "close" }',
473
+ },
466
474
  } as const satisfies Record<string, WireContract>;
467
475
 
468
476
  export const TYPE_CONTRACTS = {
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.175.2",
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",