From 4755f6d1e3f2eb2ca27076ba7493aceb9cab1e49 Mon Sep 17 00:00:00 2001 From: buenosair Date: Mon, 7 Sep 2026 13:21:49 +0000 Subject: [PATCH] feat(spawn): share host spawn-pi mesh + models.json with spawned containers - bind $LVMH_HOST_PI_RUNTIME_DIR/spawn-pi -> /root/.pi/spawn-pi (rw) so container pi's join the host pi's node mesh (spawn_pi/list_pi_nodes/ send_pi_message now reach across the host/container boundary) - bind host models.json (when present) over the stale image-baked fallback so newly added catalog models resolve in spawns --- daemon/docker.go | 20 ++++++++++++++- daemon/docker_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 3 +++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/daemon/docker.go b/daemon/docker.go index 66301b0..b6f36d3 100644 --- a/daemon/docker.go +++ b/daemon/docker.go @@ -47,11 +47,14 @@ const ( volumePiCache string = "lvmh-pi-cache" // pi package cache (git:/npm:), shared across spawns cacheMount string = "/root/.pi/agent/cache" authMountTarget string = "/root/.pi/agent/auth.json" + modelsMountTarget string = "/root/.pi/agent/models.json" envHostPiAgentDir string = "LVMH_HOST_PI_AGENT_DIR" + envHostPiRuntimeDir string = "LVMH_HOST_PI_RUNTIME_DIR" envSecretsDir string = "LVMH_SECRETS_DIR" envCloakCacheDir string = "LVMH_CLOAK_CACHE_DIR" envPlaywrightCacheDir string = "LVMH_PLAYWRIGHT_CACHE_DIR" sshMountTarget string = "/root/.ssh" + meshMountTarget string = "/root/.pi/spawn-pi" gitconfigMountTarget string = "/root/.gitconfig" workspaceMount string = "/workspace" sessionsMount string = "/pi-sessions" @@ -573,9 +576,24 @@ func (s *Spawner) createAndStart(ctx context.Context, repo, slug, model string, volumePiCache+":"+cacheMount, ) // 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. The host + // models.json rides along when present: image fallbacks are baked at + // build time and go stale the moment the dotfiles catalog changes. if hostAgent := os.Getenv(envHostPiAgentDir); hostAgent != "" { binds = append(binds, hostAgent+"/auth.json:"+authMountTarget+":ro") + if _, err := os.Stat(filepath.Join(hostAgent, "models.json")); err == nil { + binds = append(binds, hostAgent+"/models.json:"+modelsMountTarget+":ro") + } + } + // Shared spawn-pi mesh directory (node registry + AF_UNIX sockets), + // read-write: each pi creates its own socket and node file. Sharing it + // with the host puts container pi's on the same mesh as the host pi — + // without this each container is an isolated island. + if hostRuntime := os.Getenv(envHostPiRuntimeDir); hostRuntime != "" { + mesh := filepath.Join(hostRuntime, "spawn-pi") + if _, err := os.Stat(mesh); err == nil { + binds = append(binds, mesh+":"+meshMountTarget) + } } // Shared playwright browser cache (host path, read-only); the env var // below makes every playwright-based MCP use it instead of downloading. diff --git a/daemon/docker_test.go b/daemon/docker_test.go index a3b777d..b00d955 100644 --- a/daemon/docker_test.go +++ b/daemon/docker_test.go @@ -519,6 +519,64 @@ func TestSpawnerSecretsBinds(t *testing.T) { } } +func TestWorkerBindsHostModelsJSON(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + sp, _ := newTestSpawner(t, f) + agent := t.TempDir() + if err := os.WriteFile(filepath.Join(agent, "models.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(envHostPiAgentDir, agent) + + res, err := sp.Start(context.Background(), "group/project", "", "", false) + if err != nil { + t.Fatalf("Start: %v", err) + } + waitJobState(t, sp, res.SessionID, stateRunning) + creates := f.createsByName("lvmh-agent-") + binds := creates[0].HostConfig.Binds + want := agent + "/models.json:/root/.pi/agent/models.json:ro" + ok := false + for _, b := range binds { + if b == want { + ok = true + } + } + if !ok { + t.Fatalf("models.json bind missing from %v", binds) + } +} + +func TestWorkerBindsSpawnPiMesh(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + sp, _ := newTestSpawner(t, f) + runtimeDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(runtimeDir, "spawn-pi"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv(envHostPiRuntimeDir, runtimeDir) + + res, err := sp.Start(context.Background(), "group/project", "", "", false) + if err != nil { + t.Fatalf("Start: %v", err) + } + waitJobState(t, sp, res.SessionID, stateRunning) + creates := f.createsByName("lvmh-agent-") + binds := creates[0].HostConfig.Binds + want := filepath.Join(runtimeDir, "spawn-pi") + ":/root/.pi/spawn-pi" + ok := false + for _, b := range binds { + if b == want { + ok = true + } + } + if !ok { + t.Fatalf("spawn-pi mesh bind missing from %v", binds) + } +} + func TestWorkerBindsCaches(t *testing.T) { useFakeGit(t, fakeGitModeOK) f := newFakeDocker() diff --git a/docker-compose.yml b/docker-compose.yml index 591411b..d4fb6e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,9 @@ services: # docker.sock bind semantics: bind sources resolve on the HOST, so # this must be the HOST path of the pi config (auth.json etc.). LVMH_HOST_PI_AGENT_DIR: ${LVMH_PI_AGENT_DIR:-/home/alarm/.dotfiles/pi/agent} + # host pi runtime dir (spawn-pi mesh: nodes + sockets), shared rw with + # spawned containers so host pi and container pi's form one mesh + LVMH_HOST_PI_RUNTIME_DIR: ${LVMH_PI_RUNTIME_DIR:-/home/alarm/.pi} LVMH_SECRETS_DIR: /zdata/root/lvmh-secrets LVMH_CLOAK_CACHE_DIR: /home/alarm/.cloakbrowser LVMH_PLAYWRIGHT_CACHE_DIR: /home/alarm/.cache/ms-playwright