feat: model selection at spawn (POST /api/spawn model -> LVMH_MODEL -> bridge createAgentSession); ops prepare resets ops session (fresh context per prepare)

This commit is contained in:
Raphael Westphal
2026-08-20 12:43:55 +02:00
parent 5292a69d66
commit 2d0839357d
11 changed files with 553 additions and 48 deletions
+49 -8
View File
@@ -17,6 +17,7 @@ import (
"sort"
"strconv"
"strings"
"time"
)
// Route names, event query defaults and limits.
@@ -35,8 +36,9 @@ const (
maxBodyBytes int64 = 1 << 20
webIndexFallback string = "index.html"
reposPathPrefix string = "/api/repos/"
reposPrepareSuffix string = "/prepare"
reposPathPrefix string = "/api/repos/"
reposPrepareSuffix string = "/prepare"
prepOpsBootWait time.Duration = 90 * time.Second
// opsPreparePrompt is sent to the ops control session by
// POST /api/repos/<repo>/prepare.
opsPreparePrompt string = "prepare %s: clone, build a worker image, register it"
@@ -365,6 +367,7 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
var body struct {
Repo string `json:"repo"`
Branch string `json:"branch"`
Model string `json:"model"`
}
if !decodeBody(w, r, &body) {
return
@@ -377,7 +380,13 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid branch name")
return
}
res, err := s.spawn.Start(r.Context(), body.Repo, body.Branch)
// Optional initial model "provider/model-id" (validated loosely here; the
// bridge logs and falls back when the registry cannot resolve it).
if body.Model != "" && !modelSpecRe.MatchString(body.Model) {
writeError(w, http.StatusBadRequest, "model must look like provider/model-id")
return
}
res, err := s.spawn.Start(r.Context(), body.Repo, body.Branch, body.Model)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -574,7 +583,9 @@ func (s *Server) handleModelCatalog(w http.ResponseWriter, r *http.Request) {
path := envOr(envModelsFile, "")
if path == "" {
path = defaultBakedModelsFile
if _, err := os.Stat(path); err != nil {
if t := os.Getenv("LVMH_TEST_BAKED_MODELS"); t != "" {
path = t
} else if _, err := os.Stat(path); err != nil {
path = defaultModelsFile
}
}
@@ -655,12 +666,42 @@ func (s *Server) handlePrepareRepo(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "repo must look like group/project")
return
}
if err := s.hub.Prompt(opsSessionID, fmt.Sprintf(opsPreparePrompt, repo)); err != nil {
if errors.Is(err, ErrOffline) {
writeError(w, http.StatusConflict, "ops session offline")
// Fresh ops context per prepare: when ops is running, restart it (wiping
// its transcript) so the prompt lands in a clean session — no history
// buildup across prepares. When offline, try to boot it once.
if s.hub.IsOnline(opsSessionID) {
if err := s.spawn.RemoveOps(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "reset ops: "+err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
_, _ = s.store.DeleteSessionEvents(opsSessionID)
_, _ = s.store.SetSessionName(opsSessionID, "")
}
sent := false
wait := prepOpsBootWait
if w := os.Getenv("LVMH_TEST_OPS_BOOT_WAIT"); w != "" {
if d, err := time.ParseDuration(w); err == nil {
wait = d
}
}
deadline := time.Now().Add(wait)
for time.Now().Before(deadline) {
if s.hub.IsOnline(opsSessionID) ||
(s.spawn.EnsureOps(r.Context()) == nil && s.hub.IsOnline(opsSessionID)) {
if s.hub.Prompt(opsSessionID, fmt.Sprintf(opsPreparePrompt, repo)) == nil {
sent = true
}
break
}
select {
case <-r.Context().Done():
writeError(w, http.StatusRequestTimeout, "client gone while booting ops")
return
case <-time.After(2 * time.Second):
}
}
if !sent {
writeError(w, http.StatusConflict, "ops session did not come back online")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})