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.
Files changed (173) hide show
  1. localcode/__init__.py +11 -0
  2. localcode/__main__.py +5 -0
  3. localcode/_subproc_env.py +54 -0
  4. localcode/agent/__init__.py +177 -0
  5. localcode/agent/app_tasks.py +157 -0
  6. localcode/agent/constants.py +275 -0
  7. localcode/agent/context.py +1152 -0
  8. localcode/agent/goal.py +244 -0
  9. localcode/agent/helpers.py +1036 -0
  10. localcode/agent/hollow_module.py +230 -0
  11. localcode/agent/hooks.py +414 -0
  12. localcode/agent/loop.py +2855 -0
  13. localcode/agent/plateau.py +297 -0
  14. localcode/agent/project_check_gate.py +91 -0
  15. localcode/agent/prompt_context.py +264 -0
  16. localcode/agent/prompts.py +368 -0
  17. localcode/agent/reasoning_loop.py +79 -0
  18. localcode/agent/recovery.py +606 -0
  19. localcode/agent/recovery_controller.py +25 -0
  20. localcode/agent/round_policy.py +23 -0
  21. localcode/agent/sections.py +241 -0
  22. localcode/agent/state_machine.py +100 -0
  23. localcode/agent/streaming.py +324 -0
  24. localcode/agent/tool_execution.py +368 -0
  25. localcode/agent/tool_orchestration.py +54 -0
  26. localcode/agent/turn_finalization.py +135 -0
  27. localcode/app.py +1595 -0
  28. localcode/approvals.py +17 -0
  29. localcode/auto_compact.py +176 -0
  30. localcode/autonomy.py +103 -0
  31. localcode/bin/__init__.py +0 -0
  32. localcode/bin/llama-server +0 -0
  33. localcode/bin/localcode-ui +4 -0
  34. localcode/bootstrap.py +1191 -0
  35. localcode/cache.py +194 -0
  36. localcode/checkpoint.py +375 -0
  37. localcode/compact.py +73 -0
  38. localcode/compaction.py +341 -0
  39. localcode/composer.py +72 -0
  40. localcode/config.py +521 -0
  41. localcode/context.py +161 -0
  42. localcode/display.py +58 -0
  43. localcode/embeddings.py +480 -0
  44. localcode/entrypoint.py +536 -0
  45. localcode/errors.py +350 -0
  46. localcode/events.py +409 -0
  47. localcode/evidence.py +102 -0
  48. localcode/execution_policy.py +48 -0
  49. localcode/features.py +308 -0
  50. localcode/formatting.py +91 -0
  51. localcode/headless_json.py +265 -0
  52. localcode/health.py +288 -0
  53. localcode/hf_quants.py +203 -0
  54. localcode/history.py +594 -0
  55. localcode/indexer.py +144 -0
  56. localcode/injection_defense.py +204 -0
  57. localcode/launcher.py +373 -0
  58. localcode/logging_utils.py +28 -0
  59. localcode/lsp.py +153 -0
  60. localcode/mcp/__init__.py +88 -0
  61. localcode/mcp/_bridge.py +61 -0
  62. localcode/mcp/_config.py +33 -0
  63. localcode/mcp/_transports.py +128 -0
  64. localcode/mcp/client.py +395 -0
  65. localcode/memory_guard.py +291 -0
  66. localcode/model_config.py +288 -0
  67. localcode/model_delete.py +444 -0
  68. localcode/model_families.py +279 -0
  69. localcode/models.py +236 -0
  70. localcode/models_catalog.py +901 -0
  71. localcode/notebook.py +123 -0
  72. localcode/output.py +403 -0
  73. localcode/patching.py +65 -0
  74. localcode/paths.py +303 -0
  75. localcode/performance.py +623 -0
  76. localcode/permissions.py +79 -0
  77. localcode/permissions_v2.py +310 -0
  78. localcode/plans.py +198 -0
  79. localcode/process_registry.py +271 -0
  80. localcode/protocol/__init__.py +56 -0
  81. localcode/protocol/events.py +116 -0
  82. localcode/protocol/jsonl.py +153 -0
  83. localcode/protocol/outcomes.py +231 -0
  84. localcode/reasoning_capabilities.py +59 -0
  85. localcode/recommendations.py +87 -0
  86. localcode/recovery.py +134 -0
  87. localcode/redaction.py +164 -0
  88. localcode/runtime.py +2331 -0
  89. localcode/server_manager.py +1069 -0
  90. localcode/session.py +296 -0
  91. localcode/shell.py +83 -0
  92. localcode/skills/debug.md +21 -0
  93. localcode/skills/edit-verified.md +30 -0
  94. localcode/skills/explain.md +19 -0
  95. localcode/skills/finish-verified.md +11 -0
  96. localcode/skills/git-commit-safely.md +30 -0
  97. localcode/skills/locate.md +21 -0
  98. localcode/skills/plan-task.md +51 -0
  99. localcode/skills/review.md +22 -0
  100. localcode/skills/run-tests.md +24 -0
  101. localcode/skills.py +728 -0
  102. localcode/snapshots.py +195 -0
  103. localcode/sounds.py +50 -0
  104. localcode/telemetry.py +273 -0
  105. localcode/theme.py +137 -0
  106. localcode/thermal.py +160 -0
  107. localcode/thinking.py +114 -0
  108. localcode/tool_router.py +246 -0
  109. localcode/toolkit.py +1241 -0
  110. localcode/tools/__init__.py +400 -0
  111. localcode/tools/agent.py +259 -0
  112. localcode/tools/append_file.py +55 -0
  113. localcode/tools/background_process.py +115 -0
  114. localcode/tools/base.py +168 -0
  115. localcode/tools/bash.py +1160 -0
  116. localcode/tools/code_navigation.py +97 -0
  117. localcode/tools/edit_diff.py +80 -0
  118. localcode/tools/edit_file.py +477 -0
  119. localcode/tools/facts.py +149 -0
  120. localcode/tools/glob_tool.py +60 -0
  121. localcode/tools/grep.py +65 -0
  122. localcode/tools/inspect_symbol.py +266 -0
  123. localcode/tools/launch_app.py +93 -0
  124. localcode/tools/list_files.py +42 -0
  125. localcode/tools/multi_edit.py +167 -0
  126. localcode/tools/plan_mode.py +86 -0
  127. localcode/tools/project_check.py +685 -0
  128. localcode/tools/read_file.py +261 -0
  129. localcode/tools/read_state.py +223 -0
  130. localcode/tools/skill_tool.py +41 -0
  131. localcode/tools/syntax_check.py +216 -0
  132. localcode/tools/todo_write.py +200 -0
  133. localcode/tools/tool_call_repair.py +157 -0
  134. localcode/tools/web_fetch.py +182 -0
  135. localcode/tools/web_search.py +45 -0
  136. localcode/tools/write_file.py +281 -0
  137. localcode/tui/__init__.py +1 -0
  138. localcode/tui/app.py +485 -0
  139. localcode/tui/bridge.py +78 -0
  140. localcode/tui/clipboard_image.py +153 -0
  141. localcode/tui/paste_collapse.py +95 -0
  142. localcode/tui/screens/__init__.py +0 -0
  143. localcode/tui/screens/chat.py +5052 -0
  144. localcode/tui/screens/confirm.py +123 -0
  145. localcode/tui/screens/mode_picker.py +79 -0
  146. localcode/tui/screens/model_picker.py +962 -0
  147. localcode/tui/screens/setup.py +891 -0
  148. localcode/tui/styles/__init__.py +0 -0
  149. localcode/tui/styles/app.tcss +197 -0
  150. localcode/tui/widgets/__init__.py +0 -0
  151. localcode/tui/widgets/approval.py +2 -0
  152. localcode/tui/widgets/chat_log.py +2063 -0
  153. localcode/tui/widgets/messages/__init__.py +21 -0
  154. localcode/tui/widgets/messages/diff.py +287 -0
  155. localcode/tui/widgets/voice_visualizer.py +122 -0
  156. localcode/turn_diff.py +180 -0
  157. localcode/ui/FORK_COMMIT +1 -0
  158. localcode/ui/__init__.py +58 -0
  159. localcode/ui/launch.py +204 -0
  160. localcode/ui/picker_cli.py +165 -0
  161. localcode/ui/plugin/localcode.ts +626 -0
  162. localcode/ui/ports.py +42 -0
  163. localcode/ui/server_cmd.py +89 -0
  164. localcode/ui/supervisor.py +736 -0
  165. localcode/undo.py +121 -0
  166. localcode/verification.py +87 -0
  167. localcode/voice.py +948 -0
  168. localcode-0.4.0.dist-info/METADATA +160 -0
  169. localcode-0.4.0.dist-info/RECORD +173 -0
  170. localcode-0.4.0.dist-info/WHEEL +5 -0
  171. localcode-0.4.0.dist-info/entry_points.txt +3 -0
  172. localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
  173. 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