openclaw-node-harness 2.0.2 → 2.0.4

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.
@@ -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, natsConnectOpts } = require('../lib/nats-resolve');
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,245 @@ 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
+ default:
289
+ log(`COLLAB EVENT: ${eventType} for ${taskId}`);
290
+ }
291
+ }
292
+
293
+ // ── Plan Events → Kanban ────────────────────────────
294
+
295
+ /**
296
+ * Handle plan-specific events. Materializes subtasks in kanban and tracks progress.
297
+ */
298
+ function handlePlanEvent(eventType, data) {
299
+ const plan = data;
300
+ const parentTaskId = plan?.parent_task_id;
301
+
302
+ switch (eventType) {
303
+ case 'plan.created':
304
+ log(`PLAN CREATED: ${plan.plan_id} for task ${parentTaskId} (${plan.subtasks?.length || 0} subtasks, ${plan.estimated_waves || 0} waves)`);
305
+ if (parentTaskId) {
306
+ updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
307
+ next_action: `Plan created: ${plan.subtasks?.length || 0} subtasks in ${plan.estimated_waves || 0} waves. ${plan.requires_approval ? 'Awaiting approval.' : 'Auto-executing.'}`,
308
+ updated_at: isoTimestamp(),
309
+ });
310
+ }
311
+ break;
312
+
313
+ case 'plan.approved':
314
+ log(`PLAN APPROVED: ${plan.plan_id}`);
315
+ if (parentTaskId) {
316
+ updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
317
+ status: 'running',
318
+ next_action: `Plan approved. Dispatching wave 0...`,
319
+ updated_at: isoTimestamp(),
320
+ });
321
+ }
322
+ break;
323
+
324
+ case 'plan.wave_started': {
325
+ // Materialize new subtasks into kanban as child tasks
326
+ const readySubtasks = (plan.subtasks || []).filter(st => st.status === 'queued' || st.status === 'blocked');
327
+ log(`PLAN WAVE: ${plan.plan_id} — materializing ${readySubtasks.length} subtasks in kanban`);
328
+
329
+ for (const st of readySubtasks) {
330
+ // Only materialize local/soul/human subtasks — mesh subtasks are tracked via mesh events
331
+ if (st.delegation?.mode === 'local' || st.delegation?.mode === 'soul' || st.delegation?.mode === 'human') {
332
+ materializeSubtask(plan, st);
333
+ }
334
+ }
335
+
336
+ if (parentTaskId) {
337
+ const completed = (plan.subtasks || []).filter(s => s.status === 'completed').length;
338
+ const total = (plan.subtasks || []).length;
339
+ updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
340
+ next_action: `Plan executing: ${completed}/${total} subtasks done`,
341
+ updated_at: isoTimestamp(),
342
+ });
343
+ }
344
+ break;
345
+ }
346
+
347
+ case 'plan.subtask_completed': {
348
+ const completed = (plan.subtasks || []).filter(s => s.status === 'completed').length;
349
+ const total = (plan.subtasks || []).length;
350
+ log(`PLAN PROGRESS: ${plan.plan_id} — ${completed}/${total} subtasks done`);
351
+ if (parentTaskId) {
352
+ updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
353
+ next_action: `Plan: ${completed}/${total} subtasks complete`,
354
+ updated_at: isoTimestamp(),
355
+ });
356
+ }
357
+ break;
358
+ }
359
+
360
+ case 'plan.completed':
361
+ log(`PLAN COMPLETED: ${plan.plan_id}`);
362
+ // Parent task completion handled by the daemon (marks waiting-user)
363
+ break;
364
+
365
+ case 'plan.aborted':
366
+ log(`PLAN ABORTED: ${plan.plan_id}`);
367
+ if (parentTaskId) {
368
+ updateTaskInPlace(ACTIVE_TASKS_PATH, parentTaskId, {
369
+ status: 'blocked',
370
+ next_action: `Plan aborted — needs triage`,
371
+ updated_at: isoTimestamp(),
372
+ });
373
+ }
374
+ break;
375
+
376
+ default:
377
+ log(`PLAN EVENT: ${eventType} for plan ${plan?.plan_id || 'unknown'}`);
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Materialize a plan subtask as a child task in active-tasks.md.
383
+ * For local/soul/human delegation modes only — mesh subtasks use the existing mesh dispatch.
384
+ */
385
+ function materializeSubtask(plan, subtask) {
386
+ try {
387
+ const tasks = readTasks(ACTIVE_TASKS_PATH);
388
+
389
+ // Check if already materialized
390
+ if (tasks.find(t => t.task_id === subtask.subtask_id)) {
391
+ log(` SKIP: ${subtask.subtask_id} already in kanban`);
392
+ return;
393
+ }
394
+
395
+ const mode = subtask.delegation?.mode || 'local';
396
+ const soulId = subtask.delegation?.soul_id || null;
397
+
398
+ const taskEntry = {
399
+ task_id: subtask.subtask_id,
400
+ title: subtask.title,
401
+ status: mode === 'human' ? 'blocked' : 'queued',
402
+ owner: mode === 'soul' ? 'Daedalus' : null,
403
+ soul_id: soulId,
404
+ success_criteria: subtask.success_criteria || [],
405
+ artifacts: [],
406
+ next_action: mode === 'human'
407
+ ? 'Needs Gui input'
408
+ : `Plan subtask (${mode}${soulId ? `: ${soulId}` : ''})`,
409
+ parent_id: plan.parent_task_id,
410
+ project: null, // inherited from parent via MC
411
+ description: `${subtask.description || ''}\n\n_Delegation: ${mode}${soulId ? ` → ${soulId}` : ''} | Plan: ${plan.plan_id} | Budget: ${subtask.budget_minutes}m_`,
412
+ updated_at: isoTimestamp(),
413
+ };
414
+
415
+ // Append to active-tasks.md
416
+ const content = fs.readFileSync(ACTIVE_TASKS_PATH, 'utf-8');
417
+ const yamlEntry = formatTaskYaml(taskEntry);
418
+ fs.writeFileSync(ACTIVE_TASKS_PATH, content + '\n' + yamlEntry);
419
+
420
+ log(` MATERIALIZED: ${subtask.subtask_id} → kanban (${mode})`);
421
+ } catch (err) {
422
+ log(` ERROR materializing ${subtask.subtask_id}: ${err.message}`);
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Format a task entry as YAML for active-tasks.md.
428
+ */
429
+ function formatTaskYaml(task) {
430
+ const lines = [];
431
+ lines.push(`- task_id: ${task.task_id}`);
432
+ lines.push(` title: ${task.title}`);
433
+ lines.push(` status: ${task.status}`);
434
+ if (task.owner) lines.push(` owner: ${task.owner}`);
435
+ if (task.soul_id) lines.push(` soul_id: ${task.soul_id}`);
436
+ if (task.success_criteria?.length > 0) {
437
+ lines.push(' success_criteria:');
438
+ for (const sc of task.success_criteria) {
439
+ lines.push(` - ${sc}`);
440
+ }
441
+ }
442
+ lines.push(` artifacts:`);
443
+ if (task.next_action) lines.push(` next_action: "${task.next_action.replace(/"/g, '\\"')}"`);
444
+ if (task.parent_id) lines.push(` parent_id: ${task.parent_id}`);
445
+ if (task.description) lines.push(` description: "${task.description.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`);
446
+ lines.push(` updated_at: ${task.updated_at}`);
447
+ return lines.join('\n');
448
+ }
449
+
206
450
  // ── Results: Mesh → Kanban (event-driven) ───────────
207
451
 
208
452
  /**
@@ -247,6 +491,11 @@ function handleEvent(eventType, taskId, meshTask) {
247
491
  lastHeartbeat.set(taskId, Date.now());
248
492
  return;
249
493
  default:
494
+ // Handle collab events
495
+ if (eventType.startsWith('collab.')) {
496
+ handleCollabEvent(eventType, taskId, meshTask);
497
+ return;
498
+ }
250
499
  // submitted, started — informational only
251
500
  log(`EVENT: ${eventType} ${taskId}`);
252
501
  return;
@@ -428,19 +677,30 @@ async function main() {
428
677
  log(` Dispatch interval: ${DISPATCH_INTERVAL / 1000}s`);
429
678
  log(` Mode: ${DRY_RUN ? 'dry run' : 'live'}`);
430
679
 
431
- nc = await connect(natsConnectOpts({ timeout: 5000, reconnect: true, maxReconnectAttempts: -1, reconnectTimeWait: 5000 }));
680
+ nc = await connect({
681
+ servers: NATS_URL,
682
+ timeout: 5000,
683
+ reconnect: true,
684
+ maxReconnectAttempts: 10,
685
+ reconnectTimeWait: 2000,
686
+ });
432
687
  log('Connected to NATS');
433
688
 
434
689
  // Re-reconcile on reconnect — catches events missed during NATS blip (#2)
435
- // NATS.js uses async status() iterator, not EventEmitter .on()
690
+ // Exit on permanent disconnect so launchd restarts us
436
691
  (async () => {
437
692
  for await (const s of nc.status()) {
693
+ log(`NATS status: ${s.type}`);
438
694
  if (s.type === 'reconnect') {
439
695
  log('NATS reconnected — running reconciliation');
440
696
  reconcile().catch(err => log(`RECONCILE on reconnect failed: ${err.message}`));
441
697
  }
442
698
  }
443
699
  })();
700
+ nc.closed().then(() => {
701
+ log('NATS connection permanently closed — exiting for launchd restart');
702
+ process.exit(1);
703
+ });
444
704
 
445
705
  // Reconcile any orphaned tasks from a previous crash (#2, #10)
446
706
  await reconcile();
@@ -454,7 +714,12 @@ async function main() {
454
714
  for await (const msg of sub) {
455
715
  try {
456
716
  const payload = JSON.parse(sc.decode(msg.data));
457
- handleEvent(payload.event, payload.task_id, payload.task);
717
+ // Route plan events separately (they use plan_id, not task_id)
718
+ if (payload.event && payload.event.startsWith('plan.')) {
719
+ handlePlanEvent(payload.event, payload.plan || payload);
720
+ } else {
721
+ handleEvent(payload.event, payload.task_id, payload.task);
722
+ }
458
723
  } catch (err) {
459
724
  log(`ERROR parsing event: ${err.message}`);
460
725
  }
@@ -467,13 +732,11 @@ async function main() {
467
732
 
468
733
  // Dispatch loop (polls active-tasks.md)
469
734
  while (running) {
470
- let lastAttemptedTask = null;
471
735
  try {
472
736
  // Only dispatch if no active tasks in the mesh from this bridge
473
737
  if (dispatched.size === 0) {
474
738
  const task = findDispatchable();
475
739
  if (task) {
476
- lastAttemptedTask = task;
477
740
  await dispatchTask(task);
478
741
  consecutiveSubmitFailures = 0; // reset on success
479
742
  }
@@ -498,11 +761,12 @@ async function main() {
498
761
  log(`DISPATCH ERROR (${consecutiveSubmitFailures}/${MAX_SUBMIT_FAILURES}): ${err.message}`);
499
762
 
500
763
  if (consecutiveSubmitFailures >= MAX_SUBMIT_FAILURES) {
501
- // Use the captured task reference don't re-query which could return a different task
502
- if (lastAttemptedTask) {
503
- log(`BLOCKING: ${lastAttemptedTask.task_id} after ${MAX_SUBMIT_FAILURES} consecutive submit failures`);
764
+ // Find the task we were trying to dispatch and mark it blocked
765
+ const failedTask = findDispatchable();
766
+ if (failedTask) {
767
+ log(`BLOCKING: ${failedTask.task_id} after ${MAX_SUBMIT_FAILURES} consecutive submit failures`);
504
768
  try {
505
- updateTaskInPlace(ACTIVE_TASKS_PATH, lastAttemptedTask.task_id, {
769
+ updateTaskInPlace(ACTIVE_TASKS_PATH, failedTask.task_id, {
506
770
  status: 'blocked',
507
771
  next_action: `Mesh submit failed ${MAX_SUBMIT_FAILURES}x — check NATS connectivity`,
508
772
  updated_at: isoTimestamp(),
@@ -25,8 +25,12 @@ const os = require('os');
25
25
 
26
26
  const NODE_ID = process.env.OPENCLAW_NODE_ID ||
27
27
  os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
28
+ // NOTE: REPO_DIR defaults to ~/openclaw (runtime). The git repo lives at
29
+ // ~/openclaw-node. See mesh-deploy.js "Two-directory problem" comment.
28
30
  const REPO_DIR = process.env.OPENCLAW_REPO_DIR ||
29
- path.join(os.homedir(), 'openclaw-node');
31
+ path.join(os.homedir(), 'openclaw');
32
+ const REPO_REMOTE_URL = process.env.OPENCLAW_REPO_URL ||
33
+ 'https://github.com/moltyguibros-design/openclaw-node.git';
30
34
  const DEPLOY_SCRIPT = path.join(REPO_DIR, 'bin', 'mesh-deploy.js');
31
35
 
32
36
  const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
@@ -60,122 +64,140 @@ let deploying = false; // prevent concurrent deploys
60
64
 
61
65
  async function executeDeploy(trigger, resultsKv, nodesKv) {
62
66
  if (deploying) {
63
- console.log(`[deploy-listener] Already deploying — ignoring trigger for ${trigger.sha}`);
67
+ console.log(`[deploy-listener] Already deploying — ignoring trigger for ${trigger.sha} (from ${trigger.initiator || 'unknown'})`);
64
68
  return;
65
69
  }
66
70
 
67
71
  deploying = true;
68
72
  const startedAt = new Date().toISOString();
69
- const resultKey = `${trigger.sha}-${NODE_ID}`;
73
+ // Sanitize sha for NATS KV key safety (KV rejects whitespace, path seps, etc.)
74
+ const safeSha = (trigger.sha || 'unknown').replace(/[^a-fA-F0-9.-]/g, '');
75
+ const resultKey = `${safeSha}-${NODE_ID}`;
70
76
 
71
- console.log(`[deploy-listener] ═══ Deploy triggered: ${trigger.sha} by ${trigger.initiator} ═══`);
72
-
73
- // Write "deploying" status so lead sees we're working
74
77
  try {
75
- await resultsKv.put(resultKey, sc.encode(JSON.stringify({
76
- nodeId: NODE_ID, sha: trigger.sha, status: 'deploying', startedAt,
77
- })));
78
- } catch {}
78
+ console.log(`[deploy-listener] ═══ Deploy triggered: ${trigger.sha} by ${trigger.initiator} ═══`);
79
79
 
80
- const result = {
81
- nodeId: NODE_ID,
82
- sha: trigger.sha,
83
- status: 'success',
84
- startedAt,
85
- completedAt: null,
86
- durationSeconds: 0,
87
- componentsDeployed: [],
88
- warnings: [],
89
- errors: [],
90
- log: '',
91
- };
80
+ // Write "deploying" status so lead sees we're working
81
+ try {
82
+ await resultsKv.put(resultKey, sc.encode(JSON.stringify({
83
+ nodeId: NODE_ID, sha: trigger.sha, status: 'deploying', startedAt,
84
+ })));
85
+ } catch {}
92
86
 
93
- try {
94
- // Verify repo exists
95
- if (!fs.existsSync(path.join(REPO_DIR, '.git'))) {
96
- throw new Error(`Repo not found at ${REPO_DIR}`);
97
- }
87
+ const result = {
88
+ nodeId: NODE_ID,
89
+ sha: trigger.sha,
90
+ status: 'success',
91
+ startedAt,
92
+ completedAt: null,
93
+ durationSeconds: 0,
94
+ componentsDeployed: [],
95
+ warnings: [],
96
+ errors: [],
97
+ log: '',
98
+ };
98
99
 
99
- // Validate branch name to prevent command injection (trigger.branch comes from NATS)
100
- const branch = (trigger.branch || 'main').replace(/[^a-zA-Z0-9._/-]/g, '');
101
- if (!branch || branch !== (trigger.branch || 'main')) {
102
- throw new Error(`Invalid branch name: ${trigger.branch}`);
103
- }
100
+ try {
101
+ // Validate branch name to prevent command injection (trigger.branch comes from NATS)
102
+ const branch = (trigger.branch || 'main').replace(/[^a-zA-Z0-9._/-]/g, '');
103
+ if (!branch || branch !== (trigger.branch || 'main')) {
104
+ throw new Error(`Invalid branch name: ${trigger.branch}`);
105
+ }
104
106
 
105
- // Git fetch + ff merge
106
- execSync(`git fetch origin ${branch}`, {
107
- cwd: REPO_DIR, encoding: 'utf8', timeout: 60000,
108
- });
109
- execSync(`git merge origin/${branch} --ff-only`, {
110
- cwd: REPO_DIR, encoding: 'utf8', timeout: 30000,
111
- });
112
-
113
- // Build deploy command filter requested components against what this node runs
114
- let cmd = `node "${DEPLOY_SCRIPT}" --local`;
115
- if (trigger.components && !trigger.components.includes('all')) {
116
- const applicable = trigger.components.filter(c => NODE_COMPONENTS.has(c));
117
- if (applicable.length === 0) {
118
- console.log(`[deploy-listener] No applicable components for role=${NODE_ROLE} — skipping`);
119
- result.status = 'skipped';
120
- result.log = `No matching components for role ${NODE_ROLE}`;
121
- deploying = false;
122
- result.completedAt = new Date().toISOString();
123
- try { await resultsKv.put(resultKey, sc.encode(JSON.stringify(result))); } catch {}
124
- return;
107
+ // Bootstrap git repo if directory exists but .git doesn't
108
+ // (provisioner may have copied files without git clone)
109
+ if (!fs.existsSync(path.join(REPO_DIR, '.git'))) {
110
+ if (!fs.existsSync(REPO_DIR)) {
111
+ throw new Error(`Repo dir not found at ${REPO_DIR}`);
112
+ }
113
+ console.log(`[deploy-listener] No .git found — bootstrapping git repo`);
114
+ execSync('git init', { cwd: REPO_DIR, encoding: 'utf8', timeout: 10000 });
115
+ execSync(`git remote add origin ${REPO_REMOTE_URL}`, {
116
+ cwd: REPO_DIR, encoding: 'utf8', timeout: 10000,
117
+ });
118
+ execSync(`git fetch origin ${branch}`, {
119
+ cwd: REPO_DIR, encoding: 'utf8', timeout: 60000,
120
+ });
121
+ execSync(`git reset --hard origin/${branch}`, {
122
+ cwd: REPO_DIR, encoding: 'utf8', timeout: 30000,
123
+ });
124
+ console.log(`[deploy-listener] Git bootstrapped from origin/${branch}`);
125
+ } else {
126
+ // Normal path: fetch + ff merge
127
+ execSync(`git fetch origin ${branch}`, {
128
+ cwd: REPO_DIR, encoding: 'utf8', timeout: 60000,
129
+ });
130
+ execSync(`git merge origin/${branch} --ff-only`, {
131
+ cwd: REPO_DIR, encoding: 'utf8', timeout: 30000,
132
+ });
125
133
  }
126
- for (const c of applicable) cmd += ` --component ${c}`;
127
- }
128
- if (trigger.force) cmd += ' --force';
129
-
130
- console.log(`[deploy-listener] Running: ${cmd}`);
131
- const output = execSync(cmd, {
132
- cwd: REPO_DIR,
133
- encoding: 'utf8',
134
- timeout: 300000, // 5 min max (npm install can be slow)
135
- env: { ...process.env, OPENCLAW_REPO_DIR: REPO_DIR },
136
- });
137
-
138
- result.log = output.slice(-5000);
139
- result.status = 'success';
140
- result.sha = execSync('git rev-parse --short HEAD', {
141
- cwd: REPO_DIR, encoding: 'utf8',
142
- }).trim();
143
134
 
144
- console.log(`[deploy-listener] Successnow at ${result.sha}`);
135
+ // Build deploy commandfilter requested components against what this node runs
136
+ let cmd = `"${process.execPath}" "${DEPLOY_SCRIPT}" --local`;
137
+ if (trigger.components && !trigger.components.includes('all')) {
138
+ const applicable = trigger.components.filter(c => NODE_COMPONENTS.has(c));
139
+ if (applicable.length === 0) {
140
+ console.log(`[deploy-listener] No applicable components for role=${NODE_ROLE} — skipping`);
141
+ result.status = 'skipped';
142
+ result.log = `No matching components for role ${NODE_ROLE}`;
143
+ result.completedAt = new Date().toISOString();
144
+ try { await resultsKv.put(resultKey, sc.encode(JSON.stringify(result))); } catch {}
145
+ return;
146
+ }
147
+ for (const c of applicable) cmd += ` --component ${c}`;
148
+ }
149
+ if (trigger.force) cmd += ' --force';
145
150
 
146
- } catch (err) {
147
- result.status = 'failed';
148
- result.errors.push(err.message);
149
- result.log = (err.stdout || err.stderr || err.message).slice(-5000);
150
- console.error(`[deploy-listener] Deploy FAILED: ${err.message}`);
151
- }
151
+ console.log(`[deploy-listener] Running: ${cmd}`);
152
+ const output = execSync(cmd, {
153
+ cwd: REPO_DIR,
154
+ encoding: 'utf8',
155
+ timeout: 300000, // 5 min max (npm install can be slow)
156
+ env: { ...process.env, OPENCLAW_REPO_DIR: REPO_DIR },
157
+ });
152
158
 
153
- result.completedAt = new Date().toISOString();
154
- result.durationSeconds = Math.round(
155
- (new Date(result.completedAt) - new Date(result.startedAt)) / 1000
156
- );
159
+ result.log = output.slice(-5000);
160
+ result.status = 'success';
161
+ result.sha = execSync('git rev-parse --short HEAD', {
162
+ cwd: REPO_DIR, encoding: 'utf8',
163
+ }).trim();
157
164
 
158
- // Write final result to KV
159
- try {
160
- await resultsKv.put(resultKey, sc.encode(JSON.stringify(result)));
161
- } catch (err) {
162
- console.error(`[deploy-listener] Failed to write result: ${err.message}`);
163
- }
165
+ console.log(`[deploy-listener] Success now at ${result.sha}`);
164
166
 
165
- // Update our deployVersion in the nodes registry
166
- if (result.status === 'success' && nodesKv) {
167
+ } catch (err) {
168
+ result.status = 'failed';
169
+ result.errors.push(err.message);
170
+ result.log = (err.stdout || err.stderr || err.message).slice(-5000);
171
+ console.error(`[deploy-listener] Deploy FAILED: ${err.message}`);
172
+ }
173
+
174
+ result.completedAt = new Date().toISOString();
175
+ result.durationSeconds = Math.round(
176
+ (new Date(result.completedAt) - new Date(result.startedAt)) / 1000
177
+ );
178
+
179
+ // Write final result to KV
167
180
  try {
168
- const existing = await nodesKv.get(NODE_ID);
169
- if (existing && existing.value) {
170
- const node = JSON.parse(sc.decode(existing.value));
171
- node.deployVersion = result.sha;
172
- node.lastDeploy = result.completedAt;
173
- await nodesKv.put(NODE_ID, sc.encode(JSON.stringify(node)));
174
- }
175
- } catch {}
176
- }
181
+ await resultsKv.put(resultKey, sc.encode(JSON.stringify(result)));
182
+ } catch (err) {
183
+ console.error(`[deploy-listener] Failed to write result: ${err.message}`);
184
+ }
177
185
 
178
- deploying = false;
186
+ // Update our deployVersion in the nodes registry
187
+ if (result.status === 'success' && nodesKv) {
188
+ try {
189
+ const existing = await nodesKv.get(NODE_ID);
190
+ if (existing && existing.value) {
191
+ const node = JSON.parse(sc.decode(existing.value));
192
+ node.deployVersion = result.sha;
193
+ node.lastDeploy = result.completedAt;
194
+ await nodesKv.put(NODE_ID, sc.encode(JSON.stringify(node)));
195
+ }
196
+ } catch {}
197
+ }
198
+ } finally {
199
+ deploying = false;
200
+ }
179
201
  }
180
202
 
181
203
  // ── Auto-Catch-Up ────────────────────────────────────────────────────────
@@ -32,7 +32,7 @@
32
32
  *
33
33
  * ENVIRONMENT:
34
34
  * OPENCLAW_DEPLOY_BRANCH — git branch (default: main)
35
- * OPENCLAW_REPO_DIR — repo location (default: ~/openclaw-node)
35
+ * OPENCLAW_REPO_DIR — repo location (default: ~/openclaw)
36
36
  * OPENCLAW_NATS — NATS server URL (from env or openclaw.env)
37
37
  */
38
38
 
@@ -47,7 +47,15 @@ const crypto = require('crypto');
47
47
  const IS_MAC = os.platform() === 'darwin';
48
48
  const HOME = os.homedir();
49
49
  const DEPLOY_BRANCH = process.env.OPENCLAW_DEPLOY_BRANCH || 'main';
50
- const REPO_DIR = process.env.OPENCLAW_REPO_DIR || path.join(HOME, 'openclaw-node');
50
+ const REPO_DIR = process.env.OPENCLAW_REPO_DIR || path.join(HOME, 'openclaw');
51
+
52
+ // KNOWN ISSUE: Two-directory problem
53
+ // ~/openclaw-node is the git repo (source of truth). mesh-deploy pulls into it,
54
+ // then copies files to ~/openclaw (the runtime location). These can drift if
55
+ // files are edited directly in ~/openclaw without back-porting to the repo.
56
+ // Resolution path: unify to a single directory. Either symlink ~/openclaw →
57
+ // ~/openclaw-node, or change REPO_DIR default + DIRS to point at the same tree.
58
+ // Until then, mesh-deploy.js is the only sanctioned way to propagate changes.
51
59
 
52
60
  // Standard directory layout
53
61
  const DIRS = {
@@ -884,7 +892,7 @@ async function main() {
884
892
 
885
893
  if (!fs.existsSync(REPO_DIR)) {
886
894
  fail(`Repo not found at ${REPO_DIR}`);
887
- console.log(` Clone it: git clone https://github.com/moltyguibros-design/openclaw-node.git ${REPO_DIR}`);
895
+ console.log(` Clone it: git clone https://github.com/moltyguibros-design/openclaw-node.git "${REPO_DIR}"`);
888
896
  process.exit(1);
889
897
  }
890
898
 
@@ -29,7 +29,7 @@ const HEALTH_BUCKET = "MESH_NODE_HEALTH";
29
29
  const KV_TTL_MS = 120_000; // entries expire after 2 minutes if node dies
30
30
 
31
31
  const REPO_DIR = process.env.OPENCLAW_REPO_DIR ||
32
- path.join(os.homedir(), 'openclaw-node');
32
+ path.join(os.homedir(), 'openclaw');
33
33
 
34
34
  const sc = StringCodec();
35
35
  const IS_MAC = os.platform() === "darwin";