infinicode 2.8.60 → 2.8.62

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.
@@ -74,9 +74,11 @@ Wants=network-online.target
74
74
  Type=simple
75
75
  ExecStart=${service.command} ${service.args.map(shellQuote).join(' ')}
76
76
  WorkingDirectory=${service.workingDir ?? homedir()}
77
- Restart=always
78
- RestartSec=5
79
- ${envLines}
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
@@ -126,6 +126,8 @@ export async function roboparkRobotRuntime(opts) {
126
126
  const child = spawn(entry.command, entry.args, {
127
127
  env: { ...process.env, ...entry.env, ROBOPARK_ROBOT_RUNTIME: '1' },
128
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',
129
131
  });
130
132
  entry.child = child;
131
133
  child.on('error', error => console.error(chalk.red(` ${entry.label} failed to start: ${error.message}`)));
@@ -146,23 +148,34 @@ export async function roboparkRobotRuntime(opts) {
146
148
  start(entry); }, RESTART_DELAY_MS);
147
149
  });
148
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
+ };
149
165
  const stopChild = async (entry) => {
150
166
  const child = entry.child;
151
167
  if (!child)
152
168
  return;
153
169
  await new Promise(resolve => {
154
- const timer = setTimeout(resolve, 5_000);
170
+ const timer = setTimeout(() => {
171
+ signalChild(entry, 'SIGKILL');
172
+ resolve();
173
+ }, 5_000);
155
174
  child.once('exit', () => {
156
175
  clearTimeout(timer);
157
176
  resolve();
158
177
  });
159
- try {
160
- child.kill('SIGTERM');
161
- }
162
- catch {
163
- clearTimeout(timer);
164
- resolve();
165
- }
178
+ signalChild(entry, 'SIGTERM');
166
179
  });
167
180
  };
