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
+14 -7
View File
@@ -81,6 +81,9 @@ var repoPathRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)+$`)
// branchRe guards spawn branch names against git option injection.
var branchRe = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`)
// modelSpecRe matches spawn "model" specs: provider/model-id.
var modelSpecRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
// validRepoPath accepts "group/project" style paths (at least two segments).
func validRepoPath(repo string) bool { return repoPathRe.MatchString(repo) }
@@ -236,7 +239,7 @@ func (s *Spawner) deleteJob(sessionID string) {
}
// Start launches the async spawn pipeline and returns the new sessionId.
func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult, error) {
func (s *Spawner) Start(ctx context.Context, repo, branch, model string) (SpawnResult, error) {
exists, err := s.imageExists(ctx)
if err != nil {
return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err)
@@ -250,7 +253,7 @@ func (s *Spawner) Start(ctx context.Context, repo, branch string) (SpawnResult,
}
sessionID := newUUID()
s.setJob(sessionID, repo, stateCloning, "", "")
go s.runJob(repo, branch, sessionID)
go s.runJob(repo, branch, model, sessionID)
return SpawnResult{SessionID: sessionID, ImageUsed: s.resolveImage(repo)}, nil
}
@@ -289,7 +292,7 @@ func (s *Spawner) slugLock(slug string) *sync.Mutex {
}
// runJob is the async clone→build→create→start pipeline.
func (s *Spawner) runJob(repo, branch, sessionID string) {
func (s *Spawner) runJob(repo, branch, model, sessionID string) {
slug := repoSlug(repo)
lock := s.slugLock(slug)
lock.Lock()
@@ -305,7 +308,7 @@ func (s *Spawner) runJob(repo, branch, sessionID string) {
return
}
s.setJob(sessionID, repo, stateCreating, "", "")
containerID, image, err := s.createAndStart(s.ctx, repo, slug, sessionID)
containerID, image, err := s.createAndStart(s.ctx, repo, slug, model, sessionID)
if err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error())
return
@@ -364,7 +367,7 @@ func giteaTokenEnv(store *Store) string {
}
// workerEnv assembles the env for a spawned worker container.
func workerEnv(s *Spawner, repo, sessionID string) []string {
func workerEnv(s *Spawner, repo, model, sessionID string) []string {
env := []string{
envProviderAPIKey + "=" + os.Getenv(envProviderAPIKey),
envToken + "=" + daemonToken,
@@ -379,6 +382,10 @@ func workerEnv(s *Spawner, repo, sessionID string) []string {
"PLAYWRIGHT_BROWSERS_PATH=/pw-browsers",
envLVMHRepo + "=" + repo,
}
// Optional initial model selection (provider/model-id).
if model != "" {
env = append(env, "LVMH_MODEL="+model)
}
// Gitea token (write scope) so agents can push and open PRs.
if e := giteaTokenEnv(s.store); e != "" {
env = append(env, e)
@@ -515,7 +522,7 @@ func extractBuildError(body []byte) string {
// createAndStart provisions volumes, creates and starts the worker container.
// A repo-registered custom image (see /api/repos) overrides the default
// worker image; the ops agent builds and registers those.
func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID string) (string, string, error) {
func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model, sessionID string) (string, string, error) {
image := s.resolveImage(repo)
if image != imageRefWorker {
exists, err := s.imageRefExists(ctx, image)
@@ -581,7 +588,7 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, sessionID stri
}
cfg := &container.Config{
Image: image,
Env: workerEnv(s, repo, sessionID),
Env: workerEnv(s, repo, model, sessionID),
Labels: map[string]string{labelSession: sessionID},
}
hostCfg := &container.HostConfig{