@nanobpm/nano-workforce 0.201.4 → 0.202.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -112,6 +112,67 @@ test("reconnect-resync: re-register + re-claim rebuilds the same claim (never bl
112
112
  assert.equal(reg.count(), 1);
113
113
  });
114
114
 
115
+ test("replaceInstance: an authoritative resync drops a claim the supervisor did NOT re-assert (a lost RELEASE no longer strands the job)", () => {
116
+ const reg = new ClaimRegistry();
117
+ reg.claim("wk-a", "8420");
118
+ reg.claim("wk-a", "8421");
119
+ // The hub missed the `release` for 8420 during an outage. On reconnect the supervisor restates its
120
+ // authoritative snapshot: wk-a now owns only 8421. The non-re-asserted 8420 is dropped.
121
+ reg.replaceInstance("wk-a", ["8421"]);
122
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["8421"]);
123
+ assert.equal(reg.isClaimed("8420"), false, "the stranded claim was dropped");
124
+ assert.equal(reg.count(), 1);
125
+ });
126
+
127
+ test("replaceInstance: a re-asserted claim is unchanged; an added one is claimed", () => {
128
+ const reg = new ClaimRegistry();
129
+ reg.claim("wk-a", "8420");
130
+ reg.replaceInstance("wk-a", ["8420", "8421"]);
131
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["8420", "8421"]);
132
+ assert.equal(reg.ownerOf("8420"), "wk-a");
133
+ assert.equal(reg.ownerOf("8421"), "wk-a");
134
+ });
135
+
136
+ test("replaceInstance: an empty snapshot drops every claim the instance held", () => {
137
+ const reg = new ClaimRegistry();
138
+ reg.claim("wk-a", "8420");
139
+ reg.claim("wk-a", "8421");
140
+ reg.replaceInstance("wk-a", []);
141
+ assert.deepEqual(reg.jobKeysFor("wk-a"), []);
142
+ assert.equal(reg.count(), 0);
143
+ });
144
+
145
+ test("replaceInstance: a jobKey that moved to another instance is NOT dropped by the old instance's resync", () => {
146
+ const reg = new ClaimRegistry();
147
+ reg.claim("wk-a", "8420");
148
+ reg.claim("wk-b", "8420"); // 8420 moved to wk-b
149
+ // wk-a's authoritative resync no longer mentions 8420 (it does not run it) — and must not blank the
150
+ // live owner wk-b.
151
+ reg.replaceInstance("wk-a", []);
152
+ assert.deepEqual(reg.jobKeysFor("wk-b"), ["8420"], "the current owner's claim survives");
153
+ assert.equal(reg.ownerOf("8420"), "wk-b");
154
+ });
155
+
156
+ test("replaceInstance: only the resyncing instance's claims are touched; siblings are untouched", () => {
157
+ const reg = new ClaimRegistry();
158
+ reg.claim("wk-a", "8420");
159
+ reg.claim("wk-b", "8500");
160
+ reg.replaceInstance("wk-a", []);
161
+ assert.deepEqual(reg.jobKeysFor("wk-a"), []);
162
+ assert.deepEqual(reg.jobKeysFor("wk-b"), ["8500"], "an unrelated instance's claim is untouched");
163
+ });
164
+
165
+ test("replaceInstance is idempotent and ignores empty jobKeys / an empty instance", () => {
166
+ const reg = new ClaimRegistry();
167
+ reg.claim("wk-a", "8420");
168
+ reg.replaceInstance("wk-a", ["8420", ""]); // the empty jobKey is ignored
169
+ reg.replaceInstance("wk-a", ["8420"]); // repeat — no churn
170
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["8420"]);
171
+ reg.replaceInstance("", ["9999"]); // an empty instance is ignored
172
+ assert.equal(reg.isClaimed("9999"), false);
173
+ assert.equal(reg.count(), 1);
174
+ });
175
+
115
176
  test("reconcile drops claims for absent instances, keeps present ones, and reports the released", () => {
116
177
  const reg = new ClaimRegistry();
117
178
  reg.claim("wk-a", "8420");
@@ -52,7 +52,9 @@ export interface ClaimSnapshot {
52
52
  * It needs no durable backing: claims are ephemeral and re-announced. On a WS reconnect the supervisor
53
53
  * re-`register`s every worker and re-`claim`s every active jobKey, so the registry is rebuilt from that
54
54
  * full-state resync — which is exactly what makes it survive the reconnect that the old relay-derived
55
- * path stranded the jobKey across.
55
+ * path stranded the jobKey across. A supervisor may close its resync with an AUTHORITATIVE snapshot via
56
+ * {@link replaceInstance} (issue #893), dropping any claim it did not re-assert so a lost `release`
57
+ * cannot strand a job; omitting it keeps the additive re-claim behaviour.
56
58
  */
57
59
  export class ClaimRegistry {
58
60
  /** worker instance → the set of jobKeys it currently owns (insertion-ordered). */
@@ -107,6 +109,38 @@ export class ClaimRegistry {
107
109
  this.#jobsOf.delete(instance);
108
110
  }
109
111
 
112
+ /**
113
+ * Authoritatively REPLACE every claim held by `instance` with `jobKeys` — the reconnect-resync
114
+ * contract (issue #893). A reconnecting supervisor restates the full, current ownership snapshot for
115
+ * one worker instance; its view WINS. Every claim this instance held that is NOT in `jobKeys` is
116
+ * dropped (so a `release` lost during a hub outage no longer strands the job as "working" until the
117
+ * worker exits entirely), and every jobKey in `jobKeys` is (re-)claimed — an idempotent no-op for one
118
+ * it already owned, a {@link claim} move for one another instance held.
119
+ *
120
+ * It keeps the registry's existing guarantees: a jobKey that has MOVED to another instance is never
121
+ * blanked by the OLD instance's resync (the old instance no longer holds it, so the drop pass cannot
122
+ * touch it), and repeated resyncs are safe. Only `instance`'s own claims are considered — a sibling's
123
+ * claims are untouched. An empty `instance` is ignored; empty jobKeys in the snapshot are skipped.
124
+ *
125
+ * This is ADDITIVE on the wire: a supervisor that never sends a resync snapshot keeps the pre-#893
126
+ * merge behaviour (claims cleared only by an explicit `release` or the presence-driven
127
+ * {@link reconcile}). Authoritative replacement is safe precisely because ownership is advisory,
128
+ * short-lived visibility the supervisor can always restate (ADR 0056 — the registry never gates a
129
+ * BPMN flow).
130
+ */
131
+ replaceInstance(instance: string, jobKeys: Iterable<string>): void {
132
+ if (instance === "") return;
133
+ const next = new Set<string>();
134
+ for (const jobKey of jobKeys) if (jobKey !== "") next.add(jobKey);
135
+ const current = this.#jobsOf.get(instance);
136
+ if (current) {
137
+ for (const jobKey of [...current]) {
138
+ if (!next.has(jobKey)) this.release(instance, jobKey);
139
+ }
140
+ }
141
+ for (const jobKey of next) this.claim(instance, jobKey);
142
+ }
143
+
110
144
  /**
111
145
  * Drop every claim whose owning instance is NOT in `presentInstances` — the bounded-memory
112
146
  * reconcile a maintenance tick drives against the live presence set, so a departed supervisor's
@@ -56,6 +56,10 @@ function releaseFrame(instance: string, jobKey: string): Frame {
56
56
  return { lane: "control", family: RELEASE_FAMILY, seq: 1, payload: { instance, jobKey } };
57
57
  }
58
58
 
59
+ function resyncFrame(instance: string, jobKeys: string[]): Frame {
60
+ return { lane: "control", family: CLAIM_FAMILY, seq: 1, payload: { instance, jobKeys } };
61
+ }
62
+
59
63
  function mountClaim(): { hub: AgenticHub; transport: ReturnType<typeof memTransport> } {
60
64
  const transport = memTransport();
61
65
  const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
@@ -189,6 +193,218 @@ test("a malformed claim payload is rejected without touching the registry (advis
189
193
  }
190
194
  });
191
195
 
196
+ test("a resync snapshot frame REPLACES an instance's claims — a RELEASE lost during an outage no longer strands the job", async () => {
197
+ const { transport } = mountClaim();
198
+ try {
199
+ const peer = fakeConn("c1", "host-supervisor");
200
+ transport.connect(peer.conn);
201
+ await flush();
202
+ peer.feed(claimFrame("wk-a", "8420"));
203
+ peer.feed(claimFrame("wk-a", "8421"));
204
+ await flush();
205
+
206
+ const reg = currentClaimRegistry();
207
+ assert(reg);
208
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420", "8421"]);
209
+
210
+ // The hub missed the `release` for 8420 during an outage. On reconnect the supervisor restates its
211
+ // authoritative snapshot: wk-a now owns only 8421. The non-re-asserted 8420 is dropped.
212
+ peer.feed(resyncFrame("wk-a", ["8421"]));
213
+ await flush();
214
+ assertEquals(reg.jobKeysFor("wk-a"), ["8421"], "the stranded claim was dropped on resync");
215
+ assertEquals(reg.count(), 1);
216
+ } finally {
217
+ family.teardown?.();
218
+ }
219
+ });
220
+
221
+ test("a resync snapshot does NOT blank a jobKey that moved to another instance", async () => {
222
+ const { transport } = mountClaim();
223
+ try {
224
+ const peer = fakeConn("c1", "host-supervisor");
225
+ transport.connect(peer.conn);
226
+ await flush();
227
+ peer.feed(claimFrame("wk-a", "8420"));
228
+ peer.feed(claimFrame("wk-b", "8420")); // 8420 moved to wk-b
229
+ await flush();
230
+
231
+ // wk-a's authoritative resync no longer mentions 8420 — and must not blank the live owner wk-b.
232
+ peer.feed(resyncFrame("wk-a", []));
233
+ await flush();
234
+
235
+ const reg = currentClaimRegistry();
236
+ assert(reg);
237
+ assertEquals(reg.jobKeysFor("wk-b"), ["8420"], "the current owner's claim survives");
238
+ assertEquals(reg.ownerOf("8420"), "wk-b");
239
+ } finally {
240
+ family.teardown?.();
241
+ }
242
+ });
243
+
244
+ test("a supervisor that never sends a resync snapshot keeps today's additive claim behaviour", async () => {
245
+ const { transport } = mountClaim();
246
+ try {
247
+ const peer = fakeConn("c1", "host-supervisor");
248
+ transport.connect(peer.conn);
249
+ await flush();
250
+ // Only per-job claims, no snapshot frame — a claim is never dropped except by an explicit release.
251
+ peer.feed(claimFrame("wk-a", "8420"));
252
+ peer.feed(claimFrame("wk-a", "8421"));
253
+ await flush();
254
+
255
+ const reg = currentClaimRegistry();
256
+ assert(reg);
257
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420", "8421"], "additive — neither claim is dropped");
258
+ assertEquals(reg.count(), 2);
259
+ } finally {
260
+ family.teardown?.();
261
+ }
262
+ });
263
+
264
+ test("a resync snapshot with a non-string instance is ignored (advisory, connection kept)", async () => {
265
+ const { transport } = mountClaim();
266
+ try {
267
+ const peer = fakeConn("c1", "host-supervisor");
268
+ transport.connect(peer.conn);
269
+ await flush();
270
+ peer.feed(claimFrame("wk-a", "8420"));
271
+ await flush();
272
+ // A malformed snapshot (no instance) is rejected without touching the registry.
273
+ peer.feed({ lane: "control", family: CLAIM_FAMILY, seq: 1, payload: { jobKeys: ["8421"] } });
274
+ await flush();
275
+
276
+ const reg = currentClaimRegistry();
277
+ assert(reg);
278
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420"], "the registry was untouched by the malformed snapshot");
279
+ assertEquals(reg.count(), 1);
280
+ } finally {
281
+ family.teardown?.();
282
+ }
283
+ });
284
+
285
+ test("a snapshot frame carrying BOTH jobKeys and a malformed jobKey is rejected — the snapshot branch never bypasses validation", async () => {
286
+ const { transport } = mountClaim();
287
+ try {
288
+ const peer = fakeConn("c1", "host-supervisor");
289
+ transport.connect(peer.conn);
290
+ await flush();
291
+ peer.feed(claimFrame("wk-a", "8420"));
292
+ await flush();
293
+ // The bypass the reviewer flagged: `jobKeys` present routed the frame straight to replaceInstance,
294
+ // so a smuggled empty `jobKey` was never validated. The union validator rejects it.
295
+ peer.feed({ lane: "control", family: CLAIM_FAMILY, seq: 1, payload: { instance: "wk-a", jobKey: "", jobKeys: ["8421"] } });
296
+ await flush();
297
+
298
+ const reg = currentClaimRegistry();
299
+ assert(reg);
300
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420"], "the malformed both-fields frame touched nothing");
301
+ assertEquals(reg.count(), 1);
302
+ } finally {
303
+ family.teardown?.();
304
+ }
305
+ });
306
+
307
+ test("a frame with a valid jobKey but a PRESENT malformed jobKeys is rejected — a broken snapshot can't launder as a per-job claim", async () => {
308
+ const { transport } = mountClaim();
309
+ try {
310
+ const peer = fakeConn("c1", "host-supervisor");
311
+ transport.connect(peer.conn);
312
+ await flush();
313
+ peer.feed(claimFrame("wk-a", "8420"));
314
+ await flush();
315
+ // The reviewer's case: `jobKeys` is PRESENT but malformed ("invalid"), while `jobKey` is valid.
316
+ // Presence — not validity — selects the snapshot variant, so a broken `jobKeys` must be rejected,
317
+ // never treated as absent and accepted as a bare per-job claim that mutates ownership.
318
+ peer.feed({ lane: "control", family: CLAIM_FAMILY, seq: 1, payload: { instance: "wk-a", jobKey: "8421", jobKeys: "invalid" } });
319
+ await flush();
320
+
321
+ const reg = currentClaimRegistry();
322
+ assert(reg);
323
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420"], "the malformed-snapshot frame touched nothing");
324
+ assertEquals(reg.count(), 1);
325
+ } finally {
326
+ family.teardown?.();
327
+ }
328
+ });
329
+
330
+ test("a snapshot frame with a PRESENT malformed jobKeys array is rejected as a broken snapshot (presence is the discriminant)", async () => {
331
+ const { transport } = mountClaim();
332
+ try {
333
+ const peer = fakeConn("c1", "host-supervisor");
334
+ transport.connect(peer.conn);
335
+ await flush();
336
+ peer.feed(claimFrame("wk-a", "8420"));
337
+ await flush();
338
+ // `jobKeys` present but not a string array → a broken snapshot; it is rejected outright, never
339
+ // mistaken for a per-job claim. (No `jobKey` here either, but presence alone is enough to reject.)
340
+ peer.feed({ lane: "control", family: CLAIM_FAMILY, seq: 1, payload: { instance: "wk-a", jobKeys: "8421" } });
341
+ await flush();
342
+
343
+ const reg = currentClaimRegistry();
344
+ assert(reg);
345
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420"], "the malformed frame touched nothing");
346
+ assertEquals(reg.count(), 1);
347
+ } finally {
348
+ family.teardown?.();
349
+ }
350
+ });
351
+
352
+ test("an EMPTY snapshot is a valid authoritative resync — the supervisor asserting it now owns nothing", async () => {
353
+ const { transport } = mountClaim();
354
+ try {
355
+ const peer = fakeConn("c1", "host-supervisor");
356
+ transport.connect(peer.conn);
357
+ await flush();
358
+ peer.feed(claimFrame("wk-a", "8420"));
359
+ await flush();
360
+
361
+ const reg = currentClaimRegistry();
362
+ assert(reg);
363
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420"]);
364
+
365
+ // The producer emits one snapshot per instance INCLUDING empty sets — an empty `jobKeys` is the
366
+ // meaningful "owns nothing now" resync, not a malformed frame.
367
+ peer.feed(resyncFrame("wk-a", []));
368
+ await flush();
369
+ assertEquals(reg.jobKeysFor("wk-a"), [], "the empty snapshot cleared the instance's claims");
370
+ assertEquals(reg.count(), 0);
371
+ } finally {
372
+ family.teardown?.();
373
+ }
374
+ });
375
+
376
+ test("a snapshot whose jobKeys contains an EMPTY-STRING element is rejected — it can't masquerade as an 'owns nothing' resync and clear real claims", async () => {
377
+ const { transport } = mountClaim();
378
+ try {
379
+ const peer = fakeConn("c1", "host-supervisor");
380
+ transport.connect(peer.conn);
381
+ await flush();
382
+ peer.feed(claimFrame("wk-a", "8420"));
383
+ await flush();
384
+
385
+ const reg = currentClaimRegistry();
386
+ assert(reg);
387
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420"]);
388
+
389
+ // The empty-ARRAY resync is valid ("owns nothing"), but a present `jobKeys` with an empty-STRING
390
+ // element is a BROKEN snapshot — `replaceInstance` silently skips empty keys, so `[""]` would
391
+ // otherwise masquerade as an authoritative empty snapshot and clear every real claim. It is held
392
+ // to the same non-empty bar as the per-job `jobKey` and rejected outright, touching nothing.
393
+ peer.feed({ lane: "control", family: CLAIM_FAMILY, seq: 1, payload: { instance: "wk-a", jobKeys: [""] } });
394
+ await flush();
395
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420"], "the sole-empty-string snapshot did not clear the real claim");
396
+ assertEquals(reg.count(), 1);
397
+
398
+ // A MIXED array with one empty element is equally a broken snapshot — fail closed, not partial.
399
+ peer.feed({ lane: "control", family: CLAIM_FAMILY, seq: 1, payload: { instance: "wk-a", jobKeys: ["8421", ""] } });
400
+ await flush();
401
+ assertEquals(reg.jobKeysFor("wk-a"), ["8420"], "the mixed empty-element snapshot touched nothing — not even the valid element");
402
+ assertEquals(reg.count(), 1);
403
+ } finally {
404
+ family.teardown?.();
405
+ }
406
+ });
407
+
192
408
  test("teardown clears the singleton", () => {
193
409
  mountClaim();
194
410
  assert(currentClaimRegistry() !== undefined);
@@ -13,7 +13,11 @@
13
13
  // the frame, NOT the connection id (`conn.id`); that is what lets one per-host supervisor multiplex N
14
14
  // distinct workers' ownership over a single connection. On a reconnect the supervisor re-`register`s
15
15
  // every worker and re-`claim`s every active jobKey, and the (idempotent) claim handler rebuilds the
16
- // registry from that resync.
16
+ // registry from that resync. A supervisor may additionally close its resync with an AUTHORITATIVE
17
+ // snapshot frame (`claim` carrying the full `jobKeys` set for an instance, issue #893 — the
18
+ // `claim.snapshot` wire contract in `app/contracts.ts`): the handler then `replaceInstance`s, dropping
19
+ // any claim the supervisor did NOT re-assert — so a `release` lost during a hub outage no longer
20
+ // strands the job. A supervisor that omits the snapshot keeps the pre-#893 additive behaviour.
17
21
  //
18
22
  // Liveness: a worker holding a claim reads "working" even with ZERO transcript — visibility no longer
19
23
  // depends on terminal bytes landing/correlating. A bounded-memory maintenance tick reconciles the
@@ -46,6 +50,81 @@ function readString(value: unknown, key: string): string | undefined {
46
50
  return typeof field === "string" ? field : undefined;
47
51
  }
48
52
 
53
+ /**
54
+ * The classification of a `claim` frame's `jobKeys` field. Its PRESENCE — NOT its validity — is the
55
+ * discriminant that tells an authoritative resync snapshot (`{ instance, jobKeys }`, issue #893) apart
56
+ * from a classic per-job claim (`{ instance, jobKey }`). So a PRESENT-but-malformed `jobKeys` is a
57
+ * BROKEN snapshot and must be rejected; it must never be treated as "absent" and laundered through the
58
+ * per-job validator (which tolerates the extra field), because that would let a malformed snapshot
59
+ * field mutate ownership on a frame that merely carries a valid `jobKey`.
60
+ */
61
+ type JobKeysField =
62
+ | { present: false }
63
+ | { present: true; malformed: true }
64
+ | { present: true; malformed: false; jobKeys: readonly string[] };
65
+
66
+ /**
67
+ * Classify a frame payload's `jobKeys` field. It rides the existing `claim` wire family as a
68
+ * forward-compatible enrichment — the protocol's structural validators tolerate the extra field, so an
69
+ * older hub ignores it and keeps additive behaviour. This is the SINGLE source that both the validator
70
+ * and the handler branch on, so the "present ⇒ snapshot" discriminant cannot drift between them.
71
+ */
72
+ function readJobKeysField(value: unknown): JobKeysField {
73
+ if (!value || typeof value !== "object") return { present: false };
74
+ if (!Object.hasOwn(value, "jobKeys")) return { present: false };
75
+ const field = Object.getOwnPropertyDescriptor(value, "jobKeys")?.value;
76
+ // Each element must be a NON-EMPTY string — the canonical per-job validator requires a non-empty
77
+ // `jobKey`, so a snapshot entry is held to the same bar. An empty-string element is NOT a benign
78
+ // "owns nothing" signal (that is the empty ARRAY): `replaceInstance` silently skips empty keys, so
79
+ // `{ instance, jobKeys: [""] }` would otherwise masquerade as an authoritative empty snapshot and
80
+ // clear every real claim. Fail closed — a present `jobKeys` with any empty element is a broken
81
+ // snapshot and is rejected, never laundered through the per-job validator.
82
+ if (!Array.isArray(field) || !field.every((k) => typeof k === "string" && k !== "")) {
83
+ return { present: true, malformed: true };
84
+ }
85
+ return { present: true, malformed: false, jobKeys: field };
86
+ }
87
+
88
+ /**
89
+ * The `claim` family carries TWO payload variants (issue #893 — the `claim.snapshot` wire contract in
90
+ * `app/contracts.ts`): the classic per-job claim `{ instance, jobKey }` and the authoritative resync
91
+ * snapshot `{ instance, jobKeys }`. The pinned `@nanobpm/agentic` validator predates the snapshot
92
+ * variant and REQUIRES a non-empty `jobKey`, so it cannot validate the union — routing a snapshot
93
+ * through `validatePayload` would reject the very frame that closes a resync. Validate the union
94
+ * locally instead: a payload is well-formed iff it is EITHER a valid snapshot (non-empty `instance`
95
+ * plus a `jobKeys` array of NON-EMPTY strings — an EMPTY array is the meaningful "owns nothing now"
96
+ * resync) OR a valid per-job claim per the canonical validator. This is a UNION with a VALIDATED
97
+ * OVERLAP, not an exclusive-or: a frame carrying BOTH fields is accepted, but only when BOTH halves
98
+ * validate — it is validated as a snapshot AND must still pass the per-job validator, so a malformed
99
+ * `jobKey` can no longer smuggle past on a snapshot frame.
100
+ */
101
+ function validateClaimPayload(payload: unknown): { ok: boolean; codes: string[] } {
102
+ const field = readJobKeysField(payload);
103
+ if (field.present) {
104
+ // PRESENCE selects the snapshot variant unconditionally. A present-but-malformed `jobKeys` is a
105
+ // broken snapshot — reject it here rather than letting it fall through to the per-job validator,
106
+ // which would accept it (extra fields tolerated) on any frame that also carries a valid `jobKey`.
107
+ if (field.malformed) {
108
+ return { ok: false, codes: ["bad-jobKeys"] };
109
+ }
110
+ const instance = readString(payload, "instance");
111
+ if (instance) {
112
+ // A frame carrying BOTH variants must satisfy both — the snapshot branch must never launder a
113
+ // malformed per-job field past validation. (readJobKeysField defined the value as an object.)
114
+ if (typeof payload === "object" && payload !== null && !Object.hasOwn(payload, "jobKey")) {
115
+ return { ok: true, codes: [] };
116
+ }
117
+ const both = validatePayload(CLAIM_FAMILY, payload);
118
+ return both.ok ? { ok: true, codes: [] } : { ok: false, codes: both.errors.map((e) => e.code) };
119
+ }
120
+ // A snapshot-shaped payload with a missing/empty instance is NOT a valid per-job claim either
121
+ // (jobKey is absent) — report the snapshot violation precisely.
122
+ return { ok: false, codes: ["bad-instance"] };
123
+ }
124
+ const result = validatePayload(CLAIM_FAMILY, payload);
125
+ return result.ok ? { ok: true, codes: [] } : { ok: false, codes: result.errors.map((e) => e.code) };
126
+ }
127
+
49
128
  interface MountState {
50
129
  readonly registry: ClaimRegistry;
51
130
  timer: ReturnType<typeof setTimeout> | undefined;
@@ -72,9 +151,26 @@ export const family: AgenticFamily = {
72
151
  // malformed payload is rejected before it touches the registry (advisory: logged, connection
73
152
  // kept). Both mutations are idempotent, matching the wire contract.
74
153
  ctx.hub.registerFamilyHandler(CLAIM_FAMILY, (frame: Frame, _conn: HubConnection) => {
75
- const result = validatePayload(CLAIM_FAMILY, frame.payload);
76
- if (!result.ok) {
77
- ctx.log.warn("agentic claim: malformed payload", { errors: result.errors.map((e) => e.code) });
154
+ // An authoritative resync SNAPSHOT (`{ instance, jobKeys }`, issue #893): the reconnecting
155
+ // supervisor restates the full current ownership for one instance and its view WINS —
156
+ // `replaceInstance` drops every claim it did NOT re-assert (so a `release` lost during a hub
157
+ // outage no longer strands the job). A supervisor that never sends it keeps additive behaviour.
158
+ // EVERY claim frame is validated first — as the snapshot/per-job union (a both-fields frame is
159
+ // accepted only when both halves validate) — so the snapshot
160
+ // branch never bypasses validation (a malformed frame is dropped before it touches the
161
+ // registry, exactly like a malformed per-job claim).
162
+ const valid = validateClaimPayload(frame.payload);
163
+ if (!valid.ok) {
164
+ ctx.log.warn("agentic claim: malformed payload", { errors: valid.codes });
165
+ return;
166
+ }
167
+ const field = readJobKeysField(frame.payload);
168
+ if (field.present && !field.malformed) {
169
+ // Validated above, so the instance is present and non-empty (and a malformed `jobKeys` was
170
+ // already rejected, so `field.present && field.malformed` can never reach the registry).
171
+ const instance = readString(frame.payload, "instance");
172
+ if (!instance) return;
173
+ registry.replaceInstance(instance, field.jobKeys);
78
174
  return;
79
175
  }
80
176
  const instance = readString(frame.payload, "instance");
package/app/contracts.ts CHANGED
@@ -443,6 +443,14 @@ export const WIRE_CONTRACTS = {
443
443
  "Op-tagged relay control frame a worker terminal chunk producer emits and the hub consumes. The op-tagged shape superseded the legacy positional `{stream, offset, chunk}` frame (nano-ide #234/#236); a producer must emit the op-tagged shape or the hub rejects it as `malformed relay message payload`.",
444
444
  shape: '{ op: "produce", incarnation: number, stream: string, offset: number, chunk: string }',
445
445
  },
446
+ "claim.snapshot": {
447
+ category: "wire",
448
+ name: "claim.snapshot",
449
+ owner: "app/agentic/families/claim.family.ts (hub consumer); nano-supervisor#63 (producer)",
450
+ semantics:
451
+ "Authoritative reconnect-resync snapshot riding the EXISTING `claim` wire family (wire code 8, nano-ide#542) as a forward-compatible enrichment — the issue #893 resync boundary. A reconnecting supervisor closes its per-instance resync with ONE snapshot frame carrying the full current jobKey set for that instance; the hub validates it (as a snapshot/per-job UNION with a VALIDATED OVERLAP, not an exclusive-or — a both-fields frame is accepted only when BOTH halves validate; the pinned `@nanobpm/agentic` validator predates this variant and requires a non-empty `jobKey`, so the union is validated hub-side until nano-ide versions the shape into the canonical protocol payloads) and calls `ClaimRegistry.replaceInstance(instance, jobKeys)`, dropping every claim the supervisor did NOT re-assert so a `release` lost during a hub outage no longer strands the job. `jobKeys` MAY be the empty ARRAY (the meaningful 'owns nothing now' resync — this is why the producer emits one snapshot per instance INCLUDING empty sets), but every ELEMENT must be a NON-EMPTY string (held to the same bar as the per-job `jobKey`); a present `jobKeys` that is a non-array, has a non-string element, or has an empty-string element is a BROKEN snapshot and is rejected (fail closed), never laundered through the per-job validator. The frame's explicit `instance` is the owning worker, never the connection id. Compatibility: the protocol's structural validators tolerate the extra field, so an older hub ignores it and keeps additive behaviour; a supervisor that never sends a snapshot keeps the pre-#893 additive behaviour. Distinct from the classic per-job claim `{ instance, jobKey }` (the `@nanobpm/agentic` `ClaimPayload`) — consume this ONE shape for the resync boundary, do not re-declare a synonym.",
452
+ shape: '{ instance: string, jobKeys: string[] } — a `claim` (wire code 8) frame whose `jobKeys` array PRESENCE is the discriminant; a UNION with the per-job `{ instance: string, jobKey: string }` (a both-fields frame is accepted only when both halves validate, not an exclusive-or)',
453
+ },
446
454
  "io.nanobpm.agentTask.repository": {
447
455
  category: "wire",
448
456
  name: "io.nanobpm.agentTask.repository",