@nanobpm/nano-workforce 0.171.8 → 0.171.9
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,9 @@
|
|
|
1
|
+
## [0.171.9](https://github.com/nanobpm/nano-workforce/compare/v0.171.8...v0.171.9) (2026-09-01)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **agentic:** spare a still-active job's relay stream on a mid-job reconnect ([#690](https://github.com/nanobpm/nano-workforce/issues/690)) ([e0bb51e](https://github.com/nanobpm/nano-workforce/commit/e0bb51e5cb4a2e1c92f3760a3a4e9c70b8ef3803)), closes [661/#688](https://github.com/661/nano-workforce/issues/688) [#reconcile](https://github.com/nanobpm/nano-workforce/issues/reconcile) [#689](https://github.com/nanobpm/nano-workforce/issues/689) [#689](https://github.com/nanobpm/nano-workforce/issues/689) [#689](https://github.com/nanobpm/nano-workforce/issues/689)
|
|
6
|
+
|
|
1
7
|
## [0.171.8](https://github.com/nanobpm/nano-workforce/compare/v0.171.7...v0.171.8) (2026-09-01)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
|
@@ -138,6 +138,22 @@ test("instanceForConnection: resolves the worker instance owning a connection (H
|
|
|
138
138
|
assertEquals(registry.instanceForConnection(""), undefined, "empty connection → undefined");
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test("isInstanceLive: an instance is live while ANY of its connections is open — survives a reconnect (#689)", () => {
|
|
142
|
+
const store = createPresenceStore(memSqlite());
|
|
143
|
+
store.ensureSchema();
|
|
144
|
+
// worker-L reconnected: its OLD connection (cOld) is closed, a NEW one (cNew) is open. Presence is
|
|
145
|
+
// keyed by instance, so both rows exist; only cNew is live.
|
|
146
|
+
store.register({ instance: "worker-L", connectionId: "cOld", identity: "leaf", capability: {} });
|
|
147
|
+
store.register({ instance: "worker-L", connectionId: "cNew", identity: "leaf", capability: {} });
|
|
148
|
+
store.register({ instance: "worker-Gone", connectionId: "cGone", identity: "leaf", capability: {} });
|
|
149
|
+
const registry = new PresenceRegistry(store, () => new Set(["cNew"]));
|
|
150
|
+
|
|
151
|
+
assertEquals(registry.isInstanceLive("worker-L"), true, "live via its new connection despite the old one dropping");
|
|
152
|
+
assertEquals(registry.isInstanceLive("worker-Gone"), false, "no live connection → not live");
|
|
153
|
+
assertEquals(registry.isInstanceLive("unknown"), false, "unknown instance → not live");
|
|
154
|
+
assertEquals(registry.isInstanceLive(""), false, "empty instance → not live");
|
|
155
|
+
});
|
|
156
|
+
|
|
141
157
|
test("attributionOf: resolves a worker instance's durable identity + host for job attribution (#485)", () => {
|
|
142
158
|
const store = createPresenceStore(memSqlite());
|
|
143
159
|
store.ensureSchema();
|
|
@@ -148,6 +148,25 @@ export class PresenceRegistry {
|
|
|
148
148
|
return undefined;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Whether a worker instance currently has a LIVE hub connection — i.e. the worker is present now,
|
|
153
|
+
* on any connection. Used by the relay slice to distinguish a worker that merely RECONNECTED
|
|
154
|
+
* mid-job (its old producer connection dropped, a new one re-registered under the SAME instance,
|
|
155
|
+
* so the instance is still live) from a worker that truly EXITED (all its connections gone, so the
|
|
156
|
+
* instance is no longer live). The former must NOT complete/archive its still-active job stream on
|
|
157
|
+
* the stale-producer reconcile; the latter must. Presence is keyed by instance across reconnects,
|
|
158
|
+
* so this survives the connection churn that a single-connection liveness check cannot. An empty or
|
|
159
|
+
* unknown instance is not live.
|
|
160
|
+
*/
|
|
161
|
+
isInstanceLive(instance: string): boolean {
|
|
162
|
+
if (instance === "") return false;
|
|
163
|
+
const live = this.#liveConnectionIds();
|
|
164
|
+
for (const row of this.#store.list()) {
|
|
165
|
+
if (row.instance === instance && live.has(row.connectionId)) return true;
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
151
170
|
/**
|
|
152
171
|
* Resolve a worker instance's durable identity attributes (presence identity + host) for job
|
|
153
172
|
* attribution (#485). Returns the most recently registered matching row's attributes, or undefined
|
|
@@ -153,6 +153,7 @@ function mkCorrelatedService(
|
|
|
153
153
|
attributionForInstance?: (instance: string) => { identity?: string; host?: string } | undefined;
|
|
154
154
|
correlationStore?: AgenticCorrelationStore;
|
|
155
155
|
resolveElementInstance?: (jobKey: string, processInstanceKey?: string) => Promise<string | undefined>;
|
|
156
|
+
isInstanceLive?: (instance: string) => boolean;
|
|
156
157
|
now?: () => string;
|
|
157
158
|
} = {},
|
|
158
159
|
): { service: RelayTranscriptService; hub: CapturingHub } {
|
|
@@ -167,6 +168,7 @@ function mkCorrelatedService(
|
|
|
167
168
|
attributionForInstance: extra.attributionForInstance,
|
|
168
169
|
correlationStore: extra.correlationStore,
|
|
169
170
|
resolveElementInstance: extra.resolveElementInstance,
|
|
171
|
+
isInstanceLive: extra.isInstanceLive,
|
|
170
172
|
now: extra.now,
|
|
171
173
|
});
|
|
172
174
|
return { service, hub };
|
|
@@ -885,6 +887,75 @@ test("H6 correlation write-side: a producer disconnect releases its job correlat
|
|
|
885
887
|
service.teardown();
|
|
886
888
|
});
|
|
887
889
|
|
|
890
|
+
test("#689 mid-job reconnect: a stale producer whose instance is still live keeps its job correlation", () => {
|
|
891
|
+
const registry = new ConnectionRegistry();
|
|
892
|
+
const correlation = new CorrelationRegistry();
|
|
893
|
+
const byConnection = new Map([["conn-old", "worker-L"]]);
|
|
894
|
+
// Derive liveness from REAL connections exactly as production's PresenceRegistry.isInstanceLive
|
|
895
|
+
// does — an instance is live iff some connection attributed to it is still open in the registry —
|
|
896
|
+
// rather than a static stub decoupled from the connection churn. This makes the test exercise the
|
|
897
|
+
// very reconnect race the seam guards: the instance is live only while a real live connection
|
|
898
|
+
// (conn-old, then conn-new) backs it.
|
|
899
|
+
const isInstanceLive = (instance: string): boolean => {
|
|
900
|
+
for (const [connId, inst] of byConnection) {
|
|
901
|
+
if (inst === instance && registry.has(connId)) return true;
|
|
902
|
+
}
|
|
903
|
+
return false;
|
|
904
|
+
};
|
|
905
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection, {
|
|
906
|
+
isInstanceLive,
|
|
907
|
+
});
|
|
908
|
+
const p = connect("conn-old", registry);
|
|
909
|
+
hub.handler?.(produce(jobStream("5749"), 1, "booting agent"), p.conn);
|
|
910
|
+
assertEquals(correlation.jobKeysFor("worker-L"), ["5749"], "the job is linked on first produce");
|
|
911
|
+
|
|
912
|
+
// The producer's WS connection blips (client reconnects). Model the reconnect faithfully: the new
|
|
913
|
+
// connection re-registers under the SAME instance BEFORE the reconcile frame, so there is never a
|
|
914
|
+
// gap where the instance is not live — presence keeps worker-L live across the churn. The old
|
|
915
|
+
// producer connection then drops. A subsequent frame drives #reconcile — which, seeing the instance
|
|
916
|
+
// still live via conn-new, must NOT archive the still-active job.
|
|
917
|
+
byConnection.set("conn-new", "worker-L");
|
|
918
|
+
const p2 = connect("conn-new", registry);
|
|
919
|
+
registry.remove("conn-old");
|
|
920
|
+
const other = connect("cons", registry);
|
|
921
|
+
hub.handler?.(grant(0), other.conn);
|
|
922
|
+
assertEquals(correlation.jobKeysFor("worker-L"), ["5749"], "a mid-job reconnect keeps the correlation");
|
|
923
|
+
assertEquals(service.transcriptOf(jobStream("5749")), undefined, "the still-active stream is NOT archived");
|
|
924
|
+
|
|
925
|
+
// The worker resumes producing on the SAME job over its already-open NEW connection → re-attributed,
|
|
926
|
+
// still one job, transcript still live (not terminal).
|
|
927
|
+
hub.handler?.(produce(jobStream("5749"), 1, "resumed output"), p2.conn);
|
|
928
|
+
assertEquals(correlation.jobKeysFor("worker-L"), ["5749"], "the resumed producer stays linked to the same job");
|
|
929
|
+
assertEquals(service.liveFallback(jobStream("5749"))?.ring !== undefined, true, "the transcript is still live, not completed");
|
|
930
|
+
|
|
931
|
+
// Only once the worker truly EXITS (all its connections gone → isInstanceLive false) does a later
|
|
932
|
+
// reconcile complete the stream and release the correlation.
|
|
933
|
+
registry.remove("conn-new");
|
|
934
|
+
hub.handler?.(grant(0), other.conn);
|
|
935
|
+
assertEquals(correlation.jobKeysFor("worker-L"), [], "a true worker-exit completes + releases the job");
|
|
936
|
+
assertEquals(service.transcriptOf(jobStream("5749"))?.status, "completed", "the exited worker's stream is archived");
|
|
937
|
+
service.teardown();
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
test("#689 mid-job reconnect: with no isInstanceLive wired, a producer disconnect completes as before", () => {
|
|
941
|
+
const registry = new ConnectionRegistry();
|
|
942
|
+
const correlation = new CorrelationRegistry();
|
|
943
|
+
const byConnection = new Map([["prod", "worker-N"]]);
|
|
944
|
+
// Default seam (isInstanceLive omitted → () => false): a producer disconnect completes the stream,
|
|
945
|
+
// preserving the prior always-complete-on-disconnect behaviour for callers that don't wire presence.
|
|
946
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
|
|
947
|
+
const p = connect("prod", registry);
|
|
948
|
+
hub.handler?.(produce(jobStream("kN"), 1, "x"), p.conn);
|
|
949
|
+
assertEquals(correlation.jobKeysFor("worker-N"), ["kN"]);
|
|
950
|
+
|
|
951
|
+
registry.remove("prod");
|
|
952
|
+
const other = connect("cons", registry);
|
|
953
|
+
hub.handler?.(grant(0), other.conn);
|
|
954
|
+
assertEquals(correlation.jobKeysFor("worker-N"), [], "disconnect completes + releases when liveness is unknown");
|
|
955
|
+
assertEquals(service.transcriptOf(jobStream("kN"))?.status, "completed");
|
|
956
|
+
service.teardown();
|
|
957
|
+
});
|
|
958
|
+
|
|
888
959
|
test("H6 correlation write-side: non-job streams are never linked; a link retries until the instance resolves", () => {
|
|
889
960
|
const registry = new ConnectionRegistry();
|
|
890
961
|
const correlation = new CorrelationRegistry();
|
|
@@ -252,6 +252,19 @@ export interface RelayTranscriptServiceOptions {
|
|
|
252
252
|
* ({@link currentPresenceRegistry}). A resolver returning undefined → no linking (advisory).
|
|
253
253
|
*/
|
|
254
254
|
readonly instanceForConnection?: (connectionId: string) => string | undefined;
|
|
255
|
+
/**
|
|
256
|
+
* Whether a worker instance is still live on any hub connection (#689). Wired to the presence
|
|
257
|
+
* registry's {@link PresenceRegistry.isInstanceLive}. The disconnect-driven reconcile uses it to
|
|
258
|
+
* spare a still-active job's stream when its worker merely RECONNECTED mid-job (old producer
|
|
259
|
+
* connection dropped, a new one re-registered under the same instance): completing then would
|
|
260
|
+
* archive a live job's transcript and release its correlation, wedging the cockpit (the reconnected
|
|
261
|
+
* worker's produce frames hit a terminal `completed` stream and are ignored) while the harness
|
|
262
|
+
* keeps the engine lease. When wired to presence, a true worker-exit still completes: presence
|
|
263
|
+
* has dropped the instance, so this returns false. Omitted (`() => false`, the static default) →
|
|
264
|
+
* the prior always-complete-on-disconnect behaviour — every disconnect completes, reconnect
|
|
265
|
+
* included (the seam never consults presence, so its value does not vary).
|
|
266
|
+
*/
|
|
267
|
+
readonly isInstanceLive?: (instance: string) => boolean;
|
|
255
268
|
/**
|
|
256
269
|
* Resolve a producing worker instance's durable identity attributes (identity / host) — read at
|
|
257
270
|
* job-completion time and persisted with the attribution so a PAST session stays attributable to a
|
|
@@ -317,6 +330,8 @@ export class RelayTranscriptService {
|
|
|
317
330
|
readonly #correlation: () => CorrelationLink | undefined;
|
|
318
331
|
/** The connection → producing-instance resolver (H6, #149). */
|
|
319
332
|
readonly #instanceForConnection: (connectionId: string) => string | undefined;
|
|
333
|
+
/** Whether a worker instance is still live on any connection (#689) — gates disconnect completion. */
|
|
334
|
+
readonly #isInstanceLive: (instance: string) => boolean;
|
|
320
335
|
/** Resolve a worker instance's durable identity attributes for attribution (#485). */
|
|
321
336
|
readonly #attributionForInstance: (instance: string) => WorkerAttribution | undefined;
|
|
322
337
|
/** The durable worker-attribution store, or undefined when unpersisted (#485). */
|
|
@@ -331,6 +346,7 @@ export class RelayTranscriptService {
|
|
|
331
346
|
this.#log = options.log;
|
|
332
347
|
this.#correlation = options.correlation ?? currentCorrelation;
|
|
333
348
|
this.#instanceForConnection = options.instanceForConnection ?? (() => undefined);
|
|
349
|
+
this.#isInstanceLive = options.isInstanceLive ?? (() => false);
|
|
334
350
|
this.#attributionForInstance = options.attributionForInstance ?? (() => undefined);
|
|
335
351
|
this.#resolveElementInstance = options.resolveElementInstance;
|
|
336
352
|
this.#now = options.now ?? (() => new Date().toISOString());
|
|
@@ -718,6 +734,19 @@ export class RelayTranscriptService {
|
|
|
718
734
|
#reconcile(): void {
|
|
719
735
|
for (const [stream, state] of this.#streams) {
|
|
720
736
|
if (state.producer !== undefined && !this.#registry.has(state.producer)) {
|
|
737
|
+
// Producer connection gone. Normally that means the job it was relaying ended — release the
|
|
738
|
+
// correlation and flush+complete its ephemeral transcript. BUT a worker that merely
|
|
739
|
+
// RECONNECTED mid-job (#689) also loses its old producer connection while its job keeps
|
|
740
|
+
// running (the harness holds the engine lease and extends it). Presence re-registers the SAME
|
|
741
|
+
// instance under the new connection, so the instance stays live even though this specific
|
|
742
|
+
// producer connection is gone. Completing then would archive a still-active job's transcript
|
|
743
|
+
// to `historical` and drop its correlation — and because a completed stream is terminal
|
|
744
|
+
// (`#observe` ignores later frames), the reconnected worker could NEVER re-correlate: the
|
|
745
|
+
// cockpit shows the worker idle with a frozen transcript while the job is genuinely running.
|
|
746
|
+
// So spare the stream while its instance is still live; the next `produce` re-attributes the
|
|
747
|
+
// live connection (via `#observe`), a NEW job supersedes it (via `#link`), or — once the
|
|
748
|
+
// worker truly exits — presence drops the instance and a later reconcile completes it.
|
|
749
|
+
if (state.instance !== undefined && this.#isInstanceLive(state.instance)) continue;
|
|
721
750
|
// Producer connection gone → the job it was relaying ended: release its correlation.
|
|
722
751
|
this.#unlink(stream, state);
|
|
723
752
|
// ...and flush+complete an ephemeral, not-yet-completed transcript exactly as before.
|
|
@@ -857,6 +886,11 @@ export function createRelayFamily(options: {
|
|
|
857
886
|
// are read per call, so this works regardless of family mount order (relay may mount before
|
|
858
887
|
// presence/correlation). Absent registries → no linking, still advisory-correct.
|
|
859
888
|
instanceForConnection: (connectionId) => currentPresenceRegistry()?.instanceForConnection(connectionId),
|
|
889
|
+
// #689: is the producing worker instance still live on ANY connection? Gates the
|
|
890
|
+
// disconnect-driven reconcile so a mid-job RECONNECT (old producer connection dropped, the
|
|
891
|
+
// same instance re-registered) does not archive a still-active job's stream and wedge its
|
|
892
|
+
// correlation. Read per call for the same mount-order independence as the resolvers above.
|
|
893
|
+
isInstanceLive: (instance) => currentPresenceRegistry()?.isInstanceLive(instance) ?? false,
|
|
860
894
|
// #485: resolve a completed job's worker attribution (presence identity/host) from the live
|
|
861
895
|
// presence registry, read per call for the same mount-order independence. Absent → attribution
|
|
862
896
|
// records instance only.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.171.
|
|
3
|
+
"version": "0.171.9",
|
|
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",
|