infinicode 2.8.84 → 2.8.86

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.
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import chalk from 'chalk';
11
11
  import { spawn, execSync } from 'node:child_process';
12
- import { existsSync } from 'node:fs';
12
+ import { cpSync, existsSync, mkdirSync, statSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
13
14
  import { dirname, join } from 'node:path';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  function pkgDir() {
@@ -68,6 +69,33 @@ function findPython() {
68
69
  // ENOENT/"not found" error from the OS rather than a silent fallback.
69
70
  return process.platform === 'win32' ? 'python' : 'python3';
70
71
  }
72
+ function resolveSchedulerDataDir(explicit) {
73
+ if (explicit)
74
+ return explicit;
75
+ if (process.env.SCHEDULER_DATA_DIR)
76
+ return process.env.SCHEDULER_DATA_DIR;
77
+ const stable = join(homedir(), '.robopark', 'scheduler');
78
+ const stableDb = join(stable, 'scheduler.db');
79
+ if (existsSync(stableDb))
80
+ return stable;
81
+ // Older releases stored data beside whichever npm installation launched
82
+ // the scheduler. Local, global and npx installs therefore created separate
83
+ // databases. Migrate the largest existing DB once into stable user storage.
84
+ const legacyCandidates = [join(pkgDir(), '..', 'data')];
85
+ if (process.platform === 'win32' && process.env.APPDATA) {
86
+ legacyCandidates.push(join(process.env.APPDATA, 'npm', 'node_modules', 'data'));
87
+ }
88
+ const legacy = legacyCandidates
89
+ .filter((candidate, index, all) => all.indexOf(candidate) === index)
90
+ .filter(candidate => existsSync(join(candidate, 'scheduler.db')))
91
+ .sort((a, b) => statSync(join(b, 'scheduler.db')).size - statSync(join(a, 'scheduler.db')).size)[0];
92
+ mkdirSync(stable, { recursive: true });
93
+ if (legacy) {
94
+ cpSync(legacy, stable, { recursive: true, force: false, errorOnExist: false });
95
+ console.log(chalk.yellow(` migrated scheduler data: ${legacy} -> ${stable}`));
96
+ }
97
+ return stable;
98
+ }
71
99
  /** Resolve the actual infinicode CLI entry point so `robopark setup --start`
72
100
  * works even when global bins are not on PATH.
73
101
  *
@@ -110,6 +138,7 @@ export async function roboparkServe(opts) {
110
138
  const python = findPython();
111
139
  const schedulerDir = dirname(schedulerPath);
112
140
  const requirements = join(schedulerDir, 'requirements.txt');
141
+ const dataDir = resolveSchedulerDataDir(opts.dataDir);
113
142
  // Ensure scheduler Python dependencies are installed before starting.
114
143
  if (existsSync(requirements)) {
115
144
  console.log(chalk.dim(' ensuring scheduler Python deps…'));
@@ -125,7 +154,7 @@ export async function roboparkServe(opts) {
125
154
  ...process.env,
126
155
  SCHEDULER_HOST: host,
127
156
  SCHEDULER_PORT: String(port),
128
- SCHEDULER_DATA_DIR: opts.dataDir ?? join(pkgDir(), '..', 'data'),
157
+ SCHEDULER_DATA_DIR: dataDir,
129
158
  };
130
159
  if (opts.gateway)
131
160
  env.INFINIBOT_GATEWAY_URL = opts.gateway;
@@ -138,6 +167,7 @@ export async function roboparkServe(opts) {
138
167
  console.log(` scheduler: ${chalk.cyan(schedulerPath)}`);
139
168
  console.log(` python: ${chalk.cyan(python)}`);
140
169
  console.log(` listen: ${chalk.cyan(`${host}:${port}`)}`);
170
+ console.log(` data: ${chalk.cyan(dataDir)}`);
141
171
  if (opts.gateway)
142
172
  console.log(` gateway: ${chalk.cyan(opts.gateway)}`);
143
173
  console.log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.84",
3
+ "version": "2.8.86",
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",
@@ -604,6 +604,9 @@ async def init_db():
604
604
  ("sessions", "voice_stack_id", "TEXT"),
605
605
  ("sessions", "initiated_by", "TEXT"),
606
606
  ("sessions", "event_token_hash", "TEXT"),
607
+ # Speaker tests are executed only by preview_agent, which can
608
+ # release and restore the active media publisher around ALSA I/O.
609
+ ("shell_requests", "claimed_at", "TEXT"),
607
610
  ):
608
611
  try:
609
612
  await db.execute(f"ALTER TABLE {_table} ADD COLUMN {_column} {_coldef}")
@@ -839,10 +842,70 @@ async def broadcast(event_type: str, data: dict):
839
842
  ws_clients.remove(client)
840
843
 
841
844
  # =============================================================================
842
- # ROBOT ENDPOINTS
843
- # =============================================================================
844
-
845
- @app.get("/api/robots", response_model=List[Robot])
845
+ # ROBOT ENDPOINTS
846
+ # =============================================================================
847
+
848
+ CONNECTING_SESSION_LEASE_SECONDS = max(
849
+ 15, int(os.getenv("ROBOPARK_CONNECTING_SESSION_LEASE_SECONDS", "45"))
850
+ )
851
+ ACTIVE_SESSION_LEASE_SECONDS = max(
852
+ CONNECTING_SESSION_LEASE_SECONDS,
853
+ int(os.getenv("ROBOPARK_ACTIVE_SESSION_LEASE_SECONDS", "180")),
854
+ )
855
+
856
+
857
+ async def _reap_robot_session_if_stale(db, robot_id: str, current_session_id: Optional[str],
858
+ robot_status: Optional[str] = None) -> Optional[str]:
859
+ """Release an invalid or expired session pointer without operator action."""
860
+ if not current_session_id:
861
+ return None
862
+
863
+ async with db.execute(
864
+ "SELECT id, started_at, last_activity_at, ended_at FROM sessions WHERE id = ?",
865
+ (current_session_id,),
866
+ ) as cursor:
867
+ session = await cursor.fetchone()
868
+
869
+ reason = None
870
+ if not session:
871
+ reason = "missing_session"
872
+ elif session["ended_at"]:
873
+ reason = "ended_session"
874
+ else:
875
+ timestamp = session["last_activity_at"] or session["started_at"]
876
+ try:
877
+ activity_age = (datetime.utcnow() - datetime.fromisoformat(timestamp)).total_seconds()
878
+ except (TypeError, ValueError):
879
+ activity_age = ACTIVE_SESSION_LEASE_SECONDS + 1
880
+ lease = (
881
+ CONNECTING_SESSION_LEASE_SECONDS
882
+ if robot_status == "connecting"
883
+ else ACTIVE_SESSION_LEASE_SECONDS
884
+ )
885
+ if activity_age >= lease:
886
+ reason = "stale_connecting" if robot_status == "connecting" else "stale_activity"
887
+
888
+ if not reason:
889
+ return None
890
+
891
+ now = datetime.utcnow().isoformat()
892
+ if session and not session["ended_at"]:
893
+ await db.execute(
894
+ "UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND ended_at IS NULL",
895
+ (now, reason, current_session_id),
896
+ )
897
+ await db.execute(
898
+ "UPDATE robots SET status = 'idle', current_session_id = NULL, connected_server_id = NULL "
899
+ "WHERE id = ? AND current_session_id = ?",
900
+ (robot_id, current_session_id),
901
+ )
902
+ logger.warning(
903
+ "Automatically released %s session %s for robot %s",
904
+ reason, current_session_id, robot_id,
905
+ )
906
+ return reason
907
+
908
+ @app.get("/api/robots", response_model=List[Robot])
846
909
  async def list_robots():
847
910
  """List all robots"""
848
911
  async with aiosqlite.connect(DB_PATH) as db:
@@ -909,11 +972,23 @@ async def request_session(robot_id: str,
909
972
  (robot_id, device_row["name"]),
910
973
  )
911
974
  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")
975
+ robot = {
976
+ "id": robot_id,
977
+ "name": device_row["name"],
978
+ "status": "idle",
979
+ "current_session_id": None,
980
+ }
981
+ else:
982
+ raise HTTPException(404, "Robot not found")
983
+ stale_reason = await _reap_robot_session_if_stale(
984
+ db, robot_id, robot["current_session_id"], robot["status"]
985
+ )
986
+ if stale_reason:
987
+ await db.commit()
988
+ async with db.execute("SELECT * FROM robots WHERE id = ?", (robot_id,)) as cursor:
989
+ robot = await cursor.fetchone()
990
+ if robot["current_session_id"] or robot["status"] != "idle":
991
+ raise HTTPException(409, f"Robot is {robot['status']}, not idle")
917
992
 
918
993
  # Find least-loaded online server
919
994
  async with db.execute("""
@@ -1046,7 +1121,7 @@ async def simulate_trigger(robot_id: str):
1046
1121
  async with aiosqlite.connect(DB_PATH) as db:
1047
1122
  db.row_factory = aiosqlite.Row
1048
1123
  async with db.execute(
1049
- "SELECT id, current_session_id FROM robots WHERE id = ?", (robot_id,)
1124
+ "SELECT id, current_session_id, status FROM robots WHERE id = ?", (robot_id,)
1050
1125
  ) as c:
1051
1126
  robot = await c.fetchone()
1052
1127
  if not robot:
@@ -1059,11 +1134,14 @@ async def simulate_trigger(robot_id: str):
1059
1134
  (device["id"], device["name"], device["character_id"]),
1060
1135
  )
1061
1136
  await db.commit()
1062
- robot = {"id": device["id"], "current_session_id": None}
1137
+ robot = {"id": device["id"], "current_session_id": None, "status": "idle"}
1063
1138
  if not await _effective_device_production_mode(db, robot_id):
1064
1139
  return {"queued": False, "reason": "device_production_mode_off"}
1065
- if robot["current_session_id"]:
1066
- return {"queued": False, "reason": "session_active"}
1140
+ stale_reason = await _reap_robot_session_if_stale(
1141
+ db, robot_id, robot["current_session_id"], robot["status"]
1142
+ )
1143
+ if robot["current_session_id"] and not stale_reason:
1144
+ return {"queued": False, "reason": "session_active"}
1067
1145
  await db.execute(
1068
1146
  "INSERT INTO trigger_commands (robot_id, source, requested_at) VALUES (?, ?, ?) "
1069
1147
  "ON CONFLICT(robot_id) DO UPDATE SET source = excluded.source, requested_at = excluded.requested_at",
@@ -1072,7 +1150,12 @@ async def simulate_trigger(robot_id: str):
1072
1150
  await db.commit()
1073
1151
  await broadcast("robot_triggered", {"robot_id": robot_id, "source": "dashboard"})
1074
1152
  await _log_history("robot", "robot", robot_id, "simulate_trigger", "operator")
1075
- return {"queued": True, "source": "dashboard"}
1153
+ return {
1154
+ "queued": True,
1155
+ "source": "dashboard",
1156
+ "recovered_stale_session": bool(stale_reason),
1157
+ "recovery_reason": stale_reason,
1158
+ }
1076
1159
 
1077
1160
  @app.post("/api/robots/{robot_id}/simulate-stop")
1078
1161
  async def simulate_stop(robot_id: str):
@@ -1487,14 +1570,31 @@ async def session_joined(session_id: str,
1487
1570
  session = await c.fetchone()
1488
1571
  if not session:
1489
1572
  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()
1573
+ if session["joined_at"]:
1574
+ if not session["ended_at"]:
1575
+ await db.execute(
1576
+ "UPDATE sessions SET last_activity_at = ? WHERE id = ?",
1577
+ (now, session_id),
1578
+ )
1579
+ await db.execute(
1580
+ "UPDATE robots SET status = 'running' WHERE id = ? AND current_session_id = ?",
1581
+ (session["robot_id"], session_id),
1582
+ )
1583
+ await db.commit()
1584
+ return {
1585
+ "status": "already_joined",
1586
+ "joined_at": session["joined_at"],
1587
+ "latency_ms": _session_latency_ms(session["started_at"], session["joined_at"]),
1588
+ }
1589
+ await db.execute(
1590
+ "UPDATE sessions SET joined_at = ?, last_activity_at = ? WHERE id = ?",
1591
+ (now, now, session_id),
1592
+ )
1593
+ await db.execute(
1594
+ "UPDATE robots SET status = 'running' WHERE id = ? AND current_session_id = ?",
1595
+ (session["robot_id"], session_id),
1596
+ )
1597
+ await db.commit()
1498
1598
  latency_ms = _session_latency_ms(session["started_at"], now)
1499
1599
  await broadcast("session_joined", {"session_id": session_id, "latency_ms": latency_ms})
1500
1600
  await _log_history("session", "session", session_id, "joined", "robot", f"latency_ms={latency_ms}")
@@ -1955,19 +2055,38 @@ async def health_checker():
1955
2055
  await db.execute("UPDATE devices SET status = 'offline' WHERE id = ?", (did,))
1956
2056
  await broadcast("device_status_changed", {"device_id": did, "status": "offline"})
1957
2057
 
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:
2058
+ # Release stale robot session pointers with the same lease rules
2059
+ # used by the trigger path. Failed joins cannot require manual
2060
+ # operator recovery or block an unattended robot indefinitely.
2061
+ async with db.execute(
2062
+ "SELECT id, status, current_session_id FROM robots WHERE current_session_id IS NOT NULL"
2063
+ ) as c:
2064
+ session_owners = await c.fetchall()
2065
+ for owner in session_owners:
2066
+ reason = await _reap_robot_session_if_stale(
2067
+ db, owner["id"], owner["current_session_id"], owner["status"]
2068
+ )
2069
+ if reason:
2070
+ await broadcast("session_expired", {
2071
+ "session_id": owner["current_session_id"],
2072
+ "robot_id": owner["id"],
2073
+ "reason": reason,
2074
+ })
2075
+
2076
+ # Reap old orphan rows that are no longer owned by a robot.
2077
+ session_cutoff = (
2078
+ datetime.utcnow() - timedelta(seconds=ACTIVE_SESSION_LEASE_SECONDS)
2079
+ ).isoformat()
2080
+ async with db.execute(
2081
+ "SELECT id, robot_id FROM sessions "
2082
+ "WHERE ended_at IS NULL AND COALESCE(last_activity_at, started_at) < ? "
2083
+ "AND NOT EXISTS (SELECT 1 FROM robots r WHERE r.current_session_id = sessions.id)",
2084
+ (session_cutoff,),
2085
+ ) as c:
1967
2086
  stale_sessions = await c.fetchall()
1968
2087
  for session in stale_sessions:
1969
2088
  await db.execute(
1970
- "UPDATE sessions SET ended_at = ?, end_reason = 'silence' "
2089
+ "UPDATE sessions SET ended_at = ?, end_reason = 'stale_orphan' "
1971
2090
  "WHERE id = ? AND ended_at IS NULL",
1972
2091
  (datetime.utcnow().isoformat(), session["id"]),
1973
2092
  )
@@ -1979,7 +2098,7 @@ async def health_checker():
1979
2098
  await broadcast("session_expired", {
1980
2099
  "session_id": session["id"],
1981
2100
  "robot_id": session["robot_id"],
1982
- "reason": "silence",
2101
+ "reason": "stale_orphan",
1983
2102
  })
1984
2103
 
1985
2104
  await db.commit()
@@ -3201,8 +3320,9 @@ async def device_supervisor_status(device_id: str, payload: SupervisorStatusRepo
3201
3320
  pending = []
3202
3321
  try:
3203
3322
  async with db.execute(
3204
- "SELECT service_name, action, kind, params, id FROM shell_requests "
3205
- "WHERE device_id = ? AND completed_at IS NULL ORDER BY requested_at",
3323
+ "SELECT service_name, action, kind, params, id FROM shell_requests "
3324
+ "WHERE device_id = ? AND completed_at IS NULL AND kind != 'speaker_test' "
3325
+ "ORDER BY requested_at",
3206
3326
  (device_id,),
3207
3327
  ) as c:
3208
3328
  shell_rows = [dict(r) for r in await c.fetchall()]
@@ -3580,13 +3700,26 @@ async def next_device_speaker_test(device_id: str, authorization: Optional[str]
3580
3700
  """Allow the always-running preview agent to execute audio tests."""
3581
3701
  if not await _authorize_device(device_id, authorization):
3582
3702
  raise HTTPException(401, "Invalid device token")
3703
+ now = datetime.utcnow()
3704
+ stale_before = (now - timedelta(seconds=45)).isoformat()
3583
3705
  async with aiosqlite.connect(DB_PATH) as db:
3584
3706
  db.row_factory = aiosqlite.Row
3707
+ await db.execute("BEGIN IMMEDIATE")
3585
3708
  async with db.execute(
3586
3709
  "SELECT id, params FROM shell_requests WHERE device_id = ? AND kind = 'speaker_test' "
3587
- "AND completed_at IS NULL ORDER BY requested_at LIMIT 1", (device_id,),
3710
+ "AND completed_at IS NULL AND (claimed_at IS NULL OR claimed_at < ?) "
3711
+ "ORDER BY requested_at LIMIT 1", (device_id, stale_before),
3588
3712
  ) as cursor:
3589
3713
  row = await cursor.fetchone()
3714
+ if row:
3715
+ claimed = await db.execute(
3716
+ "UPDATE shell_requests SET claimed_at = ? WHERE id = ? AND completed_at IS NULL "
3717
+ "AND (claimed_at IS NULL OR claimed_at < ?)",
3718
+ (now.isoformat(), row["id"], stale_before),
3719
+ )
3720
+ if claimed.rowcount != 1:
3721
+ row = None
3722
+ await db.commit()
3590
3723
  return {"request": {"id": row["id"], "params": json.loads(row["params"] or "{}")} if row else None}
3591
3724
 
3592
3725
 
@@ -4055,25 +4188,35 @@ async def _auto_elevenlabs_voice(robot_name: Optional[str], current: Optional[st
4055
4188
 
4056
4189
 
4057
4190
  async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
4058
- """Public helper to resolve the effective voice config for a robot id."""
4059
- async with aiosqlite.connect(DB_PATH) as db:
4060
- db.row_factory = aiosqlite.Row
4061
- stack_dict = await _resolve_voice_stack_for_robot(db, robot_id)
4191
+ """Public helper to resolve the effective voice config for a robot id."""
4192
+ async with aiosqlite.connect(DB_PATH) as db:
4193
+ db.row_factory = aiosqlite.Row
4194
+ # Dashboard routes commonly use the human robot name ("bmw"), while
4195
+ # assignments are persisted against the enrolled device id. Resolve
4196
+ # that alias once so character, stack and greetings use one identity.
4197
+ async with db.execute(
4198
+ "SELECT id FROM devices WHERE id = ? OR lower(name) = lower(?) "
4199
+ "ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END LIMIT 1",
4200
+ (robot_id, robot_id, robot_id),
4201
+ ) as c:
4202
+ canonical_device = await c.fetchone()
4203
+ canonical_id = canonical_device["id"] if canonical_device else robot_id
4204
+ stack_dict = await _resolve_voice_stack_for_robot(db, canonical_id)
4062
4205
 
4063
4206
  # Determine character preset + name
4064
4207
  character_id = None
4065
4208
  character_name = None
4066
4209
  system_prompt = None
4067
4210
  async with db.execute(
4068
- "SELECT character_preset_id FROM robot_voice_configs WHERE robot_id = ?", (robot_id,)
4211
+ "SELECT character_preset_id FROM robot_voice_configs WHERE robot_id = ?", (canonical_id,)
4069
4212
  ) as c:
4070
4213
  rvc = await c.fetchone()
4071
4214
  if rvc and rvc["character_preset_id"]:
4072
4215
  character_id = rvc["character_preset_id"]
4073
4216
  else:
4074
4217
  async with db.execute(
4075
- "SELECT character_id FROM robots WHERE id = ? UNION ALL SELECT character_id FROM devices WHERE id = ?",
4076
- (robot_id, robot_id),
4218
+ "SELECT character_id FROM robots WHERE id = ? UNION ALL SELECT character_id FROM devices WHERE id = ?",
4219
+ (canonical_id, canonical_id),
4077
4220
  ) as c:
4078
4221
  row = await c.fetchone()
4079
4222
  if row:
@@ -4088,21 +4231,23 @@ async def _resolve_robot_voice_config(robot_id: str) -> RobotVoiceConfig:
4088
4231
  system_prompt = row["system_prompt"]
4089
4232
  if not character_name:
4090
4233
  async with db.execute(
4091
- "SELECT name FROM robots WHERE id = ? UNION ALL SELECT name FROM devices WHERE id = ?",
4092
- (robot_id, robot_id),
4234
+ "SELECT name FROM robots WHERE id = ? UNION ALL SELECT name FROM devices WHERE id = ?",
4235
+ (canonical_id, canonical_id),
4093
4236
  ) as c:
4094
4237
  row = await c.fetchone()
4095
4238
  if row:
4096
4239
  character_name = row["name"]
4097
4240
 
4098
- async with db.execute("SELECT greeting_phrases FROM devices WHERE id = ?", (robot_id,)) as c:
4241
+ async with db.execute("SELECT greeting_phrases FROM devices WHERE id = ?", (canonical_id,)) as c:
4099
4242
  device_row = await c.fetchone()
4100
4243
  greeting_phrases = []
4101
4244
  if device_row and device_row["greeting_phrases"]:
4102
4245
  try:
4103
4246
  greeting_phrases = [str(p) for p in _json.loads(device_row["greeting_phrases"]) if str(p).strip()]
4104
- except Exception:
4105
- greeting_phrases = []
4247
+ except Exception:
4248
+ greeting_phrases = []
4249
+ if not greeting_phrases and (character_name or character_id):
4250
+ greeting_phrases = [f"Hello, I am {character_name or character_id}. How can I help you?"]
4106
4251
  if stack_dict:
4107
4252
  stack_dict["tts_voice"] = await _auto_elevenlabs_voice(character_name or character_id, stack_dict.get("tts_voice"))
4108
4253
  voice_stack = VoiceStack(**stack_dict) if stack_dict else None
@@ -4748,10 +4893,10 @@ async def _robot_telemetry(db, robot_id: str) -> Optional[dict]:
4748
4893
  readiness_code, readiness_message = "server_unavailable", f"LiveKit server {robot['connected_server_id']} is {server_status or 'missing'}"
4749
4894
  elif not robot["current_session_id"]:
4750
4895
  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"
4896
+ elif robot["status"] == "connecting" and session_age is not None and session_age > CONNECTING_SESSION_LEASE_SECONDS:
4897
+ readiness_code, readiness_message = "stuck_session", f"joining LiveKit for {session_age}s; automatic recovery pending"
4898
+ elif activity_age is not None and activity_age > ACTIVE_SESSION_LEASE_SECONDS:
4899
+ readiness_code, readiness_message = "stale_session", f"no session activity for {activity_age}s; automatic recovery pending"
4755
4900
  else:
4756
4901
  readiness_code, readiness_message = "in_session", "conversation session is active"
4757
4902
 
@@ -1233,44 +1233,95 @@ class LiveKitPublisher:
1233
1233
  if self._capture and self.video_source:
1234
1234
  self._tasks.append(asyncio.create_task(self._video_loop()))
1235
1235
 
1236
- async def _play_remote_audio(self, track, sid: str) -> None:
1237
- try:
1238
- import pyaudio
1239
- except Exception as e:
1240
- logger.warning(f"pyaudio unavailable for playback: {e}")
1241
- return
1242
- OUT_RATE = 48000
1243
- OUT_CHANNELS = 2
1244
- pa = pyaudio.PyAudio()
1245
- # Same MME-vs-WASAPI gotcha as mic capture (see PyAudioCapture._resolve_device):
1246
- # PyAudio's global default output device resolves through MME, which on this
1247
- # machine will accept writes and report success with zero exceptions while
1248
- # never actually reaching the real speakers. Prefer WASAPI's default output,
1249
- # which is what Windows' own volume mixer/meter is backed by.
1250
- output_device_index = None
1236
+ async def _play_remote_audio(self, track, sid: str) -> None:
1237
+ OUT_RATE = 48000
1238
+ OUT_CHANNELS = 2
1251
1239
  selected_output = str(self.agent.audio_playback_device or "default")
1252
- if selected_output.strip().isdigit():
1253
- output_device_index = int(selected_output.strip())
1254
- try:
1255
- if selected_output.lower() == "default":
1256
- wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
1257
- idx = wasapi.get("defaultOutputDevice")
1258
- if idx is not None and idx >= 0:
1259
- output_device_index = idx
1260
- elif output_device_index is None:
1261
- needle = selected_output.lower()
1262
- for i in range(pa.get_device_count()):
1263
- info = pa.get_device_info_by_index(i)
1264
- if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
1265
- output_device_index = i
1266
- break
1267
- except Exception as e:
1268
- logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
1269
- out = pa.open(
1270
- format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
1271
- output_device_index=output_device_index,
1272
- frames_per_buffer=960,
1273
- )
1240
+ pa = None
1241
+ output_device_index = None
1242
+
1243
+ # RoboVision inventories Linux devices through sounddevice/PortAudio,
1244
+ # but the numeric indices are not stable across PyAudio builds. More
1245
+ # importantly, BMW's production USB adapter is already proven through
1246
+ # ALSA's plughw conversion path. Use that exact endpoint for live TTS
1247
+ # instead of reopening the unrelated PyAudio index.
1248
+ if sys.platform.startswith("linux") and "hw:" in selected_output:
1249
+ import re
1250
+ import subprocess
1251
+
1252
+ match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
1253
+ if not match:
1254
+ logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
1255
+ return
1256
+ alsa_device = f"plug{match.group(1)}"
1257
+ process = subprocess.Popen(
1258
+ [
1259
+ "aplay", "-q", "-D", alsa_device, "-t", "raw",
1260
+ "-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
1261
+ ],
1262
+ stdin=subprocess.PIPE,
1263
+ stderr=subprocess.PIPE,
1264
+ )
1265
+ if process.stdin is None:
1266
+ logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
1267
+ process.kill()
1268
+ return
1269
+
1270
+ class _AplayOutput:
1271
+ def write(self, chunk: bytes) -> None:
1272
+ if process.poll() is not None:
1273
+ detail = ""
1274
+ if process.stderr is not None:
1275
+ detail = process.stderr.read().decode("utf-8", errors="replace").strip()
1276
+ raise OSError(detail or f"aplay exited {process.returncode}")
1277
+ process.stdin.write(chunk)
1278
+ process.stdin.flush()
1279
+
1280
+ def stop_stream(self) -> None:
1281
+ if process.stdin and not process.stdin.closed:
1282
+ process.stdin.close()
1283
+ try:
1284
+ process.wait(timeout=2)
1285
+ except subprocess.TimeoutExpired:
1286
+ process.terminate()
1287
+
1288
+ def close(self) -> None:
1289
+ return
1290
+
1291
+ out = _AplayOutput()
1292
+ output_device_index = alsa_device
1293
+ else:
1294
+ try:
1295
+ import pyaudio
1296
+ except Exception as e:
1297
+ logger.warning(f"pyaudio unavailable for playback: {e}")
1298
+ return
1299
+ pa = pyaudio.PyAudio()
1300
+ # Same MME-vs-WASAPI gotcha as mic capture (see
1301
+ # PyAudioCapture._resolve_device): prefer the endpoint backing the
1302
+ # Windows volume mixer instead of the silent MME default.
1303
+ if selected_output.strip().isdigit():
1304
+ output_device_index = int(selected_output.strip())
1305
+ try:
1306
+ if selected_output.lower() == "default":
1307
+ wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
1308
+ idx = wasapi.get("defaultOutputDevice")
1309
+ if idx is not None and idx >= 0:
1310
+ output_device_index = idx
1311
+ elif output_device_index is None:
1312
+ needle = selected_output.lower()
1313
+ for i in range(pa.get_device_count()):
1314
+ info = pa.get_device_info_by_index(i)
1315
+ if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
1316
+ output_device_index = i
1317
+ break
1318
+ except Exception as e:
1319
+ logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
1320
+ out = pa.open(
1321
+ format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
1322
+ output_device_index=output_device_index,
1323
+ frames_per_buffer=960,
1324
+ )
1274
1325
  logger.info(f"audio out: opened playback stream for {sid} (device_index={output_device_index})")
1275
1326
  frame_count = 0
1276
1327
  mismatch_logged = False
@@ -1409,9 +1460,10 @@ class LiveKitPublisher:
1409
1460
  f"audio out: {sid} received {frame_count} frames, "
1410
1461
  f"writer wrote {written_frames[0]} chunks, queue backlog at close={write_queue.qsize()}"
1411
1462
  )
1412
- out.stop_stream()
1413
- out.close()
1414
- pa.terminate()
1463
+ out.stop_stream()
1464
+ out.close()
1465
+ if pa is not None:
1466
+ pa.terminate()
1415
1467
 
1416
1468
  async def stop(self) -> None:
1417
1469
  self._stop_event.set()