localcode 0.4.0__py3-none-macosx_13_0_arm64.whl
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.
- localcode/__init__.py +11 -0
- localcode/__main__.py +5 -0
- localcode/_subproc_env.py +54 -0
- localcode/agent/__init__.py +177 -0
- localcode/agent/app_tasks.py +157 -0
- localcode/agent/constants.py +275 -0
- localcode/agent/context.py +1152 -0
- localcode/agent/goal.py +244 -0
- localcode/agent/helpers.py +1036 -0
- localcode/agent/hollow_module.py +230 -0
- localcode/agent/hooks.py +414 -0
- localcode/agent/loop.py +2855 -0
- localcode/agent/plateau.py +297 -0
- localcode/agent/project_check_gate.py +91 -0
- localcode/agent/prompt_context.py +264 -0
- localcode/agent/prompts.py +368 -0
- localcode/agent/reasoning_loop.py +79 -0
- localcode/agent/recovery.py +606 -0
- localcode/agent/recovery_controller.py +25 -0
- localcode/agent/round_policy.py +23 -0
- localcode/agent/sections.py +241 -0
- localcode/agent/state_machine.py +100 -0
- localcode/agent/streaming.py +324 -0
- localcode/agent/tool_execution.py +368 -0
- localcode/agent/tool_orchestration.py +54 -0
- localcode/agent/turn_finalization.py +135 -0
- localcode/app.py +1595 -0
- localcode/approvals.py +17 -0
- localcode/auto_compact.py +176 -0
- localcode/autonomy.py +103 -0
- localcode/bin/__init__.py +0 -0
- localcode/bin/llama-server +0 -0
- localcode/bin/localcode-ui +4 -0
- localcode/bootstrap.py +1191 -0
- localcode/cache.py +194 -0
- localcode/checkpoint.py +375 -0
- localcode/compact.py +73 -0
- localcode/compaction.py +341 -0
- localcode/composer.py +72 -0
- localcode/config.py +521 -0
- localcode/context.py +161 -0
- localcode/display.py +58 -0
- localcode/embeddings.py +480 -0
- localcode/entrypoint.py +536 -0
- localcode/errors.py +350 -0
- localcode/events.py +409 -0
- localcode/evidence.py +102 -0
- localcode/execution_policy.py +48 -0
- localcode/features.py +308 -0
- localcode/formatting.py +91 -0
- localcode/headless_json.py +265 -0
- localcode/health.py +288 -0
- localcode/hf_quants.py +203 -0
- localcode/history.py +594 -0
- localcode/indexer.py +144 -0
- localcode/injection_defense.py +204 -0
- localcode/launcher.py +373 -0
- localcode/logging_utils.py +28 -0
- localcode/lsp.py +153 -0
- localcode/mcp/__init__.py +88 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +128 -0
- localcode/mcp/client.py +395 -0
- localcode/memory_guard.py +291 -0
- localcode/model_config.py +288 -0
- localcode/model_delete.py +444 -0
- localcode/model_families.py +279 -0
- localcode/models.py +236 -0
- localcode/models_catalog.py +901 -0
- localcode/notebook.py +123 -0
- localcode/output.py +403 -0
- localcode/patching.py +65 -0
- localcode/paths.py +303 -0
- localcode/performance.py +623 -0
- localcode/permissions.py +79 -0
- localcode/permissions_v2.py +310 -0
- localcode/plans.py +198 -0
- localcode/process_registry.py +271 -0
- localcode/protocol/__init__.py +56 -0
- localcode/protocol/events.py +116 -0
- localcode/protocol/jsonl.py +153 -0
- localcode/protocol/outcomes.py +231 -0
- localcode/reasoning_capabilities.py +59 -0
- localcode/recommendations.py +87 -0
- localcode/recovery.py +134 -0
- localcode/redaction.py +164 -0
- localcode/runtime.py +2331 -0
- localcode/server_manager.py +1069 -0
- localcode/session.py +296 -0
- localcode/shell.py +83 -0
- localcode/skills/debug.md +21 -0
- localcode/skills/edit-verified.md +30 -0
- localcode/skills/explain.md +19 -0
- localcode/skills/finish-verified.md +11 -0
- localcode/skills/git-commit-safely.md +30 -0
- localcode/skills/locate.md +21 -0
- localcode/skills/plan-task.md +51 -0
- localcode/skills/review.md +22 -0
- localcode/skills/run-tests.md +24 -0
- localcode/skills.py +728 -0
- localcode/snapshots.py +195 -0
- localcode/sounds.py +50 -0
- localcode/telemetry.py +273 -0
- localcode/theme.py +137 -0
- localcode/thermal.py +160 -0
- localcode/thinking.py +114 -0
- localcode/tool_router.py +246 -0
- localcode/toolkit.py +1241 -0
- localcode/tools/__init__.py +400 -0
- localcode/tools/agent.py +259 -0
- localcode/tools/append_file.py +55 -0
- localcode/tools/background_process.py +115 -0
- localcode/tools/base.py +168 -0
- localcode/tools/bash.py +1160 -0
- localcode/tools/code_navigation.py +97 -0
- localcode/tools/edit_diff.py +80 -0
- localcode/tools/edit_file.py +477 -0
- localcode/tools/facts.py +149 -0
- localcode/tools/glob_tool.py +60 -0
- localcode/tools/grep.py +65 -0
- localcode/tools/inspect_symbol.py +266 -0
- localcode/tools/launch_app.py +93 -0
- localcode/tools/list_files.py +42 -0
- localcode/tools/multi_edit.py +167 -0
- localcode/tools/plan_mode.py +86 -0
- localcode/tools/project_check.py +685 -0
- localcode/tools/read_file.py +261 -0
- localcode/tools/read_state.py +223 -0
- localcode/tools/skill_tool.py +41 -0
- localcode/tools/syntax_check.py +216 -0
- localcode/tools/todo_write.py +200 -0
- localcode/tools/tool_call_repair.py +157 -0
- localcode/tools/web_fetch.py +182 -0
- localcode/tools/web_search.py +45 -0
- localcode/tools/write_file.py +281 -0
- localcode/tui/__init__.py +1 -0
- localcode/tui/app.py +485 -0
- localcode/tui/bridge.py +78 -0
- localcode/tui/clipboard_image.py +153 -0
- localcode/tui/paste_collapse.py +95 -0
- localcode/tui/screens/__init__.py +0 -0
- localcode/tui/screens/chat.py +5052 -0
- localcode/tui/screens/confirm.py +123 -0
- localcode/tui/screens/mode_picker.py +79 -0
- localcode/tui/screens/model_picker.py +962 -0
- localcode/tui/screens/setup.py +891 -0
- localcode/tui/styles/__init__.py +0 -0
- localcode/tui/styles/app.tcss +197 -0
- localcode/tui/widgets/__init__.py +0 -0
- localcode/tui/widgets/approval.py +2 -0
- localcode/tui/widgets/chat_log.py +2063 -0
- localcode/tui/widgets/messages/__init__.py +21 -0
- localcode/tui/widgets/messages/diff.py +287 -0
- localcode/tui/widgets/voice_visualizer.py +122 -0
- localcode/turn_diff.py +180 -0
- localcode/ui/FORK_COMMIT +1 -0
- localcode/ui/__init__.py +58 -0
- localcode/ui/launch.py +204 -0
- localcode/ui/picker_cli.py +165 -0
- localcode/ui/plugin/localcode.ts +626 -0
- localcode/ui/ports.py +42 -0
- localcode/ui/server_cmd.py +89 -0
- localcode/ui/supervisor.py +736 -0
- localcode/undo.py +121 -0
- localcode/verification.py +87 -0
- localcode/voice.py +948 -0
- localcode-0.4.0.dist-info/METADATA +160 -0
- localcode-0.4.0.dist-info/RECORD +173 -0
- localcode-0.4.0.dist-info/WHEEL +5 -0
- localcode-0.4.0.dist-info/entry_points.txt +3 -0
- localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
- localcode-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
"""Single source of truth for the local llama-server subprocess lifecycle.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
The codebase previously scattered `pkill -f llama-server` and `lsof -ti :8081`
|
|
6
|
+
across the TUI setup flow, the runtime gateway, and the /model switch
|
|
7
|
+
handler. Each site had its own idea of "kill the old server" and "start a
|
|
8
|
+
new one," leading to:
|
|
9
|
+
- stale servers surviving TUI exits (no atexit cleanup)
|
|
10
|
+
- zombie children after ungraceful crashes (no process-group kill)
|
|
11
|
+
- next launch picking up the zombie via healthcheck, then failing in a
|
|
12
|
+
hard-to-diagnose way (wrong model, wrong binary, mid-shutdown)
|
|
13
|
+
- `/model` switches killing the server without actually restarting it
|
|
14
|
+
|
|
15
|
+
This module centralises that state. Every place that starts or stops
|
|
16
|
+
`llama-server` now goes through `ServerManager.get()`.
|
|
17
|
+
|
|
18
|
+
Lifecycle contract
|
|
19
|
+
------------------
|
|
20
|
+
- Start the server with `mgr.start(cmd, model_path)`. Blocks until the HTTP
|
|
21
|
+
health endpoint returns OK or the timeout fires. Returns True on success.
|
|
22
|
+
- Switch models with `mgr.restart(cmd, model_path)` — shorthand for
|
|
23
|
+
shutdown + start.
|
|
24
|
+
- Check health without side effects via `mgr.is_healthy()`.
|
|
25
|
+
- Exit cleanly: an atexit handler calls `shutdown()` on process exit. The
|
|
26
|
+
handler runs even on sys.exit or normal TUI quit; for SIGTERM/SIGINT the
|
|
27
|
+
Python default handler also fires atexit, so we're covered.
|
|
28
|
+
- Ungraceful kill (SIGKILL, jetsam, power loss) leaves a stale PID file;
|
|
29
|
+
the next `ServerManager.get()` call reconciles it — sends SIGKILL to the
|
|
30
|
+
recorded PID if still alive, otherwise just deletes the file.
|
|
31
|
+
|
|
32
|
+
Why a process group
|
|
33
|
+
-------------------
|
|
34
|
+
We launch `llama-server` with `start_new_session=True` so the child becomes
|
|
35
|
+
the leader of a new process group. Shutdown then sends SIGTERM to the whole
|
|
36
|
+
group (via `os.killpg`), not just the immediate child. This catches any
|
|
37
|
+
worker threads or forked helpers the server might spawn in future versions
|
|
38
|
+
(currently llama-server doesn't fork, but the invariant is cheap to keep).
|
|
39
|
+
|
|
40
|
+
Why a PID file
|
|
41
|
+
--------------
|
|
42
|
+
The Popen object lives in this Python process. If the Python process dies
|
|
43
|
+
and a new one starts, we need to find the old server to clean it up. PID
|
|
44
|
+
file at ~/.localcode/llama-server.pid records the group leader PID; the new
|
|
45
|
+
process reads it, killpg's it, and deletes the file.
|
|
46
|
+
|
|
47
|
+
Why port-based last-resort kill
|
|
48
|
+
-------------------------------
|
|
49
|
+
If the PID file is missing, we fall back to killing whatever is bound to
|
|
50
|
+
:8081 (covers an already-running server from a different install).
|
|
51
|
+
"""
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import atexit
|
|
55
|
+
import os
|
|
56
|
+
import signal
|
|
57
|
+
import subprocess
|
|
58
|
+
import threading
|
|
59
|
+
import time
|
|
60
|
+
import urllib.error
|
|
61
|
+
import urllib.request
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
from typing import Optional
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
from ._subproc_env import clean_env
|
|
67
|
+
from .paths import (
|
|
68
|
+
server_pid_file,
|
|
69
|
+
stuck_server_marker_path,
|
|
70
|
+
pressure_kill_marker_path,
|
|
71
|
+
lifecycle_log_path,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# PID file stays GLOBAL — only one llama-server runs per machine (single port,
|
|
75
|
+
# single GPU budget), so it keeps serving you when you `cd` between projects.
|
|
76
|
+
# See paths.py for the global-vs-project split rationale.
|
|
77
|
+
PID_FILE = server_pid_file()
|
|
78
|
+
DEFAULT_PORT = 8081
|
|
79
|
+
# Preferred-range scan (8081-8099) before falling through to an OS-assigned
|
|
80
|
+
# ephemeral port. If all 19 are occupied, `find_free_port()` falls back to
|
|
81
|
+
# `bind(('', 0))` so localcode can never be shut out by port contention.
|
|
82
|
+
PORT_FALLBACK_RANGE = 19
|
|
83
|
+
HEALTH_TIMEOUT_S = 120 # a 10 GB model can take ~60s cold on slower disks
|
|
84
|
+
|
|
85
|
+
# Env var to force an exact port — takes precedence over all scanning.
|
|
86
|
+
# For users with firewall rules, SSH tunnels, or reverse proxies that
|
|
87
|
+
# need localcode reachable on a specific port.
|
|
88
|
+
PORT_ENV_VAR = "LOCALCODE_PORT"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _port_in_use(port: int) -> bool:
|
|
92
|
+
"""True if a TCP socket is already listening on `port`.
|
|
93
|
+
|
|
94
|
+
Uses bind() without SO_REUSEADDR — the pessimistic check: "if *I*
|
|
95
|
+
can't bind right now, treat it as busy." Safer than connect(),
|
|
96
|
+
which could hang on a D-state process that holds the fd but isn't
|
|
97
|
+
accept()ing. Consequence: a port in TIME_WAIT from our own prior
|
|
98
|
+
server will read as busy for ~60s — fine, we just walk to the next.
|
|
99
|
+
"""
|
|
100
|
+
import socket as _s
|
|
101
|
+
try:
|
|
102
|
+
with _s.socket(_s.AF_INET, _s.SOCK_STREAM) as s:
|
|
103
|
+
s.bind(("127.0.0.1", port))
|
|
104
|
+
return False
|
|
105
|
+
except OSError:
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _os_ephemeral_port() -> int:
|
|
110
|
+
"""Ask the kernel for a guaranteed-free port.
|
|
111
|
+
|
|
112
|
+
`bind(('', 0))` tells the OS to pick any free port from the ephemeral
|
|
113
|
+
range (49152-65535 on macOS). There is a tiny race between the close
|
|
114
|
+
here and whoever binds it next, but in practice it's the most
|
|
115
|
+
reliable way to get a port nobody else is using. We rely on this as
|
|
116
|
+
the last resort when every port in our preferred range is held.
|
|
117
|
+
"""
|
|
118
|
+
import socket as _s
|
|
119
|
+
with _s.socket(_s.AF_INET, _s.SOCK_STREAM) as s:
|
|
120
|
+
s.bind(("127.0.0.1", 0))
|
|
121
|
+
return s.getsockname()[1]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def find_free_port(preferred: int = DEFAULT_PORT,
|
|
125
|
+
scan_range: int = PORT_FALLBACK_RANGE) -> int:
|
|
126
|
+
"""Return a port we can bind to *right now*.
|
|
127
|
+
|
|
128
|
+
Selection strategy, in order:
|
|
129
|
+
1. If $LOCALCODE_PORT is set, return that exact port without
|
|
130
|
+
scanning. Intended for users with firewall / reverse-proxy /
|
|
131
|
+
tunnel setups that need a pinned port. If it's occupied at
|
|
132
|
+
launch, the server will fail to bind — the user asked for it,
|
|
133
|
+
we don't second-guess.
|
|
134
|
+
2. If `preferred` is free, return it.
|
|
135
|
+
3. Walk `preferred + 1 .. preferred + scan_range` and return the
|
|
136
|
+
first free port.
|
|
137
|
+
4. Fall back to an OS-assigned ephemeral port via bind(0). This
|
|
138
|
+
is the hard guarantee that localcode can always find a port,
|
|
139
|
+
even on a machine where 8081-8099 are all taken by other
|
|
140
|
+
services.
|
|
141
|
+
|
|
142
|
+
Pure helper — does not kill anything. Callers that want to reclaim
|
|
143
|
+
the preferred port from a stale process should do that separately
|
|
144
|
+
before calling this (see `ServerManager._pick_free_port`).
|
|
145
|
+
"""
|
|
146
|
+
import os
|
|
147
|
+
forced = os.environ.get(PORT_ENV_VAR, "").strip()
|
|
148
|
+
if forced:
|
|
149
|
+
try:
|
|
150
|
+
return int(forced)
|
|
151
|
+
except ValueError:
|
|
152
|
+
# Malformed env var — fall through to normal selection
|
|
153
|
+
# rather than failing. A bad env var shouldn't brick the app.
|
|
154
|
+
pass
|
|
155
|
+
if not _port_in_use(preferred):
|
|
156
|
+
return preferred
|
|
157
|
+
for offset in range(1, scan_range + 1):
|
|
158
|
+
candidate = preferred + offset
|
|
159
|
+
if not _port_in_use(candidate):
|
|
160
|
+
return candidate
|
|
161
|
+
return _os_ephemeral_port()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _rewrite_port_arg(cmd: list[str], port: int) -> list[str]:
|
|
165
|
+
"""Return a copy of `cmd` with `--port <N>` set to `port`."""
|
|
166
|
+
out = list(cmd)
|
|
167
|
+
try:
|
|
168
|
+
i = out.index("--port")
|
|
169
|
+
out[i + 1] = str(port)
|
|
170
|
+
except (ValueError, IndexError):
|
|
171
|
+
pass
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
STUCK_SERVER_MARKER = stuck_server_marker_path()
|
|
176
|
+
|
|
177
|
+
# Persistent (append-only) lifecycle log for every server start/stop/kill/
|
|
178
|
+
# recovery event. Per-project (`<project_root>/.localcode/lifecycle.log`) so
|
|
179
|
+
# each project keeps its own diagnostic timeline across sessions.
|
|
180
|
+
LIFECYCLE_LOG = lifecycle_log_path()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _lifecycle_log(event: str, **fields) -> None:
|
|
184
|
+
"""Compatibility shim — routes to the central event sink.
|
|
185
|
+
|
|
186
|
+
All historical call sites in this module + tools/__init__.py + agent.py
|
|
187
|
+
+ setup.py emit through `_lifecycle_log(event, **fields)`. This shim
|
|
188
|
+
forwards them to `events.emit(event, **fields)` so we get ONE
|
|
189
|
+
structured JSONL log (`.localcode/events.jsonl`) instead of the
|
|
190
|
+
text "key=value" lifecycle.log + JSONL turns.jsonl + marker file
|
|
191
|
+
sprawl. See `src/localcode/events.py` for the full schema.
|
|
192
|
+
|
|
193
|
+
The old `lifecycle.log` text file is no longer written. Callers
|
|
194
|
+
that want to inspect events should use `events.jsonl` (parseable
|
|
195
|
+
with `jq`) or call `events.read_events()` / `events.find_recent()`.
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
from .events import emit
|
|
199
|
+
emit(event, **fields)
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _system_free_memory_mb() -> int:
|
|
205
|
+
"""Return approximate system-wide free memory in MiB.
|
|
206
|
+
|
|
207
|
+
Uses `vm_stat` (already in PATH on every Mac). Returns 0 on any
|
|
208
|
+
parse failure — caller should treat 0 as "couldn't tell, proceed
|
|
209
|
+
with caution" rather than "definitely zero free."
|
|
210
|
+
|
|
211
|
+
Free memory = (free + inactive) * page_size. Inactive pages are
|
|
212
|
+
cached file content the kernel can release on demand, so they
|
|
213
|
+
behave as free for a new large allocation. Wired and active are
|
|
214
|
+
not counted (they belong to running apps that need them).
|
|
215
|
+
"""
|
|
216
|
+
try:
|
|
217
|
+
r = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=2)
|
|
218
|
+
page_size = 16384 # M-series default; vm_stat header confirms
|
|
219
|
+
free_pages = 0
|
|
220
|
+
inactive_pages = 0
|
|
221
|
+
for line in r.stdout.splitlines():
|
|
222
|
+
if "page size of" in line:
|
|
223
|
+
# "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
|
|
224
|
+
try:
|
|
225
|
+
page_size = int(line.split("page size of")[1].split("bytes")[0].strip())
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
elif line.startswith("Pages free:"):
|
|
229
|
+
free_pages = int(line.split(":")[1].strip().rstrip("."))
|
|
230
|
+
elif line.startswith("Pages inactive:"):
|
|
231
|
+
inactive_pages = int(line.split(":")[1].strip().rstrip("."))
|
|
232
|
+
if free_pages == 0 and inactive_pages == 0:
|
|
233
|
+
return 0
|
|
234
|
+
total_bytes = (free_pages + inactive_pages) * page_size
|
|
235
|
+
return total_bytes // (1024 * 1024)
|
|
236
|
+
except Exception:
|
|
237
|
+
return 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _wait_for_memory_release(min_free_mb: int, timeout_s: float = 8.0) -> bool:
|
|
241
|
+
"""Block until system free memory ≥ `min_free_mb` or `timeout_s` elapses.
|
|
242
|
+
|
|
243
|
+
Used between killing an old server and spawning a new one, so that
|
|
244
|
+
we don't double-commit ~10 GB while the kernel is still releasing
|
|
245
|
+
the dead server's wired Metal pages. Returns True if the threshold
|
|
246
|
+
was met, False on timeout. Polls every 250 ms.
|
|
247
|
+
|
|
248
|
+
A False return is informational, not fatal — caller can decide to
|
|
249
|
+
proceed anyway (the new server might still fit, just at higher
|
|
250
|
+
pressure-kill risk). Logs the outcome so we have evidence later.
|
|
251
|
+
"""
|
|
252
|
+
import time
|
|
253
|
+
deadline = time.monotonic() + timeout_s
|
|
254
|
+
last_seen = -1
|
|
255
|
+
while time.monotonic() < deadline:
|
|
256
|
+
free_mb = _system_free_memory_mb()
|
|
257
|
+
last_seen = free_mb
|
|
258
|
+
if free_mb >= min_free_mb:
|
|
259
|
+
_lifecycle_log("memory_released", free_mb=free_mb, target_mb=min_free_mb)
|
|
260
|
+
return True
|
|
261
|
+
time.sleep(0.25)
|
|
262
|
+
_lifecycle_log("memory_release_timeout", free_mb=last_seen, target_mb=min_free_mb,
|
|
263
|
+
timeout_s=timeout_s)
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _mark_stuck_server(pid: int) -> None:
|
|
268
|
+
"""Persist a note that PID `pid` wouldn't die under SIGKILL.
|
|
269
|
+
|
|
270
|
+
Read by `localcode.health.find_stuck_servers()` on the next launch.
|
|
271
|
+
Cleared when health check confirms PID is gone.
|
|
272
|
+
"""
|
|
273
|
+
try:
|
|
274
|
+
STUCK_SERVER_MARKER.parent.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
STUCK_SERVER_MARKER.write_text(f"{pid}\n{time.time():.0f}\n")
|
|
276
|
+
except Exception:
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class ServerManager:
|
|
281
|
+
"""Owns the llama-server subprocess. Use `ServerManager.get()`."""
|
|
282
|
+
|
|
283
|
+
_instance: Optional["ServerManager"] = None
|
|
284
|
+
_instance_lock = threading.Lock()
|
|
285
|
+
|
|
286
|
+
@classmethod
|
|
287
|
+
def get(cls) -> "ServerManager":
|
|
288
|
+
with cls._instance_lock:
|
|
289
|
+
if cls._instance is None:
|
|
290
|
+
cls._instance = cls()
|
|
291
|
+
atexit.register(cls._instance.shutdown)
|
|
292
|
+
return cls._instance
|
|
293
|
+
|
|
294
|
+
def __init__(self) -> None:
|
|
295
|
+
self._process: Optional[subprocess.Popen] = None
|
|
296
|
+
self._model_path: Optional[str] = None
|
|
297
|
+
self._port: int = DEFAULT_PORT
|
|
298
|
+
self._pressure_thread: Optional[threading.Thread] = None
|
|
299
|
+
self._lock = threading.Lock()
|
|
300
|
+
self._last_exit_code: Optional[int] = None # disconnect diagnostics
|
|
301
|
+
self._last_death_was_pressure: bool = False
|
|
302
|
+
# Idle auto-suspend: a watchdog shuts the server down after
|
|
303
|
+
# `_idle_timeout_s` of inactivity (stops the GPU cooking the laptop
|
|
304
|
+
# while the user reads); the next chat call respawns via
|
|
305
|
+
# _restart_server. 0 disables; default 10 min. Env: LOCALCODE_IDLE_SUSPEND_S.
|
|
306
|
+
import os as _os
|
|
307
|
+
try:
|
|
308
|
+
self._idle_timeout_s: float = float(
|
|
309
|
+
_os.environ.get("LOCALCODE_IDLE_SUSPEND_S", "600"),
|
|
310
|
+
)
|
|
311
|
+
except ValueError:
|
|
312
|
+
self._idle_timeout_s = 600.0
|
|
313
|
+
self._last_activity_ts: float = 0.0
|
|
314
|
+
self._idle_thread: Optional[threading.Thread] = None
|
|
315
|
+
# Post-start verification: what the SERVER says it loaded, and the
|
|
316
|
+
# complaint if that disagreed with what we asked for. Never trust
|
|
317
|
+
# `_model_path` (what we *wanted*) as evidence of what is serving.
|
|
318
|
+
self._verified_model: Optional[str] = None
|
|
319
|
+
self._verification_error: Optional[str] = None
|
|
320
|
+
# On construction, clean up any stale PID file from a prior crash.
|
|
321
|
+
self._reap_stale_pid_file()
|
|
322
|
+
|
|
323
|
+
def mark_activity(self) -> None:
|
|
324
|
+
"""Record that the server just handled (or is about to handle)
|
|
325
|
+
a request. Resets the idle-suspend countdown."""
|
|
326
|
+
import time as _t
|
|
327
|
+
self._last_activity_ts = _t.time()
|
|
328
|
+
|
|
329
|
+
def request_started(self) -> None:
|
|
330
|
+
"""Mark a chat request in flight. The idle watchdog must NEVER
|
|
331
|
+
suspend the server while a request is live: a single long agentic
|
|
332
|
+
round (a 10+ minute decode is routine for a 30B+ model on local
|
|
333
|
+
hardware) used to count as "idle" because activity was only marked at
|
|
334
|
+
request START — observed live as `idle_suspend idle_s=612` firing
|
|
335
|
+
mid-build, shutting the server down under an active stream and
|
|
336
|
+
cascading into back-to-back reloads (35 GB of weights each) and a dead
|
|
337
|
+
session. Pair with request_finished() in a finally block."""
|
|
338
|
+
with self._lock:
|
|
339
|
+
self._inflight = getattr(self, "_inflight", 0) + 1
|
|
340
|
+
self.mark_activity()
|
|
341
|
+
|
|
342
|
+
def request_finished(self) -> None:
|
|
343
|
+
"""Mark a chat request complete (see request_started)."""
|
|
344
|
+
with self._lock:
|
|
345
|
+
self._inflight = max(0, getattr(self, "_inflight", 0) - 1)
|
|
346
|
+
self.mark_activity()
|
|
347
|
+
|
|
348
|
+
def set_idle_timeout(self, seconds: float) -> None:
|
|
349
|
+
"""Update the idle auto-suspend window. 0 disables suspend."""
|
|
350
|
+
self._idle_timeout_s = max(0.0, float(seconds))
|
|
351
|
+
|
|
352
|
+
def _idle_watchdog(self) -> None:
|
|
353
|
+
import time as _t
|
|
354
|
+
while True:
|
|
355
|
+
_t.sleep(30)
|
|
356
|
+
try:
|
|
357
|
+
if self._idle_timeout_s <= 0:
|
|
358
|
+
continue
|
|
359
|
+
if self._process is None or self._process.poll() is not None:
|
|
360
|
+
continue
|
|
361
|
+
if self._last_activity_ts == 0.0:
|
|
362
|
+
continue
|
|
363
|
+
# A live request is never idle, no matter how long its decode
|
|
364
|
+
# runs. This is the primary guard; the streaming path also
|
|
365
|
+
# marks activity per chunk as a belt-and-braces backstop.
|
|
366
|
+
if getattr(self, "_inflight", 0) > 0:
|
|
367
|
+
continue
|
|
368
|
+
idle = _t.time() - self._last_activity_ts
|
|
369
|
+
if idle >= self._idle_timeout_s:
|
|
370
|
+
_lifecycle_log(
|
|
371
|
+
"idle_suspend",
|
|
372
|
+
idle_s=round(idle, 1),
|
|
373
|
+
threshold_s=self._idle_timeout_s,
|
|
374
|
+
)
|
|
375
|
+
self.shutdown(reason="idle_suspend")
|
|
376
|
+
except Exception:
|
|
377
|
+
# Never let the watchdog crash the process.
|
|
378
|
+
pass
|
|
379
|
+
|
|
380
|
+
def _ensure_idle_thread(self) -> None:
|
|
381
|
+
if self._idle_thread is not None and self._idle_thread.is_alive():
|
|
382
|
+
return
|
|
383
|
+
self._idle_thread = threading.Thread(
|
|
384
|
+
target=self._idle_watchdog,
|
|
385
|
+
name="lc-idle-suspend",
|
|
386
|
+
daemon=True,
|
|
387
|
+
)
|
|
388
|
+
self._idle_thread.start()
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def port(self) -> int:
|
|
392
|
+
"""Port the live server is actually listening on. May differ
|
|
393
|
+
from DEFAULT_PORT (8081) if the default was held by a stuck
|
|
394
|
+
process at start time and we fell back to 8082 / 8083 / etc.
|
|
395
|
+
Callers MUST read this AFTER start()/restart() and update
|
|
396
|
+
their HTTP client base URL — without that, requests go to the
|
|
397
|
+
old default port and fail with connection-refused while a
|
|
398
|
+
perfectly good server runs on the fallback. (See RESUME.md
|
|
399
|
+
port-isolation note.)"""
|
|
400
|
+
return self._port
|
|
401
|
+
|
|
402
|
+
# ────────────────────────────────────────────────────────────────
|
|
403
|
+
# Public API
|
|
404
|
+
# ────────────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
def start(self, cmd: list[str], model_path: str, port: int = DEFAULT_PORT,
|
|
407
|
+
timeout_s: int = HEALTH_TIMEOUT_S) -> bool:
|
|
408
|
+
"""Start the server. Kills any pre-existing instance first. Blocks
|
|
409
|
+
until the health endpoint returns OK or `timeout_s` elapses.
|
|
410
|
+
Returns True on success, False on healthcheck timeout.
|
|
411
|
+
|
|
412
|
+
Port fallback: if the default port is held by a D-state ("stuck
|
|
413
|
+
in a Metal syscall that won't release") process that can't be
|
|
414
|
+
killed, incrementally try 8082, 8083 … up to 8085. Without this,
|
|
415
|
+
a single hung llama-server from a prior session blocks every
|
|
416
|
+
subsequent localcode launch until reboot — which was user-
|
|
417
|
+
reported on 2026-04-23.
|
|
418
|
+
"""
|
|
419
|
+
requested_port = port
|
|
420
|
+
with self._lock:
|
|
421
|
+
# Nothing is verified until the post-health probe below says so.
|
|
422
|
+
self._verified_model = None
|
|
423
|
+
self._verification_error = None
|
|
424
|
+
had_prior = self._process is not None
|
|
425
|
+
_prev_model = self._model_path # capture before shutdown clears it
|
|
426
|
+
self._shutdown_locked(reason="start_or_model_swap")
|
|
427
|
+
# Wait for the OLD server's memory to release before spawning the
|
|
428
|
+
# new one (kernel takes 1-3 s to free wired Metal memory; spawning
|
|
429
|
+
# into that window double-commits). Best-effort: ~11 GB target, 8 s.
|
|
430
|
+
if had_prior:
|
|
431
|
+
# Only a genuine model change is "model_switch"; a same-model
|
|
432
|
+
# relaunch (crash/disconnect recovery) is "server_restart".
|
|
433
|
+
_reason = ("model_switch" if (model_path and model_path != _prev_model)
|
|
434
|
+
else "server_restart")
|
|
435
|
+
_lifecycle_log("memory_wait_start", reason=_reason, target_mb=11000)
|
|
436
|
+
_wait_for_memory_release(min_free_mb=11000, timeout_s=8.0)
|
|
437
|
+
|
|
438
|
+
# Find a port we can actually bind to. If the default is
|
|
439
|
+
# held by an undead process, pick the next free one and
|
|
440
|
+
# rewrite the --port argument in cmd.
|
|
441
|
+
port = self._pick_free_port(port)
|
|
442
|
+
cmd = _rewrite_port_arg(cmd, port)
|
|
443
|
+
|
|
444
|
+
# Single source of truth for env scrubbing — see _subproc_env.py.
|
|
445
|
+
# Strips MallocStackLogging* + MallocNanoZone, the libsystem-init
|
|
446
|
+
# warning sources we battled all of 2026-04-26.
|
|
447
|
+
env = clean_env()
|
|
448
|
+
env["GGML_BACKEND_PATH"] = ""
|
|
449
|
+
# Relax macOS GPU interactivity watchdog (AGX CDM context-store
|
|
450
|
+
# timeout). llama.cpp upstream PR #22216 sets this in-process, but
|
|
451
|
+
# AGX driver may read the env at first Metal touch — setting it in
|
|
452
|
+
# the parent env before spawn is the belt-and-suspenders approach.
|
|
453
|
+
env.setdefault("AGX_RELAX_CDM_CTXSTORE_TIMEOUT", "1")
|
|
454
|
+
# Disable Metal residency sets on at-risk launches → prevents the wired-cap SIGKILL.
|
|
455
|
+
try:
|
|
456
|
+
from .memory_guard import metal_residency_env
|
|
457
|
+
_renv = metal_residency_env(str(model_path), cmd)
|
|
458
|
+
if _renv:
|
|
459
|
+
env.update(_renv)
|
|
460
|
+
_lifecycle_log("metal_no_residency", model=str(model_path))
|
|
461
|
+
except Exception:
|
|
462
|
+
pass
|
|
463
|
+
# Capture the server's OWN stdout/stderr to server.log (this runtime
|
|
464
|
+
# path used to send both to /dev/null, discarding llama-server's
|
|
465
|
+
# final ggml/Metal error on the recurring -9). Append per-spawn.
|
|
466
|
+
log_fh = None
|
|
467
|
+
try:
|
|
468
|
+
from .paths import global_state_dir
|
|
469
|
+
log_fh = open(global_state_dir() / "server.log", "a", buffering=1)
|
|
470
|
+
log_fh.write(f"\n===== llama-server spawn (port {port}) =====\n")
|
|
471
|
+
except Exception:
|
|
472
|
+
log_fh = None
|
|
473
|
+
try: # close any prior spawn's handle before replacing it
|
|
474
|
+
if getattr(self, "_log_fh", None) is not None:
|
|
475
|
+
self._log_fh.close()
|
|
476
|
+
except Exception:
|
|
477
|
+
pass
|
|
478
|
+
self._log_fh = log_fh
|
|
479
|
+
self._process = subprocess.Popen(
|
|
480
|
+
cmd,
|
|
481
|
+
stdout=(log_fh or subprocess.DEVNULL),
|
|
482
|
+
stderr=subprocess.STDOUT if log_fh else subprocess.DEVNULL,
|
|
483
|
+
start_new_session=True, # process group leader → killpg works
|
|
484
|
+
env=env,
|
|
485
|
+
)
|
|
486
|
+
self._model_path = model_path
|
|
487
|
+
self._port = port
|
|
488
|
+
|
|
489
|
+
# Layer 1: kernel-enforced memory ceiling via jetsam — the hard
|
|
490
|
+
# backstop (sanctioned Apple API) that kills llama-server before it
|
|
491
|
+
# can push the system over the edge. ONLY installed on small Macs
|
|
492
|
+
# (<48 GB): there a runaway server must be killed before it freezes
|
|
493
|
+
# the machine. On a large Mac a big model legitimately needs most of
|
|
494
|
+
# RAM, and a FATAL highwater there just SIGKILLs (-9) the server
|
|
495
|
+
# mid-decode when its footprint spikes past total−reserve — the
|
|
496
|
+
# mystery long-task crash. Big machines rely on Layer 2 (graceful).
|
|
497
|
+
try:
|
|
498
|
+
from .memory_guard import (
|
|
499
|
+
set_jetsam_highwater, recommended_jetsam_limit_mb,
|
|
500
|
+
should_set_fatal_jetsam_limit, start_pressure_monitor,
|
|
501
|
+
)
|
|
502
|
+
if should_set_fatal_jetsam_limit():
|
|
503
|
+
_limit = recommended_jetsam_limit_mb()
|
|
504
|
+
set_jetsam_highwater(self._process.pid, _limit)
|
|
505
|
+
_lifecycle_log("jetsam_highwater_set", pid=self._process.pid,
|
|
506
|
+
limit_mb=_limit)
|
|
507
|
+
else:
|
|
508
|
+
_lifecycle_log("jetsam_highwater_skipped",
|
|
509
|
+
pid=self._process.pid,
|
|
510
|
+
reason="large_ram_uses_pressure_monitor")
|
|
511
|
+
except Exception:
|
|
512
|
+
pass
|
|
513
|
+
|
|
514
|
+
# Layer 2: userspace pressure monitor — polls
|
|
515
|
+
# kern.memorystatus_vm_pressure_level every 500 ms and SIGTERMs our
|
|
516
|
+
# server on WARN. Complements the jetsam ceiling (acts on system-
|
|
517
|
+
# wide pressure, not just our own RSS).
|
|
518
|
+
try:
|
|
519
|
+
self._pressure_thread = start_pressure_monitor(
|
|
520
|
+
self._process,
|
|
521
|
+
on_pressure=self._on_pressure_kill,
|
|
522
|
+
)
|
|
523
|
+
except Exception:
|
|
524
|
+
self._pressure_thread = None
|
|
525
|
+
self._write_pid_file(self._process.pid)
|
|
526
|
+
# Log the FULL launch command (which flags was the server using?);
|
|
527
|
+
# truncated to 4000 chars (~80 args) to keep the event small.
|
|
528
|
+
_flags_str = " ".join(str(c) for c in cmd)[:4000]
|
|
529
|
+
_lifecycle_log("server_started", pid=self._process.pid, port=port,
|
|
530
|
+
model=Path(model_path).name,
|
|
531
|
+
free_mb_after_spawn=_system_free_memory_mb(),
|
|
532
|
+
flags=_flags_str)
|
|
533
|
+
ok = self._wait_healthy(port, timeout_s)
|
|
534
|
+
_lifecycle_log("server_health_result", port=port, healthy=ok,
|
|
535
|
+
free_mb=_system_free_memory_mb())
|
|
536
|
+
if ok:
|
|
537
|
+
# A healthy port is NOT proof the model we asked for is loaded.
|
|
538
|
+
# `_shutdown_locked()` only kills a process THIS manager owns, and
|
|
539
|
+
# `_kill_port()` deliberately refuses to kill a healthy foreign
|
|
540
|
+
# llama-server (it belongs to another terminal's session) — so a
|
|
541
|
+
# start() can sail through the healthcheck while a prior session's
|
|
542
|
+
# server answers on the port. Live on 2026-08-22: /model switched
|
|
543
|
+
# to Muse Glimmer, UI said "server: ready", and pid 49152 was still
|
|
544
|
+
# serving Qwen3.8-27B. Ask the server what it loaded and FAIL if it
|
|
545
|
+
# isn't what we asked for. We do NOT kill the foreign server (that
|
|
546
|
+
# would end someone else's session) — we detect and report.
|
|
547
|
+
ok = self._verify_loaded_model(model_path, port,
|
|
548
|
+
requested_port=requested_port)
|
|
549
|
+
if ok:
|
|
550
|
+
self.mark_activity()
|
|
551
|
+
self._ensure_idle_thread()
|
|
552
|
+
self._last_exit_code = None
|
|
553
|
+
self._last_death_was_pressure = False
|
|
554
|
+
return ok
|
|
555
|
+
|
|
556
|
+
def _verify_loaded_model(self, model_path: str, port: int,
|
|
557
|
+
requested_port: int | None = None) -> bool:
|
|
558
|
+
"""Confirm the live server on `port` really loaded `model_path`.
|
|
559
|
+
|
|
560
|
+
Returns False (and records `verification_error`) only on a genuine
|
|
561
|
+
MISMATCH. An unknown answer — older server, `/props` unreachable —
|
|
562
|
+
leaves `verified_model` None and returns True: we can't prove it's
|
|
563
|
+
wrong, but callers/UI must then say "unverified" rather than name a
|
|
564
|
+
model confidently.
|
|
565
|
+
|
|
566
|
+
Port fallback is the same class of bug: if we bound 8082 because a
|
|
567
|
+
foreign server held 8081, every caller still pointed at 8081 is
|
|
568
|
+
talking to that foreign server. We do not fail here — the existing
|
|
569
|
+
design is that callers re-read `mgr.port` after start() and rewrite
|
|
570
|
+
their base_url (runtime._restart_server does exactly this), which is
|
|
571
|
+
the recovery that keeps localcode usable when a port is squatted.
|
|
572
|
+
We log it loudly so a caller that forgot is diagnosable.
|
|
573
|
+
"""
|
|
574
|
+
if requested_port is not None and port != requested_port:
|
|
575
|
+
_lifecycle_log("server_port_fallback", requested=requested_port,
|
|
576
|
+
actual=port,
|
|
577
|
+
note="callers must re-read mgr.port into base_url")
|
|
578
|
+
reported = probe_loaded_model(port, timeout=2.0)
|
|
579
|
+
if reported is None:
|
|
580
|
+
self._verified_model = None
|
|
581
|
+
self._verification_error = None
|
|
582
|
+
_lifecycle_log("model_verify_unknown", port=port,
|
|
583
|
+
requested=Path(model_path).name)
|
|
584
|
+
return True
|
|
585
|
+
if model_identity_matches(model_path, reported):
|
|
586
|
+
self._verified_model = reported
|
|
587
|
+
self._verification_error = None
|
|
588
|
+
_lifecycle_log("model_verify_ok", port=port,
|
|
589
|
+
model=Path(reported).name)
|
|
590
|
+
return True
|
|
591
|
+
self._verified_model = reported
|
|
592
|
+
self._verification_error = (
|
|
593
|
+
f"Wrong model on port {port}: asked for {model_path}, "
|
|
594
|
+
f"server is serving {reported}. A llama-server from another "
|
|
595
|
+
f"session is holding this port (localcode never kills a healthy "
|
|
596
|
+
f"one). Quit that session, or set LOCALCODE_PORT to a free port."
|
|
597
|
+
)
|
|
598
|
+
_lifecycle_log("model_verify_mismatch", port=port,
|
|
599
|
+
requested=model_path, actual=reported)
|
|
600
|
+
return False
|
|
601
|
+
|
|
602
|
+
def restart(self, cmd: list[str], model_path: str, port: int = DEFAULT_PORT,
|
|
603
|
+
timeout_s: int = HEALTH_TIMEOUT_S) -> bool:
|
|
604
|
+
"""Convenience: shutdown + start. Callers switching models should
|
|
605
|
+
use this rather than open-coding kill-then-start, to ensure we go
|
|
606
|
+
through the single lifecycle path.
|
|
607
|
+
"""
|
|
608
|
+
return self.start(cmd, model_path, port=port, timeout_s=timeout_s)
|
|
609
|
+
|
|
610
|
+
def shutdown(self, force: bool = False, reason: str = "") -> None:
|
|
611
|
+
"""Stop the server. Idempotent; safe to call multiple times.
|
|
612
|
+
|
|
613
|
+
``force=False`` (default, used for model swaps): graceful path —
|
|
614
|
+
SIGTERM → wait up to 3 s for clean shutdown → SIGKILL fallback.
|
|
615
|
+
Lets llama-server release its Metal allocations cleanly so the
|
|
616
|
+
replacement model can claim them without an OOM.
|
|
617
|
+
|
|
618
|
+
``force=True`` (used on app exit): straight to SIGKILL with a
|
|
619
|
+
0.5 s reap timeout. The kernel reclaims the Metal allocations
|
|
620
|
+
on process death anyway, and skipping the 5–10 s graceful
|
|
621
|
+
cleanup is the difference between a snappy exit and the user
|
|
622
|
+
wondering if localcode is hung.
|
|
623
|
+
|
|
624
|
+
``reason`` is recorded on the server_stopped lifecycle event so a
|
|
625
|
+
restart storm is diagnosable from events.jsonl alone.
|
|
626
|
+
"""
|
|
627
|
+
with self._lock:
|
|
628
|
+
if force:
|
|
629
|
+
self._pending_stop_reason = reason or "app_exit_force"
|
|
630
|
+
self._force_kill_locked()
|
|
631
|
+
else:
|
|
632
|
+
self._shutdown_locked(reason=reason)
|
|
633
|
+
|
|
634
|
+
def _force_kill_locked(self) -> None:
|
|
635
|
+
"""SIGKILL-immediately path for app exit. Must be called with
|
|
636
|
+
self._lock held."""
|
|
637
|
+
if self._process is None:
|
|
638
|
+
return
|
|
639
|
+
pid = self._process.pid
|
|
640
|
+
try:
|
|
641
|
+
pgid = os.getpgid(pid)
|
|
642
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
643
|
+
except ProcessLookupError:
|
|
644
|
+
pass
|
|
645
|
+
except Exception:
|
|
646
|
+
try:
|
|
647
|
+
self._process.kill()
|
|
648
|
+
except Exception:
|
|
649
|
+
pass
|
|
650
|
+
try:
|
|
651
|
+
self._process.wait(timeout=0.5)
|
|
652
|
+
except Exception:
|
|
653
|
+
pass
|
|
654
|
+
self._process = None
|
|
655
|
+
self._model_path = None
|
|
656
|
+
self._port = None
|
|
657
|
+
self._verified_model = None
|
|
658
|
+
self._verification_error = None
|
|
659
|
+
|
|
660
|
+
def is_healthy(self, port: int | None = None) -> bool:
|
|
661
|
+
"""Non-blocking probe of the port the server ACTUALLY bound (default was 8081, wrong after port fallback)."""
|
|
662
|
+
return _probe_health(port if port is not None else self._port, timeout=1.0)
|
|
663
|
+
|
|
664
|
+
def is_running(self) -> bool:
|
|
665
|
+
"""True if we have a tracked Popen and it hasn't exited."""
|
|
666
|
+
with self._lock:
|
|
667
|
+
return self._process is not None and self._process.poll() is None
|
|
668
|
+
|
|
669
|
+
@property
|
|
670
|
+
def current_model(self) -> Optional[str]:
|
|
671
|
+
"""The model we ASKED the server to load. Not evidence of what is
|
|
672
|
+
actually serving — see `verified_model`."""
|
|
673
|
+
return self._model_path
|
|
674
|
+
|
|
675
|
+
@property
|
|
676
|
+
def verified_model(self) -> Optional[str]:
|
|
677
|
+
"""The model path the live server REPORTED after the last start(),
|
|
678
|
+
or None when verification hasn't run or couldn't answer."""
|
|
679
|
+
return self._verified_model
|
|
680
|
+
|
|
681
|
+
@property
|
|
682
|
+
def verification_error(self) -> Optional[str]:
|
|
683
|
+
"""Human-readable complaint when the server is serving a different
|
|
684
|
+
model than the one requested. None when verified or unknown."""
|
|
685
|
+
return self._verification_error
|
|
686
|
+
|
|
687
|
+
def disconnect_diagnostics(self) -> dict:
|
|
688
|
+
"""Snapshot of WHY the server is unreachable (disconnect CLASS:
|
|
689
|
+
memory-guard kill / crash / not-running / wedged) for recovery
|
|
690
|
+
logging. exit_code is `-signal` on POSIX. Never raises."""
|
|
691
|
+
running = False
|
|
692
|
+
try:
|
|
693
|
+
if self._process is not None:
|
|
694
|
+
rc = self._process.poll()
|
|
695
|
+
running = rc is None
|
|
696
|
+
if rc is not None:
|
|
697
|
+
self._last_exit_code = rc
|
|
698
|
+
except Exception:
|
|
699
|
+
pass
|
|
700
|
+
ec = None if running else self._last_exit_code
|
|
701
|
+
cls = (
|
|
702
|
+
"memory_guard_kill" if self._last_death_was_pressure
|
|
703
|
+
else "sigkill_or_jetsam" if ec == -9
|
|
704
|
+
else "sigterm" if ec == -15
|
|
705
|
+
else "crash_exit" if isinstance(ec, int) and ec > 0
|
|
706
|
+
else "wedged_listener" if running else "not_running"
|
|
707
|
+
)
|
|
708
|
+
return {"running": running, "exit_code": ec,
|
|
709
|
+
"pressure_kill": bool(self._last_death_was_pressure),
|
|
710
|
+
"disconnect_class": cls, "port": self._port,
|
|
711
|
+
"free_mb": _system_free_memory_mb()}
|
|
712
|
+
|
|
713
|
+
# ────────────────────────────────────────────────────────────────
|
|
714
|
+
# Internals
|
|
715
|
+
# ────────────────────────────────────────────────────────────────
|
|
716
|
+
|
|
717
|
+
def _shutdown_locked(self, reason: str = "") -> None:
|
|
718
|
+
"""Must be called with self._lock held.
|
|
719
|
+
|
|
720
|
+
Supervisor pattern: SIGTERM → wait 3s → SIGKILL → wait 2s. If
|
|
721
|
+
BOTH waits time out, the process is in D-state (uninterruptible
|
|
722
|
+
kernel sleep — Metal mmap/GPU). We record that in a marker file
|
|
723
|
+
so the next localcode launch's health check can detect it and
|
|
724
|
+
refuse to start, instead of letting the OS OOM-kill the user.
|
|
725
|
+
|
|
726
|
+
`reason` lands on the server_stopped lifecycle event. The restart
|
|
727
|
+
storm of 2026-07-12 (7 stops in 15 min) was invisible precisely
|
|
728
|
+
because stops carried no reason — never emit a bare stop again.
|
|
729
|
+
"""
|
|
730
|
+
self._pending_stop_reason = reason or getattr(self, "_pending_stop_reason", "") or "unspecified"
|
|
731
|
+
if self._process is not None:
|
|
732
|
+
pid = self._process.pid
|
|
733
|
+
stuck = False
|
|
734
|
+
try:
|
|
735
|
+
pgid = os.getpgid(pid)
|
|
736
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
737
|
+
try:
|
|
738
|
+
self._process.wait(timeout=3)
|
|
739
|
+
except subprocess.TimeoutExpired:
|
|
740
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
741
|
+
try:
|
|
742
|
+
self._process.wait(timeout=2)
|
|
743
|
+
except subprocess.TimeoutExpired:
|
|
744
|
+
# Both signals delivered but process won't
|
|
745
|
+
# exit — D-state. Mark it for the next launch.
|
|
746
|
+
stuck = True
|
|
747
|
+
except ProcessLookupError:
|
|
748
|
+
pass
|
|
749
|
+
except Exception:
|
|
750
|
+
# Last ditch: plain SIGKILL on the child
|
|
751
|
+
try:
|
|
752
|
+
self._process.kill()
|
|
753
|
+
except Exception:
|
|
754
|
+
pass
|
|
755
|
+
if stuck:
|
|
756
|
+
_mark_stuck_server(pid)
|
|
757
|
+
_lifecycle_log("server_stopped", pid=pid, stuck=stuck,
|
|
758
|
+
reason=getattr(self, "_pending_stop_reason", "unspecified"),
|
|
759
|
+
free_mb_at_stop=_system_free_memory_mb())
|
|
760
|
+
self._pending_stop_reason = ""
|
|
761
|
+
self._process = None
|
|
762
|
+
self._model_path = None
|
|
763
|
+
|
|
764
|
+
# 2. Kill whatever the PID file points at (covers prior Python process)
|
|
765
|
+
self._kill_pid_file()
|
|
766
|
+
|
|
767
|
+
# 3. Last-resort: anything on our port (covers other-user llama-server,
|
|
768
|
+
# detached orphans, etc.)
|
|
769
|
+
self._kill_port(self._port)
|
|
770
|
+
|
|
771
|
+
def _reap_stale_pid_file(self) -> None:
|
|
772
|
+
"""Reconcile the PID file on manager construction.
|
|
773
|
+
|
|
774
|
+
HARD-LEARNED (2026-07-12, live): this used to killpg(SIGKILL)
|
|
775
|
+
whatever pid the file named, unverified. Consequences observed the
|
|
776
|
+
same day: (a) launching a second localcode killed the first
|
|
777
|
+
session's HEALTHY server mid-build, twice, ending the user's run;
|
|
778
|
+
(b) under restart churn a recycled pid let the killpg land on the
|
|
779
|
+
TUI's own process group — the whole app died with `zsh: killed`
|
|
780
|
+
and 60 GB free. A world-shared pid file must never be treated as
|
|
781
|
+
a kill list. Now: only a VERIFIED llama-server (see
|
|
782
|
+
`_pid_is_our_llama_server`) that is NOT serving healthily gets
|
|
783
|
+
killed; anything else is left alone and only the file is removed.
|
|
784
|
+
"""
|
|
785
|
+
self._kill_pid_file()
|
|
786
|
+
|
|
787
|
+
@staticmethod
|
|
788
|
+
def _pid_is_our_llama_server(pid: int) -> bool:
|
|
789
|
+
"""True iff `pid` is alive AND its command line is a llama-server.
|
|
790
|
+
|
|
791
|
+
The pgid==pid check exploits our own spawn signature
|
|
792
|
+
(start_new_session=True makes every server we launch its own
|
|
793
|
+
process-group leader). A recycled pid belonging to some other
|
|
794
|
+
program fails the name check; a process in someone else's group
|
|
795
|
+
(e.g. the TUI's) fails the leader check. Both must pass before we
|
|
796
|
+
are willing to signal it.
|
|
797
|
+
"""
|
|
798
|
+
try:
|
|
799
|
+
if os.getpgid(pid) != pid:
|
|
800
|
+
return False
|
|
801
|
+
except Exception:
|
|
802
|
+
return False
|
|
803
|
+
try:
|
|
804
|
+
out = subprocess.run(
|
|
805
|
+
["ps", "-p", str(pid), "-o", "command="],
|
|
806
|
+
capture_output=True, text=True, timeout=3,
|
|
807
|
+
).stdout.strip()
|
|
808
|
+
except Exception:
|
|
809
|
+
return False
|
|
810
|
+
return "llama-server" in out
|
|
811
|
+
|
|
812
|
+
def _kill_pid_file(self) -> None:
|
|
813
|
+
if not PID_FILE.exists():
|
|
814
|
+
return
|
|
815
|
+
pid, _file_port = 0, 0
|
|
816
|
+
try:
|
|
817
|
+
parts = PID_FILE.read_text().strip().split()
|
|
818
|
+
pid = int(parts[0]) if parts else 0
|
|
819
|
+
_file_port = int(parts[1]) if len(parts) > 1 else 0
|
|
820
|
+
except Exception:
|
|
821
|
+
pid = 0
|
|
822
|
+
if pid > 0 and self._pid_is_our_llama_server(pid):
|
|
823
|
+
# A healthy, serving llama-server is NOT stale — it belongs to a
|
|
824
|
+
# live session (possibly this one, possibly another terminal).
|
|
825
|
+
# Killing it ends that user's in-flight build. Leave it; the
|
|
826
|
+
# setup screen's reuse/port-fallback logic handles coexistence.
|
|
827
|
+
_check_port = _file_port or self._port
|
|
828
|
+
if _probe_health(_check_port, timeout=1.0):
|
|
829
|
+
_lifecycle_log("pid_reap_skipped_healthy", pid=pid, port=_check_port)
|
|
830
|
+
return
|
|
831
|
+
try:
|
|
832
|
+
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
|
833
|
+
_lifecycle_log("pid_reap_killed", pid=pid)
|
|
834
|
+
except ProcessLookupError:
|
|
835
|
+
pass
|
|
836
|
+
except Exception:
|
|
837
|
+
try:
|
|
838
|
+
os.kill(pid, signal.SIGKILL)
|
|
839
|
+
except Exception:
|
|
840
|
+
pass
|
|
841
|
+
elif pid > 0:
|
|
842
|
+
_lifecycle_log("pid_reap_skipped_not_ours", pid=pid)
|
|
843
|
+
try:
|
|
844
|
+
PID_FILE.unlink()
|
|
845
|
+
except FileNotFoundError:
|
|
846
|
+
pass
|
|
847
|
+
except Exception:
|
|
848
|
+
pass
|
|
849
|
+
|
|
850
|
+
def _on_pressure_kill(self, level: int) -> None:
|
|
851
|
+
"""Called from the pressure-monitor thread when we decide to kill
|
|
852
|
+
our llama-server to protect the system. Writes a breadcrumb file
|
|
853
|
+
so the next launch's health check can tell the user what
|
|
854
|
+
happened, and the TUI can also poll the file to surface the
|
|
855
|
+
event in-session.
|
|
856
|
+
|
|
857
|
+
ALSO clears `self._process` so callers that check `is_running()`
|
|
858
|
+
don't think the server is still alive — the prior version
|
|
859
|
+
forgot this and `_request_with_retry`'s "is_conn_err →
|
|
860
|
+
_restart_server" path could race with `is_running` returning
|
|
861
|
+
True for a process that had already been pressure-killed.
|
|
862
|
+
"""
|
|
863
|
+
killed_pid = self._process.pid if self._process is not None else 0
|
|
864
|
+
if self._process is not None: # breadcrumb: OOM-guard reap, not a crash
|
|
865
|
+
try:
|
|
866
|
+
self._last_exit_code = self._process.poll()
|
|
867
|
+
except Exception:
|
|
868
|
+
pass
|
|
869
|
+
self._last_death_was_pressure = True
|
|
870
|
+
try:
|
|
871
|
+
marker = pressure_kill_marker_path()
|
|
872
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
873
|
+
marker.write_text(f"level={level}\ntime={time.time():.0f}\n")
|
|
874
|
+
except Exception:
|
|
875
|
+
pass
|
|
876
|
+
# Logged DISTINCTLY as `pressure_kill` (OOM-guard reap vs a crash).
|
|
877
|
+
_lifecycle_log("pressure_kill", level=level, pid=killed_pid,
|
|
878
|
+
guard="memory_pressure", exit_code=self._last_exit_code,
|
|
879
|
+
free_mb=_system_free_memory_mb())
|
|
880
|
+
# Clear internal state so callers see "no server" not a phantom Popen.
|
|
881
|
+
# Safe from the pressure-monitor thread (assignment is atomic in CPython).
|
|
882
|
+
self._process = None
|
|
883
|
+
self._model_path = None
|
|
884
|
+
self._verified_model = None
|
|
885
|
+
|
|
886
|
+
def _pick_free_port(self, preferred: int) -> int:
|
|
887
|
+
"""Reclaim `preferred` if we can, otherwise delegate to the
|
|
888
|
+
shared `find_free_port()` selector (scan → ephemeral fallback).
|
|
889
|
+
|
|
890
|
+
If $LOCALCODE_PORT is set, the user has pinned a port and we
|
|
891
|
+
skip the preferred-port kill dance entirely — they want exactly
|
|
892
|
+
that port even if it means waiting for the holder to clear.
|
|
893
|
+
"""
|
|
894
|
+
import os
|
|
895
|
+
if os.environ.get(PORT_ENV_VAR, "").strip():
|
|
896
|
+
return find_free_port(preferred)
|
|
897
|
+
# Try to reclaim the preferred port from any prior holder
|
|
898
|
+
# (our own stale server, a sibling install, etc.). If the
|
|
899
|
+
# holder is D-state and won't die, `find_free_port` will walk
|
|
900
|
+
# to a neighbour or ephemeral — localcode still launches.
|
|
901
|
+
self._kill_port(preferred)
|
|
902
|
+
time.sleep(0.3)
|
|
903
|
+
return find_free_port(preferred)
|
|
904
|
+
|
|
905
|
+
def _kill_port(self, port: int) -> None:
|
|
906
|
+
"""Kill a WEDGED llama-server bound to `port`. Best-effort; silent on
|
|
907
|
+
failure (lsof not installed, permission denied, etc.).
|
|
908
|
+
|
|
909
|
+
Two guards, both learned live (2026-07-12):
|
|
910
|
+
- Only signal a process whose command line is a llama-server —
|
|
911
|
+
this sweep must never SIGKILL an arbitrary process that happens
|
|
912
|
+
to sit on our port (that's the user's dev server, another app…).
|
|
913
|
+
- Never signal a HEALTHY llama-server: it belongs to a live session
|
|
914
|
+
(another terminal). Killing it ends that session's build mid-
|
|
915
|
+
flight; the caller should use port fallback (8082, 8083, …)
|
|
916
|
+
instead. Only an unresponsive listener — the actual "wedged
|
|
917
|
+
zombie blocks every launch until reboot" case this sweep exists
|
|
918
|
+
for — gets killed.
|
|
919
|
+
"""
|
|
920
|
+
if _probe_health(port, timeout=1.0):
|
|
921
|
+
_lifecycle_log("port_kill_skipped_healthy", port=port)
|
|
922
|
+
return
|
|
923
|
+
try:
|
|
924
|
+
r = subprocess.run(
|
|
925
|
+
["lsof", "-ti", f":{port}"],
|
|
926
|
+
capture_output=True, text=True, timeout=3,
|
|
927
|
+
)
|
|
928
|
+
for pid_str in r.stdout.split():
|
|
929
|
+
try:
|
|
930
|
+
pid = int(pid_str)
|
|
931
|
+
except ValueError:
|
|
932
|
+
continue
|
|
933
|
+
try:
|
|
934
|
+
out = subprocess.run(
|
|
935
|
+
["ps", "-p", str(pid), "-o", "command="],
|
|
936
|
+
capture_output=True, text=True, timeout=3,
|
|
937
|
+
).stdout
|
|
938
|
+
if "llama-server" not in out:
|
|
939
|
+
_lifecycle_log("port_kill_skipped_not_ours", port=port, pid=pid)
|
|
940
|
+
continue
|
|
941
|
+
except Exception:
|
|
942
|
+
continue
|
|
943
|
+
try:
|
|
944
|
+
os.kill(pid, signal.SIGKILL)
|
|
945
|
+
except ProcessLookupError:
|
|
946
|
+
pass
|
|
947
|
+
except Exception:
|
|
948
|
+
pass
|
|
949
|
+
except Exception:
|
|
950
|
+
pass
|
|
951
|
+
|
|
952
|
+
def _write_pid_file(self, pid: int) -> None:
|
|
953
|
+
try:
|
|
954
|
+
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
955
|
+
# "pid port" — the port lets the next launch's reaper health-probe
|
|
956
|
+
# the RIGHT server before deciding it's stale (a bare pid forced it
|
|
957
|
+
# to probe the default port and mis-kill fallback-port servers).
|
|
958
|
+
PID_FILE.write_text(f"{pid} {self._port}")
|
|
959
|
+
except Exception:
|
|
960
|
+
# PID file is a recovery aid, not a correctness requirement.
|
|
961
|
+
pass
|
|
962
|
+
|
|
963
|
+
def _wait_healthy(self, port: int, timeout_s: int) -> bool:
|
|
964
|
+
deadline = time.time() + timeout_s
|
|
965
|
+
while time.time() < deadline:
|
|
966
|
+
# If the subprocess already died, fail fast
|
|
967
|
+
with self._lock:
|
|
968
|
+
p = self._process
|
|
969
|
+
if p is not None and p.poll() is not None:
|
|
970
|
+
return False
|
|
971
|
+
if _probe_health(port, timeout=1.0):
|
|
972
|
+
return True
|
|
973
|
+
# Poll at 0.25 s (was 1 s): the server can finish loading at any
|
|
974
|
+
# point in the interval, so a coarse 1 s poll adds up to ~1 s of
|
|
975
|
+
# dead wait to every warm-start's time-to-ready. 0.25 s cuts that
|
|
976
|
+
# to ≤0.25 s; the probe itself is a cheap local /health GET.
|
|
977
|
+
time.sleep(0.25)
|
|
978
|
+
return False
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _probe_health(port: int, timeout: float = 1.0) -> bool:
|
|
982
|
+
"""Single HTTP health probe against the llama-server `/health` endpoint.
|
|
983
|
+
|
|
984
|
+
Returns True **only** when the server responds 200 — i.e. the model is
|
|
985
|
+
fully loaded and inference endpoints (`/v1/chat/completions`) will
|
|
986
|
+
actually serve requests.
|
|
987
|
+
|
|
988
|
+
While the model is loading, llama-server's pre-routing middleware
|
|
989
|
+
intercepts every endpoint (including `/health`) and returns 503
|
|
990
|
+
`{"error":{"message":"Loading model","code":503}}`. Treating 503 as
|
|
991
|
+
"alive" here was the root cause of the E3102 bug: model switch would
|
|
992
|
+
report "Server ready" the instant the HTTP listener came up, then the
|
|
993
|
+
next user message would hit the still-loading model and get its own
|
|
994
|
+
503 back as a user-facing "Lost connection" error.
|
|
995
|
+
|
|
996
|
+
A transport-level failure (connection refused, timeout) also returns
|
|
997
|
+
False — the server may be mid-restart or not yet listening.
|
|
998
|
+
"""
|
|
999
|
+
url = f"http://127.0.0.1:{port}/health"
|
|
1000
|
+
try:
|
|
1001
|
+
with urllib.request.urlopen(url, timeout=timeout) as r:
|
|
1002
|
+
r.read()
|
|
1003
|
+
return r.status == 200
|
|
1004
|
+
except urllib.error.HTTPError:
|
|
1005
|
+
# 503 during load, 5xx on internal errors — not ready either way.
|
|
1006
|
+
return False
|
|
1007
|
+
except Exception:
|
|
1008
|
+
# Connection refused, read timeout — process is still coming up.
|
|
1009
|
+
return False
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def probe_loaded_model(port: int, timeout: float = 2.0) -> Optional[str]:
|
|
1013
|
+
"""Ask the server WHICH MODEL FILE IT ACTUALLY LOADED. None if unknown.
|
|
1014
|
+
|
|
1015
|
+
Why this exists (live evidence, 2026-08-22): the user ran `/model` to
|
|
1016
|
+
switch to Muse Glimmer. The status bar said "model: Muse Glimmer 30B
|
|
1017
|
+
UD-Q8_K_XL", the model claimed to be Muse Glimmer — and the only
|
|
1018
|
+
llama-server on the machine was an hour-old pid serving
|
|
1019
|
+
`Qwen3.8-27B-UD-Q8_K_XL.gguf` on 8081. `/health` says "a server is up",
|
|
1020
|
+
it does NOT say "the server you asked for, with the model you asked
|
|
1021
|
+
for". Nothing in the stack ever asked the second question, so a start()
|
|
1022
|
+
that silently reused a foreign session's server reported "ready".
|
|
1023
|
+
|
|
1024
|
+
Prefer `/props` (`model_path`, an absolute path). Fall back to
|
|
1025
|
+
`/v1/models` (`data[0].id`, usually just the basename). Cheap and
|
|
1026
|
+
total: short timeout, never raises — an unreachable or older server
|
|
1027
|
+
just yields None ("unknown"), which callers treat as unverified
|
|
1028
|
+
rather than as a mismatch.
|
|
1029
|
+
"""
|
|
1030
|
+
base = f"http://127.0.0.1:{port}"
|
|
1031
|
+
try:
|
|
1032
|
+
with urllib.request.urlopen(f"{base}/props", timeout=timeout) as r:
|
|
1033
|
+
if r.status == 200:
|
|
1034
|
+
import json as _json
|
|
1035
|
+
data = _json.loads(r.read().decode("utf-8", "replace"))
|
|
1036
|
+
mp = data.get("model_path") or data.get("model")
|
|
1037
|
+
if isinstance(mp, str) and mp.strip():
|
|
1038
|
+
return mp.strip()
|
|
1039
|
+
except Exception:
|
|
1040
|
+
pass
|
|
1041
|
+
try:
|
|
1042
|
+
with urllib.request.urlopen(f"{base}/v1/models", timeout=timeout) as r:
|
|
1043
|
+
if r.status == 200:
|
|
1044
|
+
import json as _json
|
|
1045
|
+
data = _json.loads(r.read().decode("utf-8", "replace"))
|
|
1046
|
+
entries = data.get("data") or []
|
|
1047
|
+
if entries:
|
|
1048
|
+
mid = entries[0].get("id")
|
|
1049
|
+
if isinstance(mid, str) and mid.strip():
|
|
1050
|
+
return mid.strip()
|
|
1051
|
+
except Exception:
|
|
1052
|
+
pass
|
|
1053
|
+
return None
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def model_identity_matches(requested: str, reported: str) -> bool:
|
|
1057
|
+
"""True if `reported` names the same GGUF as `requested`.
|
|
1058
|
+
|
|
1059
|
+
Compare resolved BASENAMES, case-sensitively: the server reports the
|
|
1060
|
+
path it was launched with, which may be absolute where we hold a
|
|
1061
|
+
relative path (or vice versa), but the filename is the model's
|
|
1062
|
+
identity. Case-sensitive on purpose — `Qwen3.8-27B-UD-Q8_K_XL.gguf`
|
|
1063
|
+
and a differently-cased near-miss are different files on a
|
|
1064
|
+
case-sensitive volume, and silently equating them is the exact class
|
|
1065
|
+
of "wrong model, confident UI" bug this guards.
|
|
1066
|
+
"""
|
|
1067
|
+
if not requested or not reported:
|
|
1068
|
+
return False
|
|
1069
|
+
return Path(str(requested)).name == Path(str(reported)).name
|