openclaw-node-harness 2.1.0 → 2.2.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.
Files changed (52) hide show
  1. package/bin/lane-watchdog.js +54 -23
  2. package/bin/mesh-agent.js +49 -18
  3. package/bin/mesh-bridge.js +3 -2
  4. package/bin/mesh-deploy.js +4 -0
  5. package/bin/mesh-health-publisher.js +41 -1
  6. package/bin/mesh-task-daemon.js +14 -4
  7. package/bin/mesh.js +17 -43
  8. package/install.sh +3 -2
  9. package/lib/agent-activity.js +2 -2
  10. package/lib/exec-safety.js +163 -0
  11. package/lib/kanban-io.js +20 -33
  12. package/lib/llm-providers.js +27 -0
  13. package/lib/mcp-knowledge/core.mjs +7 -5
  14. package/lib/mcp-knowledge/server.mjs +8 -1
  15. package/lib/mesh-collab.js +274 -250
  16. package/lib/mesh-harness.js +6 -0
  17. package/lib/mesh-plans.js +84 -45
  18. package/lib/mesh-tasks.js +113 -81
  19. package/lib/nats-resolve.js +4 -4
  20. package/lib/pre-compression-flush.mjs +2 -0
  21. package/lib/session-store.mjs +6 -3
  22. package/mission-control/package-lock.json +4188 -3698
  23. package/mission-control/package.json +2 -2
  24. package/mission-control/src/app/api/diagnostics/route.ts +8 -0
  25. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +8 -0
  26. package/mission-control/src/app/api/memory/graph/route.ts +34 -18
  27. package/mission-control/src/app/api/memory/search/route.ts +9 -5
  28. package/mission-control/src/app/api/mesh/identity/route.ts +13 -5
  29. package/mission-control/src/app/api/mesh/nodes/route.ts +8 -0
  30. package/mission-control/src/app/api/settings/gateway/route.ts +62 -0
  31. package/mission-control/src/app/api/souls/[id]/evolution/route.ts +49 -12
  32. package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
  33. package/mission-control/src/app/api/souls/[id]/propagate/route.ts +24 -5
  34. package/mission-control/src/app/api/souls/route.ts +6 -4
  35. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +7 -1
  36. package/mission-control/src/app/api/tasks/[id]/route.ts +20 -4
  37. package/mission-control/src/app/api/tasks/route.ts +68 -9
  38. package/mission-control/src/app/api/workspace/read/route.ts +11 -0
  39. package/mission-control/src/lib/config.ts +11 -2
  40. package/mission-control/src/lib/db/index.ts +16 -1
  41. package/mission-control/src/lib/memory/extract.ts +2 -1
  42. package/mission-control/src/lib/memory/retrieval.ts +3 -2
  43. package/mission-control/src/lib/sync/tasks.ts +4 -1
  44. package/mission-control/src/middleware.ts +82 -0
  45. package/package.json +1 -1
  46. package/services/launchd/ai.openclaw.lane-watchdog.plist +1 -1
  47. package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
  48. package/services/launchd/ai.openclaw.mesh-agent.plist +4 -0
  49. package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
  50. package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
  51. package/services/launchd/ai.openclaw.mission-control.plist +5 -4
  52. package/uninstall.sh +37 -9
@@ -150,6 +150,29 @@ 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
+ const isCasConflict = err.code === '10071' || (err.message && err.message.includes('wrong last sequence'));
170
+ if (!isCasConflict || attempt === maxRetries - 1) throw err;
171
+ // CAS conflict — retry
172
+ }
173
+ }
174
+ }
175
+
153
176
  async delete(sessionId) {
154
177
  await this.kv.delete(sessionId);
155
178
  }
