openclaw-node-harness 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/bin/lane-watchdog.js +23 -2
  2. package/bin/mesh-agent.js +38 -16
  3. package/bin/mesh-bridge.js +3 -2
  4. package/bin/mesh-health-publisher.js +41 -1
  5. package/bin/mesh-task-daemon.js +5 -0
  6. package/bin/mesh.js +8 -19
  7. package/install.sh +3 -2
  8. package/lib/agent-activity.js +2 -2
  9. package/lib/exec-safety.js +105 -0
  10. package/lib/kanban-io.js +15 -31
  11. package/lib/llm-providers.js +16 -0
  12. package/lib/mcp-knowledge/core.mjs +7 -5
  13. package/lib/mcp-knowledge/server.mjs +8 -1
  14. package/lib/mesh-collab.js +268 -250
  15. package/lib/mesh-plans.js +66 -45
  16. package/lib/mesh-tasks.js +89 -73
  17. package/lib/nats-resolve.js +4 -4
  18. package/lib/pre-compression-flush.mjs +2 -0
  19. package/lib/session-store.mjs +6 -3
  20. package/mission-control/src/app/api/memory/search/route.ts +6 -3
  21. package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
  22. package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
  23. package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
  24. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +7 -1
  25. package/mission-control/src/app/api/workspace/read/route.ts +11 -0
  26. package/mission-control/src/lib/config.ts +9 -0
  27. package/mission-control/src/lib/db/index.ts +16 -1
  28. package/mission-control/src/lib/memory/extract.ts +2 -1
  29. package/mission-control/src/lib/memory/retrieval.ts +3 -2
  30. package/mission-control/src/middleware.ts +82 -0
  31. package/package.json +1 -1
  32. package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
  33. package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
  34. package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
  35. package/services/launchd/ai.openclaw.mission-control.plist +1 -1
  36. package/uninstall.sh +37 -9
@@ -150,6 +150,28 @@ class CollabStore {
150
150
  return JSON.parse(sc.decode(entry.value));
151
151
  }
152
152
 
153
+ /**
154
+ * Compare-and-swap helper: read → mutate → write with optimistic concurrency.
155
+ * Re-reads and retries on conflict (up to maxRetries).
156
+ * mutateFn receives the parsed data and must return the updated object, or falsy to skip.
157
+ */
158
+ async _updateWithCAS(key, mutateFn, maxRetries = 3) {
159
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
160
+ const entry = await this.kv.get(key);
161
+ if (!entry) return null;
162
+ const data = JSON.parse(sc.decode(entry.value));
163
+ const updated = mutateFn(data);
164
+ if (!updated) return null;
165
+ try {
166
+ await this.kv.put(key, sc.encode(JSON.stringify(updated)), { previousSeq: entry.revision });
167
+ return updated;
168
+ } catch (err) {
169
+ if (attempt === maxRetries - 1) throw err;
170
+ // conflict — retry
171
+ }
172
+ }
173
+ }
174
+
153
175
  async delete(sessionId) {
154
176
  await this.kv.delete(sessionId);
155
177
  }