168
181
  const recycleAfterMeshUpdate = async () => {
@@ -217,7 +230,7 @@ export async function roboparkRobotRuntime(opts) {
217
230
  const shutdown = () => {
218
231
  stopping = true;
219
232
  for (const entry of [...children, preview])
220
- entry.child?.kill('SIGTERM');
233
+ signalChild(entry, 'SIGTERM');
221
234
  };
222
235
  process.once('SIGINT', shutdown);
223
236
  process.once('SIGTERM', shutdown);
@@ -162,10 +162,10 @@ async function setupRobot(config, opts, ctx, internal) {
162
162
  // repointed the mesh connection while the scheduler (enroll/heartbeat/
163
163
  // preview-agent) silently kept talking to whatever hub auto-discovery
164
164
  // found (e.g. a real hub reachable over Tailscale), not the one requested.
165
- const hubHost = new URL(hubUrl).hostname;
166
- const schedulerUrl = network === 'tailscale'
167
- ? `${hubUrl.replace(/\/+$/, '')}/robopark`
168
- : `http://${hubHost}:${opts.schedulerPort ? parseInt(opts.schedulerPort, 10) : DEFAULT_SCHEDULER_PORT}`;
165
+ // Keep the scheduler private on the hub. Both LAN and Tailscale robots use
166
+ // the authenticated mesh proxy instead of depending on port 8080 binding,
167
+ // firewall rules, or a separately routable scheduler address.
168
+ const schedulerUrl = `${hubUrl.replace(/\/+$/, '')}/robopark`;
169
169
  // Vision (camera/motion detection -> presence-triggered session) is the
170
170
  // production trigger — on by default. --no-vision opts out (e.g. no camera
171
171
  // on this box, or RoboVisionAI_PI isn't installed).
@@ -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
  }
@@ -180,13 +180,10 @@ robotProgram
180
180
  .action(async (opts) => {
181
181
  if (opts.lan && opts.tailscale)
182
182
  throw new Error('choose exactly one of --lan or --tailscale');
183
- const hub = new URL(opts.hubUrl);
184
183
  await roboparkRobotRuntime({
185
184
  name: opts.name,
186
185
  hubUrl: opts.hubUrl,
187
- schedulerUrl: opts.schedulerUrl ?? (opts.tailscale
188
- ? `${opts.hubUrl.replace(/\/+$/, '')}/robopark`
189
- : `http://${hub.hostname}:8080`),
186
+ schedulerUrl: opts.schedulerUrl ?? `${opts.hubUrl.replace(/\/+$/, '')}/robopark`,
190
187
  token: opts.token,
191
188
  port: opts.port,
192
189
  network: opts.tailscale ? 'tailscale' : 'lan',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.60",
3
+ "version": "2.8.62",
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",
@@ -7,9 +7,10 @@ import os
7
7
  import sys
8
8
  import time
9
9
  import numpy as np
10
- import threading
11
- import requests
12
- import base64
10
+ import threading
11
+ import requests
12
+ import base64
13
+ import struct
13
14
 
14
15
  app = Flask(__name__)
15
16
  CORS(app)
@@ -20,6 +21,9 @@ camera = None
20
21
  audio_input_device = "default"
21
22
  audio_output_device = "default"
22
23
  ROBOVISION_AUDIO_URL = os.getenv("ROBOVISION_AUDIO_URL", "http://127.0.0.1:8000")
24
+ _camera_inventory_cache = []
25
+ _camera_inventory_cache_at = 0.0
26
+ CAMERA_INVENTORY_CACHE_SECONDS = 30.0
23
27
 
24
28
  def _open_camera(index):
25
29
  # On Windows, cv2.VideoCapture(index) with no explicit backend can
@@ -318,13 +322,53 @@ def caption_mode():
318
322
 
319
323
  @app.route('/api/cameras', methods=['GET'])
320
324
  def list_cameras():
325
+ global _camera_inventory_cache, _camera_inventory_cache_at
326
+ now = time.monotonic()
327
+ if now - _camera_inventory_cache_at < CAMERA_INVENTORY_CACHE_SECONDS:
328
+ return jsonify({"cameras": _camera_inventory_cache, "current": current_camera_index})
329
+
321
330
  available = []
322
- candidates = sorted(glob.glob('/dev/video*')) if sys.platform != 'win32' else list(range(4))
323
- for candidate in candidates:
324
- cap = _open_camera(candidate)
325
- if cap.isOpened():
326
- available.append({"index": candidate, "id": str(candidate), "name": f"Camera {candidate}", "backend": "v4l2" if sys.platform != 'win32' else "dshow"})
331
+ if sys.platform.startswith('linux'):
332
+ # Query capabilities without starting a stream. Opening every V4L2
333
+ # node through OpenCV also opens metadata/output nodes and can contend
334
+ # with the camera stream already owned by RoboVision.
335
+ import fcntl
336
+ vidioc_querycap = 0x80685600
337
+ video_capture = 0x00000001
338
+ video_capture_mplane = 0x00001000
339
+ device_caps_flag = 0x80000000
340
+ for candidate in sorted(glob.glob('/dev/video*')):
341
+ fd = None
342
+ try:
343
+ fd = os.open(candidate, os.O_RDONLY | os.O_NONBLOCK)
344
+ capability = bytearray(104)
345
+ fcntl.ioctl(fd, vidioc_querycap, capability, True)
346
+ capabilities = struct.unpack_from('=I', capability, 84)[0]
347
+ device_caps = struct.unpack_from('=I', capability, 88)[0]
348
+ effective = device_caps if capabilities & device_caps_flag else capabilities
349
+ if not effective & (video_capture | video_capture_mplane):
350
+ continue
351
+ card = bytes(capability[16:48]).split(b'\0', 1)[0].decode('utf-8', 'replace')
352
+ available.append({
353
+ "index": candidate,
354
+ "id": candidate,
355
+ "name": card or f"Camera {candidate}",
356
+ "backend": "v4l2",
357
+ })
358
+ except (OSError, ValueError):
359
+ continue
360
+ finally:
361
+ if fd is not None:
362
+ os.close(fd)
363
+ else:
364
+ for candidate in range(4):
365
+ cap = _open_camera(candidate)
366
+ if cap.isOpened():
367
+ available.append({"index": candidate, "id": str(candidate), "name": f"Camera {candidate}", "backend": "dshow"})
327
368
  cap.release()
369
+
370
+ _camera_inventory_cache = available
371
+ _camera_inventory_cache_at = now
328
372
  return jsonify({"cameras": available, "current": current_camera_index})
329
373
 
330
374
  @app.route('/api/camera/switch', methods=['POST'])
@@ -374,11 +418,6 @@ def _audio_inventory():
374
418
  @app.route('/api/media/inventory', methods=['GET'])
375
419
  def media_inventory():
376
420
  cameras = list_cameras().get_json().get("cameras", [])
377
- if not cameras and sys.platform != 'win32':
378
- # The active V4L2 handle may reject a second probe. The node path is
379
- # still a valid selectable camera and is safer than hiding it.
380
- cameras = [{"id": path, "index": path, "name": f"Camera {path}", "backend": "v4l2"}
381
- for path in sorted(glob.glob('/dev/video*'))]
382
421
  inputs, outputs, audio_state = _audio_inventory()
383
422
  return jsonify({
384
423
  "video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}] + cameras,