diff --git a/Package.swift b/Package.swift index 33856a3e..fc94ac85 100644 --- a/Package.swift +++ b/Package.swift @@ -7,8 +7,9 @@ import PackageDescription // • the app executable's PRODUCT name — which becomes the on-disk binary and // therefore the OS process name (Activity Monitor / `ps`): // dev -> nucleic-local beta -> nucleic-beta rc -> nucleic-rc stable -> nucleic -// • a compile-time define the app reads (`BuildChannel`) to pick its build identity -// and the warning banner (red local / blue beta / gold release-candidate / none stable). +// • a compile-time define (`NUCLEIC_STABLE/RC/BETA/DEV`) read by NucleicApp for its build +// identity + warning banner (red local / blue beta / gold release-candidate / none stable), +// and by NucleicCore's `ContainerManager` to suffix non-release container names. // Branch mapping (see BUILD.md): dev builds from `dev`, beta from `staging`, // rc from `rc`, stable from `main`. let nucleicChannel = (Context.environment["NUCLEIC_CHANNEL"] ?? "dev").lowercased() @@ -68,7 +69,12 @@ let package = Package( // the `User`/`Platform` types used directly by `ContainerEngine`. .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationOCI", package: "containerization"), - ]), + ], + // The build channel reaches the container-naming code here: `ContainerManager` appends a + // per-channel suffix (e.g. `-beta`, `-local`) to non-release container names so a beta / + // local-dev build's containers don't collide with a release's on the shared on-disk + // container store (ContainerEngine.defaultStorageRoot). Same define NucleicApp's banner uses. + swiftSettings: [.define(channelDefine)]), .executableTarget( name: "NucleicApp", dependencies: ["NucleicCore", .product(name: "SwiftTerm", package: "SwiftTerm")], diff --git a/Sources/NucleicCore/Container/ContainerEngine+Rootfs.swift b/Sources/NucleicCore/Container/ContainerEngine+Rootfs.swift index 62774edf..6461ac1f 100644 --- a/Sources/NucleicCore/Container/ContainerEngine+Rootfs.swift +++ b/Sources/NucleicCore/Container/ContainerEngine+Rootfs.swift @@ -119,12 +119,17 @@ extension ContainerEngine { /// `keepNames` (dead per-session containers, plus the shared control containers — which are /// always rebuilt fresh), then prune superseded image caches. There are no live VMs to reap; /// the registry is empty in a fresh process. + /// + /// GC is scoped to THIS build channel: several installed builds (release + beta + local dev) + /// share this store, each owning only its `ContainerManager.channelSuffix`-tagged clones, so a + /// beta launch must not reap a release's per-session clones (and vice-versa). public func reconcileDisk(keepNames: Set) async { if let clones = try? FileManager.default.contentsOfDirectory( at: instancesDir, includingPropertiesForKeys: nil) { for clone in clones where clone.pathExtension == "ext4" { let name = clone.deletingPathExtension().lastPathComponent + guard ContainerManager.ownsContainer(named: name) else { continue } // not our channel if !keepNames.contains(name) { try? FileManager.default.removeItem(at: clone) } diff --git a/Sources/NucleicCore/Container/ContainerManager.swift b/Sources/NucleicCore/Container/ContainerManager.swift index 5c201567..56bc4789 100644 --- a/Sources/NucleicCore/Container/ContainerManager.swift +++ b/Sources/NucleicCore/Container/ContainerManager.swift @@ -21,11 +21,45 @@ public actor ContainerManager { self.engine = engine } + /// Per-channel suffix appended to every container name on non-release builds, so several + /// installed Nucleic builds (release + beta + local dev, etc.) running against the **shared** + /// on-disk container store (`ContainerEngine.defaultStorageRoot`) don't collide on their + /// per-container rootfs clones. Empty for the stable release; `-beta` / `-rc` / `-local` for the + /// non-release channels. Baked in at build time from the `NUCLEIC_CHANNEL` define (mirrors + /// NucleicApp's `BuildChannel`); NucleicCore is compiled with that define — see Package.swift. + public nonisolated static let channelSuffix: String = { + #if NUCLEIC_STABLE + return "" + #elseif NUCLEIC_RC + return "-rc" + #elseif NUCLEIC_BETA + return "-beta" + #else + return "-local" + #endif + }() + + /// Every non-empty `channelSuffix` a build can carry. Used to tell a stable build's untagged + /// containers apart from other channels' tagged ones during disk GC. Keep in lockstep with the + /// `channelSuffix` cases above. + public nonisolated static let nonReleaseSuffixes = ["-local", "-beta", "-rc"] + + /// Whether a container/clone name belongs to THIS build channel's namespace. The on-disk + /// container store is shared across installed builds (`ContainerEngine.defaultStorageRoot`), so + /// the launch-time disk GC uses this to reap only its own channel's clones and never another + /// build's. A non-release build owns names ending in its `channelSuffix`; the stable release owns + /// names carrying none of the non-release tags. + public nonisolated static func ownsContainer(named name: String) -> Bool { + channelSuffix.isEmpty + ? !nonReleaseSuffixes.contains(where: name.hasSuffix) + : name.hasSuffix(channelSuffix) + } + /// Stable container name for a session. Lowercased because `UUID().uuidString` (hence /// `SessionID.short`) is uppercase, while container names are conventionally lowercase - /// and our reconcile parser matches lowercase hex. + /// and our reconcile parser matches lowercase hex. Carries the per-channel `channelSuffix`. public nonisolated static func containerName(for session: SessionID) -> String { - "nucleic-\(session.short.lowercased())" + "nucleic-\(session.short.lowercased())\(channelSuffix)" } /// The single **primary** container shared by all Nucleic Control projects that haven't @@ -33,14 +67,15 @@ public actor ContainerManager { /// the reconcile/list parser never mistakes it for a per-session container (and so a /// per-session `teardown` — which computes a `nucleic-` name — never removes it). /// Its busy/idle lifecycle is ref-counted by name across every control session that uses it. - /// Also the home of Claude sessions when backend-splitting is on. - public static let sharedControlContainerName = "nucleic-control" + /// Also the home of Claude sessions when backend-splitting is on. Carries `channelSuffix`. + public static let sharedControlContainerName = "nucleic-control" + channelSuffix /// The separate shared control container for GPT/Codex sessions, used only when /// `ContainerServiceSettings.splitControlContainersByBackend` is on — keeping competitive /// Claude and GPT agents out of one sandbox where they kill each other's processes. Same /// non-`nucleic-<8 hex>` shape, so it's likewise invisible to the per-session list parser. - public static let codexControlContainerName = "nucleic-control-codex" + /// The `channelSuffix` lands at the very end (e.g. `nucleic-control-codex-beta`). + public static let codexControlContainerName = "nucleic-control-codex" + channelSuffix /// Every shared control container name that can exist. Lifecycle sweeps (reconcile, teardown, /// status, recreate) iterate this so they stay correct no matter how the split setting is diff --git a/Tests/NucleicCoreTests/ContainerSandboxTests.swift b/Tests/NucleicCoreTests/ContainerSandboxTests.swift index 186c99e5..6bc25613 100644 --- a/Tests/NucleicCoreTests/ContainerSandboxTests.swift +++ b/Tests/NucleicCoreTests/ContainerSandboxTests.swift @@ -42,25 +42,26 @@ struct ContainerSandboxTests { @Test func containerNameDerivesFromSessionShortID() { let id = SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb") - #expect(ContainerManager.containerName(for: id) == "nucleic-abcdef12") + #expect(ContainerManager.containerName(for: id) == "nucleic-abcdef12\(ContainerManager.channelSuffix)") } /// `UUID().uuidString` is uppercase; the container name must be lowercased so it stays a stable, /// collision-free key for the engine's registry and on-disk rootfs clones. @Test func containerNameLowercasesUppercaseUUID() { let id = SessionID(rawValue: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F") - #expect(ContainerManager.containerName(for: id) == "nucleic-e621e1f8") + #expect(ContainerManager.containerName(for: id) == "nucleic-e621e1f8\(ContainerManager.channelSuffix)") } /// The shared control container names must be distinct from each other and from any per-session /// container name — otherwise a per-session `teardown` (which derives a `nucleic-` name) /// or the disk-GC reconcile could clobber the shared container out from under live sessions. @Test func sharedControlContainerNamesAreDistinct() { - #expect(ContainerManager.sharedControlContainerName == "nucleic-control") - #expect(ContainerManager.codexControlContainerName == "nucleic-control-codex") + let s = ContainerManager.channelSuffix + #expect(ContainerManager.sharedControlContainerName == "nucleic-control\(s)") + #expect(ContainerManager.codexControlContainerName == "nucleic-control-codex\(s)") #expect(ContainerManager.codexControlContainerName != ContainerManager.sharedControlContainerName) #expect(ContainerManager.allSharedControlContainerNames == - ["nucleic-control", "nucleic-control-codex"]) + ["nucleic-control\(s)", "nucleic-control-codex\(s)"]) // A per-session name never equals a shared name. let perSession = ContainerManager.containerName( @@ -71,14 +72,48 @@ struct ContainerSandboxTests { /// With splitting off, every backend shares `nucleic-control`; with it on, only the GPT/Codex /// family moves to `nucleic-control-codex` while Claude stays on `nucleic-control`. @Test func sharedContainerNameSplitsCodexFromClaude() { + let s = ContainerManager.channelSuffix // Splitting off → one shared container regardless of backend. for backend in [BackendID.claudeCode, .codex, .codexExec] { - #expect(ContainerManager.sharedContainerName(for: backend, split: false) == "nucleic-control") + #expect(ContainerManager.sharedContainerName(for: backend, split: false) == "nucleic-control\(s)") } // Splitting on → Codex family diverges; Claude stays put. - #expect(ContainerManager.sharedContainerName(for: .claudeCode, split: true) == "nucleic-control") - #expect(ContainerManager.sharedContainerName(for: .codex, split: true) == "nucleic-control-codex") - #expect(ContainerManager.sharedContainerName(for: .codexExec, split: true) == "nucleic-control-codex") + #expect(ContainerManager.sharedContainerName(for: .claudeCode, split: true) == "nucleic-control\(s)") + #expect(ContainerManager.sharedContainerName(for: .codex, split: true) == "nucleic-control-codex\(s)") + #expect(ContainerManager.sharedContainerName(for: .codexExec, split: true) == "nucleic-control-codex\(s)") + } + + /// The per-channel suffix is one of the known channel tags — empty for the stable release, a + /// `-`-prefixed tag otherwise — and it lands at the very end of every container name so several + /// builds installed side-by-side don't collide on the shared on-disk container store. Channel is + /// fixed at build time, so this is channel-agnostic (the default `dev` test build resolves + /// `-local`; a `NUCLEIC_CHANNEL=stable` build resolves `""`). + @Test func channelSuffixTagsNonReleaseNames() { + let s = ContainerManager.channelSuffix + #expect(["", "-beta", "-rc", "-local"].contains(s)) + // Only the stable release goes untagged; every other channel carries a tag. + #expect(s.isEmpty || s.hasPrefix("-")) + let perSession = ContainerManager.containerName( + for: SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb")) + #expect(perSession.hasSuffix(s)) + #expect(ContainerManager.sharedControlContainerName.hasSuffix(s)) + } + + /// Launch-time disk GC must reap only this channel's clones from the shared on-disk store, so + /// `ownsContainer` claims names carrying this build's suffix and disowns every other channel's. + @Test func ownsOnlyThisChannelsContainers() { + // Names this build mints are always ours. + #expect(ContainerManager.ownsContainer(named: ContainerManager.sharedControlContainerName)) + #expect(ContainerManager.ownsContainer(named: ContainerManager.codexControlContainerName)) + #expect(ContainerManager.ownsContainer(named: ContainerManager.containerName( + for: SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb")))) + + // A clone tagged for any *other* channel is never ours. + let foreign = (["", "-local", "-beta", "-rc"]).filter { $0 != ContainerManager.channelSuffix } + for fs in foreign { + #expect(!ContainerManager.ownsContainer(named: "nucleic-control\(fs)")) + #expect(!ContainerManager.ownsContainer(named: "nucleic-abcdef12\(fs)")) + } } // MARK: - sandbox config persistence