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
|
||||
|
||||
+18
-9
@@ -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,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user