Prepare dependency generations before selecting them. Keep shipped tool bytes separate from writable additions, and store facts beside their entries. Validate proposed plugin sets before config publication. Restore the previous config if the facts write fails. Consolidate duplicate updater, backup, setup, and voice helpers. Repair launcher selection, dependency consumers, download ownership, update feeds, and native Windows process and file handling. Verification: 206 changed/prior-failing Python files reported 4630 passed, one failed, and 330 skipped. Fix the remaining Hindsight fixture boundary. The final targeted rerun reported 234 passed and two skipped. The store review regression batch reported 83 passed and one skipped. Desktop TypeScript checks, 56 selected Electron tests, 24 release tests, and the removed-import/compatibility guards passed. This is an integration checkpoint, not full audit acceptance. The complete Python suite has not run on this fixed tree. Crash-atomic plugin publication, generation cleanup, receipt correlation, and packaged lifecycle acceptance remain open in docs/pm-audit-status.md.
201 lines
7.9 KiB
Python
201 lines
7.9 KiB
Python
"""Subprocess lifecycle manager for the google_meet bot.
|
|
|
|
One active meeting at a time, recorded in ``$HERMES_HOME/workspace/meetings/.active.json``
|
|
(``pid, meeting_id, out_dir, url, started_at, session_id, log_path, mode``) so tool calls
|
|
across turns can find the bot. The bot is a detached subprocess reached via files only
|
|
(``<meeting-id>/status.json``, ``<meeting-id>/transcript.txt``), so the agent loop can't block.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
from hermes_constants import get_hermes_home
|
|
|
|
from plugins.google_meet._jsonfile import read_json, write_json_atomic
|
|
|
|
|
|
def _root() -> Path:
|
|
return Path(get_hermes_home()) / "workspace" / "meetings"
|
|
|
|
|
|
def _active_file() -> Path:
|
|
return _root() / ".active.json"
|
|
|
|
|
|
def _read_active() -> Optional[Dict[str, Any]]:
|
|
p = _active_file()
|
|
if not p.is_file():
|
|
return None
|
|
try:
|
|
return json.loads(p.read_text(encoding="utf-8-sig"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _write_active(data: Dict[str, Any]) -> None:
|
|
write_json_atomic(_active_file(), data)
|
|
|
|
|
|
def _pid_alive(pid: int) -> bool:
|
|
# Not ``os.kill(pid, 0)``: on Windows that can kill the target (bpo-14484).
|
|
from gateway.status import _pid_exists
|
|
return bool(pid) and _pid_exists(pid)
|
|
|
|
|
|
def _kill(pid: int, sig) -> None:
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.kill(pid, sig)
|
|
|
|
|
|
_NO_ACTIVE = {"ok": False, "reason": "no active meeting"}
|
|
|
|
|
|
def start(url: str, *, out_dir: Optional[Path] = None, headed: bool = False,
|
|
auth_state: Optional[str] = None, guest_name: str = "Hermes Agent", duration: Optional[str] = None,
|
|
session_id: Optional[str] = None, mode: str = "transcribe", realtime_model: Optional[str] = None,
|
|
realtime_voice: Optional[str] = None, realtime_instructions: Optional[str] = None,
|
|
realtime_api_key: Optional[str] = None) -> Dict[str, Any]:
|
|
"""Spawn the meet_bot subprocess for *url*, stopping any running bot first (one active meeting)."""
|
|
from plugins.google_meet.meet_bot import _is_safe_meet_url, _meeting_id_from_url
|
|
if not _is_safe_meet_url(url):
|
|
return {"ok": False, "error": "refusing: only https://meet.google.com/ URLs are allowed. got: " + repr(url)}
|
|
if _pid_alive(int((_read_active() or {}).get("pid", 0))):
|
|
stop(reason="replaced by new meet_join")
|
|
meeting_id = _meeting_id_from_url(url)
|
|
out = out_dir or (_root() / meeting_id)
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
# Wipe stale files from a previous run of this meeting id.
|
|
for name in ("transcript.txt", "status.json"):
|
|
with contextlib.suppress(OSError):
|
|
(out / name).unlink()
|
|
env = {**os.environ, "HERMES_MEET_URL": url, "HERMES_MEET_OUT_DIR": str(out),
|
|
"HERMES_MEET_GUEST_NAME": guest_name}
|
|
for value, var in (
|
|
(headed and "1", "HERMES_MEET_HEADED"),
|
|
(auth_state, "HERMES_MEET_AUTH_STATE"),
|
|
(duration, "HERMES_MEET_DURATION"),
|
|
(mode, "HERMES_MEET_MODE"), # bot defaults to transcribe when unset (v1 behavior)
|
|
(realtime_model, "HERMES_MEET_REALTIME_MODEL"),
|
|
(realtime_voice, "HERMES_MEET_REALTIME_VOICE"),
|
|
(realtime_instructions, "HERMES_MEET_REALTIME_INSTRUCTIONS")):
|
|
if value:
|
|
env[var] = value
|
|
# Resolve the realtime key at SPAWN time in the parent, where the profile secret scope
|
|
# (a contextvar) is installed; the detached child inherits env, not scope.
|
|
if not realtime_api_key:
|
|
from agent.secret_scope import get_secret
|
|
realtime_api_key = get_secret("HERMES_MEET_REALTIME_KEY") or get_secret("OPENAI_API_KEY")
|
|
if realtime_api_key:
|
|
env["HERMES_MEET_REALTIME_KEY"] = realtime_api_key
|
|
log_path = out / "bot.log"
|
|
# Detach: stdout/stderr → log file, new session so parent signals don't propagate.
|
|
with open(log_path, "ab", buffering=0) as log_fh:
|
|
proc = subprocess.Popen([sys.executable, "-m", "plugins.google_meet.meet_bot"], stdin=subprocess.DEVNULL,
|
|
stdout=log_fh, stderr=subprocess.STDOUT, env=env, start_new_session=True,
|
|
close_fds=True)
|
|
record = {"pid": proc.pid, "meeting_id": meeting_id, "out_dir": str(out), "url": url,
|
|
"started_at": time.time(), "session_id": session_id, "log_path": str(log_path), "mode": mode}
|
|
_write_active(record)
|
|
return {"ok": True, **record}
|
|
|
|
|
|
def status() -> Dict[str, Any]:
|
|
"""Return the current meeting state, or ``{"ok": False, "reason": ...}``."""
|
|
active = _read_active()
|
|
if not active:
|
|
return dict(_NO_ACTIVE)
|
|
pid = int(active.get("pid", 0))
|
|
alive = _pid_alive(pid) if pid else False
|
|
|
|
status_path = Path(active.get("out_dir", "")) / "status.json"
|
|
bot_status: Dict[str, Any] = {}
|
|
if status_path.is_file():
|
|
try:
|
|
bot_status = json.loads(status_path.read_text(encoding="utf-8-sig"))
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"ok": True,
|
|
"alive": alive,
|
|
"pid": pid,
|
|
"meetingId": active.get("meeting_id"),
|
|
"url": active.get("url"),
|
|
"startedAt": active.get("started_at"),
|
|
"outDir": active.get("out_dir"),
|
|
**bot_status,
|
|
}
|
|
|
|
|
|
def transcript(last: Optional[int] = None) -> Dict[str, Any]:
|
|
"""Read the current transcript file (empty result if the bot hasn't written one yet)."""
|
|
active = _read_active()
|
|
if not active:
|
|
return dict(_NO_ACTIVE)
|
|
tp = Path(active.get("out_dir", "")) / "transcript.txt"
|
|
if not tp.is_file():
|
|
return {
|
|
"ok": True,
|
|
"meetingId": active.get("meeting_id"),
|
|
"lines": [],
|
|
"total": 0,
|
|
"path": str(tp),
|
|
}
|
|
text = tp.read_text(encoding="utf-8-sig", errors="replace")
|
|
all_lines = [ln for ln in text.splitlines() if ln.strip()]
|
|
return {"ok": True, "meetingId": active.get("meeting_id"),
|
|
"lines": all_lines[-last:] if last else all_lines, "total": len(all_lines), "path": str(tp)}
|
|
|
|
|
|
def enqueue_say(text: str) -> Dict[str, Any]:
|
|
"""Append a ``say`` request to ``<out_dir>/say_queue.jsonl``.
|
|
Refused when no meeting is active or the active bot is transcribe-only."""
|
|
text = (text or "").strip()
|
|
if not text:
|
|
return {"ok": False, "reason": "text is required"}
|
|
active = _read_active()
|
|
if not active:
|
|
return dict(_NO_ACTIVE)
|
|
if active.get("mode") != "realtime":
|
|
return {"ok": False, "reason": ("active meeting is in transcribe mode — pass mode='realtime' "
|
|
"to meet_join to enable agent speech")}
|
|
out_dir = Path(active.get("out_dir", ""))
|
|
if not out_dir.is_dir():
|
|
return {"ok": False, "reason": f"out_dir missing: {out_dir}"}
|
|
queue_path = out_dir / "say_queue.jsonl"
|
|
entry = {"id": uuid.uuid4().hex[:12], "text": text}
|
|
with queue_path.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps(entry) + "\n")
|
|
return {"ok": True, "meetingId": active.get("meeting_id"), "enqueued_id": entry["id"],
|
|
"queue_path": str(queue_path)}
|
|
|
|
|
|
def stop(*, reason: str = "requested") -> Dict[str, Any]:
|
|
"""SIGTERM the active bot (SIGKILL after 10s), then clear the active pointer."""
|
|
active = _read_active()
|
|
if not active:
|
|
return dict(_NO_ACTIVE)
|
|
pid = int(active.get("pid", 0))
|
|
out_dir = active.get("out_dir")
|
|
if _pid_alive(pid):
|
|
_kill(pid, signal.SIGTERM)
|
|
for _ in range(20):
|
|
if not _pid_alive(pid):
|
|
break
|
|
time.sleep(0.5)
|
|
else:
|
|
_kill(pid, signal.SIGKILL) # windows-footgun: ok — POSIX-only plugin (google_meet registers no-op on Windows; see __init__.py)
|
|
_active_file().unlink(missing_ok=True)
|
|
return {"ok": True, "reason": reason, "meetingId": active.get("meeting_id"),
|
|
"transcriptPath": str(Path(out_dir) / "transcript.txt") if out_dir else None}
|