@@ -159,15 +182,15 @@ class CollabStore {
159
182
  */
160
183
  async appendAudit(sessionId, event, detail = {}) {
161
184
  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,
185
+ await this._updateWithCAS(sessionId, (session) => {
186
+ if (!session.audit_log) session.audit_log = [];
187
+ session.audit_log.push({
188
+ ts: new Date().toISOString(),
189
+ event,
190
+ ...detail,
191
+ });
192
+ return session;
169
193
  });
170
- await this.put(session);
171
194
  } catch (err) {
172
195
  // Best-effort — never block on audit, but log first N failures per session
173
196
  const count = (_auditErrorCounts.get(sessionId) || 0) + 1;
@@ -243,67 +266,59 @@ class CollabStore {
243
266
  * Returns the updated session or null if session full/closed.
244
267
  */
245
268
  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
- });
269
+ return this._updateWithCAS(sessionId, (session) => {
270
+ if (session.status !== COLLAB_STATUS.RECRUITING) return null;
264
271
 
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
- }
272
+ // Check max_nodes
273
+ if (session.max_nodes && session.nodes.length >= session.max_nodes) return null;
271
274
 
272
- // For sequential mode, build turn order
273
- if (session.mode === COLLAB_MODE.SEQUENTIAL) {
274
- session.turn_order.push(nodeId);
275
- }
275
+ // Check duplicate
276
+ if (session.nodes.find(n => n.node_id === nodeId)) return null;
276
277
 
277
- await this.put(session);
278
- return session;
278
+ session.nodes.push({
279
+ node_id: nodeId,
280
+ role,
281
+ scope: Array.isArray(scope) ? scope : [scope],
282
+ joined_at: new Date().toISOString(),
283
+ status: 'active',
284
+ });
285
+
286
+ // Set recruiting deadline on first join
287
+ if (session.nodes.length === 1) {
288
+ session.recruiting_deadline = new Date(
289
+ Date.now() + session.join_window_s * 1000
290
+ ).toISOString();
291
+ }
292
+
293
+ // For sequential mode, build turn order
294
+ if (session.mode === COLLAB_MODE.SEQUENTIAL) {
295
+ session.turn_order.push(nodeId);
296
+ }
297
+
298
+ return session;
299
+ });
279
300
  }
280
301
 
281
302
  /**
282
303
  * Remove a node from the session (graceful leave or kick).
283
304
  */
284
305
  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;
306
+ return this._updateWithCAS(sessionId, (session) => {
307
+ session.nodes = session.nodes.filter(n => n.node_id !== nodeId);
308
+ session.turn_order = session.turn_order.filter(id => id !== nodeId);
309
+ return session;
310
+ });
293
311
  }
294
312
 
295
313
  /**
296
314
  * Update a node's status within the session.
297
315
  */
298
316
  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;
317
+ return this._updateWithCAS(sessionId, (session) => {
318
+ const node = session.nodes.find(n => n.node_id === nodeId);
319
+ if (node) node.status = status;
320
+ return session;
321
+ });
307
322
  }
308
323
 
309
324
  /**
@@ -323,54 +338,54 @@ class CollabStore {
323
338
  * Start a new round. Returns the round object with shared_intel.
324
339
  */
325
340
  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;
341
+ let round = null;
342
+ const result = await this._updateWithCAS(sessionId, (session) => {
343
+ session.current_round++;
344
+ session.status = COLLAB_STATUS.ACTIVE;
345
+
346
+ // Snapshot recruited count on first round (immutable baseline for quorum)
347
+ if (session.current_round === 1) {
348
+ session.recruited_count = session.nodes.length;
349
+ }
331
350
 
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
- }
351
+ // Per-round node health: prune nodes marked 'dead' before starting.
352
+ const deadNodes = session.nodes.filter(n => n.status === 'dead');
353
+ if (deadNodes.length > 0) {
354
+ session.nodes = session.nodes.filter(n => n.status !== 'dead');
355
+ session.turn_order = session.turn_order.filter(
356
+ id => !deadNodes.find(d => d.node_id === id)
357
+ );
358
+ }
336
359
 
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
- }
360
+ // Check if we still have enough nodes after pruning
361
+ if (session.nodes.length < session.min_nodes) {
362
+ session.status = COLLAB_STATUS.ABORTED;
363
+ round = null;
364
+ return session;
365
+ }
346
366
 
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
- }
367
+ // Compile shared intel from previous round
368
+ const sharedIntel = this.compileSharedIntel(session);
354
369
 
