From 9026a0adca15329249c99aa95678b7e1011828de Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:04:22 +0200 Subject: [PATCH] Hawk's llama-server is the one on llama_api_port; other instances are left alone Hawk took the first llama-server.exe in tasklist: with a second instance started by the user it could read the wrong one as alive or stop it on a restart. Now: the process listening on llama_api_port; a server Hawk started in the last 3 minutes that is not listening yet still counts (no second start while it loads); the name lookup only as a fallback and only when it is unambiguous. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- opencode_hawk/llama_restart.py | 2 ++ opencode_hawk/probes.py | 47 +++++++++++++++++++++++++++++----- tests/selftest_coordinator.py | 38 +++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index abf8c4f..923751d 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ from the dashboard. | `auto_accept_external_dirs` | `true` | Auto-accept external-directory permission prompts (monitored sessions only) | | `auto_answer_questions` | `true` | Auto-answer question-tool prompts | | `continue_via_attach` | `true` | Inject through the Desktop sidecar so the GUI streams live | -| `llama_api_port` | `1234` | llama-server API port | +| `llama_api_port` | `1234` | llama-server API port. Hawk's server is the one listening on this port; llama-server instances you run on other ports are left alone | | `llama_bat_path` | `""` | Script that starts llama-server (`.bat` on Windows, shell script on Linux); the watchdog uses it to respawn a dead server. Empty = no respawn | | `llama_pid` | `0` | 0 = find llama-server by process name | | `gpu_vram_total_mb` | `0` | VRAM shown in charts; 0 = auto-detect | diff --git a/opencode_hawk/llama_restart.py b/opencode_hawk/llama_restart.py index d6f36f9..54a2890 100644 --- a/opencode_hawk/llama_restart.py +++ b/opencode_hawk/llama_restart.py @@ -418,6 +418,8 @@ def _respawn(spec) -> int | None: env=spec.get("env") or None, stdout=fh, stderr=subprocess.STDOUT, ) + from . import probes + probes.record_llama_spawn(proc.pid) return proc.pid except Exception as e: coordinator.log_debug("llama_restart respawn: %s" % e) diff --git a/opencode_hawk/probes.py b/opencode_hawk/probes.py index 0b1d88e..08c87fc 100644 --- a/opencode_hawk/probes.py +++ b/opencode_hawk/probes.py @@ -196,26 +196,58 @@ def llama_slots(cfg): return None +SPAWN_FILE = "llama_spawn.json" +SPAWN_GRACE_S = 180 # a server Hawk just started counts as Hawk's own until it listens (model loading) + + +def record_llama_spawn(pid) -> None: + """Remember the llama-server Hawk just started (see llama_pid).""" + try: + core.save_json(core.home_dir() / SPAWN_FILE, {"pid": int(pid), "at": time.time()}) + except Exception as e: + core.log_debug("record_llama_spawn: %s" % e) + + def llama_pid(cfg) -> int | None: - """Return the llama-server process id (config override, else auto-detect by - image name), or None if not found.""" + """Hawk's llama-server: the config override, else the process listening on llama_api_port. Other + llama-server instances (other ports, started by the user) are never Hawk's: Hawk neither counts them as its + server nor stops them. A server Hawk started in the last SPAWN_GRACE_S that is not listening yet still + counts (so no second one is started while the model loads). Name lookup only when listeners cannot be + read, and then only if exactly one llama-server runs.""" pid = int(cfg.get("llama_pid") or 0) if pid: return pid + port = int(cfg.get("llama_api_port") or 1234) + try: + import psutil + for conn in psutil.net_connections(kind="tcp"): + if conn.status == psutil.CONN_LISTEN and conn.laddr and conn.laddr.port == port and conn.pid: + return conn.pid + spawn = core.load_json(core.home_dir() / SPAWN_FILE, {}) or {} + sp = int(spawn.get("pid") or 0) + if sp and time.time() - float(spawn.get("at") or 0) < SPAWN_GRACE_S and psutil.pid_exists(sp): + return sp + return None + except Exception as e: # listeners unreadable (permissions): fall back to the name, only if unambiguous + core.log_debug("llama_pid by port: %s" % e) + pids = _llama_pids_by_name() + return pids[0] if len(pids) == 1 else None + + +def _llama_pids_by_name() -> list: if os.name != "nt": - return hawk_linux.find_pid("llama-server") + p = hawk_linux.find_pid("llama-server") + return [p] if p else [] try: out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq llama-server.exe", "/FO", "CSV", "/NH"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, creationflags=core.creation_flags()).stdout - for m in re.finditer(r'"([^"]+)","(\d+)"', out): - if "llama" in m.group(1).lower(): - return int(m.group(2)) + return [int(m.group(2)) for m in re.finditer(r'"([^"]+)","(\d+)"', out) if "llama" in m.group(1).lower()] except Exception as e: core.log_debug("llama_pid: %s" % e) - return None + return [] def llama_server_alive(cfg) -> bool: @@ -274,6 +306,7 @@ def _llama_respawn_locked(cfg, timeout_s=None) -> bool: else: argv = [bat] if os.access(bat, os.X_OK) else ["sh", bat] proc = core.spawn_independent(argv, stdout=fh, stderr=subprocess.STDOUT) + record_llama_spawn(proc.pid) except Exception as e: core.log("LLAMA-DOWN: respawn launch failed: %s" % e) return False diff --git a/tests/selftest_coordinator.py b/tests/selftest_coordinator.py index 5345b1c..2e14b51 100644 --- a/tests/selftest_coordinator.py +++ b/tests/selftest_coordinator.py @@ -614,6 +614,44 @@ def mkllama_idle_test(cpu_frac, slots_processing, gate_slots, "OK" if ok_m else "MISMATCH", "escalation note written to session DB as synthetic user msg + text part")) + # N00000: Hawk's llama-server is the one on llama_api_port; the user's other instances are never Hawk's + import psutil as _ps, tempfile as _tf2, collections as _col + from opencode_hawk import probes as _pr + _Conn = _col.namedtuple("_Conn", "status laddr pid") + _Addr = _col.namedtuple("_Addr", "ip port") + _home2 = Path(_tf2.mkdtemp(prefix="hawk-port-")) + _old_home2 = os.environ.get("COORD_HOME") + os.environ["COORD_HOME"] = str(_home2) + _saved2 = (_ps.net_connections, _ps.pid_exists, _pr._llama_pids_by_name) + try: + _ps.net_connections = lambda kind="tcp": [_Conn(_ps.CONN_LISTEN, _Addr("127.0.0.1", 8080), 111), + _Conn(_ps.CONN_LISTEN, _Addr("127.0.0.1", 1234), 222)] + ok_p1 = hawk.llama_pid({"llama_api_port": 1234}) == 222 + _ps.net_connections = lambda kind="tcp": [_Conn(_ps.CONN_LISTEN, _Addr("127.0.0.1", 8080), 111)] + ok_p2 = hawk.llama_pid({"llama_api_port": 1234}) is None # only the user's instance: Hawk's server is down + _pr.record_llama_spawn(333) + _ps.pid_exists = lambda pid: pid == 333 + ok_p3 = hawk.llama_pid({"llama_api_port": 1234}) == 333 # just started, still loading: Hawk's own + def _no_access(kind="tcp"): + raise _ps.AccessDenied() + _ps.net_connections = _no_access + _pr._llama_pids_by_name = lambda: [111, 222] + ok_p4 = hawk.llama_pid({"llama_api_port": 1234}) is None # cannot tell which: never guess + _pr._llama_pids_by_name = lambda: [222] + ok_p5 = hawk.llama_pid({"llama_api_port": 1234}) == 222 + finally: + _ps.net_connections, _ps.pid_exists, _pr._llama_pids_by_name = _saved2 + if _old_home2 is None: + os.environ.pop("COORD_HOME", None) + else: + os.environ["COORD_HOME"] = _old_home2 + shutil.rmtree(_home2, ignore_errors=True) + ok_port = all([ok_p1, ok_p2, ok_p3, ok_p4, ok_p5]) + results.append(("llama_by_port", "pass", "pass" if ok_port else "fail", ok_port, + "Hawk's llama-server is the one on its port; other instances are left alone", [])) + print("%-22s expect=%-9s got=%-9s %s %s" % ("llama_by_port", "pass", "pass" if ok_port else "fail", + "OK" if ok_port else "MISMATCH", [ok_p1, ok_p2, ok_p3, ok_p4, ok_p5])) + # N0000: one instance only: a lock another process holds is refused; free again once that process ends import subprocess as _sp1, tempfile as _tf1 _lockhome = Path(_tf1.mkdtemp(prefix="hawk-lock-"))