infinicode 2.8.83 → 2.8.85

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.83",
3
+ "version": "2.8.85",
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",
@@ -839,10 +839,70 @@ async def broadcast(event_type: str, data: dict):
839
839
  ws_clients.remove(client)
840
840
 
841
841
  # =============================================================================
842
- # ROBOT ENDPOINTS
843
- # =============================================================================
844
-
845
- @app.get("/api/robots", response_model=List[Robot])
842
+ # ROBOT ENDPOINTS
843
+ # =============================================================================
844
+
845
+ CONNECTING_SESSION_LEASE_SECONDS = max(
846
+ 15, int(os.getenv("ROBOPARK_CONNECTING_SESSION_LEASE_SECONDS", "45"))
847
+ )
848
+ ACTIVE_SESSION_LEASE_SECONDS = max(
849
+ CONNECTING_SESSION_LEASE_SECONDS,
850
+ int(os.getenv("ROBOPARK_ACTIVE_SESSION_LEASE_SECONDS", "180")),
851
+ )
852
+
853
+
854
+ async def _reap_robot_session_if_stale(db, robot_id: str, current_session_id: Optional[str],
855
+ robot_status: Optional[str] = None) -> Optional[str]:
856
+ """Release an invalid or expired session pointer without operator action."""
857
+ if not current_session_id:
858
+ return None
859
+
860
+ async with db.execute(
861
+ "SELECT id, started_at, last_activity_at, ended_at FROM sessions WHERE id = ?",
862
+ (current_session_id,),
863
+ ) as cursor:
864
+ session = await cursor.fetchone()
865
+
866
+ reason = None
867
+ if not session:
868
+ reason = "missing_session"
869
+ elif session["ended_at"]:
870
+ reason = "ended_session"
871
+ else:
872
+ timestamp = session["last_activity_at"] or session["started_at"]
873
+ try:
874
+ activity_age = (datetime.utcnow() - datetime.fromisoformat(timestamp)).total_seconds()
875
+ except (TypeError, ValueError):
876
+ activity_age = ACTIVE_SESSION_LEASE_SECONDS + 1
877
+ lease = (
878
+ CONNECTING_SESSION_LEASE_SECONDS
879
+ if robot_status == "connecting"
880
+ else ACTIVE_SESSION_LEASE_SECONDS
881
+ )
882
+ if activity_age >= lease:
883
+ reason = "stale_connecting" if robot_status == "connecting" else "stale_activity"
884
+
885
+ if not reason:
886
+ return None
887
+
888
+ now = datetime.utcnow().isoformat()
889
+ if session and not session["ended_at"]:
890
+ await db.execute(
891
+ "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND ended_at IS NULL",
892
+ (now, reason, current_session_id),
893
+ )
894
+ await db.execute(
895
+ "UPDATE robots SET status = 'idle', current_session_id = NULL, connected_server_id = NULL "
896
+ "WHERE id = ? AND current_session_id = ?",
897
+ (robot_id, current_session_id),
898
+ )
899
+ logger.warning(
900
+ "Automatically released %s session %s for robot %s",
901
+ reason, current_session_id, robot_id,
902
+ )
903
+ return reason
904
+
905
+ @app.get("/api/robots", response_model=List[Robot])
846
906
  async def list_robots():
847
907
  """List all robots"""
848
908
  async with aiosqlite.connect(DB_PATH) as db:
@@ -909,11 +969,23 @@ async def request_session(robot_id: str,
909
969
  (robot_id, device_row["name"]),
910
970
  )
911
971
  await db.commit()
912
- robot = {"id": robot_id, "name": device_row["name"], "status": "idle"}
913
- else:
914
- raise HTTPException(404, "Robot not found")
915
- if robot["status"] != "idle":
916
- raise HTTPException(400, f"Robot is {robot['status']}, not idle")
972
+ robot = {
973
+ "id": robot_id,
974
+ "name": device_row["name"],
975
+ "status": "idle",
976
+ "current_session_id": None,
977
+ }
978
+ else:
979
+ raise HTTPException(404, "Robot not found")
980
+ stale_reason = await _reap_robot_session_if_stale(
981
+ db, robot_id, robot["current_session_id"], robot["status"]
982
+ )
983
+ if stale_reason:
984
+ await db.commit()
985
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as cursor:
986
+ robot = await cursor.fetchone()
987
+ if robot["current_session_id"] or robot["status"] != "idle":
988
+ raise HTTPException(409, f"Robot is {robot['status']}, not idle")
917
989
 