@@ -159,15 +181,15 @@ class CollabStore {
159
181
  */
160
182
  async appendAudit(sessionId, event, detail = {}) {
161
183
  try {
162
- const session = await this.get(sessionId);
163
- if (!session) return;
164
- if (!session.audit_log) session.audit_log = [];
165
- session.audit_log.push({
166
- ts: new Date().toISOString(),
167
- event,
168
- ...detail,
184
+ await this._updateWithCAS(sessionId, (session) => {
185
+ if (!session.audit_log) session.audit_log = [];
186
+ session.audit_log.push({
187
+ ts: new Date().toISOString(),
188
+ event,
189
+ ...detail,
190
+ });
191
+ return session;
169
192
  });
170
- await this.put(session);
171
193
  } catch (err) {
172
194
  // Best-effort — never block on audit, but log first N failures per session
173
195
  const count = (_auditErrorCounts.get(sessionId) || 0) + 1;
@@ -243,67 +265,59 @@ class CollabStore {
243
265
  * Returns the updated session or null if session full/closed.
244
266
  */
245
267
  async addNode(sessionId, nodeId, role = 'worker', scope = '*') {
246
- const session = await this.get(sessionId);
247
- if (!session) return null;
248
- if (session.status !== COLLAB_STATUS.RECRUITING) return null;
249
-
250
- // Check max_nodes
251
- if (session.max_nodes && session.nodes.length >= session.max_nodes) return null;
252
-
253
- // Check duplicate — single-threaded event loop prevents concurrent joins
254
- // from interleaving between find() and push(). No mutex needed.
255
- if (session.nodes.find(n => n.node_id === nodeId)) return null;
256
-
257
- session.nodes.push({
258
- node_id: nodeId,
259
- role,
260
- scope: Array.isArray(scope) ? scope : [scope],
261
- joined_at: new Date().toISOString(),
262
- status: 'active',
263
- });
268
+ return this._updateWithCAS(sessionId, (session) => {
269
+ if (session.status !== COLLAB_STATUS.RECRUITING) return null;
264
270
 
265
- // Set recruiting deadline on first join
266
- if (session.nodes.length === 1) {
267
- session.recruiting_deadline = new Date(
268
- Date.now() + session.join_window_s * 1000
269
- ).toISOString();
270
- }
271
+ // Check max_nodes
272
+ if (session.max_nodes && session.nodes.length >= session.max_nodes) return null;
271
273
 
272
- // For sequential mode, build turn order
273
- if (session.mode === COLLAB_MODE.SEQUENTIAL) {
274
- session.turn_order.push(nodeId);
275
- }
274
+ // Check duplicate
275
+ if (session.nodes.find(n => n.node_id === nodeId)) return null;
276
276
 
277
- await this.put(session);
278
- return session;
277
+ session.nodes.push({
278
+ node_id: nodeId,
279
+ role,
280
+ scope: Array.isArray(scope) ? scope : [scope],
281
+ joined_at: new Date().toISOString(),
282
+ status: 'active',
283
+ });
284
+
285
+ // Set recruiting deadline on first join
286
+ if (session.nodes.length === 1) {
287
+ session.recruiting_deadline = new Date(
288
+ Date.now() + session.join_window_s * 1000
289
+ ).toISOString();
290
+ }
291
+
292
+ // For sequential mode, build turn order
293
+ if (session.mode === COLLAB_MODE.SEQUENTIAL) {
294
+ session.turn_order.push(nodeId);
295
+ }
296
+
297
+ return session;
298
+ });
279
299
  }
280
300
 
281
301
  /**
282
302
  * Remove a node from the session (graceful leave or kick).
283
303
  */
284
304
  async removeNode(sessionId, nodeId) {
285
- const session = await this.get(sessionId);
286
- if (!session) return null;
287
-
288
- session.nodes = session.nodes.filter(n => n.node_id !== nodeId);
289
- session.turn_order = session.turn_order.filter(id => id !== nodeId);
290
-
291
- await this.put(session);
292
- return session;
305
+ return this._updateWithCAS(sessionId, (session) => {
306
+ session.nodes = session.nodes.filter(n => n.node_id !== nodeId);
307
+ session.turn_order = session.turn_order.filter(id => id !== nodeId);
308
+ return session;
309
+ });
293
310
  }
294
311
 
295
312
  /**
296
313
  * Update a node's status within the session.
297
314
  */
298
315
  async setNodeStatus(sessionId, nodeId, status) {
299
- const session = await this.get(sessionId);
300
- if (!session) return null;
301
-
302
- const node = session.nodes.find(n => n.node_id === nodeId);
303
- if (node) node.status = status;
304
-
305
- await this.put(session);
306
- return session;
316
+ return this._updateWithCAS(sessionId, (session) => {
317
+ const node = session.nodes.find(n => n.node_id === nodeId);
318
+ if (node) node.status = status;
319
+ return session;
320
+ });
307
321
  }
308
322
 
309
323
  /**
@@ -323,54 +337,54 @@ class CollabStore {
323
337
  * Start a new round. Returns the round object with shared_intel.
324
338
  */
325
339
  async startRound(sessionId) {
326
- const session = await this.get(sessionId);
327
- if (!session) return null;
328
-
329
- session.current_round++;
330
- session.status = COLLAB_STATUS.ACTIVE;
340
+ let round = null;
341
+ const result = await this._updateWithCAS(sessionId, (session) => {
342
+ session.current_round++;
343
+ session.status = COLLAB_STATUS.ACTIVE;
344
+
345
+ // Snapshot recruited count on first round (immutable baseline for quorum)
346
+ if (session.current_round === 1) {
347
+ session.recruited_count = session.nodes.length;
348
+ }
331
349
 
332
- // Snapshot recruited count on first round (immutable baseline for quorum)
333
- if (session.current_round === 1) {
334
- session.recruited_count = session.nodes.length;
335
- }
350
+ // Per-round node health: prune nodes marked 'dead' before starting.
351
+ const deadNodes = session.nodes.filter(n => n.status === 'dead');
352
+ if (deadNodes.length > 0) {
353
+ session.nodes = session.nodes.filter(n => n.status !== 'dead');
354
+ session.turn_order = session.turn_order.filter(
355
+ id => !deadNodes.find(d => d.node_id === id)
356
+ );
357
+ }
336
358
 
337
- // Per-round node health: prune nodes marked 'dead' before starting.
338
- // This prevents hanging on reflections from nodes that will never respond.
339
- const deadNodes = session.nodes.filter(n => n.status === 'dead');
340
- if (deadNodes.length > 0) {
341
- session.nodes = session.nodes.filter(n => n.status !== 'dead');
342
- session.turn_order = session.turn_order.filter(
343
- id => !deadNodes.find(d => d.node_id === id)
344
- );
345
- }
359
+ // Check if we still have enough nodes after pruning
360
+ if (session.nodes.length < session.min_nodes) {
361
+ session.status = COLLAB_STATUS.ABORTED;
362
+ round = null;
363
+ return session;
364
+ }
346
365
 
347
- // Check if we still have enough nodes after pruning
348
- if (session.nodes.length < session.min_nodes) {
349
- // Not enough active nodes to continue — will be caught by caller
350
- session.status = COLLAB_STATUS.ABORTED;
351
- await this.put(session);
352
- return null;
353
- }
366
+ // Compile shared intel from previous round
367
+ const sharedIntel = this.compileSharedIntel(session);
354
368
 
355
- // Compile shared intel from previous round
356
- const sharedIntel = this.compileSharedIntel(session);
369
+ round = {
370
+ round_number: session.current_round,
371
+ started_at: new Date().toISOString(),
372
+ completed_at: null,
373
+ shared_intel: sharedIntel,
374
+ reflections: [],
375
+ };
357
376
 
358
- const round = {
359
- round_number: session.current_round,
360
- started_at: new Date().toISOString(),
361
- completed_at: null,
362
- shared_intel: sharedIntel,
363
- reflections: [],
364
- };
377
+ session.rounds.push(round);
365
378
 
366
- session.rounds.push(round);
379
+ // Sequential mode: set first turn
380
+ if (session.mode === COLLAB_MODE.SEQUENTIAL && session.turn_order.length > 0) {
381
+ session.current_turn = session.turn_order[0];
382
+ }
367
383
 
368
- // Sequential mode: set first turn
369
- if (session.mode === COLLAB_MODE.SEQUENTIAL && session.turn_order.length > 0) {
370
- session.current_turn = session.turn_order[0];
371
- }
384
+ return session;
385
+ });
372
386
 
373
- await this.put(session);
387
+ if (!result) return null;
374
388
  return round;
375
389
  }
376
390
 
@@ -378,38 +392,35 @@ class CollabStore {
378
392
  * Submit a reflection from a node for the current round.
379
393
  */
380
394
  async submitReflection(sessionId, reflection) {
381
- const session = await this.get(sessionId);
382
- if (!session) return null;
395
+ return this._updateWithCAS(sessionId, (session) => {
396
+ // Only accept reflections on active sessions
397
+ if (session.status !== COLLAB_STATUS.ACTIVE) return null;
383
398
 
384
- // Only accept reflections on active sessions
385
- if (session.status !== COLLAB_STATUS.ACTIVE) return null;
386
-
387
- const currentRound = session.rounds[session.rounds.length - 1];
388
- if (!currentRound) return null;
389
-
390
- // Prevent duplicate reflections from same node
391
- if (currentRound.reflections.find(r => r.node_id === reflection.node_id)) return null;
392
-
393
- currentRound.reflections.push({
394
- node_id: reflection.node_id,
395
- summary: reflection.summary || '',
396
- learnings: reflection.learnings || '',
397
- artifacts: reflection.artifacts || [],
398
- confidence: reflection.confidence || 0.5,
399
- vote: reflection.vote || 'continue',
400
- parse_failed: reflection.parse_failed || false,
401
- submitted_at: new Date().toISOString(),
402
- // Circling Strategy extensions (optional, backward compatible)
403
- circling_step: reflection.circling_step ?? null,
404
- circling_artifacts: reflection.circling_artifacts || [], // [{ type, content }]
405
- });
399
+ const currentRound = session.rounds[session.rounds.length - 1];
400
+ if (!currentRound) return null;
401
+
402
+ // Prevent duplicate reflections from same node
403
+ if (currentRound.reflections.find(r => r.node_id === reflection.node_id)) return null;
404
+
405
+ currentRound.reflections.push({
406
+ node_id: reflection.node_id,
407
+ summary: reflection.summary || '',
408
+ learnings: reflection.learnings || '',
409
+ artifacts: reflection.artifacts || [],
410
+ confidence: reflection.confidence || 0.5,
411
+ vote: reflection.vote || 'continue',
412
+ parse_failed: reflection.parse_failed || false,
413
+ submitted_at: new Date().toISOString(),
414
+ circling_step: reflection.circling_step ?? null,
415
+ circling_artifacts: reflection.circling_artifacts || [],
416
+ });
406
417
 
407
- // Update node status
408
- const node = session.nodes.find(n => n.node_id === reflection.node_id);
409
- if (node) node.status = 'reflecting';
418
+ // Update node status
419
+ const node = session.nodes.find(n => n.node_id === reflection.node_id);
420
+ if (node) node.status = 'reflecting';
410
421
 
411
- await this.put(session);
412
- return session;
422
+ return session;
423
+ });
413
424
  }
414
425
 
415
426
  /**
@@ -428,22 +439,24 @@ class CollabStore {
428
439
  * Returns the next node_id, or null if all turns done (round complete).
429
440
  */
430
441
  async advanceTurn(sessionId) {
431
- const session = await this.get(sessionId);
432
- if (!session || session.mode !== COLLAB_MODE.SEQUENTIAL) return null;
442
+ let nextTurn = null;
443
+ await this._updateWithCAS(sessionId, (session) => {
444
+ if (session.mode !== COLLAB_MODE.SEQUENTIAL) return null;
433
445
 
434
- const currentIdx = session.turn_order.indexOf(session.current_turn);
435
- const nextIdx = currentIdx + 1;
446
+ const currentIdx = session.turn_order.indexOf(session.current_turn);
447
+ const nextIdx = currentIdx + 1;
436
448
 
437
- if (nextIdx >= session.turn_order.length) {
438
- // All nodes had their turn — round is complete
439
- session.current_turn = null;
440
- await this.put(session);
441
- return null;
442
- }
449
+ if (nextIdx >= session.turn_order.length) {
450
+ session.current_turn = null;
451
+ nextTurn = null;
452
+ } else {
453
+ session.current_turn = session.turn_order[nextIdx];
454
+ nextTurn = session.current_turn;
455
+ }
443
456
 
444
- session.current_turn = session.turn_order[nextIdx];
445
- await this.put(session);
446
- return session.current_turn;
457
+ return session;
458
+ });
459
+ return nextTurn;
447
460
  }
448
461
 
449
462
  // ── Convergence ────────────────────────────────────
@@ -511,31 +524,39 @@ class CollabStore {
511
524
  * Key format: sr{N}_step{S}_{nodeRole}_{artifactType}
512
525
  */
513
526
  async storeArtifact(sessionId, key, content) {
514
- const session = await this.get(sessionId);
515
- if (!session || !session.circling) return null;
516
- session.circling.artifacts[key] = content;
517
-
518
- // Session blob size check — JetStream KV max is 1MB.
519
- // Warn early so operators can plan external artifact store before hitting the wall.
520
- const blobSize = Buffer.byteLength(JSON.stringify(session), 'utf8');
521
- if (blobSize > 950_000) {
522
- console.error(`[collab] CRITICAL: session ${sessionId} blob is ${(blobSize / 1024).toFixed(0)}KB — approaching JetStream KV 1MB limit`);
523
- } else if (blobSize > 800_000) {
524
- console.warn(`[collab] WARNING: session ${sessionId} blob is ${(blobSize / 1024).toFixed(0)}KB — consider external artifact store`);
525
- }
527
+ for (let attempt = 0; attempt < 3; attempt++) {
528
+ const entry = await this.kv.get(sessionId);
529
+ if (!entry) return null;
530
+ const session = JSON.parse(sc.decode(entry.value));
531
+ if (!session.circling) return null;
532
+ session.circling.artifacts[key] = content;
533
+
534
+ // Session blob size check — JetStream KV max is 1MB.
535
+ const blobSize = Buffer.byteLength(JSON.stringify(session), 'utf8');
536
+ if (blobSize > 950_000) {
537
+ console.error(`[collab] CRITICAL: session ${sessionId} blob is ${(blobSize / 1024).toFixed(0)}KB — approaching JetStream KV 1MB limit`);
538
+ } else if (blobSize > 800_000) {
539
+ console.warn(`[collab] WARNING: session ${sessionId} blob is ${(blobSize / 1024).toFixed(0)}KB — consider external artifact store`);
540
+ }
526
541
 
527
- try {
528
- await this.put(session);
529
- } catch (err) {
530
- // JetStream KV write failure — likely blob exceeded 1MB limit.
531
- // Remove the artifact that caused the overflow and re-persist without it.
532
- console.error(`[collab] storeArtifact FAILED for ${sessionId}/${key}: ${err.message}. Removing artifact and persisting without it.`);
533
- delete session.circling.artifacts[key];
534
- await this.put(session);
535
- return null;
542
+ try {
543
+ await this.kv.put(sessionId, sc.encode(JSON.stringify(session)), { previousSeq: entry.revision });
544
+ return session;
545
+ } catch (err) {
546
+ // Check if it's a blob-too-large error vs a CAS conflict
547
+ // CAS conflicts have specific error codes; blob failures are different
548
+ if (attempt === 2) {
549
+ // Final attempt — could be blob overflow or persistent conflict
550
+ console.error(`[collab] storeArtifact FAILED for ${sessionId}/${key}: ${err.message}. Removing artifact and persisting without it.`);
551
+ delete session.circling.artifacts[key];
552
+ try {
553
+ await this.kv.put(sessionId, sc.encode(JSON.stringify(session)));
554
+ } catch (_) { /* best effort */ }
555
+ return null;
556
+ }
557
+ // conflict — retry
558
+ }
536
559
  }
537
-
538
- return session;
539
560
  }
540
561
 
541
562
  /**
@@ -691,67 +712,63 @@ class CollabStore {
691
712
  * circling/step2 (SR == max) → finalization/step0
692
713
  */
693
714
  async advanceCirclingStep(sessionId) {
694
- const session = await this.get(sessionId);
695
- if (!session || !session.circling) return null;
715
+ let stepResult = null;
716
+ const result = await this._updateWithCAS(sessionId, (session) => {
717
+ if (!session.circling) return null;
696
718
 
697
- const c = session.circling;
698
- let needsGate = false;
699
-
700
- if (c.phase === 'init' && c.current_step === 0) {
701
- // Init complete → start circling SR1/Step1
702
- c.phase = 'circling';
703
- c.current_subround = 1;
704
- c.current_step = 1;
705
- } else if (c.phase === 'circling' && c.current_step === 1) {
706
- // Step 1 complete → Step 2 (same subround)
707
- c.current_step = 2;
708
- } else if (c.phase === 'circling' && c.current_step === 2) {
709
- // Adaptive convergence: if all active nodes voted 'converged' after step 2,
710
- // skip remaining sub-rounds and go directly to finalization.
711
- const currentRound = session.rounds[session.rounds.length - 1];
712
- const activeNodes = session.nodes.filter(n => n.status !== 'dead');
713
- const step2Reflections = currentRound
714
- ? currentRound.reflections.filter(r => r.circling_step === 2)
715
- : [];
716
- const allConverged = step2Reflections.length >= activeNodes.length &&
717
- step2Reflections.every(r => r.vote === 'converged');
718
-
719
- if (allConverged && c.current_subround < c.max_subrounds) {
720
- // Early exit — all nodes agree the work is ready
721
- if (c.automation_tier >= 2) {
722
- needsGate = true;
723
- }
724
- c.phase = 'finalization';
725
- c.current_step = 0;
726
- } else if (c.current_subround < c.max_subrounds) {
727
- // Step 2 complete, more sub-rounds → next SR/Step1
728
- // Check tier gate for Tier 3 (gates after every sub-round)
729
- if (c.automation_tier === 3) {
730
- needsGate = true;
731
- }
732
- c.current_subround++;
719
+ const c = session.circling;
720
+ let needsGate = false;
721
+
722
+ if (c.phase === 'init' && c.current_step === 0) {
723
+ c.phase = 'circling';
724
+ c.current_subround = 1;
733
725
  c.current_step = 1;
734
- } else {
735
- // Final sub-round complete → finalization
736
- // Tier 2 gates on finalization entry
737
- if (c.automation_tier >= 2) {
738
- needsGate = true;
726
+ } else if (c.phase === 'circling' && c.current_step === 1) {
727
+ c.current_step = 2;
728
+ } else if (c.phase === 'circling' && c.current_step === 2) {
729
+ const currentRound = session.rounds[session.rounds.length - 1];
730
+ const activeNodes = session.nodes.filter(n => n.status !== 'dead');
731
+ const step2Reflections = currentRound
732
+ ? currentRound.reflections.filter(r => r.circling_step === 2)
733
+ : [];
734
+ const allConverged = step2Reflections.length >= activeNodes.length &&
735
+ step2Reflections.every(r => r.vote === 'converged');
736
+
737
+ if (allConverged && c.current_subround < c.max_subrounds) {
738
+ if (c.automation_tier >= 2) {
739
+ needsGate = true;
740
+ }
741
+ c.phase = 'finalization';
742
+ c.current_step = 0;
743
+ } else if (c.current_subround < c.max_subrounds) {
744
+ if (c.automation_tier === 3) {
745
+ needsGate = true;
746
+ }
747
+ c.current_subround++;
748
+ c.current_step = 1;
749
+ } else {
750
+ if (c.automation_tier >= 2) {
751
+ needsGate = true;
752
+ }
753
+ c.phase = 'finalization';
754
+ c.current_step = 0;
739
755
  }
740
- c.phase = 'finalization';
741
- c.current_step = 0;
756
+ } else if (c.phase === 'finalization') {
757
+ c.phase = 'complete';
742
758
  }
743
- } else if (c.phase === 'finalization') {
744
- // Finalization complete → done
745
- c.phase = 'complete';
746
- }
747
759
 
748
- await this.put(session);
749
- return {
750
- phase: c.phase,
751
- subround: c.current_subround,
752
- step: c.current_step,
753
- needsGate,
754
- };
760
+ stepResult = {
761
+ phase: c.phase,
762
+ subround: c.current_subround,
763
+ step: c.current_step,
764
+ needsGate,
765
+ };
766
+
767
+ return session;
768
+ });
769
+
770
+ if (!result) return null;
771
+ return stepResult;
755
772
  }
756
773
 
757
774
  /**
@@ -759,12 +776,16 @@ class CollabStore {
759
776
  * Returns the failure count.
760
777
  */
761
778
  async recordArtifactFailure(sessionId, nodeId) {
762
- const session = await this.get(sessionId);
763
- if (!session || !session.circling) return 0;
764
- const key = `${nodeId}_sr${session.circling.current_subround}_step${session.circling.current_step}`;
765
- session.circling.artifact_failures[key] = (session.circling.artifact_failures[key] || 0) + 1;
766
- await this.put(session);
767
- return session.circling.artifact_failures[key];
779
+ let failureCount = 0;
780
+ const result = await this._updateWithCAS(sessionId, (session) => {
781
+ if (!session.circling) return null;
782
+ const key = `${nodeId}_sr${session.circling.current_subround}_step${session.circling.current_step}`;
783
+ session.circling.artifact_failures[key] = (session.circling.artifact_failures[key] || 0) + 1;
784
+ failureCount = session.circling.artifact_failures[key];
785
+ return session;
786
+ });
787
+ if (!result) return 0;
788
+ return failureCount;
768
789
  }
769
790
 
770
791
  /**
@@ -814,34 +835,32 @@ class CollabStore {
814
835
  * Mark session as converged.
815
836
  */
816
837
  async markConverged(sessionId) {
817
- const session = await this.get(sessionId);
818
- if (!session) return null;
819
- session.status = COLLAB_STATUS.CONVERGED;
838
+ return this._updateWithCAS(sessionId, (session) => {
839
+ session.status = COLLAB_STATUS.CONVERGED;
820
840
 
821
- // Close current round
822
- const currentRound = session.rounds[session.rounds.length - 1];
823
- if (currentRound) currentRound.completed_at = new Date().toISOString();
841
+ // Close current round
842
+ const currentRound = session.rounds[session.rounds.length - 1];
843
+ if (currentRound) currentRound.completed_at = new Date().toISOString();
824
844
 
825
- await this.put(session);
826
- return session;
845
+ return session;
846
+ });
827
847
  }
828
848
 
829
849
  /**
830
850
  * Mark session as completed with final result.
831
851
  */
832
852
  async markCompleted(sessionId, result) {
833
- const session = await this.get(sessionId);
834
- if (!session) return null;
835
- session.status = COLLAB_STATUS.COMPLETED;
836
- session.completed_at = new Date().toISOString();
837
- session.result = {
838
- artifacts: result.artifacts || [],
839
- summary: result.summary || '',
840
- rounds_taken: session.current_round,
841
- node_contributions: result.node_contributions || {},
842
- };
843
- await this.put(session);
844
- return session;
853
+ return this._updateWithCAS(sessionId, (session) => {
854
+ session.status = COLLAB_STATUS.COMPLETED;
855
+ session.completed_at = new Date().toISOString();
856
+ session.result = {
857
+ artifacts: result.artifacts || [],
858
+ summary: result.summary || '',
859
+ rounds_taken: session.current_round,
860
+ node_contributions: result.node_contributions || {},
861
+ };
862
+ return session;
863
+ });
845
864
  }
846
865
 
847
866
  /**
@@ -849,15 +868,14 @@ class CollabStore {
849
868
  * Callers can use truthiness to detect whether the abort actually happened.
850
869
  */
851
870
  async markAborted(sessionId, reason) {
852
- const session = await this.get(sessionId);
853
- if (!session) return null;
854
- // Guard: don't corrupt completed/aborted sessions
855
- if (['completed', 'aborted'].includes(session.status)) return null;
856
- session.status = COLLAB_STATUS.ABORTED;
857
- session.completed_at = new Date().toISOString();
858
- session.result = { success: false, summary: reason, aborted: true };
859
- await this.put(session);
860
- return session;
871
+ return this._updateWithCAS(sessionId, (session) => {
872
+ // Guard: don't corrupt completed/aborted sessions
873
+ if (['completed', 'aborted'].includes(session.status)) return null;
874
+ session.status = COLLAB_STATUS.ABORTED;
875
+ session.completed_at = new Date().toISOString();
876
+ session.result = { success: false, summary: reason, aborted: true };
877
+ return session;
878
+ });
861
879
  }
862
880
 
863
881
  /**