@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.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +104 -0
- package/app/agentOutputEnvelope.test.ts +464 -0
- package/app/agentic/README.md +48 -0
- package/app/agentic/claim-registry.test.ts +61 -0
- package/app/agentic/claim-registry.ts +35 -1
- package/app/agentic/families/claim.family.test.ts +216 -0
- package/app/agentic/families/claim.family.ts +100 -4
- package/app/contracts.ts +8 -0
- package/app/mergeLoopBehaviour.test.ts +208 -1
- package/docs/agentic-cockpit.md +34 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +108 -1
- package/resources/processes/merge-loop.bpmn +80 -2
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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",
|