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"`
|
Repo string `json:"repo"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
|
Empty bool `json:"empty"`
|
||||||
}
|
}
|
||||||
if !decodeBody(w, r, &body) {
|
if !decodeBody(w, r, &body) {
|
||||||
return
|
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")
|
writeError(w, http.StatusBadRequest, "model must look like provider/model-id")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
+18
-9
@@ -239,7 +239,7 @@ func (s *Spawner) deleteJob(sessionID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start launches the async spawn pipeline and returns the new sessionId.
|
// 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)
|
exists, err := s.imageExists(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SpawnResult{}, fmt.Errorf("docker unavailable: %w", err)
|
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()
|
sessionID := newUUID()
|
||||||
s.setJob(sessionID, repo, stateCloning, "", "")
|
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
|
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.
|
// 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)
|
slug := repoSlug(repo)
|
||||||
lock := s.slugLock(slug)
|
lock := s.slugLock(slug)
|
||||||
lock.Lock()
|
lock.Lock()
|
||||||
@@ -308,7 +308,7 @@ func (s *Spawner) runJob(repo, branch, model, sessionID string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setJob(sessionID, repo, stateCreating, "", "")
|
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 {
|
if err != nil {
|
||||||
s.setJob(sessionID, repo, stateError, "", err.Error())
|
s.setJob(sessionID, repo, stateError, "", err.Error())
|
||||||
return
|
return
|
||||||
@@ -522,7 +522,7 @@ func extractBuildError(body []byte) string {
|
|||||||
// createAndStart provisions volumes, creates and starts the worker container.
|
// createAndStart provisions volumes, creates and starts the worker container.
|
||||||
// A repo-registered custom image (see /api/repos) overrides the default
|
// A repo-registered custom image (see /api/repos) overrides the default
|
||||||
// worker image; the ops agent builds and registers those.
|
// 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)
|
image := s.resolveImage(repo)
|
||||||
if image != imageRefWorker {
|
if image != imageRefWorker {
|
||||||
exists, err := s.imageRefExists(ctx, image)
|
exists, err := s.imageRefExists(ctx, image)
|
||||||
@@ -533,7 +533,12 @@ 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)
|
return "", "", fmt.Errorf("custom image %s not built (ask the ops agent to build it)", image)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repoVolume := volumeRepoPrefix + slug
|
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)
|
fresh, err := s.ensureRepoVolume(ctx, repoVolume)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
@@ -548,6 +553,7 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model, session
|
|||||||
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 {
|
if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: volumeSessions}); err != nil {
|
||||||
return "", "", fmt.Errorf("volume %s: %w", volumeSessions, err)
|
return "", "", fmt.Errorf("volume %s: %w", volumeSessions, err)
|
||||||
}
|
}
|
||||||
@@ -555,11 +561,14 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model, session
|
|||||||
return "", "", fmt.Errorf("volume %s: %w", volumePiCache, err)
|
return "", "", fmt.Errorf("volume %s: %w", volumePiCache, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
binds := []string{
|
var binds []string
|
||||||
repoVolume + ":" + workspaceMount,
|
if !empty {
|
||||||
|
binds = append(binds, repoVolume+":"+workspaceMount)
|
||||||
|
}
|
||||||
|
binds = append(binds,
|
||||||
volumeSessions+":"+sessionsMount,
|
volumeSessions+":"+sessionsMount,
|
||||||
volumePiCache+":"+cacheMount,
|
volumePiCache+":"+cacheMount,
|
||||||
}
|
)
|
||||||
// Host pi credentials (OAuth tokens for anthropic etc.), read-only, so
|
// Host pi credentials (OAuth tokens for anthropic etc.), read-only, so
|
||||||
// spawned agents can use every model the catalog offers.
|
// spawned agents can use every model the catalog offers.
|
||||||
if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" {
|
if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" {
|
||||||
|
|||||||
@@ -495,7 +495,7 @@ func TestSpawnerSecretsBinds(t *testing.T) {
|
|||||||
sec := t.TempDir()
|
sec := t.TempDir()
|
||||||
t.Setenv(envSecretsDir, sec)
|
t.Setenv(envSecretsDir, sec)
|
||||||
|
|
||||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -526,7 +526,7 @@ func TestWorkerBindsCaches(t *testing.T) {
|
|||||||
t.Setenv(envPlaywrightCacheDir, "/host/pw")
|
t.Setenv(envPlaywrightCacheDir, "/host/pw")
|
||||||
t.Setenv(envCloakCacheDir, "/host/cb")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-21
@@ -29,7 +29,7 @@ func TestSpawnerStartHappyPath(t *testing.T) {
|
|||||||
f := newFakeDocker()
|
f := newFakeDocker()
|
||||||
sp, store := newTestSpawner(t, f)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ func TestSpawnerBuildsImageWhenMissing(t *testing.T) {
|
|||||||
f.images = 0 // image absent → ensureImage must build
|
f.images = 0 // image absent → ensureImage must build
|
||||||
sp, _ := newTestSpawner(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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ func TestSpawnerStartValidatesDockerAndDockerfile(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSpawner: %v", err)
|
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") {
|
if err == nil || !strings.Contains(err.Error(), "no worker Dockerfile") {
|
||||||
t.Fatalf("Start without dockerfile err = %v", err)
|
t.Fatalf("Start without dockerfile err = %v", err)
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,7 @@ func TestSpawnerRunJobErrorStates(t *testing.T) {
|
|||||||
tc.setup(t, f)
|
tc.setup(t, f)
|
||||||
}
|
}
|
||||||
sp, _ := newTestSpawner(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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -384,11 +384,11 @@ func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) {
|
|||||||
t.Fatal("slugLock must return distinct mutexes per slug")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Start 1: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Start 2: %v", err)
|
t.Fatalf("Start 2: %v", err)
|
||||||
}
|
}
|
||||||
@@ -436,7 +436,7 @@ func TestSpawnerCloneUsesHeaderAuthNotURLCredentials(t *testing.T) {
|
|||||||
t.Fatalf("set token: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -491,7 +491,7 @@ func TestSpawnerFailedSeedRemovesRepoVolume(t *testing.T) {
|
|||||||
f.failArchive = true // CopyToContainer fails → seed fails
|
f.failArchive = true // CopyToContainer fails → seed fails
|
||||||
sp, _ := newTestSpawner(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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -514,7 +514,7 @@ func TestSpawnerFailedSeedSurvivesVolumeRemoveFailure(t *testing.T) {
|
|||||||
f.failVolumeDelete = true // cleanup itself fails; seed error still surfaces
|
f.failVolumeDelete = true // cleanup itself fails; seed error still surfaces
|
||||||
sp, _ := newTestSpawner(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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
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.
|
// because the client is already built; close the fake server instead.
|
||||||
sp.cli.Close()
|
sp.cli.Close()
|
||||||
closeDocker(t, sp)
|
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)
|
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.volume[volumeRepoPrefix+repoSlug("group/project")] = true // repo volume exists → skip seed
|
||||||
f.failVolumeCreate = true
|
f.failVolumeCreate = true
|
||||||
sp, _ := newTestSpawner(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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -844,7 +844,7 @@ func TestSpawnerStoreFailures(t *testing.T) {
|
|||||||
dead2 := openTestStore(t)
|
dead2 := openTestStore(t)
|
||||||
_ = dead2.Close()
|
_ = dead2.Close()
|
||||||
sp2.store = dead2
|
sp2.store = dead2
|
||||||
res, err := sp2.Start(context.Background(), "group/project", "", "")
|
res, err := sp2.Start(context.Background(), "group/project", "", "", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -877,7 +877,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
|
|||||||
sp, _ := newTestSpawner(t, f)
|
sp, _ := newTestSpawner(t, f)
|
||||||
|
|
||||||
f.failCreate = true
|
f.failCreate = true
|
||||||
res, err := sp.Start(context.Background(), "group/project", "", "")
|
res, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -887,7 +887,7 @@ func TestSpawnerWorkerCreateStartFailures(t *testing.T) {
|
|||||||
|
|
||||||
f.failCreate = false
|
f.failCreate = false
|
||||||
f.failStart = true
|
f.failStart = true
|
||||||
res2, err := sp.Start(context.Background(), "group/project", "", "")
|
res2, err := sp.Start(context.Background(), "group/project", "", "", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Start 2: %v", err)
|
t.Fatalf("Start 2: %v", err)
|
||||||
}
|
}
|
||||||
@@ -906,7 +906,7 @@ func TestSpawnerBuildHTTPErrors(t *testing.T) {
|
|||||||
f.images = 0
|
f.images = 0
|
||||||
f.failBuildHTTP = true // /build endpoint itself 500s
|
f.failBuildHTTP = true // /build endpoint itself 500s
|
||||||
sp, _ := newTestSpawner(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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1186,7 +1186,7 @@ func TestSpawnerStartDedupesImageList(t *testing.T) {
|
|||||||
useFakeGit(t, fakeGitModeOK)
|
useFakeGit(t, fakeGitModeOK)
|
||||||
f := newFakeDocker()
|
f := newFakeDocker()
|
||||||
sp, _ := newTestSpawner(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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1209,7 +1209,7 @@ func TestSpawnerCustomImageUsed(t *testing.T) {
|
|||||||
t.Fatalf("set repo image: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1238,7 +1238,7 @@ func TestSpawnerCustomImageMissing(t *testing.T) {
|
|||||||
t.Fatalf("set repo image: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1266,7 +1266,7 @@ func TestSpawnerCustomImageDeleteFallsBack(t *testing.T) {
|
|||||||
t.Fatalf("delete repo image: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1287,7 +1287,7 @@ func TestSpawnerCustomImageCheckDockerDead(t *testing.T) {
|
|||||||
t.Fatalf("set repo image: %v", err)
|
t.Fatalf("set repo image: %v", err)
|
||||||
}
|
}
|
||||||
sp.images0AndDead(t)
|
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") {
|
if err == nil || !strings.Contains(err.Error(), "docker unavailable") {
|
||||||
t.Fatalf("createAndStart with dead docker = %v, want docker unavailable", err)
|
t.Fatalf("createAndStart with dead docker = %v, want docker unavailable", err)
|
||||||
}
|
}
|
||||||
@@ -1298,7 +1298,7 @@ func TestSpawnerModelEnv(t *testing.T) {
|
|||||||
f := newFakeDocker()
|
f := newFakeDocker()
|
||||||
sp, _ := newTestSpawner(t, f)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Start: %v", err)
|
t.Fatalf("Start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1317,3 +1317,34 @@ func TestSpawnerModelEnv(t *testing.T) {
|
|||||||
t.Fatalf("LVMH_MODEL missing from %v", creates[0].Env)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { existsSync } from "node:fs";
|
|||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
ModelRuntime,
|
ModelRuntime,
|
||||||
|
SessionManager,
|
||||||
} from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js";
|
} from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js";
|
||||||
|
|
||||||
const SETUP_PATH = "/workspace/.lvmh/setup.sh";
|
const SETUP_PATH = "/workspace/.lvmh/setup.sh";
|
||||||
@@ -85,8 +86,15 @@ try {
|
|||||||
console.error(`[lvmh-bridge] model resolution failed:`, err);
|
console.error(`[lvmh-bridge] model resolution failed:`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fresh session per spawn: without an explicit sessionManager the SDK
|
||||||
|
// picks up an existing session file for the cwd — sharing one transcript
|
||||||
|
// (and its saved model) across every spawn of the repo. A dedicated dir
|
||||||
|
// per daemon session id keeps spawns isolated and LVMH_MODEL authoritative.
|
||||||
|
const sessionRoot = process.env.PI_SESSION_DIR ?? "/pi-sessions";
|
||||||
|
const sessionDir = `${sessionRoot}/${process.env.LVMH_SESSION_ID ?? "default"}`;
|
||||||
|
const sessionManager = SessionManager.create(process.cwd(), sessionDir);
|
||||||
const { session } = await createAgentSession(
|
const { session } = await createAgentSession(
|
||||||
model === undefined ? {} : { model },
|
model === undefined ? { sessionManager } : { model, sessionManager },
|
||||||
);
|
);
|
||||||
// SDK does not bind extensions implicitly (unlike TUI/RPC modes); without
|
// SDK does not bind extensions implicitly (unlike TUI/RPC modes); without
|
||||||
// bindExtensions the session_start event never fires, so the lvmh plugin
|
// bindExtensions the session_start event never fires, so the lvmh plugin
|
||||||
|
|||||||
@@ -715,3 +715,28 @@ describe("spawn model selection", () => {
|
|||||||
expect(bodies[0]).toContain('"model":"zai-renaud/glm-5.3"');
|
expect(bodies[0]).toContain('"model":"zai-renaud/glm-5.3"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("empty container spawn", () => {
|
||||||
|
it("checkbox sends empty:true and resets after", async () => {
|
||||||
|
const bodies: string[] = [];
|
||||||
|
mockFetchJson((url, init) => {
|
||||||
|
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||||
|
bodies.push(String(init.body));
|
||||||
|
return { sessionId: "e1", imageUsed: "lvmh-worker:latest" };
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
|
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||||
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(tree(makeStore()));
|
||||||
|
await flush();
|
||||||
|
fireEvent.click(screen.getByText("g/p"));
|
||||||
|
const cb = screen.getByRole("checkbox");
|
||||||
|
fireEvent.click(cb);
|
||||||
|
expect(cb).toBeChecked();
|
||||||
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
|
expect(bodies[0]).toContain('"empty":true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export default function SpawnView({ store, pushToast }: Props) {
|
|||||||
const [query, setQuery] = useState<string>("");
|
const [query, setQuery] = useState<string>("");
|
||||||
const [selected, setSelected] = useState<Repo | null>(null);
|
const [selected, setSelected] = useState<Repo | null>(null);
|
||||||
const [branch, setBranch] = useState<string>("");
|
const [branch, setBranch] = useState<string>("");
|
||||||
|
const [empty, setEmpty] = useState<boolean>(false);
|
||||||
const [model, setModel] = useState<string>("");
|
const [model, setModel] = useState<string>("");
|
||||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
||||||
const [busy, setBusy] = useState<boolean>(false);
|
const [busy, setBusy] = useState<boolean>(false);
|
||||||
@@ -176,6 +177,7 @@ export default function SpawnView({ store, pushToast }: Props) {
|
|||||||
repo: selected!.path,
|
repo: selected!.path,
|
||||||
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
|
...(branch.trim().length > 0 ? { branch: branch.trim() } : {}),
|
||||||
...(model.length > 0 ? { model } : {}),
|
...(model.length > 0 ? { model } : {}),
|
||||||
|
...(empty ? { empty: true } : {}),
|
||||||
};
|
};
|
||||||
const res = await fetchJson<SpawnResponse>(Route.Spawn, {
|
const res = await fetchJson<SpawnResponse>(Route.Spawn, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -429,6 +431,18 @@ export default function SpawnView({ store, pushToast }: Props) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<label
|
||||||
|
className="repo-meta"
|
||||||
|
style={{ marginTop: 8, display: "flex", gap: 6, alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={empty}
|
||||||
|
onChange={(e) => setEmpty(e.target.checked)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
Empty container (no repo — blank workspace)
|
||||||
|
</label>
|
||||||
{error.length > 0 && <p className="error-text">{error}</p>}
|
{error.length > 0 && <p className="error-text">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user