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
+2 -1
View File
@@ -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
+19 -10
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,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)
}
}
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)
if err != nil {
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)
}
}
}
if _, err := s.cli.VolumeCreate(ctx, volume.CreateOptions{Name: volumeSessions}); err != nil {
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)
}
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 != "" {
+2 -2
View File
@@ -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
View File
@@ -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)
}
}
+9 -1
View File
@@ -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
+25
View File
@@ -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');
});
});
+14
View File
@@ -61,6 +61,7 @@ export default function SpawnView({ store, pushToast }: Props) {
const [query, setQuery] = useState<string>("");
const [selected, setSelected] = useState<Repo | null>(null);
const [branch, setBranch] = useState<string>("");
const [empty, setEmpty] = useState<boolean>(false);
const [model, setModel] = useState<string>("");
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
const [busy, setBusy] = useState<boolean>(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<SpawnResponse>(Route.Spawn, {
method: "POST",
@@ -429,6 +431,18 @@ export default function SpawnView({ store, pushToast }: Props) {
))}
</select>
</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>}
</div>
</>