feat: empty-container spawn (scratch workspace, no repo volume); fix bridge session isolation — explicit SessionManager per spawn stops SDK resuming the repo's old session (which also overrode LVMH_MODEL)

This commit is contained in:
Raphael Westphal
2026-08-20 15:12:45 +02:00
parent bdfa61a8b2
commit f5ee3a0f2d
7 changed files with 134 additions and 46 deletions
+30 -21
View File
@@ -239,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, model string) (SpawnResult, error) {
func (s *Spawner) Start(ctx context.Context, repo, branch, model string, empty bool) (SpawnResult, error) {
exists, err := s.imageExists(ctx)
if err != nil {
return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err)
@@ -253,7 +253,7 @@ func (s *Spawner) Start(ctx context.Context, repo, branch, model string) (SpawnR
}
sessionID := newUUID()
s.setJob(sessionID, repo, stateCloning, "", "")
go s.runJob(repo, branch, model, sessionID)
go s.runJob(repo, branch, model, empty, sessionID)
return SpawnResult{SessionID: sessionID, ImageUsed: s.resolveImage(repo)}, nil
}
@@ -292,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, model, sessionID string) {
func (s *Spawner) runJob(repo, branch, model string, empty bool, sessionID string) {
slug := repoSlug(repo)
lock := s.slugLock(slug)
lock.Lock()
@@ -308,7 +308,7 @@ func (s *Spawner) runJob(repo, branch, model, sessionID string) {
return
}
s.setJob(sessionID, repo, stateCreating, "", "")
containerID, image, err := s.createAndStart(s.ctx, repo, slug, model, sessionID)
containerID, image, err := s.createAndStart(s.ctx, repo, slug, model, empty, sessionID)
if err != nil {
s.setJob(sessionID, repo, stateError, "", err.Error())
return
@@ -522,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, model, sessionID string) (string, string, error) {
func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model string, empty bool, sessionID string) (string, string, error) {
image := s.resolveImage(repo)
if image != imageRefWorker {
exists, err := s.imageRefExists(ctx, image)
@@ -533,19 +533,25 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model, session
return "", "", fmt.Errorf("custom image %s not built (ask the ops agent to build it)", image)
}
}
repoVolume := volumeRepoPrefix + slug
fresh, err := s.ensureRepoVolume(ctx, repoVolume)
if err != nil {
return "", "", err
}
if fresh {
if err := s.seedVolume(ctx, slug, repoVolume, sessionID); err != nil {
// drop the half-seeded volume so the next spawn retries fresh
// instead of silently booting into an empty workspace.
if rmErr := s.cli.VolumeRemove(context.Background(), repoVolume, true); rmErr != nil {
log.Printf("spawner: remove failed seed volume %s: %v", repoVolume, rmErr)
var repoVolume string
if empty {
// Scratch spawn: no repo, no shared workspace — a blank slate.
repoVolume = ""
} else {
repoVolume = volumeRepoPrefix + slug
fresh, err := s.ensureRepoVolume(ctx, repoVolume)
if err != nil {
return "", "", err
}
if fresh {
if err := s.seedVolume(ctx, slug, repoVolume, sessionID); err != nil {
// drop the half-seeded volume so the next spawn retries fresh
// instead of silently booting into an empty workspace.
if rmErr := s.cli.VolumeRemove(context.Background(), repoVolume, true); rmErr != nil {
log.Printf("spawner: remove failed seed volume %s: %v", repoVolume, rmErr)
}
return "", "", fmt.Errorf("seed %s: %w", repoVolume, err)
}
return "", "", fmt.Errorf("seed %s: %w", repoVolume, err)
}
}
if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: volumeSessions}); err != nil {
@@ -555,11 +561,14 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model, session
return "", "", fmt.Errorf("volume %s: %w", volumePiCache, err)
}
binds := []string{
repoVolume + ":" + workspaceMount,
volumeSessions + ":" + sessionsMount,
volumePiCache + ":" + cacheMount,
var binds []string
if !empty {
binds = append(binds, repoVolume+":"+workspaceMount)
}
binds = append(binds,
volumeSessions+":"+sessionsMount,
volumePiCache+":"+cacheMount,
)
// Host pi credentials (OAuth tokens for anthropic etc.), read-only, so
// spawned agents can use every model the catalog offers.
if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" {