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.
- package/bin/lane-watchdog.js +23 -2
- package/bin/mesh-agent.js +38 -16
- package/bin/mesh-bridge.js +3 -2
- package/bin/mesh-health-publisher.js +41 -1
- package/bin/mesh-task-daemon.js +5 -0
- package/bin/mesh.js +8 -19
- package/install.sh +3 -2
- package/lib/agent-activity.js +2 -2
- package/lib/exec-safety.js +105 -0
- package/lib/kanban-io.js +15 -31
- package/lib/llm-providers.js +16 -0
- package/lib/mcp-knowledge/core.mjs +7 -5
- package/lib/mcp-knowledge/server.mjs +8 -1
- package/lib/mesh-collab.js +268 -250
- package/lib/mesh-plans.js +66 -45
- package/lib/mesh-tasks.js +89 -73
- package/lib/nats-resolve.js +4 -4
- package/lib/pre-compression-flush.mjs +2 -0
- package/lib/session-store.mjs +6 -3
- package/mission-control/src/app/api/memory/search/route.ts +6 -3
- package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
- package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
- package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +7 -1
- package/mission-control/src/app/api/workspace/read/route.ts +11 -0
- package/mission-control/src/lib/config.ts +9 -0
- package/mission-control/src/lib/db/index.ts +16 -1
- package/mission-control/src/lib/memory/extract.ts +2 -1
- package/mission-control/src/lib/memory/retrieval.ts +3 -2
- package/mission-control/src/middleware.ts +82 -0
- package/package.json +1 -1
- package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
- package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
- package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
- package/services/launchd/ai.openclaw.mission-control.plist +1 -1
- package/uninstall.sh +37 -9
package/lib/mesh-collab.js
CHANGED
|
@@ -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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
session.current_turn = session.turn_order[0];
|
|
371
|
-
}
|
|
384
|
+
return session;
|
|
385
|
+
});
|
|
372
386
|
|
|
373
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
432
|
-
|
|
442
|
+
let nextTurn = null;
|
|
443
|
+
await this._updateWithCAS(sessionId, (session) => {
|
|
444
|
+
if (session.mode !== COLLAB_MODE.SEQUENTIAL) return null;
|
|
433
445
|
|
|
434
|
-
|
|
435
|
-
|
|
446
|
+
const currentIdx = session.turn_order.indexOf(session.current_turn);
|
|
447
|
+
const nextIdx = currentIdx + 1;
|
|
436
448
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
return
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
695
|
-
|
|
715
|
+
let stepResult = null;
|
|
716
|
+
const result = await this._updateWithCAS(sessionId, (session) => {
|
|
717
|
+
if (!session.circling) return null;
|
|
696
718
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
741
|
-
c.
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
session.status = COLLAB_STATUS.CONVERGED;
|
|
838
|
+
return this._updateWithCAS(sessionId, (session) => {
|
|
839
|
+
session.status = COLLAB_STATUS.CONVERGED;
|
|
820
840
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
826
|
-
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
/**
|