diff --git a/daemon/api.go b/daemon/api.go index 20d9aa8..3df1526 100644 --- a/daemon/api.go +++ b/daemon/api.go @@ -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 diff --git a/daemon/docker.go b/daemon/docker.go index d6b139b..6b496ee 100644 --- a/daemon/docker.go +++ b/daemon/docker.go @@ -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 != "" { diff --git a/daemon/docker_test.go b/daemon/docker_test.go index ae6896f..a3b777d 100644 --- a/daemon/docker_test.go +++ b/daemon/docker_test.go @@ -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) } diff --git a/daemon/spawner_test.go b/daemon/spawner_test.go index 5fffe28..0f21807 100644 --- a/daemon/spawner_test.go +++ b/daemon/spawner_test.go @@ -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) + } +} diff --git a/docker/bridge/index.mjs b/docker/bridge/index.mjs index 4230f97..2838271 100644 --- a/docker/bridge/index.mjs +++ b/docker/bridge/index.mjs @@ -7,6 +7,7 @@ import { existsSync } from "node:fs"; import { createAgentSession, ModelRuntime, + SessionManager, } from "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.js"; const SETUP_PATH = "/workspace/.lvmh/setup.sh"; @@ -85,8 +86,15 @@ try { 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( - model === undefined ? {} : { model }, + model === undefined ? { sessionManager } : { model, sessionManager }, ); // SDK does not bind extensions implicitly (unlike TUI/RPC modes); without // bindExtensions the session_start event never fires, so the lvmh plugin diff --git a/web/src/SpawnView.test.tsx b/web/src/SpawnView.test.tsx index 89175e4..7c4823d 100644 --- a/web/src/SpawnView.test.tsx +++ b/web/src/SpawnView.test.tsx @@ -715,3 +715,28 @@ describe("spawn model selection", () => { 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'); + }); +}); diff --git a/web/src/SpawnView.tsx b/web/src/SpawnView.tsx index b845d1c..2e56967 100644 --- a/web/src/SpawnView.tsx +++ b/web/src/SpawnView.tsx @@ -61,6 +61,7 @@ export default function SpawnView({ store, pushToast }: Props) { const [query, setQuery] = useState(""); const [selected, setSelected] = useState(null); const [branch, setBranch] = useState(""); + const [empty, setEmpty] = useState(false); const [model, setModel] = useState(""); const [catalog, setCatalog] = useState(null); const [busy, setBusy] = useState(false); @@ -176,6 +177,7 @@ export default function SpawnView({ store, pushToast }: Props) { repo: selected!.path, ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}), ...(model.length > 0 ? { model } : {}), + ...(empty ? { empty: true } : {}), }; const res = await fetchJson(Route.Spawn, { method: "POST", @@ -429,6 +431,18 @@ export default function SpawnView({ store, pushToast }: Props) { ))} + {error.length > 0 &&

{error}

}