355
- // Compile shared intel from previous round
356
- const sharedIntel = this.compileSharedIntel(session);
370
+ round = {
371
+ round_number: session.current_round,
372
+ started_at: new Date().toISOString(),
373
+ completed_at: null,
374
+ shared_intel: sharedIntel,
375
+ reflections: [],
376
+ };
357
377
 
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
- };
378
+ session.rounds.push(round);
365
379
 
366
- session.rounds.push(round);
380
+ // Sequential mode: set first turn
381
+ if (session.mode === COLLAB_MODE.SEQUENTIAL && session.turn_order.length > 0) {
382
+ session.current_turn = session.turn_order[0];
383
+ }
367
384
 
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
- }
385
+ return session;
386
+ });
372
387
 
373
- await this.put(session);
388
+ if (!result) return null;
374
389
  return round;
375
390
  }
376
391
 
@@ -378,38 +393,35 @@ class CollabStore {
378
393
  * Submit a reflection from a node for the current round.
379
394
  */
380
395
  async submitReflection(sessionId, reflection) {
381
- const session = await this.get(sessionId);
382
- if (!session) return null;
396
+ return this._updateWithCAS(sessionId, (session) => {
397
+ // Only accept reflections on active sessions
398
+ if (session.status !== COLLAB_STATUS.ACTIVE) return null;
383
399
 
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
- });
400
+ const currentRound = session.rounds[session.rounds.length - 1];
401
+ if (!currentRound) return null;
402
+
403
+ // Prevent duplicate reflections from same node
404
+ if (currentRound.reflections.find(r => r.node_id === reflection.node_id)) return null;
405
+
406
+ currentRound.reflections.push({
407
+ node_id: reflection.node_id,
408
+ summary: reflection.summary || '',
409
+ learnings: reflection.learnings || '',
410
+ artifacts: reflection.artifacts || [],
411
+ confidence: reflection.confidence || 0.5,
412
+ vote: reflection.vote || 'continue',
413
+ parse_failed: reflection.parse_failed || false,
414
+ submitted_at: new Date().toISOString(),
415
+ circling_step: reflection.circling_step ?? null,
416
+ circling_artifacts: reflection.circling_artifacts || [],
417
+ });
406
418
 
407
- // Update node status
408
- const node = session.nodes.find(n => n.node_id === reflection.node_id);
409
- if (node) node.status = 'reflecting';
419
+ // Update node status
420
+ const node = session.nodes.find(n => n.node_id === reflection.node_id);
421
+ if (node) node.status = 'reflecting';
410
422
 
411
- await this.put(session);
412
- return session;
423
+ return session;
424
+ });
413
425
  }
414
426
 
415
427
  /**
@@ -428,22 +440,25 @@ class CollabStore {
428
440
  * Returns the next node_id, or null if all turns done (round complete).
429
441
  */
430
442
  async advanceTurn(sessionId) {
431
- const session = await this.get(sessionId);
432
- if (!session || session.mode !== COLLAB_MODE.SEQUENTIAL) return null;
443
+ let nextTurn = null;
444
+ await this._updateWithCAS(sessionId, (session) => {
445
+ if (session.mode !== COLLAB_MODE.SEQUENTIAL) return null;
446
+ if (session.status !== 'active') return null;
433
447
 
434
- const currentIdx = session.turn_order.indexOf(session.current_turn);
435
- const nextIdx = currentIdx + 1;
448
+ const currentIdx = session.turn_order.indexOf(session.current_turn);
449
+ const nextIdx = currentIdx + 1;
436
450
 
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
- }
451
+ if (nextIdx >= session.turn_order.length) {
452
+ session.current_turn = null;
453
+ nextTurn = null;
454
+ } else {
455
+ session.current_turn = session.turn_order[nextIdx];
456
+ nextTurn = session.current_turn;
457
+ }
443
458
 
444
- session.current_turn = session.turn_order[nextIdx];
445
- await this.put(session);
446
- return session.current_turn;
459
+ return session;
460
+ });
461
+ return nextTurn;
447
462
  }
448
463
 
