coderouter-cli 2.9.0__py3-none-any.whl → 2.9.2__py3-none-any.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.
- coderouter/config/schemas.py +579 -3
- coderouter/ingress/app.py +29 -0
- coderouter/ingress/launcher_routes.py +476 -147
- coderouter/launcher_swap.py +466 -0
- coderouter/routing/fallback.py +225 -5
- {coderouter_cli-2.9.0.dist-info → coderouter_cli-2.9.2.dist-info}/METADATA +5 -3
- {coderouter_cli-2.9.0.dist-info → coderouter_cli-2.9.2.dist-info}/RECORD +10 -9
- {coderouter_cli-2.9.0.dist-info → coderouter_cli-2.9.2.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.9.0.dist-info → coderouter_cli-2.9.2.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.9.0.dist-info → coderouter_cli-2.9.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -37,6 +37,7 @@ from dataclasses import dataclass, field
|
|
|
37
37
|
from pathlib import Path
|
|
38
38
|
from typing import Any
|
|
39
39
|
|
|
40
|
+
import httpx
|
|
40
41
|
from fastapi import APIRouter, HTTPException, Request
|
|
41
42
|
from fastapi.responses import HTMLResponse
|
|
42
43
|
from pydantic import BaseModel
|
|
@@ -116,7 +117,10 @@ class ManagedProcess:
|
|
|
116
117
|
port: int
|
|
117
118
|
options: dict[str, Any]
|
|
118
119
|
extra_args: str
|
|
119
|
-
|
|
120
|
+
# "starting" (constructing, pre-spawn) | "loading" (spawned, waiting on
|
|
121
|
+
# the readiness probe) | "running" (readiness confirmed, provider
|
|
122
|
+
# registered) | "stopped" | "error"
|
|
123
|
+
status: str = "starting"
|
|
120
124
|
# MTP / speculative-decoding controls (defaults keep existing call sites
|
|
121
125
|
# working). Recorded for introspection; the resolved flags live in the cmd.
|
|
122
126
|
draft_model_path: str | None = None
|
|
@@ -134,6 +138,39 @@ class ManagedProcess:
|
|
|
134
138
|
mtp_fallback_done: bool = False
|
|
135
139
|
fallback_cmd: list | None = None
|
|
136
140
|
started_at: float = 0.0
|
|
141
|
+
# The exact argv currently in effect (updated when the MTP fallback
|
|
142
|
+
# relaunches without spec tokens). Used to respawn on a generic
|
|
143
|
+
# auto-restart — see ``_attempt_restart``.
|
|
144
|
+
cmd: list = field(default_factory=list)
|
|
145
|
+
# Consecutive generic auto-restart attempts since the last healthy
|
|
146
|
+
# (readiness-confirmed) run. Reset to 0 on success.
|
|
147
|
+
restart_count: int = 0
|
|
148
|
+
# Set by api_stop / shutdown_launcher just before signalling the child.
|
|
149
|
+
# Tells _tail_logs the exit was requested, not a crash, so it neither
|
|
150
|
+
# auto-restarts nor mislabels a SIGTERM/SIGKILL exit as "error".
|
|
151
|
+
stopping: bool = False
|
|
152
|
+
# launcher-model-swap.md §10 Q5: set by _wait_ready_and_register once it
|
|
153
|
+
# reaches a terminal outcome (registered successfully, or gave up —
|
|
154
|
+
# timeout / superseded). SwapManager awaits this directly instead of
|
|
155
|
+
# polling ``status``. Always set exactly once per readiness attempt
|
|
156
|
+
# (see the try/finally in _wait_ready_and_register), so a waiter never
|
|
157
|
+
# hangs past its own timeout even on the rare "resolved before the
|
|
158
|
+
# first probe" bail-out path.
|
|
159
|
+
ready: asyncio.Event = field(default_factory=asyncio.Event, repr=False, compare=False)
|
|
160
|
+
# True when this process was spawned by the SwapManager
|
|
161
|
+
# (coderouter/launcher_swap.py) rather than the manual UI. Two effects:
|
|
162
|
+
# * H-1: _wait_ready_and_register skips the GENERIC provider
|
|
163
|
+
# registration ('launcher-<backend>-<port>' into the shared
|
|
164
|
+
# "launcher" profile). SwapManager does its own registration under
|
|
165
|
+
# 'launcher-swap-<name>' and its TTL unload can only deregister
|
|
166
|
+
# that one — the generic entry would leak a dead-port provider +
|
|
167
|
+
# cached adapter on every TTL cycle (unbounded with ephemeral
|
|
168
|
+
# ports).
|
|
169
|
+
# * H-2: _attempt_restart never touches a swap-managed process —
|
|
170
|
+
# crash recovery is SwapManager's job (the next request re-spawns
|
|
171
|
+
# under its per-model lock); a launcher auto-restart racing that
|
|
172
|
+
# re-spawn would fight over the same fixed port.
|
|
173
|
+
swap_managed: bool = False
|
|
137
174
|
# asyncio subprocess handle — not serialised
|
|
138
175
|
_proc: Any = field(default=None, repr=False, compare=False)
|
|
139
176
|
|
|
@@ -165,11 +202,23 @@ class LauncherRegistry:
|
|
|
165
202
|
return list(self._procs.values())
|
|
166
203
|
|
|
167
204
|
|
|
205
|
+
def _registry_for_app(app: Any) -> LauncherRegistry:
|
|
206
|
+
"""Get or create the LauncherRegistry on ``app.state``.
|
|
207
|
+
|
|
208
|
+
Split out from :func:`_registry` so non-HTTP callers (SwapManager,
|
|
209
|
+
coderouter/launcher_swap.py) that only hold the FastAPI ``app`` —
|
|
210
|
+
not a ``Request`` — can reach the same registry that manual
|
|
211
|
+
``/api/launcher/start`` launches use. Both paths must land in one
|
|
212
|
+
registry so swap-managed processes show up in the Launcher UI too.
|
|
213
|
+
"""
|
|
214
|
+
if not hasattr(app.state, "launcher"):
|
|
215
|
+
app.state.launcher = LauncherRegistry()
|
|
216
|
+
return app.state.launcher
|
|
217
|
+
|
|
218
|
+
|
|
168
219
|
def _registry(request: Request) -> LauncherRegistry:
|
|
169
220
|
"""Get or create the LauncherRegistry on app.state."""
|
|
170
|
-
|
|
171
|
-
request.app.state.launcher = LauncherRegistry()
|
|
172
|
-
return request.app.state.launcher
|
|
221
|
+
return _registry_for_app(request.app)
|
|
173
222
|
|
|
174
223
|
|
|
175
224
|
# ---------------------------------------------------------------------------
|
|
@@ -544,12 +593,230 @@ def _should_mtp_fallback(proc: ManagedProcess) -> bool:
|
|
|
544
593
|
)
|
|
545
594
|
|
|
546
595
|
|
|
547
|
-
|
|
596
|
+
# ---------------------------------------------------------------------------
|
|
597
|
+
# Readiness gating — hole #2: a launcher-started backend used to be
|
|
598
|
+
# registered as a routable provider the instant the OS process spawned, well
|
|
599
|
+
# before llama-server / vllm had finished loading the model into memory.
|
|
600
|
+
# Requests routed there during load failed (connection refused before the
|
|
601
|
+
# HTTP listener is up, or a 503 once it is). Backends are now polled for
|
|
602
|
+
# readiness and only registered once they answer, or marked "error" (never
|
|
603
|
+
# registered) if they don't within ``readiness_timeout_s``.
|
|
604
|
+
# ---------------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
_DEFAULT_READINESS_TIMEOUT_S = 300.0
|
|
607
|
+
_DEFAULT_READINESS_POLL_INTERVAL_S = 2.0
|
|
608
|
+
# Per-probe network timeout — deliberately much shorter than the poll
|
|
609
|
+
# interval so a single stuck probe cannot stall the loading→error deadline.
|
|
610
|
+
_READINESS_PROBE_TIMEOUT_S = 3.0
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
async def _backend_ready(backend: str, port: int, *, probe_timeout_s: float) -> bool:
|
|
614
|
+
"""Best-effort single readiness probe. Never raises.
|
|
615
|
+
|
|
616
|
+
llama.cpp and vllm both expose ``GET /health`` (200 once the model is
|
|
617
|
+
loaded and the server is accepting requests; llama.cpp returns 503
|
|
618
|
+
while still loading). Other backends (mlx — mlx_lm.server has no
|
|
619
|
+
documented health endpoint) fall back to a bare TCP connect: it can't
|
|
620
|
+
distinguish "loaded" from "listening", but it is a strict improvement
|
|
621
|
+
over registering the provider before the port is even open.
|
|
622
|
+
"""
|
|
623
|
+
if backend in ("llama.cpp", "vllm"):
|
|
624
|
+
try:
|
|
625
|
+
async with httpx.AsyncClient(timeout=probe_timeout_s) as client:
|
|
626
|
+
resp = await client.get(f"http://localhost:{port}/health")
|
|
627
|
+
return resp.status_code == 200
|
|
628
|
+
except Exception:
|
|
629
|
+
return False
|
|
630
|
+
|
|
631
|
+
try:
|
|
632
|
+
_reader, writer = await asyncio.wait_for(
|
|
633
|
+
asyncio.open_connection("127.0.0.1", port), timeout=probe_timeout_s
|
|
634
|
+
)
|
|
635
|
+
except Exception:
|
|
636
|
+
return False
|
|
637
|
+
writer.close()
|
|
638
|
+
with contextlib.suppress(Exception):
|
|
639
|
+
await writer.wait_closed()
|
|
640
|
+
return True
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _register_provider(proc: ManagedProcess, app: Any) -> dict[str, Any] | None:
|
|
644
|
+
"""Register ``proc`` as a routable provider once it is confirmed ready.
|
|
645
|
+
|
|
646
|
+
Shared by the initial spawn, the MTP fallback relaunch, and generic
|
|
647
|
+
auto-restart — every path that brings a backend up must go through the
|
|
648
|
+
same readiness→register sequence. Failure never raises (mirrors the
|
|
649
|
+
original inline try/except in ``api_start``).
|
|
650
|
+
"""
|
|
651
|
+
engine = getattr(app.state, "engine", None)
|
|
652
|
+
if engine is None or not hasattr(engine, "register_provider"):
|
|
653
|
+
return None
|
|
654
|
+
try:
|
|
655
|
+
summary = engine.register_provider(
|
|
656
|
+
_launcher_provider_config(proc.backend, proc.port)
|
|
657
|
+
)
|
|
658
|
+
proc.log_tail.append(
|
|
659
|
+
"[launcher] provider sync: "
|
|
660
|
+
f"{summary['provider']} -> profile '{summary['profile']}' (in-memory)"
|
|
661
|
+
)
|
|
662
|
+
return summary
|
|
663
|
+
except Exception as exc:
|
|
664
|
+
logger.warning(
|
|
665
|
+
"launcher provider sync failed",
|
|
666
|
+
extra={"backend": proc.backend, "port": proc.port, "error": str(exc)},
|
|
667
|
+
)
|
|
668
|
+
proc.log_tail.append(f"[launcher] provider sync failed: {exc}")
|
|
669
|
+
return None
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
async def _wait_ready_and_register(
|
|
673
|
+
proc: ManagedProcess, app: Any, launcher_cfg: Any
|
|
674
|
+
) -> None:
|
|
675
|
+
"""Poll ``proc`` for readiness, then register it — or time out to 'error'.
|
|
676
|
+
|
|
677
|
+
Runs as an independent background task per spawn (initial, MTP
|
|
678
|
+
fallback, or auto-restart). Bails out silently — without touching
|
|
679
|
+
``proc.status`` — the moment the process is no longer in a
|
|
680
|
+
loading-eligible state (crashed, stopped, or already resolved by a
|
|
681
|
+
concurrent readiness task), so a fast crash never races a stale
|
|
682
|
+
registration in after the fact.
|
|
683
|
+
|
|
684
|
+
launcher-model-swap.md §10 Q5: ``proc.ready`` is cleared on entry
|
|
685
|
+
(so a respawn — MTP fallback / auto-restart reusing the same
|
|
686
|
+
``ManagedProcess`` — starts a fresh readiness cycle instead of
|
|
687
|
+
reusing a stale "done" signal) and is *always* set exactly once via
|
|
688
|
+
the ``finally`` below, on every exit path (registered, timed out, or
|
|
689
|
+
bailed out early). Callers that just want to know "is this attempt
|
|
690
|
+
over" (e.g. SwapManager) can therefore plain-``await proc.ready.wait()``
|
|
691
|
+
with their own timeout, no polling of ``proc.status`` needed.
|
|
692
|
+
"""
|
|
693
|
+
proc.ready.clear()
|
|
694
|
+
try:
|
|
695
|
+
timeout_s = getattr(launcher_cfg, "readiness_timeout_s", _DEFAULT_READINESS_TIMEOUT_S)
|
|
696
|
+
poll_interval_s = getattr(
|
|
697
|
+
launcher_cfg, "readiness_poll_interval_s", _DEFAULT_READINESS_POLL_INTERVAL_S
|
|
698
|
+
)
|
|
699
|
+
deadline = time.monotonic() + timeout_s
|
|
700
|
+
|
|
701
|
+
while time.monotonic() < deadline:
|
|
702
|
+
if proc._proc is None or proc.status not in ("starting", "loading"):
|
|
703
|
+
return
|
|
704
|
+
if await _backend_ready(proc.backend, proc.port, probe_timeout_s=_READINESS_PROBE_TIMEOUT_S):
|
|
705
|
+
if proc.status not in ("starting", "loading"):
|
|
706
|
+
return # crashed / stopped while the last probe was in flight
|
|
707
|
+
proc.status = "running"
|
|
708
|
+
proc.restart_count = 0
|
|
709
|
+
proc.log_tail.append("[launcher] readiness check passed")
|
|
710
|
+
if proc.swap_managed:
|
|
711
|
+
# H-1: a swap-spawned backend must NOT also be
|
|
712
|
+
# registered under the generic port-based name into
|
|
713
|
+
# the shared "launcher" profile — SwapManager
|
|
714
|
+
# registers (and, on TTL unload, deregisters) its own
|
|
715
|
+
# 'launcher-swap-<name>' provider; a second, generic
|
|
716
|
+
# registration would outlive every unload as a
|
|
717
|
+
# dead-port provider. Readiness confirmation and the
|
|
718
|
+
# ready-Event signal (finally below) are unchanged.
|
|
719
|
+
proc.log_tail.append(
|
|
720
|
+
"[launcher] swap-managed: generic provider "
|
|
721
|
+
"registration skipped (SwapManager registers its own)"
|
|
722
|
+
)
|
|
723
|
+
else:
|
|
724
|
+
_register_provider(proc, app)
|
|
725
|
+
return
|
|
726
|
+
await asyncio.sleep(poll_interval_s)
|
|
727
|
+
|
|
728
|
+
if proc.status in ("starting", "loading"):
|
|
729
|
+
proc.status = "error"
|
|
730
|
+
proc.log_tail.append(
|
|
731
|
+
f"[launcher] readiness check timed out after {timeout_s:.0f}s "
|
|
732
|
+
"— process left running but NOT registered as a provider"
|
|
733
|
+
)
|
|
734
|
+
finally:
|
|
735
|
+
proc.ready.set()
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
# ---------------------------------------------------------------------------
|
|
739
|
+
# Generic auto-restart — hole #1: besides the one-shot MTP startup-crash
|
|
740
|
+
# fallback above, a launcher-started backend that crashed was left in
|
|
741
|
+
# status="error" forever with no supervision. Opt-in via
|
|
742
|
+
# ``LauncherConfig.auto_restart`` (see schemas.py for the default rationale).
|
|
743
|
+
# ---------------------------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
async def _attempt_restart(proc: ManagedProcess, launcher_cfg: Any) -> bool:
|
|
747
|
+
"""Respawn ``proc.cmd`` after a crash. Returns True iff the child started.
|
|
748
|
+
|
|
749
|
+
Backed off exponentially and capped at ``auto_restart_max_attempts``;
|
|
750
|
+
the counter is reset to 0 by ``_wait_ready_and_register`` on the next
|
|
751
|
+
readiness success, so a backend that stabilizes gets a fresh budget.
|
|
752
|
+
"""
|
|
753
|
+
if proc.swap_managed:
|
|
754
|
+
# H-2: swap-managed processes are supervised by SwapManager alone —
|
|
755
|
+
# a crashed one is re-spawned by the NEXT request for its model,
|
|
756
|
+
# under the manager's per-model lock. A concurrent launcher
|
|
757
|
+
# auto-restart would race that re-spawn for the same (typically
|
|
758
|
+
# fixed) port. §10 Q4's single-supervisor rule, extended to the
|
|
759
|
+
# crash path.
|
|
760
|
+
return False
|
|
761
|
+
if not getattr(launcher_cfg, "auto_restart", False):
|
|
762
|
+
return False
|
|
763
|
+
max_attempts = getattr(launcher_cfg, "auto_restart_max_attempts", 3)
|
|
764
|
+
if proc.restart_count >= max_attempts:
|
|
765
|
+
proc.log_tail.append(
|
|
766
|
+
f"[launcher] auto-restart exhausted ({proc.restart_count}/{max_attempts} "
|
|
767
|
+
"attempts); giving up"
|
|
768
|
+
)
|
|
769
|
+
return False
|
|
770
|
+
if not proc.cmd:
|
|
771
|
+
return False # nothing to relaunch (should not happen in practice)
|
|
772
|
+
|
|
773
|
+
base = getattr(launcher_cfg, "auto_restart_backoff_s", 2.0)
|
|
774
|
+
cap = getattr(launcher_cfg, "auto_restart_backoff_max_s", 30.0)
|
|
775
|
+
backoff = min(base * (2**proc.restart_count), cap)
|
|
776
|
+
proc.restart_count += 1
|
|
777
|
+
proc.log_tail.append(
|
|
778
|
+
f"[launcher] auto-restart attempt {proc.restart_count}/{max_attempts} "
|
|
779
|
+
f"in {backoff:.1f}s"
|
|
780
|
+
)
|
|
781
|
+
await asyncio.sleep(backoff)
|
|
782
|
+
|
|
783
|
+
if proc.stopping:
|
|
784
|
+
# Stopped while we were waiting out the backoff — respect it.
|
|
785
|
+
return False
|
|
786
|
+
|
|
787
|
+
try:
|
|
788
|
+
p = await asyncio.create_subprocess_exec(
|
|
789
|
+
*proc.cmd,
|
|
790
|
+
stdout=asyncio.subprocess.PIPE,
|
|
791
|
+
stderr=asyncio.subprocess.PIPE,
|
|
792
|
+
limit=_LOG_STREAM_LIMIT,
|
|
793
|
+
)
|
|
794
|
+
except (FileNotFoundError, OSError) as exc:
|
|
795
|
+
proc.log_tail.append(f"[launcher] auto-restart failed: {exc}")
|
|
796
|
+
proc.status = "error"
|
|
797
|
+
return False
|
|
798
|
+
|
|
799
|
+
proc._proc = p
|
|
800
|
+
proc.pid = p.pid
|
|
801
|
+
proc.returncode = None
|
|
802
|
+
proc.started_at = time.monotonic()
|
|
803
|
+
proc.log_tail.append(f"[launcher] auto-restart started PID {p.pid}")
|
|
804
|
+
return True
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
async def _tail_logs(
|
|
808
|
+
proc: ManagedProcess, *, app: Any = None, launcher_cfg: Any = None
|
|
809
|
+
) -> None:
|
|
548
810
|
"""Read stdout+stderr into proc.log_tail until the process exits.
|
|
549
811
|
|
|
550
812
|
Loops so that a one-shot MTP fallback (relaunch without the auto-detected
|
|
551
|
-
speculative flags) can be tailed by the same
|
|
552
|
-
|
|
813
|
+
speculative flags) and generic auto-restart can be tailed by the same
|
|
814
|
+
task. The loop exits once the process is intentionally stopped, exits
|
|
815
|
+
cleanly, or no further retry applies.
|
|
816
|
+
|
|
817
|
+
``app`` / ``launcher_cfg`` are optional so unit tests can drive this
|
|
818
|
+
function directly against a fake process without a FastAPI app (the
|
|
819
|
+
readiness/register and auto-restart machinery is then simply inert).
|
|
553
820
|
"""
|
|
554
821
|
|
|
555
822
|
async def _drain(stream: asyncio.StreamReader | None) -> None:
|
|
@@ -576,6 +843,16 @@ async def _tail_logs(proc: ManagedProcess) -> None:
|
|
|
576
843
|
break
|
|
577
844
|
proc.log_tail.append(line.decode(errors="replace").rstrip())
|
|
578
845
|
|
|
846
|
+
def _spawn_readiness_task() -> None:
|
|
847
|
+
"""Kick off (or re-kick after a respawn) the readiness→register task."""
|
|
848
|
+
if app is None:
|
|
849
|
+
return
|
|
850
|
+
task = asyncio.create_task(_wait_ready_and_register(proc, app, launcher_cfg))
|
|
851
|
+
_background_tasks.add(task)
|
|
852
|
+
task.add_done_callback(_background_tasks.discard)
|
|
853
|
+
|
|
854
|
+
_spawn_readiness_task() # covers the initial spawn done by api_start
|
|
855
|
+
|
|
579
856
|
while True:
|
|
580
857
|
p = proc._proc
|
|
581
858
|
if p is None:
|
|
@@ -585,44 +862,59 @@ async def _tail_logs(proc: ManagedProcess) -> None:
|
|
|
585
862
|
await p.wait()
|
|
586
863
|
proc.returncode = p.returncode
|
|
587
864
|
proc.pid = None
|
|
588
|
-
proc.status = "stopped" if (p.returncode or 0) == 0 else "error"
|
|
589
865
|
proc.log_tail.append(
|
|
590
866
|
f"[launcher] process exited with code {p.returncode}"
|
|
591
867
|
)
|
|
592
868
|
|
|
593
|
-
if
|
|
869
|
+
if proc.stopping:
|
|
870
|
+
# Intentional stop (api_stop / shutdown_launcher). Never a crash
|
|
871
|
+
# to heal, regardless of the exit code SIGTERM/SIGKILL produced.
|
|
872
|
+
proc.status = "stopped"
|
|
594
873
|
return
|
|
595
874
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
try:
|
|
609
|
-
p = await asyncio.create_subprocess_exec(
|
|
610
|
-
*proc.fallback_cmd,
|
|
611
|
-
stdout=asyncio.subprocess.PIPE,
|
|
612
|
-
stderr=asyncio.subprocess.PIPE,
|
|
613
|
-
limit=_LOG_STREAM_LIMIT,
|
|
875
|
+
proc.status = "stopped" if (p.returncode or 0) == 0 else "error"
|
|
876
|
+
|
|
877
|
+
if _should_mtp_fallback(proc):
|
|
878
|
+
# Auto-detected MTP crashed during startup — retry ONCE without
|
|
879
|
+
# the speculative flags. The port is unchanged; readiness is
|
|
880
|
+
# re-armed below so the relaunch is gated exactly like the
|
|
881
|
+
# initial spawn before anything routes to it.
|
|
882
|
+
rc = proc.returncode
|
|
883
|
+
proc.mtp_fallback_done = True
|
|
884
|
+
proc.log_tail.append(
|
|
885
|
+
f"[launcher] MTP startup failure detected (exit code {rc}); "
|
|
886
|
+
"retrying without speculative decoding"
|
|
614
887
|
)
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
888
|
+
proc.log_tail.append(
|
|
889
|
+
f"[launcher] cmd: {' '.join(proc.fallback_cmd)}"
|
|
890
|
+
)
|
|
891
|
+
try:
|
|
892
|
+
p = await asyncio.create_subprocess_exec(
|
|
893
|
+
*proc.fallback_cmd,
|
|
894
|
+
stdout=asyncio.subprocess.PIPE,
|
|
895
|
+
stderr=asyncio.subprocess.PIPE,
|
|
896
|
+
limit=_LOG_STREAM_LIMIT,
|
|
897
|
+
)
|
|
898
|
+
except (FileNotFoundError, OSError) as exc:
|
|
899
|
+
proc.log_tail.append(f"[launcher] fallback relaunch failed: {exc}")
|
|
900
|
+
proc.status = "error"
|
|
901
|
+
return
|
|
902
|
+
proc.cmd = proc.fallback_cmd # future auto-restarts reuse this argv
|
|
903
|
+
proc._proc = p
|
|
904
|
+
proc.pid = p.pid
|
|
905
|
+
proc.status = "loading"
|
|
906
|
+
proc.returncode = None
|
|
907
|
+
proc.started_at = time.monotonic()
|
|
908
|
+
proc.log_tail.append(f"[launcher] started PID {p.pid}")
|
|
909
|
+
_spawn_readiness_task()
|
|
910
|
+
continue # tail the relaunched process
|
|
911
|
+
|
|
912
|
+
if proc.returncode not in (0, None) and await _attempt_restart(proc, launcher_cfg):
|
|
913
|
+
proc.status = "loading"
|
|
914
|
+
_spawn_readiness_task()
|
|
915
|
+
continue # tail the restarted process
|
|
916
|
+
|
|
917
|
+
return
|
|
626
918
|
|
|
627
919
|
|
|
628
920
|
async def shutdown_launcher(app: Any) -> None:
|
|
@@ -636,6 +928,9 @@ async def shutdown_launcher(app: Any) -> None:
|
|
|
636
928
|
return
|
|
637
929
|
procs = reg.all()
|
|
638
930
|
for proc in procs:
|
|
931
|
+
# Mark intentional before signalling: _tail_logs must not treat a
|
|
932
|
+
# shutdown-triggered SIGTERM/SIGKILL as a crash to auto-restart.
|
|
933
|
+
proc.stopping = True
|
|
639
934
|
p = proc._proc
|
|
640
935
|
if p is not None and p.returncode is None:
|
|
641
936
|
with contextlib.suppress(Exception):
|
|
@@ -792,76 +1087,75 @@ def _launcher_provider_config(backend: str, port: int) -> Any:
|
|
|
792
1087
|
)
|
|
793
1088
|
|
|
794
1089
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1090
|
+
async def spawn_process(
|
|
1091
|
+
app: Any,
|
|
1092
|
+
launcher_cfg: Any,
|
|
1093
|
+
*,
|
|
1094
|
+
name: str,
|
|
1095
|
+
backend: str,
|
|
1096
|
+
model_path: str,
|
|
1097
|
+
port: int,
|
|
1098
|
+
options: dict[str, Any] | None = None,
|
|
1099
|
+
extra_args: str = "",
|
|
1100
|
+
draft_model_path: str | None = None,
|
|
1101
|
+
mtp_mode: str = "auto",
|
|
1102
|
+
swap_managed: bool = False,
|
|
1103
|
+
) -> ManagedProcess:
|
|
1104
|
+
"""Build argv, spawn the child process, and arm readiness/log tailing.
|
|
1105
|
+
|
|
1106
|
+
Extracted from (and still used by) ``POST /api/launcher/start`` so
|
|
1107
|
+
``SwapManager`` (coderouter/launcher_swap.py, launcher-model-swap.md
|
|
1108
|
+
§4.4) can spawn an on-demand backend through the exact same
|
|
1109
|
+
command-building, model-override guard, and readiness machinery —
|
|
1110
|
+
no parallel spawn path exists that could bypass
|
|
1111
|
+
``_assert_no_model_override`` / the per-backend argv shape.
|
|
1112
|
+
|
|
1113
|
+
Raises ``ValueError`` on bad input (bad ``mtp_mode``, unbalanced
|
|
1114
|
+
``extra_args`` quoting, a rejected model-override flag, an unknown
|
|
1115
|
+
backend) and ``FileNotFoundError`` when the resolved binary doesn't
|
|
1116
|
+
exist. Any other exception from ``create_subprocess_exec`` propagates
|
|
1117
|
+
as-is. Callers decide how to surface these (HTTP 400/500 for
|
|
1118
|
+
``api_start``, a retryable ``AdapterError`` for SwapManager).
|
|
1119
|
+
"""
|
|
1120
|
+
options = options if options is not None else {}
|
|
802
1121
|
configured_binary: str | None = None
|
|
803
|
-
if launcher_cfg and launcher_cfg
|
|
804
|
-
bc = launcher_cfg.backends.get(
|
|
1122
|
+
if launcher_cfg and getattr(launcher_cfg, "backends", None):
|
|
1123
|
+
bc = launcher_cfg.backends.get(backend)
|
|
805
1124
|
if bc and bc.binary:
|
|
806
1125
|
configured_binary = bc.binary
|
|
807
1126
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
raise HTTPException(
|
|
811
|
-
status_code=400,
|
|
812
|
-
detail=f"mtp_mode must be 'auto' or 'off', got {req.mtp_mode!r}.",
|
|
813
|
-
)
|
|
1127
|
+
if mtp_mode not in ("auto", "off"):
|
|
1128
|
+
raise ValueError(f"mtp_mode must be 'auto' or 'off', got {mtp_mode!r}.")
|
|
814
1129
|
|
|
815
1130
|
# Build the user token list once (options flags + extra_args) so
|
|
816
1131
|
# resolve_speculative sees exactly what _build_cmd will emit. shlex.split
|
|
817
|
-
# can raise on unbalanced quotes
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
user_tokens += shlex.split(req.extra_args)
|
|
822
|
-
except ValueError as exc:
|
|
823
|
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
1132
|
+
# can raise ValueError on unbalanced quotes — let it propagate.
|
|
1133
|
+
user_tokens = _option_tokens(options)
|
|
1134
|
+
if extra_args.strip():
|
|
1135
|
+
user_tokens += shlex.split(extra_args)
|
|
824
1136
|
|
|
825
1137
|
# Resolve MTP / speculative-decoding flags (llama.cpp only; no-op elsewhere).
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
req.model_path,
|
|
830
|
-
req.draft_model_path,
|
|
831
|
-
req.mtp_mode,
|
|
832
|
-
user_tokens,
|
|
833
|
-
)
|
|
834
|
-
except ValueError as exc:
|
|
835
|
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
1138
|
+
spec_tokens, spec_notes = resolve_speculative(
|
|
1139
|
+
backend, model_path, draft_model_path, mtp_mode, user_tokens,
|
|
1140
|
+
)
|
|
836
1141
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
binary=configured_binary,
|
|
842
|
-
spec_tokens=spec_tokens,
|
|
843
|
-
)
|
|
844
|
-
except ValueError as exc:
|
|
845
|
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
1142
|
+
cmd = _build_cmd(
|
|
1143
|
+
backend, model_path, port, options, extra_args,
|
|
1144
|
+
binary=configured_binary, spec_tokens=spec_tokens,
|
|
1145
|
+
)
|
|
846
1146
|
|
|
847
1147
|
# Speculative flags qualify for the one-shot startup-crash fallback only
|
|
848
1148
|
# when they came from AUTO detection (mtp_mode="auto", no explicit draft
|
|
849
1149
|
# model, and detection actually emitted flags). Explicit draft paths and
|
|
850
1150
|
# operator-supplied --spec-type are never auto-retried. The fallback cmd
|
|
851
1151
|
# is rebuilt from scratch with spec_tokens=None (exact — never spliced).
|
|
852
|
-
spec_auto = (
|
|
853
|
-
req.mtp_mode == "auto"
|
|
854
|
-
and req.draft_model_path is None
|
|
855
|
-
and bool(spec_tokens)
|
|
856
|
-
)
|
|
1152
|
+
spec_auto = mtp_mode == "auto" and draft_model_path is None and bool(spec_tokens)
|
|
857
1153
|
fallback_cmd: list[str] | None = None
|
|
858
1154
|
if spec_auto:
|
|
859
1155
|
try:
|
|
860
1156
|
fallback_cmd = _build_cmd(
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
binary=configured_binary,
|
|
864
|
-
spec_tokens=None,
|
|
1157
|
+
backend, model_path, port, options, extra_args,
|
|
1158
|
+
binary=configured_binary, spec_tokens=None,
|
|
865
1159
|
)
|
|
866
1160
|
except ValueError:
|
|
867
1161
|
# A failure to rebuild the non-spec command simply disables the
|
|
@@ -871,18 +1165,19 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
|
|
|
871
1165
|
proc_id = uuid.uuid4().hex[:8]
|
|
872
1166
|
proc = ManagedProcess(
|
|
873
1167
|
id=proc_id,
|
|
874
|
-
name=
|
|
875
|
-
backend=
|
|
876
|
-
model_path=
|
|
877
|
-
port=
|
|
878
|
-
options=
|
|
879
|
-
extra_args=
|
|
880
|
-
draft_model_path=
|
|
881
|
-
mtp_mode=
|
|
1168
|
+
name=name,
|
|
1169
|
+
backend=backend,
|
|
1170
|
+
model_path=model_path,
|
|
1171
|
+
port=port,
|
|
1172
|
+
options=options,
|
|
1173
|
+
extra_args=extra_args,
|
|
1174
|
+
draft_model_path=draft_model_path,
|
|
1175
|
+
mtp_mode=mtp_mode,
|
|
882
1176
|
status="starting",
|
|
883
1177
|
spec_tokens=spec_tokens,
|
|
884
1178
|
spec_auto=spec_auto and fallback_cmd is not None,
|
|
885
1179
|
fallback_cmd=fallback_cmd,
|
|
1180
|
+
swap_managed=swap_managed,
|
|
886
1181
|
)
|
|
887
1182
|
proc.log_tail.append(f"[launcher] cmd: {' '.join(cmd)}")
|
|
888
1183
|
for note in spec_notes:
|
|
@@ -898,67 +1193,53 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
|
|
|
898
1193
|
limit=_LOG_STREAM_LIMIT,
|
|
899
1194
|
)
|
|
900
1195
|
except FileNotFoundError:
|
|
901
|
-
raise
|
|
902
|
-
|
|
903
|
-
detail=f"Executable not found: {cmd[0]!r}. Is {req.backend} installed?",
|
|
1196
|
+
raise FileNotFoundError(
|
|
1197
|
+
f"Executable not found: {cmd[0]!r}. Is {backend} installed?"
|
|
904
1198
|
) from None
|
|
905
|
-
except Exception as exc:
|
|
906
|
-
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
907
1199
|
|
|
908
1200
|
proc._proc = p
|
|
909
1201
|
proc.pid = p.pid
|
|
910
|
-
proc.
|
|
1202
|
+
proc.cmd = cmd
|
|
1203
|
+
# H2 (readiness gating): status is "loading", not "running", until the
|
|
1204
|
+
# background readiness probe (armed by _tail_logs below) confirms the
|
|
1205
|
+
# backend is actually serving — see _wait_ready_and_register. Provider
|
|
1206
|
+
# registration happens there too, once ready, instead of synchronously
|
|
1207
|
+
# here (registering before the model finishes loading is exactly the
|
|
1208
|
+
# bug this closes: requests would route to a backend that isn't up yet).
|
|
1209
|
+
proc.status = "loading"
|
|
911
1210
|
proc.started_at = time.monotonic()
|
|
912
1211
|
proc.log_tail.append(f"[launcher] started PID {p.pid}")
|
|
913
1212
|
|
|
914
|
-
|
|
915
|
-
_task = asyncio.create_task(_tail_logs(proc))
|
|
1213
|
+
_registry_for_app(app).add(proc)
|
|
1214
|
+
_task = asyncio.create_task(_tail_logs(proc, app=app, launcher_cfg=launcher_cfg))
|
|
916
1215
|
_background_tasks.add(_task)
|
|
917
1216
|
_task.add_done_callback(_background_tasks.discard)
|
|
918
1217
|
|
|
919
|
-
|
|
920
|
-
# provider so requests can reach it without hand-editing providers.yaml
|
|
921
|
-
# (in-memory only — see FallbackEngine.register_provider docstring).
|
|
922
|
-
# Sync failure must never fail the start itself.
|
|
923
|
-
provider_sync: dict[str, Any] | None = None
|
|
924
|
-
engine = getattr(request.app.state, "engine", None)
|
|
925
|
-
if engine is not None and hasattr(engine, "register_provider"):
|
|
926
|
-
try:
|
|
927
|
-
provider_sync = engine.register_provider(
|
|
928
|
-
_launcher_provider_config(req.backend, req.port)
|
|
929
|
-
)
|
|
930
|
-
proc.log_tail.append(
|
|
931
|
-
"[launcher] provider sync: "
|
|
932
|
-
f"{provider_sync['provider']} -> profile "
|
|
933
|
-
f"'{provider_sync['profile']}' (in-memory)"
|
|
934
|
-
)
|
|
935
|
-
except Exception as exc:
|
|
936
|
-
logger.warning(
|
|
937
|
-
"launcher provider sync failed",
|
|
938
|
-
extra={"backend": req.backend, "port": req.port, "error": str(exc)},
|
|
939
|
-
)
|
|
940
|
-
proc.log_tail.append(f"[launcher] provider sync failed: {exc}")
|
|
1218
|
+
return proc
|
|
941
1219
|
|
|
942
|
-
return {
|
|
943
|
-
"id": proc_id,
|
|
944
|
-
"pid": p.pid,
|
|
945
|
-
"command": cmd,
|
|
946
|
-
"provider_sync": provider_sync,
|
|
947
|
-
"speculative": spec_tokens,
|
|
948
|
-
}
|
|
949
1220
|
|
|
1221
|
+
async def stop_process(app: Any, proc_id: str) -> ManagedProcess:
|
|
1222
|
+
"""Terminate a managed process (SIGTERM, then SIGKILL after 5s).
|
|
950
1223
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
proc = _registry(request).get(proc_id)
|
|
957
|
-
except KeyError:
|
|
958
|
-
raise HTTPException(
|
|
959
|
-
status_code=404, detail=f"Process {proc_id!r} not found.") from None
|
|
1224
|
+
Extracted from (and still used by) ``POST /api/launcher/stop/{id}``
|
|
1225
|
+
so SwapManager's TTL sweeper can use the identical stop sequence —
|
|
1226
|
+
including setting ``stopping = True`` first, which is what makes a
|
|
1227
|
+
TTL unload an *intentional* stop that launcher auto-restart (when
|
|
1228
|
+
enabled) never treats as a crash to heal (§10 Q4).
|
|
960
1229
|
|
|
961
|
-
|
|
1230
|
+
Raises ``KeyError`` when ``proc_id`` isn't in the registry. Idempotent
|
|
1231
|
+
on an already-stopped process (no-op signal-wise; returns its current
|
|
1232
|
+
status).
|
|
1233
|
+
"""
|
|
1234
|
+
proc = _registry_for_app(app).get(proc_id) # raises KeyError
|
|
1235
|
+
|
|
1236
|
+
# "loading"/"starting": the readiness probe hasn't confirmed the backend
|
|
1237
|
+
# yet, but the OS process is alive and stoppable — must be included here
|
|
1238
|
+
# or a slow-loading model could never be cancelled from the UI.
|
|
1239
|
+
if proc._proc and proc.status in ("running", "loading", "starting"):
|
|
1240
|
+
# Intentional stop: tell _tail_logs so it neither auto-restarts nor
|
|
1241
|
+
# mislabels whatever exit code SIGTERM/SIGKILL produces as "error".
|
|
1242
|
+
proc.stopping = True
|
|
962
1243
|
# M14: the child may already be gone (crashed / reaped) between the
|
|
963
1244
|
# status check and the signal. terminate()/kill() then raise
|
|
964
1245
|
# ProcessLookupError, which previously escaped as a 500. Suppress it
|
|
@@ -975,6 +1256,52 @@ async def api_stop(proc_id: str, request: Request) -> dict[str, Any]:
|
|
|
975
1256
|
proc.status = "stopped"
|
|
976
1257
|
proc.pid = None
|
|
977
1258
|
|
|
1259
|
+
return proc
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
@router.post("/api/launcher/start")
|
|
1263
|
+
async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
|
|
1264
|
+
"""Start a new backend process."""
|
|
1265
|
+
_require_launcher_token(request)
|
|
1266
|
+
cfg = request.app.state.config
|
|
1267
|
+
launcher_cfg = getattr(cfg, "launcher", None)
|
|
1268
|
+
try:
|
|
1269
|
+
proc = await spawn_process(
|
|
1270
|
+
request.app, launcher_cfg,
|
|
1271
|
+
name=req.name, backend=req.backend, model_path=req.model_path,
|
|
1272
|
+
port=req.port, options=req.options, extra_args=req.extra_args,
|
|
1273
|
+
draft_model_path=req.draft_model_path, mtp_mode=req.mtp_mode,
|
|
1274
|
+
)
|
|
1275
|
+
except ValueError as exc:
|
|
1276
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
1277
|
+
except FileNotFoundError as exc:
|
|
1278
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
1279
|
+
except Exception as exc:
|
|
1280
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1281
|
+
|
|
1282
|
+
return {
|
|
1283
|
+
"id": proc.id,
|
|
1284
|
+
"pid": proc.pid,
|
|
1285
|
+
"command": proc.cmd,
|
|
1286
|
+
# Provider auto-sync now happens asynchronously once the readiness
|
|
1287
|
+
# probe passes (see _wait_ready_and_register) — poll GET
|
|
1288
|
+
# /api/launcher/processes/{id} (status) or /api/launcher/logs/{id}
|
|
1289
|
+
# ("provider sync: ...") for the outcome instead of this response.
|
|
1290
|
+
"provider_sync": None,
|
|
1291
|
+
"speculative": proc.spec_tokens,
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
@router.post("/api/launcher/stop/{proc_id}")
|
|
1296
|
+
async def api_stop(proc_id: str, request: Request) -> dict[str, Any]:
|
|
1297
|
+
"""Terminate a running process (SIGTERM, then SIGKILL after 5s)."""
|
|
1298
|
+
_require_launcher_token(request)
|
|
1299
|
+
try:
|
|
1300
|
+
proc = await stop_process(request.app, proc_id)
|
|
1301
|
+
except KeyError:
|
|
1302
|
+
raise HTTPException(
|
|
1303
|
+
status_code=404, detail=f"Process {proc_id!r} not found.") from None
|
|
1304
|
+
|
|
978
1305
|
return {"id": proc_id, "status": proc.status}
|
|
979
1306
|
|
|
980
1307
|
|
|
@@ -988,7 +1315,7 @@ async def api_delete(proc_id: str, request: Request) -> dict[str, Any]:
|
|
|
988
1315
|
except KeyError:
|
|
989
1316
|
raise HTTPException(
|
|
990
1317
|
status_code=404, detail=f"Process {proc_id!r} not found.") from None
|
|
991
|
-
if proc.status
|
|
1318
|
+
if proc.status in ("running", "loading", "starting"):
|
|
992
1319
|
raise HTTPException(status_code=400, detail="Stop the process before deleting.")
|
|
993
1320
|
reg.remove(proc_id)
|
|
994
1321
|
return {"deleted": proc_id}
|
|
@@ -1258,7 +1585,8 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1258
1585
|
|
|
1259
1586
|
const statusDot = (status) => {
|
|
1260
1587
|
const map = {running:"bg-green-500", starting:"bg-yellow-500",
|
|
1261
|
-
|
|
1588
|
+
loading:"bg-yellow-500", stopped:"bg-slate-500",
|
|
1589
|
+
error:"bg-red-500"};
|
|
1262
1590
|
return `<span class="dot ${map[status] || "bg-slate-500"} mr-1.5"></span>${esc(status)}`;
|
|
1263
1591
|
};
|
|
1264
1592
|
|
|
@@ -1535,10 +1863,11 @@ _LAUNCHER_HTML = r"""<!doctype html>
|
|
|
1535
1863
|
}
|
|
1536
1864
|
tbody.innerHTML = procs.map(p => {
|
|
1537
1865
|
const modelName = p.model_path.split("/").pop();
|
|
1538
|
-
const
|
|
1866
|
+
const isActive = p.status === "running" || p.status === "loading" || p.status === "starting";
|
|
1867
|
+
const stopBtn = isActive
|
|
1539
1868
|
? `<button onclick="stopProc('${p.id}')" class="btn-sm btn-red">■ 停止</button>`
|
|
1540
1869
|
: "";
|
|
1541
|
-
const delBtn =
|
|
1870
|
+
const delBtn = !isActive
|
|
1542
1871
|
? `<button onclick="deleteProc('${p.id}')" class="btn-sm btn-slate ml-1">✕</button>`
|
|
1543
1872
|
: "";
|
|
1544
1873
|
const logBtn = `<button onclick="openLog('${p.id}','${esc(p.name)}')" class="btn-sm btn-indigo ml-1">📋 ログ</button>`;
|