process_manager.py
process_manager.pyBrowse 18 files
1,959 tokens
7,475 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1"""Subprocess lifecycle manager for the google_meet bot.2 3One active meeting at a time, recorded in ``$HERMES_HOME/workspace/meetings/.active.json``4(``pid, meeting_id, out_dir, url, started_at, session_id, log_path, mode``) so tool calls5across turns can find the bot. The bot is a detached subprocess reached via files only6(``<meeting-id>/status.json``, ``<meeting-id>/transcript.txt``), so the agent loop can't block.7"""8 9from __future__ import annotations10 11import contextlib12import json13import os14import signal15import subprocess16import sys17import time18import uuid19from pathlib import Path20from typing import Any, Dict, Optional21 22from hermes_constants import get_hermes_home23 24from plugins.google_meet._jsonfile import read_json25from utils import atomic_json_write26 27 28def _root() -> Path:29 return Path(get_hermes_home()) / "workspace" / "meetings"30 31 32def _read_active() -> Optional[Dict[str, Any]]:33 return read_json(_root() / ".active.json")34 35 36def _write_active(data: Dict[str, Any]) -> None:37 atomic_json_write(_root() / ".active.json", data)38 39 40def _pid_alive(pid: int) -> bool:41 # Not ``os.kill(pid, 0)``: on Windows that can kill the target (bpo-14484).42 from gateway.status import _pid_exists43 return bool(pid) and _pid_exists(pid)44 45 46def _kill(pid: int, sig) -> None:47 with contextlib.suppress(ProcessLookupError):48 os.kill(pid, sig)49 50 51_NO_ACTIVE = {"ok": False, "reason": "no active meeting"}52 53 54def start(url: str, *, out_dir: Optional[Path] = None, headed: bool = False,55 auth_state: Optional[str] = None, guest_name: str = "Hermes Agent", duration: Optional[str] = None,56 session_id: Optional[str] = None, mode: str = "transcribe", realtime_model: Optional[str] = None,57 realtime_voice: Optional[str] = None, realtime_instructions: Optional[str] = None,58 realtime_api_key: Optional[str] = None) -> Dict[str, Any]:59 """Spawn the meet_bot subprocess for *url*, stopping any running bot first (one active meeting)."""60 from plugins.google_meet.meet_bot import _is_safe_meet_url, _meeting_id_from_url61 if not _is_safe_meet_url(url):62 return {"ok": False, "error": "refusing: only https://meet.google.com/ URLs are allowed. got: " + repr(url)}63 if _pid_alive(int((_read_active() or {}).get("pid", 0))):64 stop(reason="replaced by new meet_join")65 meeting_id = _meeting_id_from_url(url)66 out = out_dir or (_root() / meeting_id)67 out.mkdir(parents=True, exist_ok=True)68 # Wipe stale files from a previous run of this meeting id.69 for name in ("transcript.txt", "status.json"):70 with contextlib.suppress(OSError):71 (out / name).unlink()72 env = {**os.environ, "HERMES_MEET_URL": url, "HERMES_MEET_OUT_DIR": str(out),73 "HERMES_MEET_GUEST_NAME": guest_name}74 for value, var in (75 (headed and "1", "HERMES_MEET_HEADED"),76 (auth_state, "HERMES_MEET_AUTH_STATE"),77 (duration, "HERMES_MEET_DURATION"),78 (mode, "HERMES_MEET_MODE"), # bot defaults to transcribe when unset (v1 behavior)79 (realtime_model, "HERMES_MEET_REALTIME_MODEL"),80 (realtime_voice, "HERMES_MEET_REALTIME_VOICE"),81 (realtime_instructions, "HERMES_MEET_REALTIME_INSTRUCTIONS")):82 if value:83 env[var] = value84 # Resolve the realtime key at SPAWN time in the parent, where the profile secret scope85 # (a contextvar) is installed; the detached child inherits env, not scope.86 if not realtime_api_key:87 from agent.secret_scope import get_secret88 realtime_api_key = get_secret("HERMES_MEET_REALTIME_KEY") or get_secret("OPENAI_API_KEY")89 if realtime_api_key:90 env["HERMES_MEET_REALTIME_KEY"] = realtime_api_key91 log_path = out / "bot.log"92 # Detach: stdout/stderr → log file, new session so parent signals don't propagate.93 with open(log_path, "ab", buffering=0) as log_fh:94 proc = subprocess.Popen([sys.executable, "-m", "plugins.google_meet.meet_bot"], stdin=subprocess.DEVNULL,95 stdout=log_fh, stderr=subprocess.STDOUT, env=env, start_new_session=True,96 close_fds=True)97 record = {"pid": proc.pid, "meeting_id": meeting_id, "out_dir": str(out), "url": url,98 "started_at": time.time(), "session_id": session_id, "log_path": str(log_path), "mode": mode}99 _write_active(record)100 return {"ok": True, **record}101 102 103def status() -> Dict[str, Any]:104 """Return the current meeting state, or ``{"ok": False, "reason": ...}``."""105 active = _read_active()106 if not active:107 return dict(_NO_ACTIVE)108 pid = int(active.get("pid", 0))109 return {"ok": True, "alive": _pid_alive(pid), "pid": pid, "meetingId": active.get("meeting_id"),110 "url": active.get("url"), "startedAt": active.get("started_at"), "outDir": active.get("out_dir"),111 **(read_json(Path(active.get("out_dir", "")) / "status.json") or {})}112 113 114def transcript(last: Optional[int] = None) -> Dict[str, Any]:115 """Read the current transcript file (empty result if the bot hasn't written one yet)."""116 active = _read_active()117 if not active:118 return dict(_NO_ACTIVE)119 tp = Path(active.get("out_dir", "")) / "transcript.txt"120 text = tp.read_text(encoding="utf-8", errors="replace") if tp.is_file() else ""121 all_lines = [ln for ln in text.splitlines() if ln.strip()]122 return {"ok": True, "meetingId": active.get("meeting_id"),123 "lines": all_lines[-last:] if last else all_lines, "total": len(all_lines), "path": str(tp)}124 125 126def enqueue_say(text: str) -> Dict[str, Any]:127 """Append a ``say`` request to ``<out_dir>/say_queue.jsonl``.128 Refused when no meeting is active or the active bot is transcribe-only."""129 text = (text or "").strip()130 if not text:131 return {"ok": False, "reason": "text is required"}132 active = _read_active()133 if not active:134 return dict(_NO_ACTIVE)135 if active.get("mode") != "realtime":136 return {"ok": False, "reason": ("active meeting is in transcribe mode — pass mode='realtime' "137 "to meet_join to enable agent speech")}138 out_dir = Path(active.get("out_dir", ""))139 if not out_dir.is_dir():140 return {"ok": False, "reason": f"out_dir missing: {out_dir}"}141 queue_path = out_dir / "say_queue.jsonl"142 entry = {"id": uuid.uuid4().hex[:12], "text": text}143 with queue_path.open("a", encoding="utf-8") as f:144 f.write(json.dumps(entry) + "\n")145 return {"ok": True, "meetingId": active.get("meeting_id"), "enqueued_id": entry["id"],146 "queue_path": str(queue_path)}147 148 149def stop(*, reason: str = "requested") -> Dict[str, Any]:150 """SIGTERM the active bot (SIGKILL after 10s), then clear the active pointer."""151 active = _read_active()152 if not active:153 return dict(_NO_ACTIVE)154 pid = int(active.get("pid", 0))155 out_dir = active.get("out_dir")156 if _pid_alive(pid):157 _kill(pid, signal.SIGTERM)158 for _ in range(20):159 if not _pid_alive(pid):160 break161 time.sleep(0.5)162 else:163 _kill(pid, signal.SIGKILL) # windows-footgun: ok — POSIX-only plugin (google_meet registers no-op on Windows; see __init__.py)164 (_root() / ".active.json").unlink(missing_ok=True)165 return {"ok": True, "reason": reason, "meetingId": active.get("meeting_id"),166 "transcriptPath": str(Path(out_dir) / "transcript.txt") if out_dir else None}167