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:
+2
-1
@@ -368,6 +368,7 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
|
||||
Repo string `json:"repo"`
|
||||
Branch string `json:"branch"`
|
||||
Model string `json:"model"`
|
||||
Empty bool `json:"empty"`
|
||||
}
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
@@ -386,7 +387,7 @@ func (s *Server) handleSpawn(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
res, err := s.spawn.Start(r.Context(), body.Repo, body.Branch, body.Model, body.Empty)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
||||
+30
-21
@@ -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 != "" {
|
||||
|
||||
@@ -495,7 +495,7 @@ func TestSpawnerSecretsBinds(t *testing.T) {
|
||||
sec := t.TempDir()
|
||||
t.Setenv(envSecretsDir, sec)
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -526,7 +526,7 @@ func TestWorkerBindsCaches(t *testing.T) {
|
||||
t.Setenv(envPlaywrightCacheDir, "/host/pw")
|
||||
t.Setenv(envCloakCacheDir, "/host/cb")
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
+52
-21
@@ -29,7 +29,7 @@ func TestSpawnerStartHappyPath(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
sp, store := newTestSpawner(t, f)
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "main", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "main", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func TestSpawnerBuildsImageWhenMissing(t *testing.T) {
|
||||
f.images = 0 // image absent → ensureImage must build
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func TestSpawnerStartValidatesDockerAndDockerfile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewSpawner: %v", err)
|
||||
}
|
||||
_, err = sp2.Start(context.Background(), "group/project", "", "")
|
||||
_, err = sp2.Start(context.Background(), "group/project", "", "", false)
|
||||
if err == nil || !strings.Contains(err.Error(), "no worker Dockerfile") {
|
||||
t.Fatalf("Start without dockerfile err = %v", err)
|
||||
}
|
||||
@@ -247,7 +247,7 @@ func TestSpawnerRunJobErrorStates(t *testing.T) {
|
||||
tc.setup(t, f)
|
||||
}
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -384,11 +384,11 @@ func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) {
|
||||
t.Fatal("slugLock must return distinct mutexes per slug")
|
||||
}
|
||||
|
||||
res1, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res1, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start 1: %v", err)
|
||||
}
|
||||
res2, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res2, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start 2: %v", err)
|
||||
}
|
||||
@@ -436,7 +436,7 @@ func TestSpawnerCloneUsesHeaderAuthNotURLCredentials(t *testing.T) {
|
||||
t.Fatalf("set token: %v", err)
|
||||
}
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -491,7 +491,7 @@ func TestSpawnerFailedSeedRemovesRepoVolume(t *testing.T) {
|
||||
f.failArchive = true // CopyToContainer fails → seed fails
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -514,7 +514,7 @@ func TestSpawnerFailedSeedSurvivesVolumeRemoveFailure(t *testing.T) {
|
||||
f.failVolumeDelete = true // cleanup itself fails; seed error still surfaces
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -722,7 +722,7 @@ func TestSpawnerStartDockerUnavailable(t *testing.T) {
|
||||
// because the client is already built; close the fake server instead.
|
||||
sp.cli.Close()
|
||||
closeDocker(t, sp)
|
||||
if _, err := sp.Start(context.Background(), "group/project", "", ""); err == nil || !strings.Contains(err.Error(), "docker unavailable") {
|
||||
if _, err := sp.Start(context.Background(), "group/project", "", "", false); err == nil || !strings.Contains(err.Error(), "docker unavailable") {
|
||||
t.Fatalf("Start with dead docker = %v, want docker unavailable", err)
|
||||
}
|
||||
}
|
||||
@@ -799,7 +799,7 @@ func TestSpawnerSessionsVolumeCreateFails(t *testing.T) {
|
||||
f.volume[volumeRepoPrefix+repoSlug("group/project")] = true // repo volume exists → skip seed
|
||||
f.failVolumeCreate = true
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -844,7 +844,7 @@ func TestSpawnerStoreFailures(t *testing.T) {
|
||||
dead2 := openTestStore(t)
|
||||
_ = dead2.Close()
|
||||
sp2.store = dead2
|
||||
res, err := sp2.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp2.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -877,7 +877,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
f.failCreate = true
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -887,7 +887,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
|
||||
|
||||
f.failCreate = false
|
||||
f.failStart = true
|
||||
res2, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res2, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start 2: %v", err)
|
||||
}
|
||||
@@ -906,7 +906,7 @@ func TestSpawnerBuildHTTPErrors(t *testing.T) {
|
||||
f.images = 0
|
||||
f.failBuildHTTP = true // /build endpoint itself 500s
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -1186,7 +1186,7 @@ func TestSpawnerStartDedupesImageList(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -1209,7 +1209,7 @@ func TestSpawnerCustomImageUsed(t *testing.T) {
|
||||
t.Fatalf("set repo image: %v", err)
|
||||
}
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -1238,7 +1238,7 @@ func TestSpawnerCustomImageMissing(t *testing.T) {
|
||||
t.Fatalf("set repo image: %v", err)
|
||||
}
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -1266,7 +1266,7 @@ func TestSpawnerCustomImageDeleteFallsBack(t *testing.T) {
|
||||
t.Fatalf("delete repo image: %v", err)
|
||||
}
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -1287,7 +1287,7 @@ func TestSpawnerCustomImageCheckDockerDead(t *testing.T) {
|
||||
t.Fatalf("set repo image: %v", err)
|
||||
}
|
||||
sp.images0AndDead(t)
|
||||
_, _, err := sp.createAndStart(context.Background(), "group/project", repoSlug("group/project"), "", "s1")
|
||||
_, _, err := sp.createAndStart(context.Background(), "group/project", repoSlug("group/project"), "", false, "s1")
|
||||
if err == nil || !strings.Contains(err.Error(), "docker unavailable") {
|
||||
t.Fatalf("createAndStart with dead docker = %v, want docker unavailable", err)
|
||||
}
|
||||
@@ -1298,7 +1298,7 @@ func TestSpawnerModelEnv(t *testing.T) {
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "zai-renaud/glm-5.2")
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "zai-renaud/glm-5.2", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
@@ -1317,3 +1317,34 @@ func TestSpawnerModelEnv(t *testing.T) {
|
||||
t.Fatalf("LVMH_MODEL missing from %v", creates[0].Env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnerEmptyScratch(t *testing.T) {
|
||||
useFakeGit(t, fakeGitModeOK)
|
||||
f := newFakeDocker()
|
||||
sp, _ := newTestSpawner(t, f)
|
||||
|
||||
res, err := sp.Start(context.Background(), "group/project", "", "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
waitJobState(t, sp, res.SessionID, stateRunning)
|
||||
creates := f.createsByName("lvmh-agent-")
|
||||
if len(creates) != 1 {
|
||||
t.Fatalf("creates = %d", len(creates))
|
||||
}
|
||||
binds := creates[0].HostConfig.Binds
|
||||
for _, b := range binds {
|
||||
if b == "lvmh-repo-group--project-ab12cd:/workspace" {
|
||||
t.Fatalf("empty spawn must not mount the repo volume: %v", binds)
|
||||
}
|
||||
}
|
||||
hasSessions := false
|
||||
for _, b := range binds {
|
||||
if b == "lvmh-sessions:/pi-sessions" {
|
||||
hasSessions = true
|
||||
}
|
||||
}
|
||||
if !hasSessions {
|
||||
t.Fatalf("sessions volume missing: %v", binds)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user