449
464
  // ── Convergence ────────────────────────────────────
@@ -511,31 +526,42 @@ class CollabStore {
511
526
  * Key format: sr{N}_step{S}_{nodeRole}_{artifactType}
512
527
  */
513
528
  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
- }
529
+ for (let attempt = 0; attempt < 3; attempt++) {
530
+ const entry = await this.kv.get(sessionId);
531
+ if (!entry) return null;
532
+ const session = JSON.parse(sc.decode(entry.value));
533
+ if (!session.circling) return null;
534
+ session.circling.artifacts[key] = content;
535
+
536
+ // Session blob size check — JetStream KV max is 1MB.
537
+ const blobSize = Buffer.byteLength(JSON.stringify(session), 'utf8');
538
+ if (blobSize > 950_000) {
539
+ console.error(`[collab] CRITICAL: session ${sessionId} blob is ${(blobSize / 1024).toFixed(0)}KB — approaching JetStream KV 1MB limit`);
540
+ } else if (blobSize > 800_000) {
541
+ console.warn(`[collab] WARNING: session ${sessionId} blob is ${(blobSize / 1024).toFixed(0)}KB — consider external artifact store`);
542
+ }
526
543
 
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;
544
+ try {
545
+ await this.kv.put(sessionId, sc.encode(JSON.stringify(session)), { previousSeq: entry.revision });
546
+ return session;
547
+ } catch (err) {
548
+ // Check if it's a blob-too-large error vs a CAS conflict
549
+ // CAS conflicts have specific error codes; blob failures are different
550
+ if (attempt === 2) {
551
+ // Final attempt — could be blob overflow or persistent conflict
552
+ console.error(`[collab] storeArtifact FAILED for ${sessionId}/${key}: ${err.message}. Removing artifact and persisting without it.`);
553
+ delete session.circling.artifacts[key];
554
+ try {
555
+ // Recovery write without CAS — acceptable because we're removing the artifact
556
+ // that caused the failure. Worst case: another concurrent write overwrites this,
557
+ // but that write also wouldn't have the problematic artifact.
558
+ await this.kv.put(sessionId, sc.encode(JSON.stringify(session)));
559
+ } catch (_) { /* best effort */ }
560
+ return null;
561
+ }
562
+ // conflict — retry
563
+ }
536
564
  }
537
-
538
- return session;
539
565
  }
540
566
 
541
567
  /**
@@ -691,67 +717,63 @@ class CollabStore {
691
717
  * circling/step2 (SR == max) → finalization/step0
692
718
  */
693
719
  async advanceCirclingStep(sessionId) {
694
- const session = await this.get(sessionId);
695
- if (!session || !session.circling) return null;
720
+ let stepResult = null;
721
+ const result = await this._updateWithCAS(sessionId, (session) => {
722
+ if (!session.circling) return null;
696
723
 
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++;
724
+ const c = session.circling;
725
+ let needsGate = false;
726
+
727
+ if (c.phase === 'init' && c.current_step === 0) {
728
+ c.phase = 'circling';
729
+ c.current_subround = 1;
733
730
  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;
731
+ } else if (c.phase === 'circling' && c.current_step === 1) {
732
+ c.current_step = 2;
733
+ } else if (c.phase === 'circling' && c.current_step === 2) {
734
+ const currentRound = session.rounds[session.rounds.length - 1];
735
+ const activeNodes = session.nodes.filter(n => n.status !== 'dead');
736
+ const step2Reflections = currentRound
737
+ ? currentRound.reflections.filter(r => r.circling_step === 2)
738
+ : [];
739
+ const allConverged = step2Reflections.length >= activeNodes.length &&
740
+ step2Reflections.every(r => r.vote === 'converged');
741
+
742
+ if (allConverged && c.current_subround < c.max_subrounds) {
743
+ if (c.automation_tier >= 2) {
744
+ needsGate = true;
745
+ }
746
+ c.phase = 'finalization';
747
+ c.current_step = 0;
748
+ } else if (c.current_subround < c.max_subrounds) {
749
+ if (c.automation_tier === 3) {
750
+ needsGate = true;
751
+ }
752
+ c.current_subround++;
753
+ c.current_step = 1;
754
+ } else {
755
+ if (c.automation_tier >= 2) {
756
+ needsGate = true;
757
+ }
758
+ c.phase = 'finalization';
759
+ c.current_step = 0;
739
760
  }
740
- c.phase = 'finalization';
741
- c.current_step = 0;
761
+ } else if (c.phase === 'finalization') {
762
+ c.phase = 'complete';
742
763
  }
743
- } else if (c.phase === 'finalization') {
744
- // Finalization complete → done
745
- c.phase = 'complete';
746
- }
747
764
 
748
- await this.put(session);
749
- return {
750
- phase: c.phase,
751
- subround: c.current_subround,
752
- step: c.current_step,
753
- needsGate,
754
- };
765
+ stepResult = {
766
+ phase: c.phase,
767
+ subround: c.current_subround,
768
+ step: c.current_step,
769
+ needsGate,
770
+ };
771
+
772
+ return session;
773
+ });
774
+
775
+ if (!result) return null;
776
+ return stepResult;
755
777
  }
