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.
- package/bin/lane-watchdog.js +54 -23
- package/bin/mesh-agent.js +49 -18
- package/bin/mesh-bridge.js +3 -2
- package/bin/mesh-deploy.js +4 -0
- package/bin/mesh-health-publisher.js +41 -1
- package/bin/mesh-task-daemon.js +14 -4
- package/bin/mesh.js +17 -43
- package/install.sh +3 -2
- package/lib/agent-activity.js +2 -2
- package/lib/exec-safety.js +163 -0
- package/lib/kanban-io.js +20 -33
- package/lib/llm-providers.js +27 -0
- package/lib/mcp-knowledge/core.mjs +7 -5
- package/lib/mcp-knowledge/server.mjs +8 -1
- package/lib/mesh-collab.js +274 -250
- package/lib/mesh-harness.js +6 -0
- package/lib/mesh-plans.js +84 -45
- package/lib/mesh-tasks.js +113 -81
- 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/package-lock.json +4188 -3698
- package/mission-control/package.json +2 -2
- package/mission-control/src/app/api/diagnostics/route.ts +8 -0
- package/mission-control/src/app/api/diagnostics/test-runner/route.ts +8 -0
- package/mission-control/src/app/api/memory/graph/route.ts +34 -18
- package/mission-control/src/app/api/memory/search/route.ts +9 -5
- package/mission-control/src/app/api/mesh/identity/route.ts +13 -5
- package/mission-control/src/app/api/mesh/nodes/route.ts +8 -0
- package/mission-control/src/app/api/settings/gateway/route.ts +62 -0
- package/mission-control/src/app/api/souls/[id]/evolution/route.ts +49 -12
- package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
- package/mission-control/src/app/api/souls/[id]/propagate/route.ts +24 -5
- package/mission-control/src/app/api/souls/route.ts +6 -4
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +7 -1
- package/mission-control/src/app/api/tasks/[id]/route.ts +20 -4
- package/mission-control/src/app/api/tasks/route.ts +68 -9
- package/mission-control/src/app/api/workspace/read/route.ts +11 -0
- package/mission-control/src/lib/config.ts +11 -2
- 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/lib/sync/tasks.ts +4 -1
- package/mission-control/src/middleware.ts +82 -0
- package/package.json +1 -1
- package/services/launchd/ai.openclaw.lane-watchdog.plist +1 -1
- package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
- package/services/launchd/ai.openclaw.mesh-agent.plist +4 -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 +5 -4
- package/uninstall.sh +37 -9
package/lib/mesh-collab.js
CHANGED
|
@@ -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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
269
|
+
return this._updateWithCAS(sessionId, (session) => {
|
|
270
|
+
if (session.status !== COLLAB_STATUS.RECRUITING) return null;
|
|
264
271
|
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
367
|
+
// Compile shared intel from previous round
|
|
368
|
+
const sharedIntel = this.compileSharedIntel(session);
|
|
354
369
|
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
session.current_turn = session.turn_order[0];
|
|
371
|
-
}
|
|
385
|
+
return session;
|
|
386
|
+
});
|
|
372
387
|
|
|
373
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
435
|
-
|
|
448
|
+
const currentIdx = session.turn_order.indexOf(session.current_turn);
|
|
449
|
+
const nextIdx = currentIdx + 1;
|
|
436
450
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
return
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
695
|
-
|
|
720
|
+
let stepResult = null;
|
|
721
|
+
const result = await this._updateWithCAS(sessionId, (session) => {
|
|
722
|
+
if (!session.circling) return null;
|
|
696
723
|
|
|
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++;
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
741
|
-
c.
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
session.status = COLLAB_STATUS.CONVERGED;
|
|
843
|
+
return this._updateWithCAS(sessionId, (session) => {
|
|
844
|
+
session.status = COLLAB_STATUS.CONVERGED;
|
|
820
845
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
826
|
-
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
/**
|