918
990
  # Find least-loaded online server
919
991
  async with db.execute("""
@@ -1046,7 +1118,7 @@ async def simulate_trigger(robot_id: str):
1046
1118
  async with aiosqlite.connect(DB_PATH) as db:
1047
1119
  db.row_factory = aiosqlite.Row
1048
1120
  async with db.execute(
1049
- "SELECT id, current_session_id FROM robots WHERE id = ?", (robot_id,)
1121
+ "SELECT id, current_session_id, status FROM robots WHERE id = ?", (robot_id,)
1050
1122
  ) as c:
1051
1123
  robot = await c.fetchone()
1052
1124
  if not robot:
@@ -1059,11 +1131,14 @@ async def simulate_trigger(robot_id: str):
1059
1131
  (device["id"], device["name"], device["character_id"]),
1060
1132
  )
1061
1133
  await db.commit()
1062
- robot = {"id": device["id"], "current_session_id": None}
1134
+ robot = {"id": device["id"], "current_session_id": None, "status": "idle"}
1063
1135
  if not await _effective_device_production_mode(db, robot_id):
1064
1136
  return {"queued": False, "reason": "device_production_mode_off"}
1065
- if robot["current_session_id"]:
1066
- return {"queued": False, "reason": "session_active"}
1137
+ stale_reason = await _reap_robot_session_if_stale(
1138
+ db, robot_id, robot["current_session_id"], robot["status"]
1139
+ )
1140
+ if robot["current_session_id"] and not stale_reason:
1141
+ return {"queued": False, "reason": "session_active"}
1067
1142
  await db.execute(
1068
1143
  "INSERT INTO trigger_commands (robot_id, source, requested_at) VALUES (?, ?, ?) "
1069
1144
  "ON CONFLICT(robot_id) DO UPDATE SET source = excluded.source, requested_at = excluded.requested_at",
@@ -1072,7 +1147,12 @@ async def simulate_trigger(robot_id: str):
1072
1147
  await db.commit()
1073
1148
  await broadcast("robot_triggered", {"robot_id": robot_id, "source": "dashboard"})
1074
1149
  await _log_history("robot", "robot", robot_id, "simulate_trigger", "operator")
1075
- return {"queued": True, "source": "dashboard"}
1150
+ return {
1151
+ "queued": True,
1152
+ "source": "dashboard",
1153
+ "recovered_stale_session": bool(stale_reason),
1154
+ "recovery_reason": stale_reason,
1155
+ }
1076
1156
 
1077
1157
  @app.post("/api/robots/{robot_id}/simulate-stop")
1078
1158
  async def simulate_stop(robot_id: str):
@@ -1487,14 +1567,31 @@ async def session_joined(session_id: str,
1487
1567
  session = await c.fetchone()
1488
1568
  if not session:
1489
1569
  raise HTTPException(404, "Session not found")
1490
- if session["joined_at"]:
1491
- return {
1492
- "status": "already_joined",
1493
- "joined_at": session["joined_at"],
1494
- "latency_ms": _session_latency_ms(session["started_at"], session["joined_at"]),
1495
- }
1496
- await db.execute("UPDATE sessions SET joined_at = ? WHERE id = ?", (now, session_id))
1497
- await db.commit()
1570
+ if session["joined_at"]:
1571
+ if not session["ended_at"]:
1572
+ await db.execute(
1573
+ "UPDATE sessions SET last_activity_at = ? WHERE id = ?",
1574
+ (now, session_id),
1575
+ )
1576
+ await db.execute(
1577
+ "UPDATE robots SET status = 'running' WHERE id = ? AND current_session_id = ?",
1578
+ (session["robot_id"], session_id),
1579
+ )
1580
+ await db.commit()
1581
+ return {
1582
+ "status": "already_joined",
1583
+ "joined_at": session["joined_at"],
1584
+ "latency_ms": _session_latency_ms(session["started_at"], session["joined_at"]),
1585
+ }
1586
+ await db.execute(
1587
+ "UPDATE sessions SET joined_at = ?, last_activity_at = ? WHERE id = ?",
1588
+ (now, now, session_id),
1589
+ )
1590
+ await db.execute(
1591
+ "UPDATE robots SET status = 'running' WHERE id = ? AND current_session_id = ?",
1592
+ (session["robot_id"], session_id),
1593
+ )
1594
+ await db.commit()
1498
1595
  latency_ms = _session_latency_ms(session["started_at"], now)
1499
1596
  await broadcast("session_joined", {"session_id": session_id, "latency_ms": latency_ms})
1500
1597
  await _log_history("session", "session", session_id, "joined", "robot", f"latency_ms={latency_ms}")
@@ -1955,19 +2052,38 @@ async def health_checker():
1955
2052
  await db.execute("UPDATE devices SET status = 'offline' WHERE id = ?", (did,))
1956
2053
  await broadcast("device_status_changed", {"device_id": did, "status": "offline"})
1957
2054
 
1958
- # Reap sessions leaked by a crashed worker or broken LiveKit
1959
- # disconnect. Normal sessions are closed by the worker, but
1960
- # this prevents stale rooms consuming server capacity forever.
1961
- session_cutoff = (datetime.utcnow() - timedelta(seconds=120)).isoformat()
1962
- async with db.execute(
1963
- "SELECT id, robot_id FROM sessions "
1964
- "WHERE ended_at IS NULL AND COALESCE(last_activity_at, started_at) < ?",
1965
- (session_cutoff,),
1966
- ) as c:
2055
+ # Release stale robot session pointers with the same lease rules
2056
+ # used by the trigger path. Failed joins cannot require manual
2057
+ # operator recovery or block an unattended robot indefinitely.
2058
+ async with db.execute(
2059
+ "SELECT id, status, current_session_id FROM robots WHERE current_session_id IS NOT NULL"
2060
+ ) as c:
2061
+ session_owners = await c.fetchall()
2062
+ for owner in session_owners:
2063
+ reason = await _reap_robot_session_if_stale(
2064
+ db, owner["id"], owner["current_session_id"], owner["status"]
2065
+ )
2066
+ if reason:
2067
+ await broadcast("session_expired", {
2068
+ "session_id": owner["current_session_id"],
2069
+ "robot_id": owner["id"],
2070
+ "reason": reason,
2071
+ })
2072
+
2073
+ # Reap old orphan rows that are no longer owned by a robot.
2074
+ session_cutoff = (
2075
+ datetime.utcnow() - timedelta(seconds=ACTIVE_SESSION_LEASE_SECONDS)
2076
+ ).isoformat()
2077
+ async with db.execute(
2078
+ "SELECT id, robot_id FROM sessions "
2079
+ "WHERE ended_at IS NULL AND COALESCE(last_activity_at, started_at) < ? "
2080
+ "AND NOT EXISTS (SELECT 1 FROM robots r WHERE r.current_session_id = sessions.id)",
2081
+ (session_cutoff,),
2082
+ ) as c:
1967
2083
  stale_sessions = await c.fetchall()
1968
2084
  for session in stale_sessions:
1969
2085
  await db.execute(
1970
- "UPDATE sessions SET ended_at = ?, end_reason = 'silence' "
2086
+ "UPDATE sessions SET ended_at = ?, end_reason = 'stale_orphan' "
1971
2087
  "WHERE id = ? AND ended_at IS NULL",
1972
2088
  (datetime.utcnow().isoformat(), session["id"]),
1973
2089
  )
@@ -1979,7 +2095,7 @@ async def health_checker():
1979
2095
  await broadcast("session_expired", {
1980
2096
  "session_id": session["id"],
1981
2097
  "robot_id": session["robot_id"],
1982
- "reason": "silence",
2098
+ "reason": "stale_orphan",
1983
2099
  })
1984
2100
 
1985
2101
  await db.commit()
@@ -4748,10 +4864,10 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
4748
4864
  readiness_code, readiness_message = "server_unavailable", f"LiveKit server {robot['connected_server_id']} is {server_status or 'missing'}"
4749
4865
  elif not robot["current_session_id"]:
4750
4866
  readiness_code, readiness_message = "ready", "ready for motion trigger"
4751
- elif robot["status"] == "connecting" and session_age is not None and session_age > 30:
4752
- readiness_code, readiness_message = "stuck_session", f"joining LiveKit for {session_age}s; recover robot"
4753
- elif activity_age is not None and activity_age > 180:
4754
- readiness_code, readiness_message = "stale_session", f"no session activity for {activity_age}s; recover robot"
4867
+ elif robot["status"] == "connecting" and session_age is not None and session_age > CONNECTING_SESSION_LEASE_SECONDS:
4868
+ readiness_code, readiness_message = "stuck_session", f"joining LiveKit for {session_age}s; automatic recovery pending"
4869
+ elif activity_age is not None and activity_age > ACTIVE_SESSION_LEASE_SECONDS:
4870
+ readiness_code, readiness_message = "stale_session", f"no session activity for {activity_age}s; automatic recovery pending"
4755
4871
  else:
4756
4872
  readiness_code, readiness_message = "in_session", "conversation session is active"
4757
4873
 
@@ -397,6 +397,10 @@ class PreviewAgent:
397
397
  # use the resolved hardware name reported in that same inventory.
398
398
  self.audio_capture_device = self.audio_device
399
399
  self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
400
+ # Keep RoboVision/sounddevice inventory IDs out of PyAudio. Both APIs
401
+ # number the same ALSA cards differently, so resolve the selected ID
402
+ # to its hardware name before opening any playback stream.
403
+ self.audio_playback_device = self.audio_output_device
400
404
  self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
401
405
  self.use_robovision_camera = bool(cfg.get("use_robovision_camera", False))
402
406
  self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
@@ -689,6 +693,16 @@ class PreviewAgent:
689
693
  if data.get("audio_output_device") is not None:
690
694
  changed = changed or self.audio_output_device != data["audio_output_device"]
691
695
  self.audio_output_device = data["audio_output_device"]
696
+ inventory = data.get("device_inventory") or {}
697
+ selected = str(self.audio_output_device)
698
+ self.audio_playback_device = next(
699
+ (
700
+ str(item.get("name"))
701
+ for item in inventory.get("audio_output", [])
702
+ if str(item.get("id")) == selected and item.get("name")
703
+ ),
704
+ self.audio_output_device,
705
+ )
692
706
  if changed:
693
707
  await self._sync_robovision_media_config()
694
708
  except Exception as e:
@@ -812,7 +826,7 @@ class PreviewAgent:
812
826
  self._vision_last_activity = 0.0
813
827
  self._remote_session_ended = False
814
828
  await self._stop_publisher()
815
- await asyncio.to_thread(_play_audio_effect, self.audio_output_device, "disconnect")
829
+ await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "disconnect")
816
830
  self._current_state = PreviewState()
817
831
 
818
832
  async def _heartbeat_loop(self) -> None:
@@ -865,7 +879,7 @@ class PreviewAgent:
865
879
  restore_state = self._current_state if self._publisher else None
866
880
  if restore_state:
867
881
  await self._stop_publisher()
868
- await asyncio.sleep(0.35)
882
+ await asyncio.sleep(1.0)
869
883
  try:
870
884
  result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
871
885
  result["media_owner_paused"] = bool(restore_state)
@@ -1028,7 +1042,7 @@ class PreviewAgent:
1028
1042
  await asyncio.sleep(0.5)
1029
1043
  # I/O. Camera/mic join and the preset greeting are the critical path.
1030
1044
  asyncio.create_task(
1031
- asyncio.to_thread(_play_audio_effect, self.audio_output_device, "motion")
1045
+ asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
1032
1046
  )
1033
1047
  try:
1034
1048
  r = await self._session.post(
@@ -1234,7 +1248,7 @@ class LiveKitPublisher:
1234
1248
  # never actually reaching the real speakers. Prefer WASAPI's default output,
1235
1249
  # which is what Windows' own volume mixer/meter is backed by.
1236
1250
  output_device_index = None
1237
- selected_output = str(self.agent.audio_output_device or "default")
1251
+ selected_output = str(self.agent.audio_playback_device or "default")
1238
1252
  if selected_output.strip().isdigit():
1239
1253
  output_device_index = int(selected_output.strip())
1240
1254
  try:
@@ -578,6 +578,49 @@ def _open_pcm_stream(pa, pyaudio_module, kind: str, device_index: Optional[int],
578
578
  )
579
579
 
580
580
 
581
+ def _linux_aplay_pcm16(raw: bytes, selected_output: str, sample_rate: int,
582
+ channels: int = 1) -> tuple[bool, str]:
583
+ """Play PCM through the same ALSA plughw path proven during Pi setup.
584
+
585
+ PortAudio and RoboVision enumerate devices in different index spaces. The
586
+ inventory name contains the stable ALSA hw tuple, so use it directly and
587
+ let ALSA's plug layer adapt the cached TTS sample rate/channel count.
588
+ """
589
+ import re
590
+
591
+ match = re.search(r"hw:(\d+),(\d+)", str(selected_output), re.IGNORECASE)
592
+ if not match:
593
+ return False, "selected output has no ALSA hw address"
594
+ alsa_device = f"plughw:{match.group(1)},{match.group(2)}"
595
+ command = [
596
+ "aplay", "-q", "-D", alsa_device, "-t", "raw", "-f", "S16_LE",
597
+ "-r", str(sample_rate), "-c", str(channels),
598
+ ]
599
+ last_error = "aplay failed"
600
+ # ALSA can retain an exclusive USB handle briefly after the LiveKit
601
+ # playback stream closes. Retry for a bounded period instead of declaring
602
+ # a valid device unsupported on the first EBUSY response.
603
+ for attempt in range(5):
604
+ if attempt:
605
+ time.sleep(0.5)
606
+ try:
607
+ completed = subprocess.run(
608
+ command,
609
+ input=raw,
610
+ stdout=subprocess.DEVNULL,
611
+ stderr=subprocess.PIPE,
612
+ timeout=max(5.0, len(raw) / max(1, sample_rate * channels * 2) + 3.0),
613
+ check=False,
614
+ )
615
+ except Exception as exc:
616
+ last_error = f"{type(exc).__name__}: {exc}"
617
+ continue
618
+ if completed.returncode == 0:
619
+ return True, alsa_device
620
+ last_error = completed.stderr.decode("utf-8", errors="replace").strip() or f"aplay exited {completed.returncode}"
621
+ return False, f"{alsa_device}: {last_error}"
622
+
623
+
581
624
  def _speaker_roundtrip_test(params: dict) -> dict:
582
625
  """Play a test tone + record the mic simultaneously; return metrics.
583
626
 
@@ -618,10 +661,46 @@ def _speaker_roundtrip_test(params: dict) -> dict:
618
661
  if dur > 10.0:
619
662
  return {"ok": False, "error": "cached TTS audio exceeds the 10 second test limit"}
620
663
  n_samples = int(source_rate * dur)
621
- if n_samples < 480:
622
- return {"ok": False, "error": "duration too short"}
623
-
624
- pa = pyaudio.PyAudio()
664
+ if n_samples < 480:
665
+ return {"ok": False, "error": "duration too short"}
666
+
667
+ # Cached character voice is output-only. On Linux, bypass PortAudio's
668
+ # unrelated index space and use the exact ALSA plughw endpoint selected by
669
+ # RoboVision. This is the same path used by the successful onsite test.
670
+ if playback_only and mono_pcm is not None and sys.platform.startswith("linux"):
671
+ t0 = time.monotonic()
672
+ played, detail = _linux_aplay_pcm16(mono_pcm, str(out_name), source_rate)
673
+ duration_ms = int((time.monotonic() - t0) * 1000)
674
+ if not played:
675
+ return {
676
+ "ok": False,
677
+ "error": f"ALSA playback failed: {detail}",
678
+ "duration_ms": duration_ms,
679
+ "output_device": str(out_name),
680
+ "input_device": "not opened (speaker-only test)",
681
+ "duration": dur,
682
+ }
683
+ return {
684
+ "ok": True,
685
+ "pass": True,
686
+ "played": True,
687
+ "playback_only": True,
688
+ "mode": mode,
689
+ "text": params.get("text"),
690
+ "cache_hit": bool(params.get("cache_hit")),
691
+ "tts_provider": params.get("tts_provider"),
692
+ "tts_voice": params.get("tts_voice"),
693
+ "duration": dur,
694
+ "duration_ms": duration_ms,
695
+ "sample_rate": source_rate,
696
+ "output_sample_rate": source_rate,
697
+ "output_channels": 1,
698
+ "output_device": detail,
699
+ "input_device": "not opened (speaker-only test)",
700
+ "diagnostic": "cached voice played through the selected ALSA plughw endpoint",
701
+ }
702
+
703
+ pa = pyaudio.PyAudio()
625
704
  out_idx = _resolve_audio_device(pa, out_name, "output")
626
705
  in_idx = None if playback_only else _resolve_audio_device(pa, in_name, "input")
627
706
  def _dev_name(idx):