openclaw-node-harness 2.0.0 → 2.0.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/mesh-agent.js +417 -94
- package/bin/mesh-join-token.js +129 -0
- package/bin/mesh-node-remove.js +277 -0
- package/bin/mesh-task-daemon.js +723 -15
- package/bin/openclaw-node-init.js +674 -0
- package/lib/llm-providers.js +262 -0
- package/lib/mesh-collab.js +549 -0
- package/lib/mesh-plans.js +528 -0
- package/lib/mesh-tasks.js +50 -34
- package/package.json +1 -1
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mesh-collab.js — Collaborative session management for N-node coordination.
|
|
3
|
+
*
|
|
4
|
+
* Extends the mesh task system with multi-node collaboration.
|
|
5
|
+
* Each collaborative task spawns a session where N nodes work in rounds,
|
|
6
|
+
* exchange reflections, and converge.
|
|
7
|
+
*
|
|
8
|
+
* Backed by MESH_COLLAB JetStream KV bucket (same pattern as mesh-tasks.js).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { StringCodec } = require('nats');
|
|
12
|
+
const sc = StringCodec();
|
|
13
|
+
|
|
14
|
+
const COLLAB_KV_BUCKET = 'MESH_COLLAB';
|
|
15
|
+
|
|
16
|
+
// ── Session Statuses ────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const COLLAB_STATUS = {
|
|
19
|
+
RECRUITING: 'recruiting', // waiting for nodes to join
|
|
20
|
+
ACTIVE: 'active', // rounds in progress
|
|
21
|
+
CONVERGED: 'converged', // convergence reached, collecting artifacts
|
|
22
|
+
COMPLETED: 'completed', // done, artifacts merged
|
|
23
|
+
ABORTED: 'aborted', // something broke
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ── Collaboration Modes ─────────────────────────────
|
|
27
|
+
|
|
28
|
+
const COLLAB_MODE = {
|
|
29
|
+
PARALLEL: 'parallel', // all nodes work simultaneously
|
|
30
|
+
SEQUENTIAL: 'sequential', // nodes take turns in order
|
|
31
|
+
REVIEW: 'review', // one leader + N reviewers
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ── Convergence Strategies ──────────────────────────
|
|
35
|
+
|
|
36
|
+
const CONVERGENCE = {
|
|
37
|
+
UNANIMOUS: 'unanimous', // all nodes vote converged
|
|
38
|
+
MAJORITY: 'majority', // >= threshold fraction
|
|
39
|
+
COORDINATOR: 'coordinator', // daemon decides
|
|
40
|
+
METRIC: 'metric', // mechanical test passes
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ── Session Factory ─────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a new collaboration session from a task's collaboration spec.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} taskId — parent task ID
|
|
49
|
+
* @param {object} collabSpec — the task.collaboration object
|
|
50
|
+
*/
|
|
51
|
+
function createSession(taskId, collabSpec) {
|
|
52
|
+
const sessionId = `collab-${taskId}-${Date.now()}`;
|
|
53
|
+
return {
|
|
54
|
+
session_id: sessionId,
|
|
55
|
+
task_id: taskId,
|
|
56
|
+
mode: collabSpec.mode || COLLAB_MODE.PARALLEL,
|
|
57
|
+
status: COLLAB_STATUS.RECRUITING,
|
|
58
|
+
|
|
59
|
+
// Node management
|
|
60
|
+
min_nodes: collabSpec.min_nodes || 2,
|
|
61
|
+
max_nodes: collabSpec.max_nodes || null, // null = unlimited
|
|
62
|
+
join_window_s: collabSpec.join_window_s || 30,
|
|
63
|
+
nodes: [],
|
|
64
|
+
|
|
65
|
+
// Round management
|
|
66
|
+
current_round: 0,
|
|
67
|
+
max_rounds: collabSpec.max_rounds || 5,
|
|
68
|
+
rounds: [],
|
|
69
|
+
|
|
70
|
+
// Convergence
|
|
71
|
+
convergence: {
|
|
72
|
+
type: collabSpec.convergence?.type || CONVERGENCE.UNANIMOUS,
|
|
73
|
+
threshold: collabSpec.convergence?.threshold || 0.66,
|
|
74
|
+
metric: collabSpec.convergence?.metric || null,
|
|
75
|
+
// Min quorum: minimum number of valid votes required for convergence.
|
|
76
|
+
// Prevents premature convergence when nodes die (e.g., 2/2 surviving = 100%
|
|
77
|
+
// but only 2 of 5 recruited nodes). Defaults to min_nodes.
|
|
78
|
+
min_quorum: collabSpec.convergence?.min_quorum || collabSpec.min_nodes || 2,
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// Track how many nodes were recruited (immutable after recruiting closes)
|
|
82
|
+
// Used to detect quorum loss when nodes die between rounds
|
|
83
|
+
recruited_count: 0,
|
|
84
|
+
|
|
85
|
+
// Scope strategy
|
|
86
|
+
scope_strategy: collabSpec.scope_strategy || 'shared',
|
|
87
|
+
|
|
88
|
+
// Sequential mode: turn tracking
|
|
89
|
+
turn_order: [], // node_ids in execution order
|
|
90
|
+
current_turn: null, // node_id of active node (sequential mode)
|
|
91
|
+
|
|
92
|
+
// Result (filled at completion)
|
|
93
|
+
result: null,
|
|
94
|
+
|
|
95
|
+
// Structured audit log — append-only event trail for post-mortem debugging.
|
|
96
|
+
// Each entry: { ts, event, detail }
|
|
97
|
+
audit_log: [],
|
|
98
|
+
|
|
99
|
+
// Timestamps
|
|
100
|
+
created_at: new Date().toISOString(),
|
|
101
|
+
recruiting_deadline: null, // set when first node joins
|
|
102
|
+
completed_at: null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── CollabStore (KV-backed) ─────────────────────────
|
|
107
|
+
|
|
108
|
+
class CollabStore {
|
|
109
|
+
constructor(kv) {
|
|
110
|
+
this.kv = kv;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async put(session) {
|
|
114
|
+
await this.kv.put(session.session_id, sc.encode(JSON.stringify(session)));
|
|
115
|
+
return session;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async get(sessionId) {
|
|
119
|
+
const entry = await this.kv.get(sessionId);
|
|
120
|
+
if (!entry || !entry.value) return null;
|
|
121
|
+
return JSON.parse(sc.decode(entry.value));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async delete(sessionId) {
|
|
125
|
+
await this.kv.delete(sessionId);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Append an entry to the session's audit log. Fire-and-forget.
|
|
130
|
+
*/
|
|
131
|
+
async appendAudit(sessionId, event, detail = {}) {
|
|
132
|
+
try {
|
|
133
|
+
const session = await this.get(sessionId);
|
|
134
|
+
if (!session) return;
|
|
135
|
+
if (!session.audit_log) session.audit_log = [];
|
|
136
|
+
session.audit_log.push({
|
|
137
|
+
ts: new Date().toISOString(),
|
|
138
|
+
event,
|
|
139
|
+
...detail,
|
|
140
|
+
});
|
|
141
|
+
await this.put(session);
|
|
142
|
+
} catch { /* best-effort — never block on audit */ }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* List all sessions, optionally filtered.
|
|
147
|
+
*/
|
|
148
|
+
async list(filter = {}) {
|
|
149
|
+
const sessions = [];
|
|
150
|
+
const allKeys = [];
|
|
151
|
+
const keys = await this.kv.keys();
|
|
152
|
+
for await (const key of keys) {
|
|
153
|
+
allKeys.push(key);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const key of allKeys) {
|
|
157
|
+
const entry = await this.kv.get(key);
|
|
158
|
+
if (!entry || !entry.value) continue;
|
|
159
|
+
const session = JSON.parse(sc.decode(entry.value));
|
|
160
|
+
|
|
161
|
+
if (filter.status && session.status !== filter.status) continue;
|
|
162
|
+
if (filter.task_id && session.task_id !== filter.task_id) continue;
|
|
163
|
+
|
|
164
|
+
sessions.push(session);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
sessions.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
|
168
|
+
return sessions;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Find session by task ID.
|
|
173
|
+
*/
|
|
174
|
+
async findByTaskId(taskId) {
|
|
175
|
+
const sessions = await this.list({ task_id: taskId });
|
|
176
|
+
return sessions[0] || null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Node Management ────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Add a node to the session.
|
|
183
|
+
* Returns the updated session or null if session full/closed.
|
|
184
|
+
*/
|
|
185
|
+
async addNode(sessionId, nodeId, role = 'worker', scope = '*') {
|
|
186
|
+
const session = await this.get(sessionId);
|
|
187
|
+
if (!session) return null;
|
|
188
|
+
if (session.status !== COLLAB_STATUS.RECRUITING) return null;
|
|
189
|
+
|
|
190
|
+
// Check max_nodes
|
|
191
|
+
if (session.max_nodes && session.nodes.length >= session.max_nodes) return null;
|
|
192
|
+
|
|
193
|
+
// Check duplicate
|
|
194
|
+
if (session.nodes.find(n => n.node_id === nodeId)) return null;
|
|
195
|
+
|
|
196
|
+
session.nodes.push({
|
|
197
|
+
node_id: nodeId,
|
|
198
|
+
role,
|
|
199
|
+
scope: Array.isArray(scope) ? scope : [scope],
|
|
200
|
+
joined_at: new Date().toISOString(),
|
|
201
|
+
status: 'active',
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Set recruiting deadline on first join
|
|
205
|
+
if (session.nodes.length === 1) {
|
|
206
|
+
session.recruiting_deadline = new Date(
|
|
207
|
+
Date.now() + session.join_window_s * 1000
|
|
208
|
+
).toISOString();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// For sequential mode, build turn order
|
|
212
|
+
if (session.mode === COLLAB_MODE.SEQUENTIAL) {
|
|
213
|
+
session.turn_order.push(nodeId);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await this.put(session);
|
|
217
|
+
return session;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Remove a node from the session (graceful leave or kick).
|
|
222
|
+
*/
|
|
223
|
+
async removeNode(sessionId, nodeId) {
|
|
224
|
+
const session = await this.get(sessionId);
|
|
225
|
+
if (!session) return null;
|
|
226
|
+
|
|
227
|
+
session.nodes = session.nodes.filter(n => n.node_id !== nodeId);
|
|
228
|
+
session.turn_order = session.turn_order.filter(id => id !== nodeId);
|
|
229
|
+
|
|
230
|
+
await this.put(session);
|
|
231
|
+
return session;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Update a node's status within the session.
|
|
236
|
+
*/
|
|
237
|
+
async setNodeStatus(sessionId, nodeId, status) {
|
|
238
|
+
const session = await this.get(sessionId);
|
|
239
|
+
if (!session) return null;
|
|
240
|
+
|
|
241
|
+
const node = session.nodes.find(n => n.node_id === nodeId);
|
|
242
|
+
if (node) node.status = status;
|
|
243
|
+
|
|
244
|
+
await this.put(session);
|
|
245
|
+
return session;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Check if recruiting window should close.
|
|
250
|
+
* Returns true if deadline passed OR max_nodes reached.
|
|
251
|
+
* The caller (daemon) decides whether to start or abort based on node count.
|
|
252
|
+
*/
|
|
253
|
+
isRecruitingDone(session) {
|
|
254
|
+
if (session.max_nodes && session.nodes.length >= session.max_nodes) return true;
|
|
255
|
+
if (session.recruiting_deadline && new Date() >= new Date(session.recruiting_deadline)) return true;
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Round Management ───────────────────────────────
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Start a new round. Returns the round object with shared_intel.
|
|
263
|
+
*/
|
|
264
|
+
async startRound(sessionId) {
|
|
265
|
+
const session = await this.get(sessionId);
|
|
266
|
+
if (!session) return null;
|
|
267
|
+
|
|
268
|
+
session.current_round++;
|
|
269
|
+
session.status = COLLAB_STATUS.ACTIVE;
|
|
270
|
+
|
|
271
|
+
// Snapshot recruited count on first round (immutable baseline for quorum)
|
|
272
|
+
if (session.current_round === 1) {
|
|
273
|
+
session.recruited_count = session.nodes.length;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Per-round node health: prune nodes marked 'dead' before starting.
|
|
277
|
+
// This prevents hanging on reflections from nodes that will never respond.
|
|
278
|
+
const deadNodes = session.nodes.filter(n => n.status === 'dead');
|
|
279
|
+
if (deadNodes.length > 0) {
|
|
280
|
+
session.nodes = session.nodes.filter(n => n.status !== 'dead');
|
|
281
|
+
session.turn_order = session.turn_order.filter(
|
|
282
|
+
id => !deadNodes.find(d => d.node_id === id)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Check if we still have enough nodes after pruning
|
|
287
|
+
if (session.nodes.length < session.min_nodes) {
|
|
288
|
+
// Not enough active nodes to continue — will be caught by caller
|
|
289
|
+
session.status = COLLAB_STATUS.ABORTED;
|
|
290
|
+
await this.put(session);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Compile shared intel from previous round
|
|
295
|
+
const sharedIntel = this.compileSharedIntel(session);
|
|
296
|
+
|
|
297
|
+
const round = {
|
|
298
|
+
round_number: session.current_round,
|
|
299
|
+
started_at: new Date().toISOString(),
|
|
300
|
+
completed_at: null,
|
|
301
|
+
shared_intel: sharedIntel,
|
|
302
|
+
reflections: [],
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
session.rounds.push(round);
|
|
306
|
+
|
|
307
|
+
// Sequential mode: set first turn
|
|
308
|
+
if (session.mode === COLLAB_MODE.SEQUENTIAL && session.turn_order.length > 0) {
|
|
309
|
+
session.current_turn = session.turn_order[0];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await this.put(session);
|
|
313
|
+
return round;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Submit a reflection from a node for the current round.
|
|
318
|
+
*/
|
|
319
|
+
async submitReflection(sessionId, reflection) {
|
|
320
|
+
const session = await this.get(sessionId);
|
|
321
|
+
if (!session) return null;
|
|
322
|
+
|
|
323
|
+
const currentRound = session.rounds[session.rounds.length - 1];
|
|
324
|
+
if (!currentRound) return null;
|
|
325
|
+
|
|
326
|
+
// Prevent duplicate reflections from same node
|
|
327
|
+
if (currentRound.reflections.find(r => r.node_id === reflection.node_id)) return null;
|
|
328
|
+
|
|
329
|
+
currentRound.reflections.push({
|
|
330
|
+
node_id: reflection.node_id,
|
|
331
|
+
summary: reflection.summary || '',
|
|
332
|
+
learnings: reflection.learnings || '',
|
|
333
|
+
artifacts: reflection.artifacts || [],
|
|
334
|
+
confidence: reflection.confidence || 0.5,
|
|
335
|
+
vote: reflection.vote || 'continue',
|
|
336
|
+
parse_failed: reflection.parse_failed || false,
|
|
337
|
+
submitted_at: new Date().toISOString(),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Update node status
|
|
341
|
+
const node = session.nodes.find(n => n.node_id === reflection.node_id);
|
|
342
|
+
if (node) node.status = 'reflecting';
|
|
343
|
+
|
|
344
|
+
await this.put(session);
|
|
345
|
+
return session;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Check if all reflections for the current round have been received.
|
|
350
|
+
*/
|
|
351
|
+
isRoundComplete(session) {
|
|
352
|
+
const currentRound = session.rounds[session.rounds.length - 1];
|
|
353
|
+
if (!currentRound) return false;
|
|
354
|
+
|
|
355
|
+
const activeNodes = session.nodes.filter(n => n.status !== 'dead');
|
|
356
|
+
return currentRound.reflections.length >= activeNodes.length;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Advance to next turn in sequential mode.
|
|
361
|
+
* Returns the next node_id, or null if all turns done (round complete).
|
|
362
|
+
*/
|
|
363
|
+
async advanceTurn(sessionId) {
|
|
364
|
+
const session = await this.get(sessionId);
|
|
365
|
+
if (!session || session.mode !== COLLAB_MODE.SEQUENTIAL) return null;
|
|
366
|
+
|
|
367
|
+
const currentIdx = session.turn_order.indexOf(session.current_turn);
|
|
368
|
+
const nextIdx = currentIdx + 1;
|
|
369
|
+
|
|
370
|
+
if (nextIdx >= session.turn_order.length) {
|
|
371
|
+
// All nodes had their turn — round is complete
|
|
372
|
+
session.current_turn = null;
|
|
373
|
+
await this.put(session);
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
session.current_turn = session.turn_order[nextIdx];
|
|
378
|
+
await this.put(session);
|
|
379
|
+
return session.current_turn;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── Convergence ────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Check if convergence criteria are met for the current round.
|
|
386
|
+
*/
|
|
387
|
+
checkConvergence(session) {
|
|
388
|
+
const currentRound = session.rounds[session.rounds.length - 1];
|
|
389
|
+
if (!currentRound || currentRound.reflections.length === 0) return false;
|
|
390
|
+
|
|
391
|
+
const reflections = currentRound.reflections;
|
|
392
|
+
const parseFailures = reflections.filter(r => r.parse_failed);
|
|
393
|
+
const validReflections = reflections.filter(r => !r.parse_failed);
|
|
394
|
+
const convergedCount = validReflections.filter(r => r.vote === 'converged').length;
|
|
395
|
+
const minQuorum = session.convergence.min_quorum || session.min_nodes || 2;
|
|
396
|
+
|
|
397
|
+
// Quorum check: enough valid votes must exist to make a decision.
|
|
398
|
+
// Prevents premature convergence when nodes die (e.g., 5 recruited, 2 die,
|
|
399
|
+
// 2 remaining vote converged = 100% threshold but only 2 of 5 nodes voted).
|
|
400
|
+
if (validReflections.length < minQuorum) return false;
|
|
401
|
+
|
|
402
|
+
// Parse failures are never counted as convergence votes.
|
|
403
|
+
// If ANY reflection failed to parse, unanimous is impossible.
|
|
404
|
+
// For majority, only valid votes count in both numerator and denominator.
|
|
405
|
+
|
|
406
|
+
switch (session.convergence.type) {
|
|
407
|
+
case CONVERGENCE.UNANIMOUS:
|
|
408
|
+
// All nodes must have valid, converged votes. Any parse failure blocks unanimity.
|
|
409
|
+
if (parseFailures.length > 0) return false;
|
|
410
|
+
return convergedCount === reflections.length && reflections.length > 0;
|
|
411
|
+
|
|
412
|
+
case CONVERGENCE.MAJORITY: {
|
|
413
|
+
// Only valid votes count. Parse failures are excluded from both sides.
|
|
414
|
+
// Threshold computed against valid votes only.
|
|
415
|
+
return (convergedCount / validReflections.length) >= session.convergence.threshold;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
case CONVERGENCE.COORDINATOR:
|
|
419
|
+
// Coordinator mode: daemon decides externally. Always return false here.
|
|
420
|
+
// The daemon calls markConverged() directly when it decides.
|
|
421
|
+
return false;
|
|
422
|
+
|
|
423
|
+
case CONVERGENCE.METRIC:
|
|
424
|
+
// Metric mode: checked externally by running the metric command.
|
|
425
|
+
// The daemon calls markConverged() after metric passes.
|
|
426
|
+
return false;
|
|
427
|
+
|
|
428
|
+
default:
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Check if max rounds exceeded (safety cap).
|
|
435
|
+
*/
|
|
436
|
+
isMaxRoundsReached(session) {
|
|
437
|
+
return session.current_round >= session.max_rounds;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── Intel Compilation ──────────────────────────────
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Compile shared intelligence from the previous round's reflections.
|
|
444
|
+
* This is the text sent to all nodes at the start of the next round.
|
|
445
|
+
*/
|
|
446
|
+
compileSharedIntel(session) {
|
|
447
|
+
if (session.rounds.length === 0) return '';
|
|
448
|
+
|
|
449
|
+
const prevRound = session.rounds[session.rounds.length - 1];
|
|
450
|
+
if (!prevRound || prevRound.reflections.length === 0) {
|
|
451
|
+
return '(First round — no prior intelligence.)';
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const lines = [`=== ROUND ${prevRound.round_number} SHARED INTELLIGENCE ===\n`];
|
|
455
|
+
|
|
456
|
+
for (const r of prevRound.reflections) {
|
|
457
|
+
lines.push(`## Node: ${r.node_id}${r.parse_failed ? ' [REFLECTION PARSE FAILED]' : ''}`);
|
|
458
|
+
if (r.summary) lines.push(`Summary: ${r.summary}`);
|
|
459
|
+
if (r.learnings) lines.push(`Learnings: ${r.learnings}`);
|
|
460
|
+
if (r.artifacts.length > 0) lines.push(`Artifacts: ${r.artifacts.join(', ')}`);
|
|
461
|
+
lines.push(`Confidence: ${r.confidence} | Vote: ${r.vote}`);
|
|
462
|
+
lines.push('');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const convergedCount = prevRound.reflections.filter(r => r.vote === 'converged').length;
|
|
466
|
+
const totalNodes = prevRound.reflections.length;
|
|
467
|
+
lines.push(`=== CONVERGENCE: ${convergedCount}/${totalNodes} voted converged. ===`);
|
|
468
|
+
|
|
469
|
+
return lines.join('\n');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── Session Lifecycle ──────────────────────────────
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Mark session as converged.
|
|
476
|
+
*/
|
|
477
|
+
async markConverged(sessionId) {
|
|
478
|
+
const session = await this.get(sessionId);
|
|
479
|
+
if (!session) return null;
|
|
480
|
+
session.status = COLLAB_STATUS.CONVERGED;
|
|
481
|
+
|
|
482
|
+
// Close current round
|
|
483
|
+
const currentRound = session.rounds[session.rounds.length - 1];
|
|
484
|
+
if (currentRound) currentRound.completed_at = new Date().toISOString();
|
|
485
|
+
|
|
486
|
+
await this.put(session);
|
|
487
|
+
return session;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Mark session as completed with final result.
|
|
492
|
+
*/
|
|
493
|
+
async markCompleted(sessionId, result) {
|
|
494
|
+
const session = await this.get(sessionId);
|
|
495
|
+
if (!session) return null;
|
|
496
|
+
session.status = COLLAB_STATUS.COMPLETED;
|
|
497
|
+
session.completed_at = new Date().toISOString();
|
|
498
|
+
session.result = {
|
|
499
|
+
artifacts: result.artifacts || [],
|
|
500
|
+
summary: result.summary || '',
|
|
501
|
+
rounds_taken: session.current_round,
|
|
502
|
+
node_contributions: result.node_contributions || {},
|
|
503
|
+
};
|
|
504
|
+
await this.put(session);
|
|
505
|
+
return session;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Mark session as aborted.
|
|
510
|
+
*/
|
|
511
|
+
async markAborted(sessionId, reason) {
|
|
512
|
+
const session = await this.get(sessionId);
|
|
513
|
+
if (!session) return null;
|
|
514
|
+
session.status = COLLAB_STATUS.ABORTED;
|
|
515
|
+
session.completed_at = new Date().toISOString();
|
|
516
|
+
session.result = { success: false, summary: reason, aborted: true };
|
|
517
|
+
await this.put(session);
|
|
518
|
+
return session;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Get a summary of the session for reporting.
|
|
523
|
+
*/
|
|
524
|
+
getSummary(session) {
|
|
525
|
+
return {
|
|
526
|
+
session_id: session.session_id,
|
|
527
|
+
task_id: session.task_id,
|
|
528
|
+
mode: session.mode,
|
|
529
|
+
status: session.status,
|
|
530
|
+
nodes: session.nodes.map(n => ({ id: n.node_id, role: n.role, status: n.status })),
|
|
531
|
+
current_round: session.current_round,
|
|
532
|
+
max_rounds: session.max_rounds,
|
|
533
|
+
total_reflections: session.rounds.reduce((sum, r) => sum + r.reflections.length, 0),
|
|
534
|
+
artifacts: session.result?.artifacts || [],
|
|
535
|
+
duration_ms: session.completed_at
|
|
536
|
+
? new Date(session.completed_at) - new Date(session.created_at)
|
|
537
|
+
: Date.now() - new Date(session.created_at),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
module.exports = {
|
|
543
|
+
createSession,
|
|
544
|
+
CollabStore,
|
|
545
|
+
COLLAB_STATUS,
|
|
546
|
+
COLLAB_MODE,
|
|
547
|
+
CONVERGENCE,
|
|
548
|
+
COLLAB_KV_BUCKET,
|
|
549
|
+
};
|