@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.8",
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",