openclaw-node-harness 2.0.3 → 2.1.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/README.md +646 -3
- package/bin/hyperagent.mjs +419 -0
- package/bin/mesh-agent.js +603 -81
- package/bin/mesh-bridge.js +340 -11
- package/bin/mesh-deploy-listener.js +119 -97
- package/bin/mesh-deploy.js +8 -0
- package/bin/mesh-task-daemon.js +1005 -40
- package/bin/mesh.js +423 -6
- package/config/claude-settings.json +95 -0
- package/config/daemon.json.template +2 -1
- package/config/git-hooks/pre-commit +13 -0
- package/config/git-hooks/pre-push +12 -0
- package/config/harness-rules.json +174 -0
- package/config/plan-templates/team-bugfix.yaml +52 -0
- package/config/plan-templates/team-deploy.yaml +50 -0
- package/config/plan-templates/team-feature.yaml +71 -0
- package/config/roles/qa-engineer.yaml +36 -0
- package/config/roles/solidity-dev.yaml +51 -0
- package/config/roles/tech-architect.yaml +36 -0
- package/config/rules/framework/solidity.md +22 -0
- package/config/rules/framework/typescript.md +21 -0
- package/config/rules/framework/unity.md +21 -0
- package/config/rules/universal/design-docs.md +18 -0
- package/config/rules/universal/git-hygiene.md +18 -0
- package/config/rules/universal/security.md +19 -0
- package/config/rules/universal/test-standards.md +19 -0
- package/identity/DELEGATION.md +6 -6
- package/install.sh +300 -8
- package/lib/circling-parser.js +119 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +59 -10
- package/lib/mcp-knowledge/bench.mjs +118 -0
- package/lib/mcp-knowledge/core.mjs +528 -0
- package/lib/mcp-knowledge/package.json +25 -0
- package/lib/mcp-knowledge/server.mjs +245 -0
- package/lib/mcp-knowledge/test.mjs +802 -0
- package/lib/memory-budget.mjs +261 -0
- package/lib/mesh-collab.js +354 -4
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +13 -5
- package/lib/mesh-registry.js +11 -2
- package/lib/mesh-tasks.js +67 -0
- package/lib/plan-templates.js +226 -0
- package/lib/pre-compression-flush.mjs +320 -0
- package/lib/role-loader.js +292 -0
- package/lib/rule-loader.js +358 -0
- package/lib/session-store.mjs +458 -0
- package/lib/transcript-parser.mjs +292 -0
- package/mission-control/drizzle/soul_schema_update.sql +29 -0
- package/mission-control/drizzle.config.ts +1 -4
- package/mission-control/package-lock.json +1571 -83
- package/mission-control/package.json +6 -2
- package/mission-control/scripts/gen-chronology.js +3 -3
- package/mission-control/scripts/import-pipeline-v2.js +0 -16
- package/mission-control/scripts/import-pipeline.js +0 -15
- package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
- package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
- package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
- package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
- package/mission-control/src/app/api/cowork/events/route.ts +65 -0
- package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
- package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
- package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
- package/mission-control/src/app/api/diagnostics/route.ts +97 -0
- package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
- package/mission-control/src/app/api/mesh/events/route.ts +95 -19
- package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
- package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
- package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
- package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
- package/mission-control/src/app/api/tasks/route.ts +21 -30
- package/mission-control/src/app/cowork/page.tsx +261 -0
- package/mission-control/src/app/diagnostics/page.tsx +385 -0
- package/mission-control/src/app/graph/page.tsx +26 -0
- package/mission-control/src/app/memory/page.tsx +1 -1
- package/mission-control/src/app/obsidian/page.tsx +36 -6
- package/mission-control/src/app/roadmap/page.tsx +24 -0
- package/mission-control/src/app/souls/page.tsx +2 -2
- package/mission-control/src/components/board/execution-config.tsx +431 -0
- package/mission-control/src/components/board/kanban-board.tsx +75 -9
- package/mission-control/src/components/board/kanban-column.tsx +135 -19
- package/mission-control/src/components/board/task-card.tsx +55 -2
- package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
- package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
- package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
- package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
- package/mission-control/src/components/cowork/role-picker.tsx +102 -0
- package/mission-control/src/components/cowork/session-card.tsx +284 -0
- package/mission-control/src/components/layout/sidebar.tsx +39 -2
- package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
- package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
- package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
- package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
- package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
- package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
- package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
- package/mission-control/src/lib/config.ts +58 -0
- package/mission-control/src/lib/db/index.ts +69 -0
- package/mission-control/src/lib/db/schema.ts +61 -3
- package/mission-control/src/lib/hooks.ts +309 -0
- package/mission-control/src/lib/memory/entities.ts +3 -2
- package/mission-control/src/lib/nats.ts +66 -1
- package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
- package/mission-control/src/lib/parsers/transcript.ts +4 -4
- package/mission-control/src/lib/scheduler.ts +12 -11
- package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
- package/mission-control/src/lib/sync/tasks.ts +23 -1
- package/mission-control/src/lib/task-id.ts +32 -0
- package/mission-control/src/lib/tts/index.ts +33 -9
- package/mission-control/tsconfig.json +2 -1
- package/mission-control/vitest.config.ts +14 -0
- package/package.json +15 -2
- package/services/service-manifest.json +1 -1
- package/skills/cc-godmode/references/agents.md +8 -8
- package/workspace-bin/memory-daemon.mjs +199 -5
- package/workspace-bin/session-search.mjs +204 -0
- package/workspace-bin/web-fetch.mjs +65 -0
package/bin/mesh-bridge.js
CHANGED
|
@@ -21,7 +21,7 @@ const path = require('path');
|
|
|
21
21
|
const { readTasks, updateTaskInPlace, isoTimestamp, ACTIVE_TASKS_PATH } = require('../lib/kanban-io');
|
|
22
22
|
|
|
23
23
|
const sc = StringCodec();
|
|
24
|
-
const { NATS_URL
|
|
24
|
+
const { NATS_URL } = require('../lib/nats-resolve');
|
|
25
25
|
const DISPATCH_INTERVAL = parseInt(process.env.BRIDGE_DISPATCH_INTERVAL || '10000'); // 10s
|
|
26
26
|
const LOG_DIR = path.join(process.env.HOME, '.openclaw', 'workspace', 'memory', 'mesh-logs');
|
|
27
27
|
const WORKSPACE = path.join(process.env.HOME, '.openclaw', 'workspace');
|
|
@@ -186,6 +186,11 @@ async function dispatchTask(task) {
|
|
|
186
186
|
success_criteria: task.success_criteria || [],
|
|
187
187
|
scope: task.scope || [],
|
|
188
188
|
priority: task.auto_priority || 0,
|
|
189
|
+
llm_provider: task.llm_provider || null,
|
|
190
|
+
llm_model: task.llm_model || null,
|
|
191
|
+
preferred_nodes: task.preferred_nodes || [],
|
|
192
|
+
exclude_nodes: task.exclude_nodes || [],
|
|
193
|
+
collaboration: task.collaboration || undefined,
|
|
189
194
|
});
|
|
190
195
|
|
|
191
196
|
log(`SUBMITTED: ${meshTask.task_id} to mesh (budget: ${meshTask.budget_minutes}m)`);
|
|
@@ -203,6 +208,294 @@ async function dispatchTask(task) {
|
|
|
203
208
|
log(`UPDATED: ${task.task_id} → submitted`);
|
|
204
209
|
}
|
|
205
210
|
|
|
211
|
+
// ── Collab Events → Kanban ───────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Handle collab-specific events. Updates kanban with collaboration progress.
|
|
215
|
+
*/
|
|
216
|
+
function handleCollabEvent(eventType, taskId, data) {
|
|
217
|
+
// Only process events for tasks we dispatched
|
|
218
|
+
if (!dispatched.has(taskId)) return;
|
|
219
|
+
|
|
220
|
+
const session = data; // collab events include the session object
|
|
221
|
+
|
|
222
|
+
switch (eventType) {
|
|
223
|
+
case 'collab.created':
|
|
224
|
+
log(`COLLAB CREATED: session ${session.session_id} for task ${taskId} (mode: ${session.mode})`);
|
|
225
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
226
|
+
next_action: `Collab session created (mode: ${session.mode}). Recruiting ${session.min_nodes}+ nodes...`,
|
|
227
|
+
updated_at: isoTimestamp(),
|
|
228
|
+
});
|
|
229
|
+
break;
|
|
230
|
+
|
|
231
|
+
case 'collab.joined':
|
|
232
|
+
log(`COLLAB JOINED: ${session.nodes?.length || '?'} nodes in session for ${taskId}`);
|
|
233
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
234
|
+
next_action: `${session.nodes?.length || '?'} nodes joined. ${session.status === 'recruiting' ? 'Recruiting...' : 'Working...'}`,
|
|
235
|
+
updated_at: isoTimestamp(),
|
|
236
|
+
});
|
|
237
|
+
break;
|
|
238
|
+
|
|
239
|
+
case 'collab.round_started':
|
|
240
|
+
log(`COLLAB ROUND ${session.current_round}: started for ${taskId}`);
|
|
241
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
242
|
+
next_action: `Round ${session.current_round}/${session.max_rounds} in progress (${session.nodes?.length || '?'} nodes)`,
|
|
243
|
+
updated_at: isoTimestamp(),
|
|
244
|
+
});
|
|
245
|
+
break;
|
|
246
|
+
|
|
247
|
+
case 'collab.reflection_received': {
|
|
248
|
+
const totalReflections = session.rounds?.[session.rounds.length - 1]?.reflections?.length || 0;
|
|
249
|
+
const totalNodes = session.nodes?.length || '?';
|
|
250
|
+
log(`COLLAB REFLECT: ${totalReflections}/${totalNodes} for R${session.current_round} of ${taskId}`);
|
|
251
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
252
|
+
next_action: `R${session.current_round}: ${totalReflections}/${totalNodes} reflections received`,
|
|
253
|
+
updated_at: isoTimestamp(),
|
|
254
|
+
});
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case 'collab.converged':
|
|
259
|
+
log(`COLLAB CONVERGED: ${taskId} after ${session.current_round} rounds`);
|
|
260
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
261
|
+
next_action: `Converged after ${session.current_round} rounds. Collecting artifacts...`,
|
|
262
|
+
updated_at: isoTimestamp(),
|
|
263
|
+
});
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
case 'collab.completed': {
|
|
267
|
+
const result = session.result || {};
|
|
268
|
+
const nodeNames = Object.keys(result.node_contributions || {});
|
|
269
|
+
const artifactCount = (result.artifacts || []).length;
|
|
270
|
+
log(`COLLAB COMPLETED: ${taskId} — ${result.rounds_taken} rounds, ${nodeNames.length} nodes, ${artifactCount} artifacts`);
|
|
271
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
272
|
+
next_action: `Collab completed: ${result.rounds_taken || '?'} rounds, ${nodeNames.length} nodes. ${artifactCount} artifact(s). ${result.summary || ''}`.trim(),
|
|
273
|
+
collab_result: {
|
|
274
|
+
rounds_taken: result.rounds_taken,
|
|
275
|
+
node_contributions: result.node_contributions,
|
|
276
|
+
artifacts: result.artifacts,
|
|
277
|
+
summary: result.summary,
|
|
278
|
+
},
|
|
279
|
+
updated_at: isoTimestamp(),
|
|
280
|
+
});
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
case 'collab.aborted':
|
|
285
|
+
log(`COLLAB ABORTED: ${taskId} — ${session.result?.summary || 'unknown reason'}`);
|
|
286
|
+
break;
|
|
287
|
+
|
|
288
|
+
// Circling Strategy events
|
|
289
|
+
case 'collab.circling_step_started': {
|
|
290
|
+
if (!dispatched.has(taskId)) {
|
|
291
|
+
// Auto-track CLI-submitted circling tasks
|
|
292
|
+
try {
|
|
293
|
+
const tasks = readTasks(ACTIVE_TASKS_PATH);
|
|
294
|
+
if (tasks.find(t => t.task_id === taskId && t.execution === 'mesh')) {
|
|
295
|
+
dispatched.add(taskId);
|
|
296
|
+
lastHeartbeat.set(taskId, Date.now());
|
|
297
|
+
log(`CIRCLING AUTO-TRACK: ${taskId} (CLI-submitted)`);
|
|
298
|
+
} else { break; }
|
|
299
|
+
} catch { break; }
|
|
300
|
+
}
|
|
301
|
+
const c = session.circling || {};
|
|
302
|
+
const stepName = c.phase === 'init' ? 'Init'
|
|
303
|
+
: c.phase === 'finalization' ? 'Finalization'
|
|
304
|
+
: `SR${c.current_subround}/${c.max_subrounds} Step${c.current_step}`;
|
|
305
|
+
log(`CIRCLING ${stepName}: started for ${taskId}`);
|
|
306
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
307
|
+
circling_phase: c.phase || null,
|
|
308
|
+
circling_subround: c.current_subround || 0,
|
|
309
|
+
circling_step: c.current_step || 0,
|
|
310
|
+
next_action: `${stepName} in progress (${session.nodes?.length || '?'} nodes)`,
|
|
311
|
+
updated_at: isoTimestamp(),
|
|
312
|
+
});
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
case 'collab.circling_gate': {
|
|
317
|
+
const cg = session.circling || {};
|
|
318
|
+
// Extract blocked reviewer summaries from the last round (if any)
|
|
319
|
+
const lastRound = session.rounds?.[session.rounds.length - 1];
|
|
320
|
+
const blockedVotes = lastRound?.reflections?.filter(r => r.vote === 'blocked') || [];
|
|
321
|
+
let gateMsg;
|
|
322
|
+
if (blockedVotes.length > 0) {
|
|
323
|
+
const reason = blockedVotes.map(r => r.summary).filter(Boolean).join('; ').slice(0, 150);
|
|
324
|
+
gateMsg = `[GATE] SR${cg.current_subround} blocked — ${reason || 'reviewer flagged concern'}`;
|
|
325
|
+
} else {
|
|
326
|
+
gateMsg = `[GATE] SR${cg.current_subround} complete — review reconciliationDoc and approve/reject`;
|
|
327
|
+
}
|
|
328
|
+
log(`CIRCLING GATE: ${taskId} — SR${cg.current_subround} waiting for approval`);
|
|
329
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, taskId, {
|
|
330
|
+
status: 'waiting-user',
|
|
331
|
+
next_action: gateMsg,
|
|
332
|
+
updated_at: isoTimestamp(),
|
|
333
|
+
});
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
default:
|
|
338
|
+
log(`COLLAB EVENT: ${eventType} for ${taskId}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── Plan Events → Kanban ────────────────────────────
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Handle plan-specific events. Materializes subtasks in kanban and tracks progress.
|
|
346
|
+
*/
|
|
347
|
+
function handlePlanEvent(eventType, data) {
|
|
348
|
+
const plan = data;
|
|
349
|
+
const parentTaskId = plan?.parent_task_id;
|
|
350
|
+
|
|
351
|
+
switch (eventType) {
|
|
352
|
+
case 'plan.created':
|
|
353
|
+
log(`PLAN CREATED: ${plan.plan_id} for task ${parentTaskId} (${plan.subtasks?.length || 0} subtasks, ${plan.estimated_waves || 0} waves)`);
|
|
354
|
+
if (parentTaskId) {
|
|
355
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
|
|
356
|
+
next_action: `Plan created: ${plan.subtasks?.length || 0} subtasks in ${plan.estimated_waves || 0} waves. ${plan.requires_approval ? 'Awaiting approval.' : 'Auto-executing.'}`,
|
|
357
|
+
updated_at: isoTimestamp(),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
|
|
362
|
+
case 'plan.approved':
|
|
363
|
+
log(`PLAN APPROVED: ${plan.plan_id}`);
|
|
364
|
+
if (parentTaskId) {
|
|
365
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
|
|
366
|
+
status: 'running',
|
|
367
|
+
next_action: `Plan approved. Dispatching wave 0...`,
|
|
368
|
+
updated_at: isoTimestamp(),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
|
|
373
|
+
case 'plan.wave_started': {
|
|
374
|
+
// Materialize new subtasks into kanban as child tasks
|
|
375
|
+
const readySubtasks = (plan.subtasks || []).filter(st => st.status === 'queued' || st.status === 'blocked');
|
|
376
|
+
log(`PLAN WAVE: ${plan.plan_id} — materializing ${readySubtasks.length} subtasks in kanban`);
|
|
377
|
+
|
|
378
|
+
for (const st of readySubtasks) {
|
|
379
|
+
// Only materialize local/soul/human subtasks — mesh subtasks are tracked via mesh events
|
|
380
|
+
if (st.delegation?.mode === 'local' || st.delegation?.mode === 'soul' || st.delegation?.mode === 'human') {
|
|
381
|
+
materializeSubtask(plan, st);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (parentTaskId) {
|
|
386
|
+
const completed = (plan.subtasks || []).filter(s => s.status === 'completed').length;
|
|
387
|
+
const total = (plan.subtasks || []).length;
|
|
388
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
|
|
389
|
+
next_action: `Plan executing: ${completed}/${total} subtasks done`,
|
|
390
|
+
updated_at: isoTimestamp(),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
case 'plan.subtask_completed': {
|
|
397
|
+
const completed = (plan.subtasks || []).filter(s => s.status === 'completed').length;
|
|
398
|
+
const total = (plan.subtasks || []).length;
|
|
399
|
+
log(`PLAN PROGRESS: ${plan.plan_id} — ${completed}/${total} subtasks done`);
|
|
400
|
+
if (parentTaskId) {
|
|
401
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
|
|
402
|
+
next_action: `Plan: ${completed}/${total} subtasks complete`,
|
|
403
|
+
updated_at: isoTimestamp(),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
case 'plan.completed':
|
|
410
|
+
log(`PLAN COMPLETED: ${plan.plan_id}`);
|
|
411
|
+
// Parent task completion handled by the daemon (marks waiting-user)
|
|
412
|
+
break;
|
|
413
|
+
|
|
414
|
+
case 'plan.aborted':
|
|
415
|
+
log(`PLAN ABORTED: ${plan.plan_id}`);
|
|
416
|
+
if (parentTaskId) {
|
|
417
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
|
|
418
|
+
status: 'blocked',
|
|
419
|
+
next_action: `Plan aborted — needs triage`,
|
|
420
|
+
updated_at: isoTimestamp(),
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
|
|
425
|
+
default:
|
|
426
|
+
log(`PLAN EVENT: ${eventType} for plan ${plan?.plan_id || 'unknown'}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Materialize a plan subtask as a child task in active-tasks.md.
|
|
432
|
+
* For local/soul/human delegation modes only — mesh subtasks use the existing mesh dispatch.
|
|
433
|
+
*/
|
|
434
|
+
function materializeSubtask(plan, subtask) {
|
|
435
|
+
try {
|
|
436
|
+
const tasks = readTasks(ACTIVE_TASKS_PATH);
|
|
437
|
+
|
|
438
|
+
// Check if already materialized
|
|
439
|
+
if (tasks.find(t => t.task_id === subtask.subtask_id)) {
|
|
440
|
+
log(` SKIP: ${subtask.subtask_id} already in kanban`);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const mode = subtask.delegation?.mode || 'local';
|
|
445
|
+
const soulId = subtask.delegation?.soul_id || null;
|
|
446
|
+
|
|
447
|
+
const taskEntry = {
|
|
448
|
+
task_id: subtask.subtask_id,
|
|
449
|
+
title: subtask.title,
|
|
450
|
+
status: mode === 'human' ? 'blocked' : 'queued',
|
|
451
|
+
owner: mode === 'soul' ? 'Daedalus' : null,
|
|
452
|
+
soul_id: soulId,
|
|
453
|
+
success_criteria: subtask.success_criteria || [],
|
|
454
|
+
artifacts: [],
|
|
455
|
+
next_action: mode === 'human'
|
|
456
|
+
? 'Needs Gui input'
|
|
457
|
+
: `Plan subtask (${mode}${soulId ? `: ${soulId}` : ''})`,
|
|
458
|
+
parent_id: plan.parent_task_id,
|
|
459
|
+
project: null, // inherited from parent via MC
|
|
460
|
+
description: `${subtask.description || ''}\n\n_Delegation: ${mode}${soulId ? ` → ${soulId}` : ''} | Plan: ${plan.plan_id} | Budget: ${subtask.budget_minutes}m_`,
|
|
461
|
+
updated_at: isoTimestamp(),
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
// Append to active-tasks.md
|
|
465
|
+
const content = fs.readFileSync(ACTIVE_TASKS_PATH, 'utf-8');
|
|
466
|
+
const yamlEntry = formatTaskYaml(taskEntry);
|
|
467
|
+
fs.writeFileSync(ACTIVE_TASKS_PATH, content + '\n' + yamlEntry);
|
|
468
|
+
|
|
469
|
+
log(` MATERIALIZED: ${subtask.subtask_id} → kanban (${mode})`);
|
|
470
|
+
} catch (err) {
|
|
471
|
+
log(` ERROR materializing ${subtask.subtask_id}: ${err.message}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Format a task entry as YAML for active-tasks.md.
|
|
477
|
+
*/
|
|
478
|
+
function formatTaskYaml(task) {
|
|
479
|
+
const lines = [];
|
|
480
|
+
lines.push(`- task_id: ${task.task_id}`);
|
|
481
|
+
lines.push(` title: ${task.title}`);
|
|
482
|
+
lines.push(` status: ${task.status}`);
|
|
483
|
+
if (task.owner) lines.push(` owner: ${task.owner}`);
|
|
484
|
+
if (task.soul_id) lines.push(` soul_id: ${task.soul_id}`);
|
|
485
|
+
if (task.success_criteria?.length > 0) {
|
|
486
|
+
lines.push(' success_criteria:');
|
|
487
|
+
for (const sc of task.success_criteria) {
|
|
488
|
+
lines.push(` - ${sc}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
lines.push(` artifacts:`);
|
|
492
|
+
if (task.next_action) lines.push(` next_action: "${task.next_action.replace(/"/g, '\\"')}"`);
|
|
493
|
+
if (task.parent_id) lines.push(` parent_id: ${task.parent_id}`);
|
|
494
|
+
if (task.description) lines.push(` description: "${task.description.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`);
|
|
495
|
+
lines.push(` updated_at: ${task.updated_at}`);
|
|
496
|
+
return lines.join('\n');
|
|
497
|
+
}
|
|
498
|
+
|
|
206
499
|
// ── Results: Mesh → Kanban (event-driven) ───────────
|
|
207
500
|
|
|
208
501
|
/**
|
|
@@ -247,6 +540,11 @@ function handleEvent(eventType, taskId, meshTask) {
|
|
|
247
540
|
lastHeartbeat.set(taskId, Date.now());
|
|
248
541
|
return;
|
|
249
542
|
default:
|
|
543
|
+
// Handle collab events
|
|
544
|
+
if (eventType.startsWith('collab.')) {
|
|
545
|
+
handleCollabEvent(eventType, taskId, meshTask);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
250
548
|
// submitted, started — informational only
|
|
251
549
|
log(`EVENT: ${eventType} ${taskId}`);
|
|
252
550
|
return;
|
|
@@ -428,19 +726,30 @@ async function main() {
|
|
|
428
726
|
log(` Dispatch interval: ${DISPATCH_INTERVAL / 1000}s`);
|
|
429
727
|
log(` Mode: ${DRY_RUN ? 'dry run' : 'live'}`);
|
|
430
728
|
|
|
431
|
-
nc = await connect(
|
|
729
|
+
nc = await connect({
|
|
730
|
+
servers: NATS_URL,
|
|
731
|
+
timeout: 5000,
|
|
732
|
+
reconnect: true,
|
|
733
|
+
maxReconnectAttempts: 10,
|
|
734
|
+
reconnectTimeWait: 2000,
|
|
735
|
+
});
|
|
432
736
|
log('Connected to NATS');
|
|
433
737
|
|
|
434
738
|
// Re-reconcile on reconnect — catches events missed during NATS blip (#2)
|
|
435
|
-
//
|
|
739
|
+
// Exit on permanent disconnect so launchd restarts us
|
|
436
740
|
(async () => {
|
|
437
741
|
for await (const s of nc.status()) {
|
|
742
|
+
log(`NATS status: ${s.type}`);
|
|
438
743
|
if (s.type === 'reconnect') {
|
|
439
744
|
log('NATS reconnected — running reconciliation');
|
|
440
745
|
reconcile().catch(err => log(`RECONCILE on reconnect failed: ${err.message}`));
|
|
441
746
|
}
|
|
442
747
|
}
|
|
443
748
|
})();
|
|
749
|
+
nc.closed().then(() => {
|
|
750
|
+
log('NATS connection permanently closed — exiting for launchd restart');
|
|
751
|
+
process.exit(1);
|
|
752
|
+
});
|
|
444
753
|
|
|
445
754
|
// Reconcile any orphaned tasks from a previous crash (#2, #10)
|
|
446
755
|
await reconcile();
|
|
@@ -454,7 +763,12 @@ async function main() {
|
|
|
454
763
|
for await (const msg of sub) {
|
|
455
764
|
try {
|
|
456
765
|
const payload = JSON.parse(sc.decode(msg.data));
|
|
457
|
-
|
|
766
|
+
// Route plan events separately (they use plan_id, not task_id)
|
|
767
|
+
if (payload.event && payload.event.startsWith('plan.')) {
|
|
768
|
+
handlePlanEvent(payload.event, payload.plan || payload);
|
|
769
|
+
} else {
|
|
770
|
+
handleEvent(payload.event, payload.task_id, payload.task);
|
|
771
|
+
}
|
|
458
772
|
} catch (err) {
|
|
459
773
|
log(`ERROR parsing event: ${err.message}`);
|
|
460
774
|
}
|
|
@@ -465,15 +779,23 @@ async function main() {
|
|
|
465
779
|
const stalenessTimer = setInterval(checkStaleness, HEARTBEAT_CHECK_INTERVAL);
|
|
466
780
|
log(`Heartbeat staleness check: every ${HEARTBEAT_CHECK_INTERVAL / 1000}s (warn at ${STALE_WARNING_MS / 60000}m)`);
|
|
467
781
|
|
|
782
|
+
// Wake signal — MC publishes mesh.bridge.wake after creating a mesh task
|
|
783
|
+
// so the bridge picks it up in ~1s instead of waiting for the next poll cycle
|
|
784
|
+
let wakeResolve = null;
|
|
785
|
+
const wakeSub = nc.subscribe('mesh.bridge.wake', {
|
|
786
|
+
callback: () => {
|
|
787
|
+
log('WAKE: received wake signal, triggering immediate poll');
|
|
788
|
+
if (wakeResolve) { wakeResolve(); wakeResolve = null; }
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
|
|
468
792
|
// Dispatch loop (polls active-tasks.md)
|
|
469
793
|
while (running) {
|
|
470
|
-
let lastAttemptedTask = null;
|
|
471
794
|
try {
|
|
472
795
|
// Only dispatch if no active tasks in the mesh from this bridge
|
|
473
796
|
if (dispatched.size === 0) {
|
|
474
797
|
const task = findDispatchable();
|
|
475
798
|
if (task) {
|
|
476
|
-
lastAttemptedTask = task;
|
|
477
799
|
await dispatchTask(task);
|
|
478
800
|
consecutiveSubmitFailures = 0; // reset on success
|
|
479
801
|
}
|
|
@@ -498,11 +820,12 @@ async function main() {
|
|
|
498
820
|
log(`DISPATCH ERROR (${consecutiveSubmitFailures}/${MAX_SUBMIT_FAILURES}): ${err.message}`);
|
|
499
821
|
|
|
500
822
|
if (consecutiveSubmitFailures >= MAX_SUBMIT_FAILURES) {
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
823
|
+
// Find the task we were trying to dispatch and mark it blocked
|
|
824
|
+
const failedTask = findDispatchable();
|
|
825
|
+
if (failedTask) {
|
|
826
|
+
log(`BLOCKING: ${failedTask.task_id} after ${MAX_SUBMIT_FAILURES} consecutive submit failures`);
|
|
504
827
|
try {
|
|
505
|
-
updateTaskInPlace(ACTIVE_TASKS_PATH,
|
|
828
|
+
updateTaskInPlace(ACTIVE_TASKS_PATH, failedTask.task_id, {
|
|
506
829
|
status: 'blocked',
|
|
507
830
|
next_action: `Mesh submit failed ${MAX_SUBMIT_FAILURES}x — check NATS connectivity`,
|
|
508
831
|
updated_at: isoTimestamp(),
|
|
@@ -515,10 +838,16 @@ async function main() {
|
|
|
515
838
|
}
|
|
516
839
|
}
|
|
517
840
|
|
|
518
|
-
|
|
841
|
+
// Sleep until next poll OR wake signal, whichever comes first
|
|
842
|
+
await Promise.race([
|
|
843
|
+
new Promise(r => setTimeout(r, DISPATCH_INTERVAL)),
|
|
844
|
+
new Promise(r => { wakeResolve = r; }),
|
|
845
|
+
]);
|
|
846
|
+
wakeResolve = null;
|
|
519
847
|
}
|
|
520
848
|
|
|
521
849
|
clearInterval(stalenessTimer);
|
|
850
|
+
wakeSub.unsubscribe();
|
|
522
851
|
sub.unsubscribe();
|
|
523
852
|
await nc.drain();
|
|
524
853
|
log('Bridge stopped.');
|