Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions opencode_hawk/llama_restart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 40 additions & 7 deletions opencode_hawk/probes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions tests/selftest_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-"))
Expand Down
Loading