infinicode 2.8.59 → 2.8.61

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.
@@ -73,17 +73,27 @@ Wants=network-online.target
73
73
  [Service]
74
74
  Type=simple
75
75
  ExecStart=${service.command} ${service.args.map(shellQuote).join(' ')}
76
- WorkingDirectory=${service.workingDir ?? '/opt/robopark'}
77
- Restart=always
78
- RestartSec=5
79
- ${envLines}
76
+ WorkingDirectory=${service.workingDir ?? homedir()}
77
+ Restart=always
78
+ RestartSec=5
79
+ KillMode=control-group
80
+ TimeoutStopSec=10
81
+ ${envLines}
80
82
 
81
83
  [Install]
82
84
  WantedBy=multi-user.target
83
85
  `;
84
86
  try {
85
87
  writeFileSync(unitPath, unit, 'utf8');
86
- return { ok: true, message: `wrote systemd unit ${unitPath}. Run: systemctl enable --now ${unitName}` };
88
+ const reload = spawnSync('systemctl', ['daemon-reload'], { encoding: 'utf8' });
89
+ if (reload.status !== 0) {
90
+ return { ok: false, message: `wrote ${unitPath}, but systemctl daemon-reload failed: ${reload.stderr || reload.stdout}` };
91
+ }
92
+ const enabled = spawnSync('systemctl', ['enable', '--now', unitName], { encoding: 'utf8' });
93
+ if (enabled.status !== 0) {
94
+ return { ok: false, message: `wrote ${unitPath}, but systemctl enable --now failed: ${enabled.stderr || enabled.stdout}` };
95
+ }
96
+ return { ok: true, message: `enabled and started systemd unit ${unitName}` };
87
97
  }
88
98
  catch (e) {
89
99
  return { ok: false, message: `could not write ${unitPath}: ${e instanceof Error ? e.message : String(e)}` };
@@ -101,8 +101,6 @@ export async function roboparkRobotRuntime(opts) {
101
101
  '--scheduler-url', opts.schedulerUrl, '--robot-id', opts.name,
102
102
  '--video-device', opts.videoDevice ?? 'auto', '--audio-device', opts.audioDevice ?? 'default',
103
103
  '--save-config'];
104
- if (opts.vision !== false)
105
- previewArgs.push('--robovision-url', 'http://127.0.0.1:5000');
106
104
  if (opts.enrollmentToken)
107
105
  previewArgs.push('--enrollment-token', opts.enrollmentToken);
108
106
  const preview = {
@@ -113,6 +111,13 @@ export async function roboparkRobotRuntime(opts) {
113
111
  // Keep this in the child environment, never in the persisted robot config.
114
112
  env: {
115
113
  ROBOPARK_MESH_TOKEN: opts.token,
114
+ // Keep internal service wiring out of the Commander argument boundary.
115
+ // This also lets preview start while RoboVision is still warming up.
116
+ ROBOVISION_URL: opts.vision !== false ? 'http://127.0.0.1:5000' : '',
117
+ ROBOVISION_MEDIA_URL: opts.vision !== false
118
+ ? 'http://127.0.0.1:5000/api/media/inventory'
119
+ : '',
120
+ ROBOVISION_CAMERA: opts.vision !== false ? '1' : '0',
116
121
  // Inventory changes should reach the Control Center promptly.
117
122
  HEARTBEAT_INTERVAL: '5',
118
123
  },
@@ -121,6 +126,8 @@ export async function roboparkRobotRuntime(opts) {
121
126
  const child = spawn(entry.command, entry.args, {
122
127
  env: { ...process.env, ...entry.env, ROBOPARK_ROBOT_RUNTIME: '1' },
123
128
  stdio: runtimeLog(`robot-${opts.name}-${entry.label}`, foreground),
129
+ // Give each launcher and its Python descendants one process group.
130
+ detached: process.platform !== 'win32',
124
131
  });
125
132
  entry.child = child;
126
133
  child.on('error', error => console.error(chalk.red(` ${entry.label} failed to start: ${error.message}`)));
@@ -141,23 +148,34 @@ export async function roboparkRobotRuntime(opts) {
141
148
  start(entry); }, RESTART_DELAY_MS);
142
149
  });
143
150
  };
151
+ const signalChild = (entry, signal) => {
152
+ const pid = entry.child?.pid;
153
+ if (!pid)
154
+ return;
155
+ try {
156
+ if (process.platform === 'win32')
157
+ entry.child?.kill(signal);
158
+ else
159
+ process.kill(-pid, signal);
160
+ }
161
+ catch {
162
+ // It may have exited between the liveness check and signal.
163
+ }
164
+ };
144
165
  const stopChild = async (entry) => {
145
166
  const child = entry.child;
146
167
  if (!child)
147
168
  return;
148
169
  await new Promise(resolve => {
149
- const timer = setTimeout(resolve, 5_000);
170
+ const timer = setTimeout(() => {
171
+ signalChild(entry, 'SIGKILL');
172
+ resolve();
173
+ }, 5_000);
150
174
  child.once('exit', () => {
151
175
  clearTimeout(timer);
152
176
  resolve();
153
177
  });
154
- try {
155
- child.kill('SIGTERM');
156
- }
157
- catch {
158
- clearTimeout(timer);
159
- resolve();
160
- }
178
+ signalChild(entry, 'SIGTERM');
161
179
  });
162
180
  };
163
181
  const recycleAfterMeshUpdate = async () => {
@@ -212,7 +230,7 @@ export async function roboparkRobotRuntime(opts) {
212
230
  const shutdown = () => {
213
231
  stopping = true;
214
232
  for (const entry of [...children, preview])
215
- entry.child?.kill('SIGTERM');
233
+ signalChild(entry, 'SIGTERM');
216
234
  };
217
235
  process.once('SIGINT', shutdown);
218
236
  process.once('SIGTERM', shutdown);
@@ -189,7 +189,10 @@ async function setupRobot(config, opts, ctx, internal) {
189
189
  console.log(' ' + chalk.cyan(`robopark ${runtimeArgs.join(' ')}`));
190
190
  console.log(chalk.dim(` network: ${network} (scheduler: ${schedulerUrl})`));
191
191
  console.log();
192
- if (opts.start) {
192
+ // On Linux, registerAutoStart enables and starts the systemd service. Do not
193
+ // also launch a detached copy that can leave mesh healthy while preview is
194
+ // duplicated, blocked, or running under a different HOME.
195
+ if (opts.start && !(opts.autoStart && process.platform === 'linux')) {
193
196
  const robotArgv = [process.execPath, process.argv[1], ...runtimeArgs];
194
197
  console.log(chalk.dim(' starting unified robot runtime…'));
195
198
  runDetached(robotArgv[0], robotArgv.slice(1));
@@ -77,6 +77,24 @@ function stopAllUnix() {
77
77
  const removedUnits = [];
78
78
  const errors = [];
79
79
  const self = process.pid;
80
+ if (process.platform === 'linux') {
81
+ // Disable restart policies before killing processes so systemd cannot
82
+ // repopulate the ports between process discovery and cleanup.
83
+ const list = spawnSync('systemctl', ['list-units', '--all', '--plain', '--no-legend'], { encoding: 'utf8' });
84
+ const units = (list.stdout ?? '')
85
+ .split('\n')
86
+ .map(l => l.trim().split(/\s+/)[0])
87
+ .filter((u) => !!u && u.endsWith('.service') && /infinicode|robopark/i.test(u));
88
+ for (const unit of units) {
89
+ const disable = spawnSync('systemctl', ['disable', '--now', unit], { encoding: 'utf8' });
90
+ if (disable.status === 0)
91
+ removedUnits.push(unit);
92
+ else
93
+ errors.push(`systemctl disable --now ${unit}: ${(disable.stderr || '').trim()}`);
94
+ }
95
+ if (units.length)
96
+ spawnSync('systemctl', ['daemon-reload'], { encoding: 'utf8' });
97
+ }
80
98
  const ps = spawnSync('ps', ['ax', '-o', 'pid=,command='], { encoding: 'utf8' });
81
99
  const lines = (ps.stdout ?? '').split('\n');
82
100
  const re = new RegExp(PATTERNS.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'));
@@ -96,7 +114,7 @@ function stopAllUnix() {
96
114
  }
97
115
  // Also free the well-known RoboPark ports directly — belt and suspenders
98
116
  // against a supervised process whose command line didn't match a pattern.
99
- for (const port of [47913, 47921, 47922, 8080, 5000, 5057]) {
117
+ for (const port of [47913, 47921, 47922, 8080, 5000, 5057, 8000]) {
100
118
  const lsof = spawnSync('lsof', ['-t', `-i:${port}`], { encoding: 'utf8' });
101
119
  const pids = (lsof.stdout ?? '').split('\n').map(s => s.trim()).filter(Boolean).map(Number);
102
120
  for (const pid of pids) {
@@ -112,21 +130,6 @@ function stopAllUnix() {
112
130
  // include a bare "infinicode.service" (see pi-client install scripts),
113
131
  // which a narrower glob would silently leave running (and respawning
114
132
  // whatever we just killed, if it has Restart=always).
115
- const list = spawnSync('systemctl', ['list-units', '--all', '--plain', '--no-legend'], { encoding: 'utf8' });
116
- const units = (list.stdout ?? '')
117
- .split('\n')
118
- .map(l => l.trim().split(/\s+/)[0])
119
- .filter((u) => !!u && u.endsWith('.service') && /infinicode|robopark/i.test(u));
120
- for (const unit of units) {
121
- spawnSync('systemctl', ['stop', unit], { encoding: 'utf8' });
122
- const disable = spawnSync('systemctl', ['disable', unit], { encoding: 'utf8' });
123
- if (disable.status === 0)
124
- removedUnits.push(unit);
125
- else
126
- errors.push(`systemctl disable ${unit}: ${(disable.stderr || '').trim()}`);
127
- }
128
- if (units.length)
129
- spawnSync('systemctl', ['daemon-reload'], { encoding: 'utf8' });
130
133
  }
131
134
  return { killedPids, removedUnits, errors };
132
135
  }
@@ -135,6 +135,7 @@ program
135
135
  .option('--enrollment-token <token>', 'one-time enrollment token')
136
136
  .option('--video-device <dev>', 'camera: auto, /dev/video0, 0, picamera', 'auto')
137
137
  .option('--audio-device <dev>', 'microphone: default, none, or name/index', 'default')
138
+ .option('--robovision-url <url>', 'RoboVision base URL used for camera streaming and hardware inventory')
138
139
  .option('--width <px>', 'video width', '640')
139
140
  .option('--height <px>', 'video height', '480')
140
141
  .option('--fps <n>', 'video fps', '15')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.59",
3
+ "version": "2.8.61",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",
@@ -337,7 +337,9 @@ def _get_lan_ip() -> Optional[str]:
337
337
  return None
338
338
 
339
339
 
340
- async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Optional[bool]:
340
+ async def _send_heartbeat(
341
+ scheduler_url: str, device_id: str, token: str
342
+ ) -> tuple[Optional[bool], bool]:
341
343
  try:
342
344
  inventory = _get_device_inventory()
343
345
  headers = {"Authorization": f"Bearer {token}", **_mesh_proxy_headers()}
@@ -348,6 +350,13 @@ async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Opt
348
350
  headers=headers,
349
351
  timeout=10.0,
350
352
  )
353
+ if response.status_code in (401, 404):
354
+ logger.warning(
355
+ "heartbeat credentials rejected for %s (%s)",
356
+ device_id,
357
+ response.status_code,
358
+ )
359
+ return None, False
351
360
  response.raise_for_status()
352
361
  logger.info(
353
362
  "heartbeat accepted: inventory source=%s camera=%d mic=%d speaker=%d",
@@ -356,10 +365,10 @@ async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Opt
356
365
  len(inventory.get("audio_input", [])),
357
366
  len(inventory.get("audio_output", [])),
358
367
  )
359
- return bool(response.json().get("production_mode", False))
368
+ return bool(response.json().get("production_mode", False)), True
360
369
  except Exception as e:
361
370
  logger.warning(f"heartbeat failed: {e}")
362
- return None
371
+ return None, True
363
372
 
364
373
 
365
374
  @dataclass
@@ -420,18 +429,34 @@ class PreviewAgent:
420
429
  self._last_device_config_poll = 0.0
421
430
  self._motion_reference = None
422
431
  self._last_motion_sample = 0.0
423
- self._motion_capture: Optional[VideoCapture] = None
424
- self._motion_capture_lock = asyncio.Lock()
425
-
432
+ self._motion_capture: Optional[VideoCapture] = None
433
+ self._motion_capture_lock = asyncio.Lock()
434
+ self._mesh_bootstrap_ready = False
435
+ self._next_mesh_bootstrap = 0.0
436
+
437
+ async def _ensure_mesh_identity(self) -> bool:
438
+ if not _mesh_proxy_headers():
439
+ return bool(self.device_id and self.device_token)
440
+ now = time.monotonic()
441
+ if self._mesh_bootstrap_ready:
442
+ return True
443
+ if now < self._next_mesh_bootstrap:
444
+ return False
445
+ self._next_mesh_bootstrap = now + 5.0
446
+ try:
447
+ self.device_id, self.device_token = await _bootstrap_mesh_device(
448
+ self.scheduler_url, self.robot_id
449
+ )
450
+ self._mesh_bootstrap_ready = True
451
+ return True
452
+ except Exception as exc:
453
+ logger.warning("mesh device bootstrap failed: %s", exc)
454
+ return False
455
+
426
456
  async def run(self) -> None:
427
457
  enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
428
458
  if _mesh_proxy_headers():
429
- try:
430
- self.device_id, self.device_token = await _bootstrap_mesh_device(
431
- self.scheduler_url, self.robot_id
432
- )
433
- except Exception as exc:
434
- logger.warning("mesh device bootstrap failed: %s", exc)
459
+ await self._ensure_mesh_identity()
435
460
 
436
461
  if not self.device_token and enrollment_token:
437
462
  self.device_token = await _enroll(self.scheduler_url, enrollment_token, self.robot_id)
@@ -747,13 +772,18 @@ class PreviewAgent:
747
772
  await asyncio.to_thread(_play_audio_effect, self.audio_output_device, "disconnect")
748
773
  self._current_state = PreviewState()
749
774
 
750
- async def _heartbeat_loop(self) -> None:
751
- while not self._shutdown.is_set():
752
- if self.device_id and self.device_token:
753
- production_mode = await _send_heartbeat(
754
- self.scheduler_url, self.device_id, self.device_token
755
- )
756
- if production_mode is not None:
775
+ async def _heartbeat_loop(self) -> None:
776
+ while not self._shutdown.is_set():
777
+ if _mesh_proxy_headers() and not self._mesh_bootstrap_ready:
778
+ await self._ensure_mesh_identity()
779
+ if self.device_id and self.device_token:
780
+ production_mode, credentials_ok = await _send_heartbeat(
781
+ self.scheduler_url, self.device_id, self.device_token
782
+ )
783
+ if not credentials_ok:
784
+ self._mesh_bootstrap_ready = False
785
+ self._next_mesh_bootstrap = 0.0
786
+ if production_mode is not None:
757
787
  self.production_mode = production_mode
758
788
  if not production_mode and self._vision_session_id:
759
789
  await self._end_vision_session()
@@ -1674,7 +1704,9 @@ def main() -> None:
1674
1704
  "video_device": args.video_device,
1675
1705
  "audio_device": args.audio_device,
1676
1706
  "robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
1677
- "use_robovision_camera": bool(args.robovision_url),
1707
+ "use_robovision_camera": bool(args.robovision_url) or os.getenv(
1708
+ "ROBOVISION_CAMERA", ""
1709
+ ).lower() in ("1", "true", "yes", "on"),
1678
1710
  "video_width": args.width,
1679
1711
  "video_height": args.height,
1680
1712
  "video_fps": args.fps,