openclaw-node-harness 2.0.2 → 2.0.3

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.
@@ -23,7 +23,7 @@ const os = require('os');
23
23
 
24
24
  const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
25
25
  const sc = StringCodec();
26
- const REPO_DIR = process.env.OPENCLAW_REPO_DIR || path.join(os.homedir(), 'openclaw-node');
26
+ const REPO_DIR = process.env.OPENCLAW_REPO_DIR || path.join(os.homedir(), 'openclaw');
27
27
  const NODE_ID = process.env.OPENCLAW_NODE_ID ||
28
28
  os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
29
29
 
package/bin/mesh-agent.js CHANGED
@@ -117,7 +117,7 @@ function buildInitialPrompt(task) {
117
117
  parts.push('');
118
118
  }
119
119
 
120
- if (task.success_criteria.length > 0) {
120
+ if (task.success_criteria && task.success_criteria.length > 0) {
121
121
  parts.push('## Success Criteria');
122
122
  for (const c of task.success_criteria) {
123
123
  parts.push(`- ${c}`);
@@ -132,7 +132,7 @@ function buildInitialPrompt(task) {
132
132
  parts.push('');
133
133
  }
134
134
 
135
- if (task.scope.length > 0) {
135
+ if (task.scope && task.scope.length > 0) {
136
136
  parts.push('## Scope');
137
137
  parts.push('Only modify these files/paths:');
138
138
  for (const s of task.scope) {
@@ -192,7 +192,7 @@ function buildRetryPrompt(task, previousAttempts, attemptNumber) {
192
192
  parts.push('');
193
193
  }
194
194
 
195
- if (task.scope.length > 0) {
195
+ if (task.scope && task.scope.length > 0) {
196
196
  parts.push('## Scope');
197
197
  for (const s of task.scope) {
198
198
  parts.push(`- ${s}`);
@@ -26,7 +26,7 @@ const os = require('os');
26
26
  const NODE_ID = process.env.OPENCLAW_NODE_ID ||
27
27
  os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
28
28
  const REPO_DIR = process.env.OPENCLAW_REPO_DIR ||
29
- path.join(os.homedir(), 'openclaw-node');
29
+ path.join(os.homedir(), 'openclaw');
30
30
  const DEPLOY_SCRIPT = path.join(REPO_DIR, 'bin', 'mesh-deploy.js');
31
31
 
32
32
  const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
@@ -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,7 @@ 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
51
 
52
52
  // Standard directory layout
53
53
  const DIRS = {
@@ -884,7 +884,7 @@ async function main() {
884
884
 
885
885
  if (!fs.existsSync(REPO_DIR)) {
886
886
  fail(`Repo not found at ${REPO_DIR}`);
887
- console.log(` Clone it: git clone https://github.com/moltyguibros-design/openclaw-node.git ${REPO_DIR}`);
887
+ console.log(` Clone it: git clone https://github.com/moltyguibros-design/openclaw-node.git "${REPO_DIR}"`);
888
888
  process.exit(1);
889
889
  }
890
890
 
@@ -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";
package/bin/mesh.js CHANGED
@@ -29,23 +29,44 @@ const path = require('path');
29
29
  const os = require('os');
30
30
 
31
31
  // ─── Config ──────────────────────────────────────────
32
- // NATS URL resolved via shared lib (env var → openclaw.env → .mesh-config localhost fallback)
33
- const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
32
+ // ── NATS URL resolution: env var → ~/.openclaw/openclaw.env → fallback IP ──
33
+ const NATS_FALLBACK = 'nats://100.91.131.61:4222';
34
+ function resolveNatsUrl() {
35
+ if (process.env.OPENCLAW_NATS) return process.env.OPENCLAW_NATS;
36
+ try {
37
+ const envFile = path.join(os.homedir(), '.openclaw', 'openclaw.env');
38
+ if (fs.existsSync(envFile)) {
39
+ const content = fs.readFileSync(envFile, 'utf8');
40
+ const match = content.match(/^\s*OPENCLAW_NATS\s*=\s*(.+)/m);
41
+ if (match && match[1].trim()) return match[1].trim();
42
+ }
43
+ } catch {}
44
+ return NATS_FALLBACK;
45
+ }
46
+ const NATS_URL = resolveNatsUrl();
34
47
  const SHARED_DIR = path.join(os.homedir(), 'openclaw', 'shared');
35
48
  const LOCAL_NODE = os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
36
49
  const sc = StringCodec();
37
50
 
38
51
  // ─── Known nodes (for --node shortcuts) ──────────────
39
- // Load from ~/.openclaw/mesh-aliases.json if it exists, otherwise empty.
40
- let NODE_ALIASES = {};
41
- try {
42
- const aliasFile = path.join(os.homedir(), '.openclaw', 'mesh-aliases.json');
43
- if (fs.existsSync(aliasFile)) {
44
- NODE_ALIASES = JSON.parse(fs.readFileSync(aliasFile, 'utf8'));
45
- }
46
- } catch {
47
- // File missing or malformed — proceed with no aliases
52
+ const NODE_ALIASES_DEFAULTS = {
53
+ 'ubuntu': 'calos-vmware-virtual-platform',
54
+ 'linux': 'calos-vmware-virtual-platform',
55
+ 'mac': 'moltymacs-virtual-machine-local',
56
+ 'macos': 'moltymacs-virtual-machine-local',
57
+ };
58
+
59
+ function loadNodeAliases() {
60
+ const aliasPath = path.join(os.homedir(), '.openclaw', 'mesh-aliases.json');
61
+ try {
62
+ if (fs.existsSync(aliasPath)) {
63
+ const custom = JSON.parse(fs.readFileSync(aliasPath, 'utf8'));
64
+ return { ...NODE_ALIASES_DEFAULTS, ...custom };
65
+ }
66
+ } catch {}
67
+ return NODE_ALIASES_DEFAULTS;
48
68
  }
69
+ const NODE_ALIASES = loadNodeAliases();
49
70
 
50
71
  /**
51
72
  * Resolve a node name — accepts aliases, full IDs, or "self"/"local"
@@ -98,7 +119,7 @@ function checkExecSafety(command) {
98
119
  */
99
120
  async function natsConnect() {
100
121
  try {
101
- return await connect(natsConnectOpts({ timeout: 5000 }));
122
+ return await connect({ servers: NATS_URL, timeout: 5000 });
102
123
  } catch (err) {
103
124
  console.error(`Error: Cannot connect to NATS at ${NATS_URL}`);
104
125
  console.error(`Is the NATS server running? Is Tailscale connected?`);
@@ -140,21 +161,15 @@ async function collectHeartbeats(nc, waitMs = 3000) {
140
161
  uptime: os.uptime(),
141
162
  };
142
163
 
143
- // Force-unsubscribe after deadline to prevent hanging if no messages arrive
144
- const timer = setTimeout(() => sub.unsubscribe(), waitMs);
145
-
146
164
  // Listen for heartbeats for a few seconds
147
165
  const deadline = Date.now() + waitMs;
148
166
  for await (const msg of sub) {
149
- try {
150
- const s = JSON.parse(sc.decode(msg.data));
151
- if (s.node !== LOCAL_NODE) {
152
- nodes[s.node] = s;
153
- }
154
- } catch {}
167
+ const s = JSON.parse(sc.decode(msg.data));
168
+ if (s.node !== LOCAL_NODE) {
169
+ nodes[s.node] = s;
170
+ }
155
171
  if (Date.now() >= deadline) break;
156
172
  }
157
- clearTimeout(timer);
158
173
  sub.unsubscribe();
159
174
  return nodes;
160
175
  }
@@ -575,6 +590,119 @@ async function cmdRepair(args) {
575
590
  }
576
591
  }
577
592
 
593
+ /**
594
+ * mesh deploy [--force] [--component <name>] [--node <name>] — trigger fleet deploy.
595
+ *
596
+ * Publishes mesh.deploy.trigger to NATS. All nodes with mesh-deploy-listener
597
+ * will pull from git and self-deploy. Polls MESH_DEPLOY_RESULTS for status.
598
+ */
599
+ async function cmdDeploy(args) {
600
+ const { execSync } = require('child_process');
601
+ const repoDir = process.env.OPENCLAW_REPO_DIR || path.join(os.homedir(), 'openclaw');
602
+ const force = args.includes('--force');
603
+
604
+ // Parse --component flags
605
+ const components = [];
606
+ for (let i = 0; i < args.length; i++) {
607
+ if (args[i] === '--component' && args[i + 1]) {
608
+ components.push(args[i + 1]);
609
+ i++;
610
+ }
611
+ }
612
+
613
+ // Parse --node flags (target specific nodes, default: all)
614
+ const targetNodes = [];
615
+ for (let i = 0; i < args.length; i++) {
616
+ if (args[i] === '--node' && args[i + 1]) {
617
+ targetNodes.push(resolveNode(args[i + 1]));
618
+ i++;
619
+ }
620
+ }
621
+
622
+ // Get current SHA and branch
623
+ let sha, branch;
624
+ try {
625
+ sha = execSync('git rev-parse --short HEAD', { cwd: repoDir, encoding: 'utf8' }).trim();
626
+ branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoDir, encoding: 'utf8' }).trim();
627
+ } catch {
628
+ console.error(`Error: Cannot read git state from ${repoDir}`);
629
+ process.exit(1);
630
+ }
631
+
632
+ console.log(`Deploying ${sha} (${branch})${force ? ' [FORCE]' : ''}`);
633
+ if (components.length > 0) console.log(` Components: ${components.join(', ')}`);
634
+ if (targetNodes.length > 0) console.log(` Targets: ${targetNodes.join(', ')}`);
635
+ else console.log(' Targets: all nodes');
636
+
637
+ const nc = await natsConnect();
638
+
639
+ const trigger = {
640
+ sha,
641
+ branch,
642
+ components: components.length > 0 ? components : ['all'],
643
+ nodes: targetNodes.length > 0 ? targetNodes : ['all'],
644
+ force,
645
+ initiator: LOCAL_NODE,
646
+ timestamp: new Date().toISOString(),
647
+ };
648
+
649
+ // Write "latest" marker so offline nodes can catch up
650
+ try {
651
+ const js = nc.jetstream();
652
+ const resultsKv = await js.views.kv('MESH_DEPLOY_RESULTS', { history: 5, ttl: 7 * 24 * 60 * 60 * 1000 });
653
+ await resultsKv.put('latest', sc.encode(JSON.stringify({ sha, branch })));
654
+ } catch {}
655
+
656
+ // Publish trigger
657
+ nc.publish('mesh.deploy.trigger', sc.encode(JSON.stringify(trigger)));
658
+ await nc.flush();
659
+ console.log('Deploy trigger sent.\n');
660
+
661
+ // Poll for results (15s timeout)
662
+ console.log('Waiting for node responses...');
663
+ const deadline = Date.now() + 15000;
664
+ const seen = new Set();
665
+
666
+ try {
667
+ const js = nc.jetstream();
668
+ const resultsKv = await js.views.kv('MESH_DEPLOY_RESULTS');
669
+
670
+ while (Date.now() < deadline) {
671
+ const allAliasNodes = [...new Set(Object.values(NODE_ALIASES))];
672
+ const checkNodes = targetNodes.length > 0 ? targetNodes : allAliasNodes;
673
+
674
+ for (const nodeId of checkNodes) {
675
+ if (seen.has(nodeId)) continue;
676
+ const key = `${sha}-${nodeId}`;
677
+ try {
678
+ const entry = await resultsKv.get(key);
679
+ if (entry && entry.value) {
680
+ const result = JSON.parse(sc.decode(entry.value));
681
+ if (result.status === 'success' || result.status === 'failed' || result.status === 'skipped') {
682
+ const icon = result.status === 'success' ? '\x1b[32m✓\x1b[0m' : result.status === 'skipped' ? '\x1b[33m-\x1b[0m' : '\x1b[31m✗\x1b[0m';
683
+ console.log(` ${icon} ${nodeId}: ${result.status} (${result.durationSeconds || 0}s)`);
684
+ if (result.errors && result.errors.length > 0) {
685
+ for (const e of result.errors) console.log(` Error: ${e}`);
686
+ }
687
+ seen.add(nodeId);
688
+ }
689
+ }
690
+ } catch {}
691
+ }
692
+
693
+ if (seen.size >= checkNodes.length) break;
694
+ await new Promise(r => setTimeout(r, 2000));
695
+ }
696
+ } catch {}
697
+
698
+ if (seen.size === 0) {
699
+ console.log(' (no responses yet — nodes may still be deploying)');
700
+ }
701
+
702
+ console.log('');
703
+ await nc.close();
704
+ }
705
+
578
706
  /**
579
707
  * mesh help — show usage.
580
708
  */
@@ -602,6 +730,10 @@ function cmdHelp() {
602
730
  ' mesh health --json Health check (JSON output)',
603
731
  ' mesh repair Self-repair this node',
604
732
  ' mesh repair --all Self-repair ALL nodes',
733
+ ' mesh deploy Deploy to all nodes',
734
+ ' mesh deploy --force Force deploy (even if up to date)',
735
+ ' mesh deploy --component <name> Deploy specific component',
736
+ ' mesh deploy --node <name> Deploy to specific node',
605
737
  '',
606
738
  'NODE ALIASES:',
607
739
  ' ubuntu, linux = Ubuntu VM (calos-vmware-virtual-platform)',
@@ -632,6 +764,7 @@ async function main() {
632
764
  case 'tasks': return cmdTasks(args);
633
765
  case 'health': return cmdHealth(args);
634
766
  case 'repair': return cmdRepair(args);
767
+ case 'deploy': return cmdDeploy(args);
635
768
  case 'help':
636
769
  case '--help':
637
770
  case '-h': return cmdHelp();
@@ -460,14 +460,60 @@ function installLaunchdService(meshDir, nodeBin, nodeId, provider, natsUrl) {
460
460
  return;
461
461
  }
462
462
 
463
+ // Deploy listener plist
464
+ const deployPlistPath = path.join(plistDir, 'ai.openclaw.deploy-listener.plist');
465
+ const deployPlist = `<?xml version="1.0" encoding="UTF-8"?>
466
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
467
+ <plist version="1.0">
468
+ <dict>
469
+ <key>Label</key>
470
+ <string>ai.openclaw.deploy-listener</string>
471
+ <key>ProgramArguments</key>
472
+ <array>
473
+ <string>${nodeBin}</string>
474
+ <string>${meshDir}/bin/mesh-deploy-listener.js</string>
475
+ </array>
476
+ <key>KeepAlive</key>
477
+ <true/>
478
+ <key>RunAtLoad</key>
479
+ <true/>
480
+ <key>StandardOutPath</key>
481
+ <string>${os.homedir()}/.openclaw/workspace/.tmp/mesh-deploy-listener.log</string>
482
+ <key>StandardErrorPath</key>
483
+ <string>${os.homedir()}/.openclaw/workspace/.tmp/mesh-deploy-listener.err</string>
484
+ <key>EnvironmentVariables</key>
485
+ <dict>
486
+ <key>OPENCLAW_NATS</key>
487
+ <string>${natsUrl}</string>
488
+ <key>OPENCLAW_NODE_ID</key>
489
+ <string>${nodeId}</string>
490
+ <key>OPENCLAW_NODE_ROLE</key>
491
+ <string>worker</string>
492
+ <key>OPENCLAW_REPO_DIR</key>
493
+ <string>${meshDir}</string>
494
+ <key>PATH</key>
495
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${os.homedir()}/.npm-global/bin</string>
496
+ <key>NODE_PATH</key>
497
+ <string>${meshDir}/node_modules:${meshDir}/lib</string>
498
+ </dict>
499
+ <key>ThrottleInterval</key>
500
+ <integer>30</integer>
501
+ </dict>
502
+ </plist>`;
503
+
463
504
  fs.mkdirSync(plistDir, { recursive: true });
464
505
  fs.writeFileSync(plistPath, plist);
465
- ok(`Launchd service written: ${plistPath}`);
506
+ ok(`Mesh agent service written: ${plistPath}`);
507
+ fs.writeFileSync(deployPlistPath, deployPlist);
508
+ ok(`Deploy listener service written: ${deployPlistPath}`);
466
509
 
467
510
  try {
468
511
  execSync(`launchctl unload "${plistPath}" 2>/dev/null || true`, { stdio: 'pipe' });
469
512
  execSync(`launchctl load "${plistPath}"`, { stdio: 'pipe' });
470
- ok('Service loaded and started');
513
+ ok('Mesh agent loaded and started');
514
+ execSync(`launchctl unload "${deployPlistPath}" 2>/dev/null || true`, { stdio: 'pipe' });
515
+ execSync(`launchctl load "${deployPlistPath}"`, { stdio: 'pipe' });
516
+ ok('Deploy listener loaded and started');
471
517
  } catch (e) {
472
518
  warn(`Service load warning: ${e.message}`);
473
519
  }
@@ -508,11 +554,40 @@ WantedBy=default.target
508
554
  fs.writeFileSync(servicePath, service);
509
555
  ok(`Systemd service written: ${servicePath}`);
510
556
 
557
+ // Deploy listener service
558
+ const deployServicePath = path.join(serviceDir, 'openclaw-deploy-listener.service');
559
+ const deployService = `[Unit]
560
+ Description=OpenClaw Deploy Listener
561
+ After=network-online.target
562
+ Wants=network-online.target
563
+
564
+ [Service]
565
+ Type=simple
566
+ ExecStart=${nodeBin} ${meshDir}/bin/mesh-deploy-listener.js
567
+ Restart=always
568
+ RestartSec=30
569
+ Environment=OPENCLAW_NATS=${natsUrl}
570
+ Environment=OPENCLAW_NODE_ID=${nodeId}
571
+ Environment=OPENCLAW_NODE_ROLE=worker
572
+ Environment=OPENCLAW_REPO_DIR=${meshDir}
573
+ Environment=NODE_PATH=${meshDir}/node_modules:${meshDir}/lib
574
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin:${os.homedir()}/.local/bin:${os.homedir()}/.npm-global/bin
575
+ WorkingDirectory=${meshDir}
576
+
577
+ [Install]
578
+ WantedBy=default.target
579
+ `;
580
+ fs.writeFileSync(deployServicePath, deployService);
581
+ ok(`Deploy listener service written: ${deployServicePath}`);
582
+
511
583
  try {
512
584
  execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
513
585
  execSync('systemctl --user enable openclaw-mesh-agent', { stdio: 'pipe' });
514
586
  execSync('systemctl --user start openclaw-mesh-agent', { stdio: 'pipe' });
515
- ok('Service enabled and started');
587
+ ok('Mesh agent enabled and started');
588
+ execSync('systemctl --user enable openclaw-deploy-listener', { stdio: 'pipe' });
589
+ execSync('systemctl --user start openclaw-deploy-listener', { stdio: 'pipe' });
590
+ ok('Deploy listener enabled and started');
516
591
  } catch (e) {
517
592
  warn(`Service start warning: ${e.message}`);
518
593
  warn('Try manually: systemctl --user start openclaw-mesh-agent');
@@ -630,6 +705,71 @@ async function verifyNatsHealth(natsUrl, nodeId) {
630
705
  }
631
706
  }
632
707
 
708
+ // ── Mesh Topology Discovery ──────────────────────────
709
+
710
+ async function discoverTopology(natsUrl, localNodeId) {
711
+ log('Discovering mesh topology...');
712
+
713
+ if (DRY_RUN) {
714
+ warn('[DRY RUN] Would query MESH_NODE_HEALTH and write mesh-aliases.json');
715
+ return;
716
+ }
717
+
718
+ try {
719
+ const nats = require('nats');
720
+ const nc = await nats.connect({ servers: natsUrl, timeout: 10000 });
721
+ const sc = nats.StringCodec();
722
+ const js = nc.jetstream();
723
+
724
+ const aliases = {};
725
+
726
+ // Query MESH_NODE_HEALTH for all known nodes
727
+ try {
728
+ const kv = await js.views.kv('MESH_NODE_HEALTH');
729
+ const keys = await kv.keys();
730
+ for await (const key of keys) {
731
+ const entry = await kv.get(key);
732
+ if (entry && entry.value) {
733
+ const health = JSON.parse(sc.decode(entry.value));
734
+ const nodeId = health.nodeId || key;
735
+ // Create short alias from node ID (strip common suffixes)
736
+ const short = nodeId
737
+ .replace(/-virtual-machine.*$/i, '')
738
+ .replace(/-vmware.*$/i, '')
739
+ .replace(/-local$/, '');
740
+ aliases[short] = nodeId;
741
+ if (health.role === 'lead') aliases['lead'] = nodeId;
742
+ ok(`Peer: ${nodeId} (${health.role || 'worker'}, ${health.tailscaleIp || 'unknown'})`);
743
+ }
744
+ }
745
+ } catch {
746
+ warn('MESH_NODE_HEALTH bucket not available — skipping topology');
747
+ }
748
+
749
+ // Also add self
750
+ const selfShort = localNodeId
751
+ .replace(/-virtual-machine.*$/i, '')
752
+ .replace(/-vmware.*$/i, '')
753
+ .replace(/-local$/, '');
754
+ aliases[selfShort] = localNodeId;
755
+ aliases['self'] = localNodeId;
756
+
757
+ await nc.drain();
758
+
759
+ if (Object.keys(aliases).length > 1) {
760
+ const aliasPath = path.join(os.homedir(), '.openclaw', 'mesh-aliases.json');
761
+ fs.writeFileSync(aliasPath, JSON.stringify(aliases, null, 2) + '\n', { mode: 0o644 });
762
+ ok(`Mesh aliases written: ${aliasPath} (${Object.keys(aliases).length} entries)`);
763
+ } else {
764
+ warn('No peers found in MESH_NODE_HEALTH — mesh-aliases.json will only have self');
765
+ const aliasPath = path.join(os.homedir(), '.openclaw', 'mesh-aliases.json');
766
+ fs.writeFileSync(aliasPath, JSON.stringify(aliases, null, 2) + '\n', { mode: 0o644 });
767
+ }
768
+ } catch (e) {
769
+ warn(`Topology discovery failed: ${e.message} (non-fatal)`);
770
+ }
771
+ }
772
+
633
773
  // ── Main ──────────────────────────────────────────────
634
774
 
635
775
  async function main() {
@@ -699,6 +839,10 @@ async function main() {
699
839
  const natsHealthy = await verifyNatsHealth(config.nats, nodeId);
700
840
  const healthy = serviceAlive && natsHealthy;
701
841
 
842
+ // ── Step 9: Discover mesh topology ──
843
+ step(9, 'Discovering mesh topology...');
844
+ await discoverTopology(config.nats, nodeId);
845
+
702
846
  // ── Done ──
703
847
  console.log(`\n${BOLD}${GREEN}═══════════════════════════════════════${RESET}`);
704
848
  if (healthy) {
package/lib/mesh-tasks.js CHANGED
@@ -140,15 +140,15 @@ class TaskStore {
140
140
  // Apply filters
141
141
  if (filter.status && task.status !== filter.status) continue;
142
142
  if (filter.owner && task.owner !== filter.owner) continue;
143
- if (filter.tag && !task.tags.includes(filter.tag)) continue;
143
+ if (filter.tag && (!task.tags || !task.tags.includes(filter.tag))) continue;
144
144
 
145
145
  tasks.push(task);
146
146
  }
147
147
 
148
148
  // Sort by priority (higher first), then created_at (older first)
149
149
  tasks.sort((a, b) => {
150
- if (b.priority !== a.priority) return b.priority - a.priority;
151
- return new Date(a.created_at) - new Date(b.created_at);
150
+ if ((b.priority || 0) !== (a.priority || 0)) return (b.priority || 0) - (a.priority || 0);
151
+ return (new Date(a.created_at || 0)) - (new Date(b.created_at || 0));
152
152
  });
153
153
 
154
154
  return tasks;
@@ -169,7 +169,7 @@ class TaskStore {
169
169
  if (task.exclude_nodes && task.exclude_nodes.includes(nodeId)) continue;
170
170
 
171
171
  // Respect dependencies
172
- if (task.depends_on.length > 0) {
172
+ if (task.depends_on && task.depends_on.length > 0) {
173
173
  const depsReady = await this._checkDeps(task.depends_on);
174
174
  if (!depsReady) continue;
175
175
  }
@@ -192,9 +192,8 @@ class TaskStore {
192
192
  task.status = TASK_STATUS.CLAIMED;
193
193
  task.owner = nodeId;
194
194
  task.claimed_at = new Date().toISOString();
195
- task.budget_deadline = new Date(
196
- Date.now() + task.budget_minutes * 60 * 1000
197
- ).toISOString();
195
+ const budgetMs = (task.budget_minutes || 30) * 60 * 1000;
196
+ task.budget_deadline = new Date(Date.now() + budgetMs).toISOString();
198
197
 
199
198
  await this.put(task);
200
199
  return task;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-node-harness",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "One-command installer for the OpenClaw node layer — identity, skills, souls, daemon, and Mission Control.",
5
5
  "bin": {
6
6
  "openclaw-node": "./cli.js"