coderouter-cli 2.9.0__py3-none-any.whl → 2.9.1__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.
@@ -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
- status: str = "starting" # "starting" | "running" | "stopped" | "error"
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
- if not hasattr(request.app.state, "launcher"):
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
- async def _tail_logs(proc: ManagedProcess) -> None:
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 task. The loop exits as soon
552
- as no fallback applies.
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 not _should_mtp_fallback(proc):
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
- # Auto-detected MTP crashed during startup retry ONCE without the
597
- # speculative flags. The port is unchanged, so the existing provider
598
- # registration still stands (do not re-register).
599
- rc = proc.returncode
600
- proc.mtp_fallback_done = True
601
- proc.log_tail.append(
602
- f"[launcher] MTP startup failure detected (exit code {rc}); "
603
- "retrying without speculative decoding"
604
- )
605
- proc.log_tail.append(
606
- f"[launcher] cmd: {' '.join(proc.fallback_cmd)}"
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
- except (FileNotFoundError, OSError) as exc:
616
- proc.log_tail.append(f"[launcher] fallback relaunch failed: {exc}")
617
- proc.status = "error"
618
- return
619
- proc._proc = p
620
- proc.pid = p.pid
621
- proc.status = "running"
622
- proc.returncode = None
623
- proc.started_at = time.monotonic()
624
- proc.log_tail.append(f"[launcher] started PID {p.pid}")
625
- # Loop to tail the relaunched process.
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
- @router.post("/api/launcher/start")
796
- async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
797
- """Start a new backend process."""
798
- _require_launcher_token(request)
799
- # Resolve binary path from providers.yaml launcher.backends
800
- cfg = request.app.state.config
801
- launcher_cfg = getattr(cfg, "launcher", None)
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.backends:
804
- bc = launcher_cfg.backends.get(req.backend)
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
- # Validate the MTP mode up front (only "auto" / "off" are accepted).
809
- if req.mtp_mode not in ("auto", "off"):
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 surface as 400 like other bad input.
818
- try:
819
- user_tokens = _option_tokens(req.options)
820
- if req.extra_args.strip():
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
- try:
827
- spec_tokens, spec_notes = resolve_speculative(
828
- req.backend,
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
- try:
838
- cmd = _build_cmd(
839
- req.backend, req.model_path, req.port,
840
- req.options, req.extra_args,
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
- req.backend, req.model_path, req.port,
862
- req.options, req.extra_args,
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=req.name,
875
- backend=req.backend,
876
- model_path=req.model_path,
877
- port=req.port,
878
- options=req.options,
879
- extra_args=req.extra_args,
880
- draft_model_path=req.draft_model_path,
881
- mtp_mode=req.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 HTTPException(
902
- status_code=400,
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.status = "running"
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
- _registry(request).add(proc)
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
- # Provider auto-sync: register the just-started backend as a routable
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
- @router.post("/api/launcher/stop/{proc_id}")
952
- async def api_stop(proc_id: str, request: Request) -> dict[str, Any]:
953
- """Terminate a running process (SIGTERM, then SIGKILL after 5s)."""
954
- _require_launcher_token(request)
955
- try:
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
- if proc._proc and proc.status == "running":
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 == "running":
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
- stopped:"bg-slate-500", error:"bg-red-500"};
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 stopBtn = p.status === "running"
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 = p.status !== "running"
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>`;