@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 +12 -0
- package/app/agentic/families/relay.family.test.ts +95 -1
- package/app/agentic/families/relay.family.ts +62 -20
- package/app/contracts.ts +8 -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,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 {
|
|
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
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
|
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
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
* `
|
|
539
|
-
*
|
|
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 = {
|
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.
|
|
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",
|