756
778
 
757
779
  /**
@@ -759,12 +781,16 @@ class CollabStore {
759
781
  * Returns the failure count.
760
782
  */
761
783
  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];
784
+ let failureCount = 0;
785
+ const result = await this._updateWithCAS(sessionId, (session) => {
786
+ if (!session.circling) return null;
787
+ const key = `${nodeId}_sr${session.circling.current_subround}_step${session.circling.current_step}`;
788
+ session.circling.artifact_failures[key] = (session.circling.artifact_failures[key] || 0) + 1;
789
+ failureCount = session.circling.artifact_failures[key];
790
+ return session;
791
+ });
792
+ if (!result) return 0;
793
+ return failureCount;
768
794
  }
769
795
 
770
796
  /**
@@ -814,34 +840,33 @@ class CollabStore {
814
840
  * Mark session as converged.
815
841
  */
816
842
  async markConverged(sessionId) {
817
- const session = await this.get(sessionId);
818
- if (!session) return null;
819
- session.status = COLLAB_STATUS.CONVERGED;
843
+ return this._updateWithCAS(sessionId, (session) => {
844
+ session.status = COLLAB_STATUS.CONVERGED;
820
845
 
821
- // Close current round
822
- const currentRound = session.rounds[session.rounds.length - 1];
823
- if (currentRound) currentRound.completed_at = new Date().toISOString();
846
+ // Close current round
847
+ const currentRound = session.rounds[session.rounds.length - 1];
848
+ if (currentRound) currentRound.completed_at = new Date().toISOString();
824
849
 
825
- await this.put(session);
826
- return session;
850
+ return session;
851
+ });
827
852
  }
828
853
 
829
854
  /**
830
855
  * Mark session as completed with final result.
831
856
  */
832
857
  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;
858
+ return this._updateWithCAS(sessionId, (session) => {
859
+ if (['completed', 'aborted'].includes(session.status)) return null;
860
+ session.status = COLLAB_STATUS.COMPLETED;
861
+ session.completed_at = new Date().toISOString();
862
+ session.result = {
863
+ artifacts: result.artifacts || [],
864
+ summary: result.summary || '',
865
+ rounds_taken: session.current_round,
866
+ node_contributions: result.node_contributions || {},
867
+ };
868
+ return session;
869
+ });
845
870
  }
846
871
 
847
872
  /**
@@ -849,15 +874,14 @@ class CollabStore {
849
874
  * Callers can use truthiness to detect whether the abort actually happened.
850
875
  */
851
876
  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;
877
+ return this._updateWithCAS(sessionId, (session) => {
878
+ // Guard: don't corrupt completed/aborted sessions
879
+ if (['completed', 'aborted'].includes(session.status)) return null;
880
+ session.status = COLLAB_STATUS.ABORTED;
881
+ session.completed_at = new Date().toISOString();
882
+ session.result = { success: false, summary: reason, aborted: true };
883
+ return session;
884
+ });
861
885
  }
862
886
 
863
887
  /**