Add nvrsion: per-file shared-trunk versioning for multi-agent orchestration

An opt-in (Beta, Nucleic-Control-only) versioning mode where a project's agent
sessions share one nucleic/trunk checkout, lock individual files per-edit, land each
completed edit into the trunk immediately, and release fast — instead of holding a
session-long lock until a big merge. Conflicts are structurally impossible within the
trunk (serialized per-file writes + forced re-ground), so 'merge' collapses to 'commit'.

- Phase A: ProjectNvrsion config, migration v20-nvrsion, nvrsionActive gate, Beta toggle
- Phase B: NvrsionTrunk actor (ensureTrunk/land/regroundOnGrant), shared-trunk topology
  (no per-session worktree), per-edit host-mediated path-scoped commit + release
- Phase C: NvrsionReleaseGovernor keep-warm idle eviction, launch crash-recovery,
  flip-safety guard
- Phase D: pre-land validation hook, trunk->base squash promotion + 'Promote trunk' UI

Design and rationale: docs/NVRSION.md. Full suite green (585 tests).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nucleic
2026-06-25 21:32:09 -07:00
co-authored by Claude Opus 4.8
parent 7c45577365
commit 174da0301e
11 changed files with 1799 additions and 9 deletions
+103
View File
@@ -145,6 +145,11 @@ struct ProjectSettingsSheet: View {
@State private var image: String
@State private var idleMinutes: Int
@State private var allowHostExec: Bool
// nvrsion (Beta) shared-trunk versioning; control-only (NVRSION §10).
@State private var nvrsionEnabled: Bool
@State private var nvrsionKeepWarm: Int
@State private var nvrsionPrelandHook: String
@State private var nvrsionPromoting = false
@State private var showMove = false
/// Drives the "Convert to Nucleic Control" confirmation popup (the conversion is permanent
/// a Control project can't be released so it always asks first).
@@ -168,6 +173,16 @@ struct ProjectSettingsSheet: View {
_image = State(initialValue: sandbox.image ?? "")
_idleMinutes = State(initialValue: max(1, sandbox.idleTimeoutSeconds / 60))
_allowHostExec = State(initialValue: sandbox.allowHostExec)
let nvrsion = project.nvrsion ?? ProjectNvrsion()
_nvrsionEnabled = State(initialValue: nvrsion.enabled)
_nvrsionKeepWarm = State(initialValue: nvrsion.keepWarmIdleSeconds)
_nvrsionPrelandHook = State(initialValue: nvrsion.prelandHook ?? "")
}
/// nvrsion is offered only on control projects using the shared container, with the service on.
/// (Per-session containers aren't supported in v0; see NVRSION §15.)
private var nvrsionAvailable: Bool {
controlled && containerServiceEnabled && !(project.sandbox?.perSessionContainers ?? false)
}
var body: some View {
@@ -282,6 +297,82 @@ struct ProjectSettingsSheet: View {
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
if controlled {
VStack(alignment: .leading, spacing: 10) {
Toggle(isOn: $nvrsionEnabled) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text("nvrsion — shared trunk")
Text("BETA")
.font(.caption2).bold()
.padding(.horizontal, 5).padding(.vertical, 1)
.background(AppTheme.hairline, in: .capsule)
}
Text("Sessions share one `nucleic/trunk` checkout and land each edit "
+ "into it the instant it completes, releasing the file's lock right "
+ "away — instead of holding it until the whole chat ships. Trunk "
+ "reaches your real branch only when promoted.")
.font(.caption).foregroundStyle(.secondary)
}
}
.disabled(!nvrsionAvailable)
if !nvrsionAvailable {
Text(project.sandbox?.perSessionContainers ?? false
? "Unavailable with per-session containers — nvrsion needs the shared "
+ "primary container in this version."
: "Turn on the container service in Settings → Sandbox to use nvrsion.")
.font(.caption).foregroundStyle(.secondary)
}
if nvrsionEnabled && nvrsionAvailable {
Divider()
Stepper(
"Release an idle file after \(nvrsionKeepWarm)s",
value: $nvrsionKeepWarm, in: 1...60)
Text("Keep-warm window: a file you've stopped editing is handed to a "
+ "waiting session after this long, even mid-chat. Lower = faster "
+ "hand-off, more re-reads.")
.font(.caption).foregroundStyle(.secondary)
Divider()
HStack {
Text("Pre-land check")
TextField("optional command — e.g. nvrsion-check", text: $nvrsionPrelandHook)
.textFieldStyle(.roundedBorder)
}
Text("Runs on the edited files before each lands in trunk. Non-zero exit "
+ "rejects that edit back to the agent. Leave blank to land immediately.")
.font(.caption).foregroundStyle(.secondary)
Divider()
HStack(spacing: 8) {
Button {
Task {
nvrsionPromoting = true
await store.promoteNvrsionTrunk(project.id)
nvrsionPromoting = false
}
} label: {
if nvrsionPromoting {
ProgressView().controlSize(.small)
} else {
Text("Promote trunk → \(defaultBranchPlaceholder)")
}
}
.disabled(nvrsionPromoting)
Spacer()
}
Text("Squash everything currently on the trunk into your real branch as one "
+ "commit. Trunk reaches your real branch only when you promote.")
.font(.caption).foregroundStyle(.secondary)
}
}
.padding(14)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
}
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
@@ -390,6 +481,18 @@ struct ProjectSettingsSheet: View {
allowHostExec: allowHostExec,
// Per-session containers aren't user-settable yet; preserve any existing choice.
perSessionContainers: project.sandbox?.perSessionContainers ?? false)
// nvrsion (Beta) only meaningful on control projects; store nil when off so the model
// stays clean. Preserve the trunk-branch name from any existing config.
let hook = nvrsionPrelandHook.trimmingCharacters(in: .whitespacesAndNewlines)
if controlled && nvrsionEnabled {
updated.nvrsion = ProjectNvrsion(
enabled: true,
trunkBranch: project.nvrsion?.trunkBranch ?? ProjectNvrsion.defaultTrunkBranch,
prelandHook: hook.isEmpty ? nil : hook,
keepWarmIdleSeconds: nvrsionKeepWarm)
} else {
updated.nvrsion = nil
}
await store.updateProject(updated)
dismiss()
}
+187 -9
View File
@@ -447,6 +447,19 @@ public final class AppStore: ConflictArbiter {
/// replace the old `unmergedFiles`-derived "lock". Acquired via `arbitrate` on edit, released
/// by `reconcileLocks` once work lands in the parent (any merger), plus mediated/lifecycle.
private let lockManager = LockManager()
/// Host-side coordinator for the shared nvrsion trunk (NVRSION). Owns each nvrsion project's
/// `nucleic/trunk` checkout and lands per-edit commits into it; nil-effect for non-nvrsion
/// projects (it's only ever invoked when `project.nvrsionActive`).
private let nvrsionTrunk = NvrsionTrunk()
/// Keep-warm governor (NVRSION §4): tracks which landed files a session still holds and which
/// have gone idle past their project's `keepWarmIdle`, so the sweep can release them mid-turn.
/// Internal (not private) so tests can drive `warmed`/`sweep` against a controlled clock.
let nvrsionGovernor: NvrsionReleaseGovernor
/// The keep-warm sweep loop; nil when nothing is warm (NVRSION §4). Restarts on the next land.
private var nvrsionKeepWarmTask: Task<Void, Never>?
/// How often the keep-warm sweep runs while files are warm. Internal so tests can shorten it (or
/// drive `runNvrsionSweep()` directly with a controlled clock).
var nvrsionKeepWarmInterval: Duration = .seconds(1)
/// The release-reconcile loop; nil when nothing holds a lock (LOCKING §4.4).
private var lockReconcileTask: Task<Void, Never>?
/// Re-check cadence for `reconcileLocks` a slow backstop, since release is also event-driven
@@ -568,6 +581,7 @@ public final class AppStore: ConflictArbiter {
self.containerManager = containerManager
self.conflictCoordinator = conflictCoordinator
self.now = now
self.nvrsionGovernor = NvrsionReleaseGovernor(now: now)
self.backendFactory = backendFactory
}
@@ -619,6 +633,8 @@ public final class AppStore: ConflictArbiter {
await containerManager?.reconcile(activeSessions: Array(controllers.keys))
// Rebuild held locks from authoritative git state now that controllers exist (LOCKING §6).
await reconstructLocks()
// Commit any uncommitted residue a crash left on an nvrsion trunk (NVRSION §8).
await reconcileNvrsionTrunks()
}
/// The last complete assistant reply in a transcript, or nil if unreadable/empty
@@ -742,7 +758,20 @@ public final class AppStore: ConflictArbiter {
/// Persist edits to a project's configuration (e.g. sandbox settings) and refresh the
/// in-memory list. Sandbox changes take effect on the next turn started in the project.
public func updateProject(_ project: Project) async {
let wasSandboxed = projectsByID[project.id]?.sandbox?.enabled == true
var project = project
let prev = projectsByID[project.id]
let wasSandboxed = prev?.sandbox?.enabled == true
// Flip-safety (NVRSION §10): toggling nvrsion (shared-trunk) on/off must not let the two
// versioning models coexist on live work. Refuse the flip while the project has a live
// (non-terminal) chat keep its previous nvrsion setting and surface why but let every
// other field of the update through.
let nvrsionFlip = (prev?.nvrsion?.enabled ?? false) != (project.nvrsion?.enabled ?? false)
if nvrsionFlip,
summaries.contains(where: { $0.projectID == project.id && !$0.archived && !$0.status.isTerminal })
{
project.nvrsion = prev?.nvrsion
lastError = "Finish or stop this project's chats before changing nvrsion (shared-trunk) mode."
}
do {
try await database.saveProject(project)
await loadProjects()
@@ -1910,10 +1939,15 @@ public final class AppStore: ConflictArbiter {
/// control container (the `git` interceptor shim, [[git-event]]). Outside it we couldn't
/// reliably unlock, so we don't lock at all rather than strand files.
private func lockDomain(for session: Session) -> LockDomain? {
guard projectsByID[session.projectID]?.isNucleicControlled == true else { return nil }
guard let project = projectsByID[session.projectID], project.isNucleicControlled else { return nil }
// nvrsion (NVRSION §10): the whole project shares one lock domain the trunk branch
// so every nvrsion session contends on a file and lands into the same trunk.
if project.nvrsionActive, let nvrsion = project.nvrsion {
return LockDomain(nvrsion.trunkBranch)
}
if let root = session.rootRef { return LockDomain(root) }
if let parent = session.parentRef { return LockDomain(parent) }
return projectsByID[session.projectID].map { LockDomain($0.defaultBranch.value) }
return LockDomain(project.defaultBranch.value)
}
/// Update-on-grant hook for the LockManager (LOCKING §4.5): bring a just-granted session's
@@ -1921,6 +1955,14 @@ public final class AppStore: ConflictArbiter {
/// proceeding. `.clean` if gone; `.changed` when the parent moved one of those files.
private func runUpdateOnGrant(_ id: SessionID, files: [String]) async -> LockUpdateResult {
guard let controller = controllers[id] else { return .clean }
let session = await controller.snapshot.session
// nvrsion (NVRSION §3.2): there's no per-session worktree to merge a parent into the
// session edits the shared trunk live. "Re-ground" = did a requested file move in the trunk
// since this session last had it? If so, tell the agent to re-read before editing.
if let project = projectsByID[session.projectID], project.nvrsionActive,
let trunk = session.worktreePath {
return await nvrsionTrunk.regroundOnGrant(trunkPath: trunk, session: id, files: files)
}
return await controller.updateFromParent(requestedFiles: files)
}
@@ -2072,6 +2114,128 @@ public final class AppStore: ConflictArbiter {
/// user by reusing `unmergedFiles`), and release everything for a terminal/archived holder.
/// Refreshes the "waiting"/deadlock indicators. Liveness is owned by the driver (`isIdle`), so
/// this no longer decides when to stop it just does the work.
/// The nvrsion edit loop (NVRSION §34), driven off each session's event stream. On every
/// completed edit (`.fileChange`, emitted only after a successful tool_result) land the file
/// into the shared trunk as a path-scoped commit. At turn-end (`.runFinished`) release the
/// turn's held files "keep-warm within a turn", so a sibling can't interleave between two of
/// the agent's edits to one file, but can take it once the turn settles (NVRSION §4). No-op
/// unless the session's project has nvrsion active.
private func handleNvrsionEvent(
_ sessionID: SessionID, _ controller: SessionController, _ event: AgentEvent
) async {
// Cheap pre-filter: only a completed edit or a turn boundary drives nvrsion skip the
// snapshot fetch for the common text/thinking/tool-delta events (every session, every run).
switch event.kind {
case .fileChange, .runFinished: break
default: return
}
let session = await controller.snapshot.session
guard let project = projectsByID[session.projectID], project.nvrsionActive,
let trunkPath = session.worktreePath
else { return }
switch event.kind {
case .fileChange(let change):
let rel = ConflictDetector.normalize(stripWorktreePrefix(change.path, trunkPath))
guard !rel.isEmpty else { return }
let message = session.title.isEmpty ? "nvrsion edit" : session.title
switch await nvrsionTrunk.land(
trunkPath: trunkPath, session: sessionID, paths: [rel], message: message,
prelandHook: project.nvrsion?.resolvedPrelandHook)
{
case .landed, .noop:
// Keep-warm (NVRSION §4): the file is landed but stays held; mark it warm so the
// sweep releases it once it goes idle past the project's keepWarmIdle (or at turn-end).
let idle = project.nvrsion?.keepWarmIdleSeconds ?? ProjectNvrsion.defaultKeepWarmIdleSeconds
await nvrsionGovernor.warmed(sessionID, rel, idleSeconds: idle)
startNvrsionKeepWarmIfNeeded()
case .rejected(let why):
await controller.note("nvrsion pre-land check rejected \(rel): \(why)",
icon: "exclamationmark.triangle")
case .failed(let why):
await controller.note("nvrsion could not land \(rel): \(why)",
icon: "exclamationmark.triangle")
}
case .runFinished:
// Turn-end: release every file the turn held warm (NVRSION §4).
await lockManager.releaseAll(sessionID)
await nvrsionGovernor.forget(sessionID)
default:
break
}
}
/// Start the keep-warm sweep loop if it isn't running (NVRSION §4). It wakes on its interval,
/// releases any landed file that's gone idle past its window, and parks itself once nothing is
/// warm the next land restarts it. Cheap: it only runs while files are actually held warm.
private func startNvrsionKeepWarmIfNeeded() {
guard nvrsionKeepWarmTask == nil else { return }
nvrsionKeepWarmTask = Task { [weak self] in
while let self, !Task.isCancelled {
try? await Task.sleep(for: self.nvrsionKeepWarmInterval)
if Task.isCancelled { break }
if await self.runNvrsionSweep() { break } // nothing left warm park
}
await self?.clearNvrsionKeepWarmTask()
}
}
private func clearNvrsionKeepWarmTask() { nvrsionKeepWarmTask = nil }
/// One keep-warm sweep pass: release each landed file that's gone idle past its window, then
/// report whether the governor is now empty (so the loop can park). Internal so tests can drive
/// it deterministically against a controlled clock.
@discardableResult
func runNvrsionSweep() async -> Bool {
for (session, paths) in await nvrsionGovernor.sweep() {
await lockManager.release(session, paths: paths)
}
return await nvrsionGovernor.isEmpty
}
/// Launch crash-recovery (NVRSION §8): for each nvrsion project, ensure its trunk exists and
/// commit any uncommitted residue a crash mid-edit may have left, so the trunk starts clean.
/// Lock state itself needs no reconstruction a restart kills every agent, so in-flight nvrsion
/// locks correctly vanish (and worktree-less nvrsion sessions are already skipped by
/// `reconstructLocks`); only the trunk's working tree can carry residue across a crash.
private func reconcileNvrsionTrunks() async {
for project in projects where project.nvrsionActive {
guard let nvrsion = project.nvrsion else { continue }
await nvrsionTrunk.recover(
root: project.rootPath, trunkPath: project.resolvedTrunkPath,
trunkBranch: nvrsion.trunkBranch, base: project.defaultBranch.value)
}
}
/// Promote an nvrsion project's accumulated trunk work into its real base branch (NVRSION §6)
/// the explicit "ship everything on the trunk" action. Squashes the trunk into the base as one
/// commit. Returns true on success; reports nothing-to-promote / conflicts / failure via
/// `lastError`. (Per-session autoship-on-completion is a future refinement; see docs/NVRSION.md.)
@discardableResult
public func promoteNvrsionTrunk(_ projectID: ProjectID) async -> Bool {
guard let project = projectsByID[projectID], project.nvrsionActive, let nvrsion = project.nvrsion
else { lastError = "nvrsion is not active for this project."; return false }
let base = project.defaultBranch.value
switch await nvrsionTrunk.promote(
root: project.rootPath, trunkPath: project.resolvedTrunkPath,
trunkBranch: nvrsion.trunkBranch, base: base,
message: "nvrsion: promote trunk to \(base)")
{
case .promoted(let sha):
lastError = nil
lockLog.notice("nvrsion promoted project=\(projectID.rawValue, privacy: .public) base=\(base, privacy: .public) sha=\(sha, privacy: .public)")
return true
case .nothingToPromote:
lastError = "Nothing on the nvrsion trunk to promote to \(base)."
return false
case .conflicted(let files):
lastError = "Promoting the nvrsion trunk conflicted in \(Self.lockPathList(files)). "
+ "Resolve on \(base) and retry."
return false
case .failed(let why):
lastError = "nvrsion promote failed: \(why)"
return false
}
}
private func reconcileLocks() async {
for sid in await lockManager.sessionsHoldingLocks() {
let heldPaths = await lockManager.heldPaths(sid)
@@ -2664,13 +2828,23 @@ public final class AppStore: ConflictArbiter {
let backendID = BackendID.forModel(resolvedModel) ?? project.defaultBackend ?? .claudeCode
let resolvedBase = base ?? project.defaultBranch
// nvrsion (NVRSION §2): the whole project shares one `nucleic/trunk` checkout the session
// makes NO isolated worktree; it edits the shared trunk directly and lands each edit into it.
// So it behaves like a worktree-less session whose working dir is the trunk.
let nvrsionTrunkPath: String? = project.nvrsionActive ? project.resolvedTrunkPath : nil
if let nvrsionTrunkPath, let nvrsion = project.nvrsion {
try await nvrsionTrunk.ensureTrunk(
root: project.rootPath, trunkPath: nvrsionTrunkPath,
trunkBranch: nvrsion.trunkBranch, base: project.defaultBranch.value)
}
// With a worktree (the default), the chat runs on its own isolated branch.
// Without one, it runs directly in the project's main checkout on the chosen
// branch no isolation, so merges/discards have nothing to remove.
let worktree: Worktree? = useWorktree
let worktree: Worktree? = (useWorktree && nvrsionTrunkPath == nil)
? try await worktrees.create(for: sessionID, in: project, base: resolvedBase, slug: title)
: nil
let worktreePath = worktree?.path ?? project.rootPath
let worktreePath = nvrsionTrunkPath ?? worktree?.path ?? project.rootPath
let transcriptURL = transcriptsDir
.appendingPathComponent(sessionID.rawValue)
@@ -2690,14 +2864,17 @@ public final class AppStore: ConflictArbiter {
// inherit that session's `rootRef` (the tree's top-level branch) and link to it; else
// this base *is* the root of a new tree.
let parentSession = await liveSession(whoseBranchIs: resolvedBase.value)
// nvrsion sessions all live on the shared trunk branch (their lock domain, NVRSION §10);
// they have no fork point (`baseSHA`) and no nesting the trunk is the one shared base.
let nvrsionBranch = nvrsionTrunkPath != nil ? project.nvrsion?.trunkBranch : nil
let session = Session(
id: sessionID, projectID: project.id, backend: backendID,
title: title, status: trimmedPrompt.isEmpty ? .awaitingInput : .running,
worktreePath: worktreePath, branch: worktree?.branch ?? resolvedBase.value,
worktreePath: worktreePath, branch: nvrsionBranch ?? worktree?.branch ?? resolvedBase.value,
baseSHA: worktree?.baseSHA,
parentRef: resolvedBase.value,
rootRef: parentSession?.rootRef ?? resolvedBase.value,
parentSessionID: parentSession?.id,
parentRef: nvrsionBranch ?? resolvedBase.value,
rootRef: nvrsionBranch ?? parentSession?.rootRef ?? resolvedBase.value,
parentSessionID: nvrsionBranch != nil ? nil : parentSession?.id,
model: resolvedModel, effort: effort ?? defaultEffort,
transcriptPath: transcriptURL.path, auto: resolvedAuto,
autoShip: resolvedAutoShip,
@@ -3163,6 +3340,7 @@ public final class AppStore: ConflictArbiter {
private func ingestUI(_ sessionID: SessionID, _ event: AgentEvent) async {
guard let controller = controllers[sessionID] else { return }
await handleNvrsionEvent(sessionID, controller, event)
// The account-wide rate-limit window the CLI reports out of band: mirror the
// latest one as the coarse fallback for the global quota indicator.
if case .rateLimit(let rl) = event.kind { latestRateLimit = rl }
@@ -0,0 +1,53 @@
import Foundation
/// Keep-warm release governor for nvrsion (NVRSION §4). After an agent *lands* a file it keeps the
/// lock ("warm") past that single edit, so a sibling can't interleave between two edits of one
/// logical change but only until the file goes idle past the project's `keepWarmIdle`, at which
/// point a sweep releases it without waiting for the whole turn to end. Turn-end / lifecycle drops
/// everything (the host calls `forget`). Re-landing a file refreshes its idle clock, so a file under
/// active editing stays held while a finished one is handed off quickly.
///
/// Pure timekeeping over an injected clock no locks, no git so the eviction policy is unit
/// testable in isolation; `AppStore` performs the actual `LockManager.release` for whatever `sweep`
/// returns. Releasing a file the agent later returns to simply re-acquires + re-grounds (NVRSION §4).
public actor NvrsionReleaseGovernor {
private let now: @Sendable () -> Date
private struct Warm { var landedAt: Date; var idle: TimeInterval }
/// `warm[session][path]` a file the session has landed and still holds, with its idle window.
private var warm: [SessionID: [String: Warm]] = [:]
public init(now: @escaping @Sendable () -> Date = { Date() }) { self.now = now }
/// Record (or refresh) that `session` just landed `path` and still holds it; `idleSeconds` is
/// the project's keep-warm window (NVRSION §4). Re-landing refreshes the idle clock.
public func warmed(_ session: SessionID, _ path: String, idleSeconds: Int) {
warm[session, default: [:]][path] = Warm(landedAt: now(), idle: TimeInterval(max(0, idleSeconds)))
}
/// Forget everything `session` holds warm turn-end or lifecycle (the host released the locks).
public func forget(_ session: SessionID) { warm[session] = nil }
/// The files idle past their window, grouped by session; the governor drops them from its table
/// (the caller releases the corresponding locks). A file re-landed within its window is kept.
public func sweep() -> [(session: SessionID, paths: [String])] {
let t = now()
var out: [(session: SessionID, paths: [String])] = []
for (session, files) in warm {
let due = files.filter { t.timeIntervalSince($0.value.landedAt) >= $0.value.idle }.map(\.key)
guard !due.isEmpty else { continue }
for p in due { warm[session]?[p] = nil }
if warm[session]?.isEmpty == true { warm[session] = nil }
out.append((session: session, paths: due.sorted()))
}
return out
}
/// Nothing is warm the host stops the sweep timer until the next land.
public var isEmpty: Bool { warm.isEmpty }
/// Warm paths a session currently holds, sorted (tests / introspection).
public func warmPaths(_ session: SessionID) -> [String] {
(warm[session].map { Array($0.keys) } ?? []).sorted()
}
}
+357
View File
@@ -0,0 +1,357 @@
import Foundation
import os
/// nvrsion lifecycle tracing (NVRSION). Every trunk ensure / land / reground is logged so a
/// stuck edit can be diagnosed from `log stream --predicate 'subsystem == "com.nucleic"'`
/// (category "nvrsion"). Paths + session ids are `.public` (a local project's own data).
let nvrsionLog = Logger(subsystem: "com.nucleic", category: "nvrsion")
/// Outcome of landing one edit into the trunk (NVRSION §3).
public enum NvrLandResult: Sendable, Equatable {
/// Committed `sha` is the new trunk HEAD.
case landed(sha: String)
/// Nothing to commit (the edit left the file identical to HEAD) treated as success.
case noop
/// The pre-land hook rejected the edit (NVRSION §5); `stderr` is fed back to the agent.
/// (Wired in Phase D; defined here so the contract is stable.)
case rejected(String)
/// Git failed (after retries) the edit stays on disk uncommitted; lock kept.
case failed(String)
}
/// Outcome of promoting the trunk into the real base branch (NVRSION §6).
public enum NvrPromoteResult: Sendable, Equatable {
/// The trunk's accumulated work was squashed into `base` as one commit (`sha`).
case promoted(sha: String)
/// The trunk already matches `base` nothing to promote.
case nothingToPromote
/// The squash into `base` conflicted (e.g. the base was edited outside the trunk); `files` list it.
case conflicted([String])
case failed(String)
}
/// Host-side coordinator for the shared **nvrsion trunk** (NVRSION §23). Owns the single
/// `nucleic/trunk` checkout for a project and lands each *completed* edit into it as a
/// **path-scoped** commit. The host performs every commit (the agent only writes the file, which
/// the virtiofs mount reflects on the host), so lock release is **host-certain** there is no
/// landing-detection poll, and "committed landed" can't strand a lock (NVRSION §4).
///
/// Although an `actor`, git work suspends at `await`, and Swift actors are *reentrant* two
/// `land`s would otherwise interleave their `add`+`commit` on the one shared `.git/index`. So an
/// explicit serial gate (`withGate`) makes the addcommit sequence atomic against any other trunk
/// op; the agent's own in-container git can still race the index, handled by bounded retry.
public actor NvrsionTrunk {
private let git: GitRunner
public init(git: GitRunner = GitRunner()) { self.git = git }
// MARK: Serial gate (reentrancy-safe actor mutex)
private var busy = false
private var waiters: [CheckedContinuation<Void, Never>] = []
private func acquireGate() async {
if !busy { busy = true; return }
await withCheckedContinuation { waiters.append($0) }
}
private func releaseGate() {
if waiters.isEmpty { busy = false } else { waiters.removeFirst().resume() }
}
/// Run `body` with exclusive access to this trunk's git index (serializes add+commit etc.).
private func withGate<T>(_ body: () async throws -> T) async rethrows -> T {
await acquireGate()
do { let r = try await body(); releaseGate(); return r }
catch { releaseGate(); throw error }
}
// MARK: State
/// Trunk checkouts already ensured this run (idempotence), keyed by trunk path.
private var ensured: Set<String> = []
/// `observed[session][path]` = the trunk blob sha the session last had for `path` drives the
/// on-grant re-ground decision (NVRSION §3.2). Absent path `""`.
private var observed: [SessionID: [String: String]] = [:]
// MARK: Ensure the trunk worktree exists (NVRSION §2)
/// Idempotently ensure `<repo>/.nucleic/trunk` is a worktree checked out on `trunkBranch`,
/// forking it from `base` when the branch is new. Safe to call on every nvrsion session create.
public func ensureTrunk(root: String, trunkPath: String, trunkBranch: String, base: String) async throws {
if ensured.contains(trunkPath) { return }
try await withGate {
if ensured.contains(trunkPath) { return }
let dotGit = (trunkPath as NSString).appendingPathComponent(".git")
if FileManager.default.fileExists(atPath: dotGit) {
ensured.insert(trunkPath)
nvrsionLog.info("nvrsion ensureTrunk reuse path=\(trunkPath, privacy: .public)")
return
}
let parent = (trunkPath as NSString).deletingLastPathComponent
try? FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
// Drop any stale worktree registration for this path before re-adding.
_ = try? await git.run(["worktree", "prune"], in: root)
let branchExists =
(try? await git.run(["rev-parse", "--verify", "--quiet", "\(trunkBranch)^{commit}"], in: root))?.ok == true
let args = branchExists
? ["worktree", "add", trunkPath, trunkBranch]
: ["worktree", "add", trunkPath, "-b", trunkBranch, base]
let res = try await git.run(args, in: root)
guard res.ok else {
nvrsionLog.error("nvrsion ensureTrunk FAILED path=\(trunkPath, privacy: .public) stderr=\(res.stderr, privacy: .public)")
throw GitError.commandFailed(
command: "git " + args.joined(separator: " "), status: res.status, stderr: res.stderr)
}
ensured.insert(trunkPath)
nvrsionLog.notice("nvrsion ensureTrunk created path=\(trunkPath, privacy: .public) branch=\(trunkBranch, privacy: .public) base=\(base, privacy: .public)")
}
}
// MARK: Land one edit (NVRSION §3)
/// Commit exactly `paths` (repo-relative) into the trunk as one path-scoped commit, attributed
/// to `session`. Serialized; retries a few times if the agent's own git holds `.git/index.lock`.
/// Returns `.landed`/`.noop` on success (lock may release), or `.failed` (lock kept).
///
/// `prelandHook` (NVRSION §5), when set, runs on the edited files *before* the commit; a non-zero
/// exit returns `.rejected` so the edit does **not** land (its content stays on disk, lock kept).
/// The hook runs OUTSIDE the index gate (it only reads files), so a slow hook can't wedge other
/// sessions' lands only its own edit waits.
public func land(
trunkPath: String, session: SessionID, paths: [String], message: String,
prelandHook: String? = nil
) async -> NvrLandResult {
let specs = paths.filter { !$0.isEmpty }
guard !specs.isEmpty else { return .noop }
if let prelandHook {
let (passed, output) = await runPrelandHook(prelandHook, trunkPath: trunkPath, paths: specs)
if !passed {
nvrsionLog.notice("nvrsion preland REJECT session=\(session.rawValue, privacy: .public) paths=\(specs.joined(separator: ","), privacy: .public)")
return .rejected(output.isEmpty ? "pre-land check failed" : output)
}
}
return await withGate {
for attempt in 0..<3 {
// Stage adds/mods/deletes scoped to these paths only never another session's
// in-flight file (the per-edit isolation that keeps trunk consistent).
let add = try? await git.run(["add", "-A", "--"] + specs, in: trunkPath)
let commit = try? await git.run(
["-c", "commit.gpgsign=false", "commit", "-m", message,
"--trailer", "Nucleic-Session: \(session.rawValue)", "--"] + specs,
in: trunkPath)
guard let commit else {
if attempt < 2 { try? await Task.sleep(nanoseconds: 60_000_000); continue }
return .failed("git did not run")
}
if commit.ok {
let head = (try? await git.run(["rev-parse", "HEAD"], in: trunkPath))?
.stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
await recordObserved(session: session, trunkPath: trunkPath, paths: specs)
nvrsionLog.notice("nvrsion land session=\(session.rawValue, privacy: .public) paths=\(specs.joined(separator: ","), privacy: .public) sha=\(head, privacy: .public)")
return .landed(sha: head)
}
let blob = commit.stdout + commit.stderr
if blob.contains("nothing to commit") || blob.contains("no changes added") {
// The edit produced no net change vs HEAD (identical content) fine.
await recordObserved(session: session, trunkPath: trunkPath, paths: specs)
return .noop
}
if blob.contains("index.lock") && attempt < 2 {
try? await Task.sleep(nanoseconds: 60_000_000); continue // agent's git holds it; retry
}
_ = add
nvrsionLog.error("nvrsion land FAILED session=\(session.rawValue, privacy: .public) paths=\(specs.joined(separator: ","), privacy: .public) stderr=\(commit.stderr, privacy: .public)")
return .failed(commit.stderr)
}
return .failed("exhausted retries")
}
}
// MARK: Launch crash-recovery (NVRSION §8)
/// Ensure the trunk exists, then commit any uncommitted residue a crash mid-edit may have left
/// on it (the agent wrote a file but the app died before the `.fileChange` landed it), so the
/// trunk starts each run clean and nothing is silently lost. Best-effort failures only log.
public func recover(root: String, trunkPath: String, trunkBranch: String, base: String) async {
do {
try await ensureTrunk(root: root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: base)
} catch {
nvrsionLog.error("nvrsion recover ensure-failed path=\(trunkPath, privacy: .public)")
return
}
await withGate {
let status = (try? await git.run(["status", "--porcelain"], in: trunkPath))?
.stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !status.isEmpty else { return }
_ = try? await git.run(["add", "-A"], in: trunkPath)
_ = try? await git.run(
["-c", "commit.gpgsign=false", "commit",
"-m", "nvrsion: recovered uncommitted work after restart",
"--trailer", "Nucleic-Recovery: 1"],
in: trunkPath)
nvrsionLog.notice("nvrsion recovered dirty trunk path=\(trunkPath, privacy: .public)")
}
}
// MARK: Re-ground on grant (NVRSION §3.2)
/// The nvrsion update-on-grant: did any of `files` move in the trunk since `session` last had
/// them? `.clean` on first acquaintance (matching LOCKING §4.5: re-ground only fires when a file
/// the session *already had* advanced), else `.changed` so the host tells the agent to re-read.
public func regroundOnGrant(
trunkPath: String, session: SessionID, files: [String]
) async -> LockUpdateResult {
let specs = files.filter { !$0.isEmpty }
guard !specs.isEmpty else { return .clean }
return await withGate {
var moved: [String] = []
for f in specs {
let cur = await blobSha(trunkPath: trunkPath, path: f)
if let last = observed[session]?[f], last != cur { moved.append(f) }
observed[session, default: [:]][f] = cur // now the session has seen the current content
}
if moved.isEmpty { return .clean }
nvrsionLog.notice("nvrsion reground session=\(session.rawValue, privacy: .public) files=\(moved.joined(separator: ","), privacy: .public)")
return .changed(files: moved, diff: "") // empty diff the host's notice says "re-read"
}
}
/// Forget a finished session's observed-sha state (called on release-all / lifecycle).
public func forget(_ session: SessionID) {
observed[session] = nil
}
// MARK: Promotion trunk base (NVRSION §6)
/// Squash the trunk's accumulated work into the real `base` branch as one clean commit, run in
/// the project's root checkout (which is on `base` and untouched by nvrsion agents). Then
/// best-effort merge `base` back into the trunk so the next promotion is incremental. Conflicts
/// (e.g. `base` edited outside the trunk) leave both branches untouched and are reported.
public func promote(
root: String, trunkPath: String, trunkBranch: String, base: String, message: String
) async -> NvrPromoteResult {
await withGate {
// The squash merges trunk INTO whatever `root` has checked out require that to be `base`,
// so we never land trunk work on the wrong branch.
let onBranch = (try? await git.run(["rev-parse", "--abbrev-ref", "HEAD"], in: root))?
.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
guard onBranch == base else {
return .failed("root checkout is on \(onBranch ?? "?"), not \(base) — can't promote")
}
// Nothing to do when the trunk's content already equals base.
if let diff = try? await git.run(["diff", "--quiet", base, trunkBranch], in: root), diff.status == 0 {
return .nothingToPromote
}
let merge = try? await git.run(["merge", "--squash", trunkBranch], in: root)
guard let merge else { return .failed("git did not run") }
if !merge.ok {
// Capture the conflicted paths, then reset ( --squash sets no MERGE_HEAD, so only a
// hard reset is the true inverse, mirroring WorktreeManager.integrate).
let conflicts = (try? await git.run(
["diff", "--name-only", "--diff-filter=U"], in: root))?
.stdout.split(separator: "\n").map(String.init) ?? []
_ = try? await git.run(["reset", "--hard", "HEAD"], in: root)
nvrsionLog.error("nvrsion promote CONFLICT base=\(base, privacy: .public) files=\(conflicts.joined(separator: ","), privacy: .public)")
return .conflicted(conflicts.isEmpty ? ["<unknown>"] : conflicts)
}
// --squash stages without committing; commit unless the squash was a no-op.
if let staged = try? await git.run(["diff", "--cached", "--quiet"], in: root), staged.status == 0 {
return .nothingToPromote
}
let commit = try? await git.run(
["-c", "commit.gpgsign=false", "commit", "-m", message, "--trailer", "Nucleic-Promote: 1"],
in: root)
guard let commit, commit.ok else {
_ = try? await git.run(["reset", "--hard", "HEAD"], in: root)
return .failed(commit?.stderr ?? "commit failed")
}
let head = (try? await git.run(["rev-parse", "HEAD"], in: root))?
.stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// Resync the trunk onto the new base so the next promotion squashes only NEW work
// (advances the merge-base). Best-effort: skipped harmlessly if the trunk is dirty.
_ = try? await git.run(["merge", base], in: trunkPath)
nvrsionLog.notice("nvrsion promote base=\(base, privacy: .public) sha=\(head, privacy: .public)")
return .promoted(sha: head)
}
}
// MARK: Pre-land hook (NVRSION §5)
/// How long a pre-land hook may run before it's killed and the edit rejected.
public static let prelandTimeoutSeconds: TimeInterval = 10
private enum HookOutcome: Sendable { case exited(Int32); case spawnFailed; case timedOut }
/// Run `command` via `/bin/sh -c` on the host in the trunk dir, with the edited paths in
/// `NUCLEIC_NVR_PATHS`. Returns whether it passed (exit 0 within the timeout) and its combined
/// output (stderr+stdout) to feed back. A timeout or spawn failure counts as a failure.
private func runPrelandHook(
_ command: String, trunkPath: String, paths: [String]
) async -> (passed: Bool, output: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", command]
process.currentDirectoryURL = URL(fileURLWithPath: trunkPath)
var env = ProcessInfo.processInfo.environment
env["NUCLEIC_NVR_PATHS"] = paths.joined(separator: " ")
process.environment = env
let outPipe = Pipe(), errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
process.standardInput = FileHandle.nullDevice
async let outData = Self.readToEnd(outPipe.fileHandleForReading)
async let errData = Self.readToEnd(errPipe.fileHandleForReading)
// Wait for exit (handler set *before* run, GitRunner-style, so a fast exit isn't missed).
let exitWait = Task { () -> HookOutcome in
await withCheckedContinuation { (c: CheckedContinuation<HookOutcome, Never>) in
process.terminationHandler = { c.resume(returning: .exited($0.terminationStatus)) }
do { try process.run() } catch {
process.terminationHandler = nil
c.resume(returning: .spawnFailed)
}
}
}
let outcome = await withTaskGroup(of: HookOutcome.self) { group -> HookOutcome in
group.addTask { await exitWait.value }
group.addTask {
try? await Task.sleep(for: .seconds(Self.prelandTimeoutSeconds)); return .timedOut
}
let first = await group.next() ?? .timedOut
group.cancelAll()
return first
}
let out = (String(decoding: await errData, as: UTF8.self)
+ String(decoding: await outData, as: UTF8.self))
.trimmingCharacters(in: .whitespacesAndNewlines)
switch outcome {
case .timedOut:
process.terminate()
return (false, "pre-land hook timed out after \(Int(Self.prelandTimeoutSeconds))s"
+ (out.isEmpty ? "" : "\n\(out)"))
case .spawnFailed:
return (false, "could not start pre-land hook")
case .exited(let status):
return (status == 0, out)
}
}
private static func readToEnd(_ handle: FileHandle) async -> Data {
await withCheckedContinuation { c in
DispatchQueue.global().async { c.resume(returning: (try? handle.readToEnd()) ?? Data()) }
}
}
// MARK: Helpers
private func recordObserved(session: SessionID, trunkPath: String, paths: [String]) async {
for p in paths { observed[session, default: [:]][p] = await blobSha(trunkPath: trunkPath, path: p) }
}
/// The trunk's current blob sha for `path` (`""` if absent deleted/untracked).
private func blobSha(trunkPath: String, path: String) async -> String {
let res = try? await git.run(["rev-parse", "--verify", "--quiet", "HEAD:\(path)"], in: trunkPath)
guard let res, res.ok else { return "" }
return res.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -162,6 +162,11 @@ public final class GRDBMetadataStore: SessionMetadataStore {
try db.execute(
sql: "ALTER TABLE session ADD COLUMN auto_ship_conflict INTEGER NOT NULL DEFAULT 0;")
}
migrator.registerMigration("v20-nvrsion") { db in
// Per-project nvrsion (Beta) config, JSON-encoded like sandbox_config; NULL off
// (NVRSION §8, §10). New fields are additive within the JSON, no further migration.
try db.execute(sql: "ALTER TABLE project ADD COLUMN nvrsion_config TEXT;")
}
return migrator
}
@@ -291,6 +296,7 @@ private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
var setup_script: String?
var setup_policy: String
var sandbox_config: String?
var nvrsion_config: String?
var created_at: Date
var archived_at: Date?
@@ -307,6 +313,9 @@ private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
sandbox_config = p.sandbox.flatMap { sandbox in
(try? JSONEncoder().encode(sandbox)).map { String(decoding: $0, as: UTF8.self) }
}
nvrsion_config = p.nvrsion.flatMap { nvrsion in
(try? JSONEncoder().encode(nvrsion)).map { String(decoding: $0, as: UTF8.self) }
}
created_at = p.createdAt
archived_at = p.archivedAt
}
@@ -325,6 +334,9 @@ private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
sandbox: sandbox_config.flatMap { json in
try? JSONDecoder().decode(ProjectSandbox.self, from: Data(json.utf8))
},
nvrsion: nvrsion_config.flatMap { json in
try? JSONDecoder().decode(ProjectNvrsion.self, from: Data(json.utf8))
},
createdAt: created_at,
archivedAt: archived_at)
}
+79
View File
@@ -108,6 +108,64 @@ public struct ProjectSandbox: Sendable, Codable, Equatable {
}
}
/// Per-project **nvrsion** settings (NVRSION, Beta). When active a control project's sessions
/// share a single `nucleic/trunk` checkout and land each edit into it immediately instead of
/// each session forking an isolated worktree/branch held until ship (LOCKING). `nil` on a Project
/// (or `enabled == false`) means off the default, classic per-session-worktree behavior.
///
/// Persisted as `nvrsion_config` JSON on the `project` row (mirrors `ProjectSandbox`), so new
/// fields are additive without a migration. Only meaningful for Nucleic Control projects that use
/// the shared control container see `Project.nvrsionActive` (NVRSION §10).
public struct ProjectNvrsion: Sendable, Codable, Equatable {
/// Master switch for the mode. Default off (Beta, opt-in).
public var enabled: Bool
/// The shared trunk branch all the project's nvrsion sessions edit and land into.
public var trunkBranch: String
/// Optional fast validation command run after an edit is written and **before** it lands in
/// trunk (NVRSION §5). Receives the edited paths; non-zero exit rejects the edit back to the
/// agent (kept warm, not committed). `nil`/empty land immediately (the default).
public var prelandHook: String?
/// Keep-warm idle window in seconds (NVRSION §4): a held file untouched this long is released
/// mid-turn so a waiter can take it. Lower = faster hand-off, more re-reads.
public var keepWarmIdleSeconds: Int
public static let defaultTrunkBranch = "nucleic/trunk"
public static let defaultKeepWarmIdleSeconds = 4
public init(
enabled: Bool = false,
trunkBranch: String = ProjectNvrsion.defaultTrunkBranch,
prelandHook: String? = nil,
keepWarmIdleSeconds: Int = ProjectNvrsion.defaultKeepWarmIdleSeconds
) {
self.enabled = enabled
self.trunkBranch = trunkBranch
self.prelandHook = prelandHook
self.keepWarmIdleSeconds = keepWarmIdleSeconds
}
// Tolerant decode: rows persisted before a field existed decode to its default rather than
// failing the whole blob (the store decodes with `try?`, so a strict miss drops the config).
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false
trunkBranch =
try c.decodeIfPresent(String.self, forKey: .trunkBranch).flatMap {
$0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0
} ?? ProjectNvrsion.defaultTrunkBranch
prelandHook = try c.decodeIfPresent(String.self, forKey: .prelandHook)
keepWarmIdleSeconds =
try c.decodeIfPresent(Int.self, forKey: .keepWarmIdleSeconds)
?? ProjectNvrsion.defaultKeepWarmIdleSeconds
}
/// The pre-land hook to actually run, or `nil` when unset/blank ( land immediately).
public var resolvedPrelandHook: String? {
guard let h = prelandHook?.trimmingCharacters(in: .whitespaces), !h.isEmpty else { return nil }
return h
}
}
/// App-wide container preferences, persisted in `UserDefaults`. These gate and seed the
/// *per-project* `ProjectSandbox` config above they never sandbox sessions on their own.
/// Surfaced in Settings General; read here so `NucleicCore` (e.g. `SessionController`,
@@ -419,6 +477,9 @@ public struct Project: Identifiable, Sendable, Codable, Equatable {
public var setupPolicy: SetupPolicy
/// Execution-sandbox settings. `nil` sessions run on the host (default).
public var sandbox: ProjectSandbox?
/// nvrsion (Beta) settings. `nil`/`enabled == false` classic per-session-worktree behavior
/// (the default). See `nvrsionActive` for the full gate (NVRSION §10).
public var nvrsion: ProjectNvrsion?
public var createdAt: Date
/// When the project was archived, or `nil` if it's active. Archiving hides the project
/// from the active list and stops its live sessions, but leaves all files and records in
@@ -436,6 +497,7 @@ public struct Project: Identifiable, Sendable, Codable, Equatable {
setupScript: String? = nil,
setupPolicy: SetupPolicy = .block,
sandbox: ProjectSandbox? = nil,
nvrsion: ProjectNvrsion? = nil,
createdAt: Date = Date(),
archivedAt: Date? = nil
) {
@@ -449,6 +511,7 @@ public struct Project: Identifiable, Sendable, Codable, Equatable {
self.setupScript = setupScript
self.setupPolicy = setupPolicy
self.sandbox = sandbox
self.nvrsion = nvrsion
self.createdAt = createdAt
self.archivedAt = archivedAt
}
@@ -495,6 +558,22 @@ public struct Project: Identifiable, Sendable, Codable, Equatable {
isNucleicControlled && !(effectiveSandbox?.perSessionContainers ?? false)
}
/// Whether **nvrsion** (Beta) governs this project's sessions the single gate both the spawn
/// path and teardown read so they can't drift (NVRSION §10). Requires a Nucleic Control project,
/// the mode enabled, and the shared control container (v0 doesn't support per-session
/// containers). Like `effectiveSandbox` it stays subordinate to the app-wide container-service
/// master switch, which callers check first.
public var nvrsionActive: Bool {
isNucleicControlled && (nvrsion?.enabled ?? false) && usesSharedControlContainer
}
/// Host path of the shared nvrsion trunk checkout: `<repoRoot>/.nucleic/trunk` kept inside the
/// project under `.nucleic/` (git-excluded) on the same volume, beside the worktrees dir.
public var resolvedTrunkPath: String {
let nucleicDir = (rootPath as NSString).appendingPathComponent(".nucleic")
return (nucleicDir as NSString).appendingPathComponent("trunk")
}
/// `<repoSlug>` used in the worktree path and branch derivation.
public var repoSlug: String {
let last = (rootPath as NSString).lastPathComponent
+160
View File
@@ -177,6 +177,166 @@ struct AppStoreTests {
.appendingPathComponent(".nucleic/worktrees/make-a-file/made.txt")))
}
// MARK: - nvrsion (NVRSION) shared-trunk mode, end to end
/// Turn a control project into an nvrsion project and return the refreshed `Project`.
private func enableNvrsion(_ store: AppStore, _ project: Project) async -> Project {
var p = project
p.nvrsion = ProjectNvrsion(enabled: true)
await store.updateProject(p)
return store.project(project.id) ?? p
}
@Test func nvrsionLandsEditOnTrunkAndMakesNoPerSessionWorktree() async throws {
let repo = try await GitTestRepo(controlled: true) // nvrsion is Control-only (NVRSION §10)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
#expect(project.nvrsionActive)
let session = try await store.createSession(in: project, title: "make a file", prompt: "go")
store.openSessionID = session
await store.awaitOpenSessionSettled()
let trunkPath = project.resolvedTrunkPath
// The scripted edit (made.txt) landed on `nucleic/trunk` as a path-scoped, attributed commit.
let names = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
#expect(names.contains("made.txt"))
let body = try await repo.run(["log", "-1", "--format=%B", "HEAD"], in: trunkPath).stdout
#expect(body.contains("Nucleic-Session: \(session.rawValue)"))
// No isolated per-session worktree/branch only the shared trunk branch exists.
let branches = try await repo.run(["branch", "--list"]).stdout
#expect(branches.contains("nucleic/trunk"))
#expect(!branches.contains("make-a-file"))
// And no per-session worktree directory was created under .nucleic/worktrees.
#expect(!FileManager.default.fileExists(
atPath: (repo.root as NSString).appendingPathComponent(".nucleic/worktrees/make-a-file")))
}
@Test func nvrsionSessionsShareTheOneTrunkLockDomain() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .milliseconds(20)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
let a = try await store.createSession(in: project, title: "alpha", prompt: "go")
let b = try await store.createSession(in: project, title: "bravo", prompt: "go")
await waitFor {
store.summaries.first { $0.id == a }?.status == .awaitingInput
&& store.summaries.first { $0.id == b }?.status == .awaitingInput
}
// A acquires shared.txt. Because every nvrsion session shares ONE lock domain (the trunk),
// B asking for the same file must queue not proceed independently.
#expect(await store.arbitrate(sessionID: a, task: "edit", files: ["shared.txt"]).resolution == .proceed)
let arbitration = Task { await store.arbitrate(sessionID: b, task: "edit", files: ["shared.txt"]) }
await waitFor { store.sessionsWaitingForAccess.contains(b) }
#expect(store.sessionsWaitingForAccess.contains(b))
// Releasing A grants B proving they contended in the same domain.
await store.forceReleaseLocks(a)
#expect(await arbitration.value.resolution == .proceed)
}
@Test func nvrsionKeepWarmSweepReleasesAnIdleHeldLock() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let clock = MutableClock(Date(timeIntervalSince1970: 1_700_000_000))
let store = makeStore(repo: repo, now: clock.now)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
// A session that doesn't run (empty prompt) so no turn-end clears the warm set under us.
let a = try await store.createSession(in: project, title: "alpha", prompt: "")
// It holds shared.txt (acquired via arbitrate); mark it warm with a 1s window, as a land would.
#expect(await store.arbitrate(sessionID: a, task: "edit", files: ["shared.txt"]).resolution == .proceed)
await store.nvrsionGovernor.warmed(a, "shared.txt", idleSeconds: 1)
// Before the window elapses, the sweep keeps the lock held (keep-warm).
#expect(await store.runNvrsionSweep() == false)
let held = await store.lockQueueSnapshot()
#expect(held.files.contains { $0.path == "shared.txt" && $0.holders.contains { $0.sessionID == a } })
// Past the window, the sweep releases it without waiting for the turn to end (NVRSION §4).
clock.advance(2)
#expect(await store.runNvrsionSweep() == true)
let freed = await store.lockQueueSnapshot()
#expect(!freed.files.contains { $0.path == "shared.txt" })
}
@Test func nvrsionPromoteShipsTrunkWorkToTheRealBranch() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
// A scripted session lands made.txt on the trunk.
let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
store.openSessionID = session
await store.awaitOpenSessionSettled()
let mainBefore = try await repo.revParse("refs/heads/main")
// Promote the real branch (main) gets the trunk's work as one squashed commit.
#expect(await store.promoteNvrsionTrunk(project.id) == true)
#expect(try await repo.revParse("refs/heads/main") != mainBefore)
#expect(repo.read("made.txt", in: repo.root) == "by agent\n")
// Nothing new on the trunk now a second promote reports nothing-to-promote.
#expect(await store.promoteNvrsionTrunk(project.id) == false)
#expect(store.lastError?.contains("Nothing on the nvrsion trunk") == true)
}
@Test func nvrsionPrelandHookRejectsAnEditFromLanding() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
// Enable nvrsion with a pre-land hook that always fails.
var p = created
p.nvrsion = ProjectNvrsion(enabled: true, prelandHook: "exit 1")
await store.updateProject(p)
let project = try #require(store.project(created.id))
#expect(project.nvrsionActive)
let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
store.openSessionID = session
await store.awaitOpenSessionSettled()
// The scripted edit was rejected by the hook, so it never landed on the trunk.
let trunkPath = project.resolvedTrunkPath
let inTrunk = try await repo.run(["cat-file", "-e", "HEAD:made.txt"], in: trunkPath)
#expect(inTrunk.status != 0) // made.txt is not in the trunk's committed tree
// And a rejection note was posted.
#expect(store.openTranscript.contains {
if case .note(let n) = $0.kind { return n.text.contains("pre-land check rejected") }
return false
})
}
@Test func nvrsionFlipIsRefusedWhileAChatIsLive() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created) // enabled with no live chats allowed
#expect(project.nvrsionActive)
// A non-terminal chat now exists (the scripted run settles it at awaitingInput).
let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
await waitFor { store.summaries.first { $0.id == session }?.status == .awaitingInput }
// Turning nvrsion OFF is refused while the chat is live the project stays nvrsion-active.
var off = project
off.nvrsion = ProjectNvrsion(enabled: false)
await store.updateProject(off)
#expect(store.project(project.id)?.nvrsionActive == true)
#expect(store.lastError?.contains("nvrsion") == true)
}
@Test func archivedWorktreeCleanupReclaimsPastThresholdThenRestoresOnUnarchive() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
@@ -32,6 +32,60 @@ struct GRDBMetadataStoreTests {
#expect(loaded == [project])
}
@Test func nvrsionConfigRoundTrips() async throws {
let store = try GRDBMetadataStore(path: nil)
// Default project: no nvrsion config nil (off).
let plain = makeProject()
try await store.saveProject(plain)
#expect(try await store.loadProjects().first(where: { $0.id == plain.id })?.nvrsion == nil)
// Project with an explicit nvrsion config round-trips every field (v20).
var project = makeProject()
project.nvrsion = ProjectNvrsion(
enabled: true, trunkBranch: "nucleic/trunk", prelandHook: "nvrsion-check",
keepWarmIdleSeconds: 9)
try await store.saveProject(project)
let loaded = try await store.loadProjects().first(where: { $0.id == project.id })?.nvrsion
#expect(loaded == ProjectNvrsion(
enabled: true, trunkBranch: "nucleic/trunk", prelandHook: "nvrsion-check",
keepWarmIdleSeconds: 9))
// A blank pre-land hook decodes back to nil ( land immediately), and a missing
// trunk-branch falls back to the default the tolerant decode (NVRSION §10).
let blankHook = #"{"enabled":true,"prelandHook":" ","keepWarmIdleSeconds":2}"#
let decoded = try JSONDecoder().decode(ProjectNvrsion.self, from: Data(blankHook.utf8))
#expect(decoded.resolvedPrelandHook == nil)
#expect(decoded.trunkBranch == ProjectNvrsion.defaultTrunkBranch)
}
@Test func nvrsionActiveGate() {
// A control-located repo with nvrsion on and the shared container active.
let controlPath = (ProjectCloner.controlBase as NSString).appendingPathComponent("demo")
var control = Project(
id: .generate(), name: "demo", rootPath: controlPath, defaultBranch: "main",
nvrsion: ProjectNvrsion(enabled: true), createdAt: now)
#expect(control.isNucleicControlled)
#expect(control.usesSharedControlContainer)
#expect(control.nvrsionActive)
// Disabled not active.
control.nvrsion = ProjectNvrsion(enabled: false)
#expect(!control.nvrsionActive)
// Enabled but per-session containers (no shared container) not active in v0.
control.nvrsion = ProjectNvrsion(enabled: true)
control.sandbox = ProjectSandbox(enabled: true, perSessionContainers: true)
#expect(!control.usesSharedControlContainer)
#expect(!control.nvrsionActive)
// A non-control repo is never nvrsion-active, even with the flag on.
let outside = Project(
id: .generate(), name: "demo", rootPath: "/repos/demo", defaultBranch: "main",
nvrsion: ProjectNvrsion(enabled: true), createdAt: now)
#expect(!outside.isNucleicControlled)
#expect(!outside.nvrsionActive)
}
@Test func shipDestinationResolutionPrecedence() {
var project = makeProject() // defaultBranch == "main", no autoship override
var session = makeSession(projectID: project.id)
@@ -0,0 +1,93 @@
import Foundation
import Testing
@testable import NucleicCore
/// A clock the test advances by hand, so keep-warm eviction (which is pure timekeeping) is
/// deterministic without real sleeps. Thread-safe the governor reads it from an actor.
final class MutableClock: @unchecked Sendable {
private let lock = NSLock()
private var _date: Date
init(_ date: Date = Date(timeIntervalSince1970: 1_000_000)) { _date = date }
var date: Date { lock.withLock { _date } }
func advance(_ seconds: TimeInterval) { lock.withLock { _date = _date.addingTimeInterval(seconds) } }
var now: @Sendable () -> Date { { [self] in date } }
}
@Suite("NvrsionReleaseGovernor — keep-warm eviction (NVRSION §4)")
struct NvrsionReleaseGovernorTests {
@Test func keepsAFileUntilIdlePastWindowThenSweepsIt() async throws {
let clock = MutableClock()
let gov = NvrsionReleaseGovernor(now: clock.now)
let s = SessionID.generate()
await gov.warmed(s, "a.txt", idleSeconds: 4)
// Before the window: held the sweep returns nothing and the file stays warm.
clock.advance(3)
#expect(await gov.sweep().isEmpty)
#expect(await gov.warmPaths(s) == ["a.txt"])
// Past the window: swept and dropped.
clock.advance(2) // 5 4
let swept = await gov.sweep()
#expect(swept.count == 1)
#expect(swept.first?.session == s)
#expect(swept.first?.paths == ["a.txt"])
#expect(await gov.isEmpty)
}
@Test func reLandingRefreshesTheIdleClock() async throws {
let clock = MutableClock()
let gov = NvrsionReleaseGovernor(now: clock.now)
let s = SessionID.generate()
await gov.warmed(s, "a.txt", idleSeconds: 4)
clock.advance(3)
await gov.warmed(s, "a.txt", idleSeconds: 4) // active edit refreshes the clock
clock.advance(2) // only 2 since the refresh
#expect(await gov.sweep().isEmpty) // still held active work protected
clock.advance(3) // now 5 since the refresh
#expect(await gov.sweep().first?.paths == ["a.txt"])
}
@Test func forgetDropsEverythingForASession() async throws {
let clock = MutableClock()
let gov = NvrsionReleaseGovernor(now: clock.now)
let s = SessionID.generate()
await gov.warmed(s, "a.txt", idleSeconds: 4)
await gov.warmed(s, "b.txt", idleSeconds: 4)
await gov.forget(s) // turn-end / lifecycle
#expect(await gov.isEmpty)
clock.advance(100)
#expect(await gov.sweep().isEmpty)
}
@Test func sweepsIdleFilesButKeepsActiveOnes() async throws {
let clock = MutableClock()
let gov = NvrsionReleaseGovernor(now: clock.now)
let s = SessionID.generate()
await gov.warmed(s, "old.txt", idleSeconds: 2)
clock.advance(1)
await gov.warmed(s, "new.txt", idleSeconds: 2) // landed 1s after old
clock.advance(1.5) // old: 2.5 2 (due); new: 1.5 < 2 (kept)
let swept = await gov.sweep()
#expect(swept.first?.paths == ["old.txt"])
#expect(await gov.warmPaths(s) == ["new.txt"])
}
@Test func differentSessionsAreSweptIndependently() async throws {
let clock = MutableClock()
let gov = NvrsionReleaseGovernor(now: clock.now)
let a = SessionID.generate()
let b = SessionID.generate()
await gov.warmed(a, "x", idleSeconds: 2)
clock.advance(3)
await gov.warmed(b, "y", idleSeconds: 2) // b is fresh
let swept = await gov.sweep()
#expect(swept.count == 1)
#expect(swept.first?.session == a)
#expect(await gov.warmPaths(b) == ["y"]) // b kept
}
}
@@ -0,0 +1,238 @@
import Foundation
import Testing
@testable import NucleicCore
@Suite("NvrsionTrunk — real git on temp repos (NVRSION §23)")
struct NvrsionTrunkTests {
let trunkBranch = "nucleic/trunk"
/// Create a repo + ensure its trunk; returns (repo, trunk, trunkPath).
func makeTrunk() async throws -> (GitTestRepo, NvrsionTrunk, String) {
let repo = try await GitTestRepo()
let trunk = NvrsionTrunk()
let trunkPath = repo.project().resolvedTrunkPath
try await trunk.ensureTrunk(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")
return (repo, trunk, trunkPath)
}
@Test func ensureTrunkCreatesWorktreeOnBranchOffBase() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
#expect(FileManager.default.fileExists(atPath: trunkPath))
// The trunk branch exists and starts at the base (main) HEAD.
let mainHead = try await repo.revParse("HEAD")
let trunkHead = try await repo.revParse("refs/heads/\(trunkBranch)")
#expect(trunkHead == mainHead)
// The checkout in the trunk dir is on the trunk branch.
let onBranch = try await repo.run(["rev-parse", "--abbrev-ref", "HEAD"], in: trunkPath).stdout
.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(onBranch == trunkBranch)
// Idempotent: a second ensure is a no-op and doesn't throw.
try await trunk.ensureTrunk(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")
#expect(try await repo.revParse("refs/heads/\(trunkBranch)") == trunkHead)
}
@Test func landsEditAsScopedCommitWithTrailer() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let session = SessionID.generate()
// Agent "writes" a new file into the shared trunk dir; the host lands it.
try repo.write("src/a.swift", "let a = 1\n", in: trunkPath)
let before = try await repo.revParse("refs/heads/\(trunkBranch)")
let result = await trunk.land(
trunkPath: trunkPath, session: session, paths: ["src/a.swift"], message: "add a")
guard case .landed = result else { Issue.record("expected .landed, got \(result)"); return }
let after = try await repo.revParse("refs/heads/\(trunkBranch)")
#expect(after != before) // trunk advanced
// The file is committed on the trunk branch...
let names = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
#expect(names.contains("src/a.swift"))
// ...with the session attribution trailer (NVRSION §3).
let body = try await repo.run(["log", "-1", "--format=%B", "HEAD"], in: trunkPath).stdout
#expect(body.contains("Nucleic-Session: \(session.rawValue)"))
}
@Test func landIsPathScopedAndDoesNotSweepOtherSessionsFiles() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let a = SessionID.generate()
let b = SessionID.generate()
// Both sessions have in-flight writes in the one shared trunk dir at once.
try repo.write("a.txt", "from A\n", in: trunkPath)
try repo.write("b.txt", "from B\n", in: trunkPath)
// A lands only a.txt the commit must contain a.txt and NOT b.txt (b stays uncommitted).
guard case .landed = await trunk.land(
trunkPath: trunkPath, session: a, paths: ["a.txt"], message: "A") else {
Issue.record("A land failed"); return
}
let firstNames = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
#expect(firstNames.contains("a.txt"))
#expect(!firstNames.contains("b.txt"))
// B then lands b.txt as its own commit.
guard case .landed = await trunk.land(
trunkPath: trunkPath, session: b, paths: ["b.txt"], message: "B") else {
Issue.record("B land failed"); return
}
let secondNames = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
#expect(secondNames.contains("b.txt"))
#expect(!secondNames.contains("a.txt"))
// Both files now live in the trunk tree.
#expect(repo.read("a.txt", in: trunkPath) == "from A\n")
#expect(repo.read("b.txt", in: trunkPath) == "from B\n")
}
@Test func landIsNoopWhenContentUnchanged() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let session = SessionID.generate()
try repo.write("c.txt", "v1\n", in: trunkPath)
guard case .landed = await trunk.land(
trunkPath: trunkPath, session: session, paths: ["c.txt"], message: "c") else {
Issue.record("first land failed"); return
}
// Landing again with identical content commits nothing.
let again = await trunk.land(
trunkPath: trunkPath, session: session, paths: ["c.txt"], message: "c again")
#expect(again == .noop)
}
@Test func prelandHookGatesTheCommit() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let s = SessionID.generate()
// A failing hook rejects the edit: nothing is committed and the file stays on disk.
try repo.write("gated.txt", "v1\n", in: trunkPath)
let before = try await repo.revParse("refs/heads/\(trunkBranch)")
let rejected = await trunk.land(
trunkPath: trunkPath, session: s, paths: ["gated.txt"], message: "g", prelandHook: "exit 1")
guard case .rejected = rejected else { Issue.record("expected .rejected, got \(rejected)"); return }
#expect(try await repo.revParse("refs/heads/\(trunkBranch)") == before) // trunk unchanged
let dirty = try await repo.run(["status", "--porcelain"], in: trunkPath).stdout
#expect(dirty.contains("gated.txt")) // the agent's work is still on disk, not lost
// A passing hook lets the same edit land.
guard case .landed = await trunk.land(
trunkPath: trunkPath, session: s, paths: ["gated.txt"], message: "g", prelandHook: "exit 0") else {
Issue.record("expected .landed after passing hook"); return
}
#expect(try await repo.revParse("refs/heads/\(trunkBranch)") != before)
}
@Test func prelandHookReceivesEditedPaths() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let s = SessionID.generate()
try repo.write("p.txt", "x\n", in: trunkPath)
// The hook passes only when NUCLEIC_NVR_PATHS carries the edited path.
let result = await trunk.land(
trunkPath: trunkPath, session: s, paths: ["p.txt"], message: "p",
prelandHook: #"[ "$NUCLEIC_NVR_PATHS" = "p.txt" ]"#)
guard case .landed = result else { Issue.record("expected .landed, got \(result)"); return }
}
@Test func promoteSquashesTrunkIntoBase() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
// Two sessions land work on the trunk.
try repo.write("a.txt", "from A\n", in: trunkPath)
_ = await trunk.land(trunkPath: trunkPath, session: .generate(), paths: ["a.txt"], message: "A")
try repo.write("b.txt", "from B\n", in: trunkPath)
_ = await trunk.land(trunkPath: trunkPath, session: .generate(), paths: ["b.txt"], message: "B")
let baseBefore = try await repo.revParse("refs/heads/main")
let result = await trunk.promote(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
message: "promote")
guard case .promoted = result else { Issue.record("expected .promoted, got \(result)"); return }
// The real branch (main, checked out at root) advanced and now carries both files.
#expect(try await repo.revParse("refs/heads/main") != baseBefore)
#expect(repo.read("a.txt", in: repo.root) == "from A\n")
#expect(repo.read("b.txt", in: repo.root) == "from B\n")
let body = try await repo.run(["log", "-1", "--format=%B", "main"]).stdout
#expect(body.contains("Nucleic-Promote: 1"))
// Re-promoting with no new trunk work is a no-op (trunk was resynced onto the new base).
#expect(await trunk.promote(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
message: "promote again") == .nothingToPromote)
}
@Test func promoteIsNothingWhenTrunkMatchesBase() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
// Fresh trunk == base nothing to promote.
#expect(await trunk.promote(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
message: "promote") == .nothingToPromote)
}
@Test func recoverCommitsDirtyTrunkResidueOnLaunch() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
// Simulate a crash mid-edit: a file written into the trunk but never committed.
try repo.write("half.swift", "// written, never landed\n", in: trunkPath)
let before = try await repo.revParse("refs/heads/\(trunkBranch)")
await trunk.recover(root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")
// The residue is now committed (a recovery commit), so the trunk is clean.
let status = try await repo.run(["status", "--porcelain"], in: trunkPath).stdout
.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(status.isEmpty)
let after = try await repo.revParse("refs/heads/\(trunkBranch)")
#expect(after != before)
let body = try await repo.run(["log", "-1", "--format=%B", "HEAD"], in: trunkPath).stdout
#expect(body.contains("recovered uncommitted work"))
#expect(body.contains("Nucleic-Recovery: 1"))
// Idempotent: a second recovery on a clean trunk commits nothing.
await trunk.recover(root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main")
#expect(try await repo.revParse("refs/heads/\(trunkBranch)") == after)
}
@Test func regroundFiresOnlyAfterAFileMovesUnderASession() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let a = SessionID.generate()
let b = SessionID.generate()
// A lands x.txt.
try repo.write("x.txt", "one\n", in: trunkPath)
guard case .landed = await trunk.land(
trunkPath: trunkPath, session: a, paths: ["x.txt"], message: "x1") else {
Issue.record("A land failed"); return
}
// B's FIRST acquaintance with x.txt is clean (matches LOCKING §4.5: re-ground only when a
// file the session already had advances). It now records B has seen the current content.
#expect(await trunk.regroundOnGrant(trunkPath: trunkPath, session: b, files: ["x.txt"]) == .clean)
// A changes x.txt under B.
try repo.write("x.txt", "two\n", in: trunkPath)
guard case .landed = await trunk.land(
trunkPath: trunkPath, session: a, paths: ["x.txt"], message: "x2") else {
Issue.record("A reland failed"); return
}
// Now B is granted x.txt again it moved since B last saw it re-ground.
let moved = await trunk.regroundOnGrant(trunkPath: trunkPath, session: b, files: ["x.txt"])
#expect(moved == .changed(files: ["x.txt"], diff: ""))
// And once B has re-read (state updated), a further grant with no movement is clean again.
#expect(await trunk.regroundOnGrant(trunkPath: trunkPath, session: b, files: ["x.txt"]) == .clean)
}
}
+463
View File
@@ -0,0 +1,463 @@
# nvrsion — a version manager for multi-agent orchestration (v0, Beta)
A per-file, per-edit version-control mode for Nucleic Control projects. Where git (and today's
Nucleic locking) is built for slow human coworkers who fork an isolated branch/worktree and merge a
big batch of work back hours later, **nvrsion** is built for fast agents: a session locks a *single
file* for the duration of *one edit*, the edit lands in a shared **trunk** the instant it completes,
and the lock releases immediately so the next agent can take the file and re-read it.
It is a *mode*, not a rewrite: it builds directly on the existing `LockManager` acquire path and the
shared control container. It is **opt-in per project, default off, Beta, and Nucleic-Control-only**.
**Status:** design draft (2026-06-25). Amends [[LOCKING.md]] (a control project with nvrsion on
takes the trunk path instead of the worktree-per-session path) and WORKTREE_MANAGER (nvrsion sessions
create *no* per-session worktree). Git plumbing is spelled out so it's reviewable; the Swift surface
(§12) is the stable contract.
---
## 0. Problem & motivation
### 0.1 The lock-hold window is too long
Today a session = an isolated worktree + branch forked at an immutable `baseSHA`. A file's lock is
acquired at the session's first edit and **held until the session's whole branch lands in its parent**
(autoship merge, or a mediated/agent merge detected by the git interceptor). See LOCKING §4.4. That is
correct and leak-free, but the *hold window is the entire session*: a second agent that needs the same
file waits for the first agent to finish **all** its edits and ship.
That window is sized for humans. A human holds a file for an afternoon, so isolation (a private
worktree) and batch merge (one review-able PR) are the right trade. Agents edit a file in seconds.
Making a second agent wait an entire session to touch one file — when the first agent touched it once,
early, and moved on — is the slowness this targets.
### 0.2 The git model carries weight nvrsion doesn't need
The per-session worktree exists to give each agent an isolated copy that can diverge and be merged.
But if the lock already guarantees **no two sessions edit a file at once** (LOCKING §2.2), and a granted
session is forced to **re-ground on the latest content before it edits** (the existing
`grantedNeedsReground` handshake, [LockManager.swift:262](../Sources/NucleicCore/LockManager.swift)),
then for the locked file *there is never any divergence to merge*. The isolated copy, the fork point,
the per-session branch, the nested-worktree cascade (LOCKING §5), and the conflict-resolution flow are
all machinery for a divergence that, under a per-file lock + re-ground, **cannot occur**.
### 0.3 The core bet
> Serialize writes per file (already done) + force re-ground before each edit (already done) ⇒
> textual merge conflicts within the trunk are **structurally impossible**, so "merge" collapses to
> "commit," and the lock need only be held for one edit.
The cost is the loss of the isolation buffer: a half-written or broken edit is visible to every other
agent the instant it lands (classic trunk-based development). That trade is accepted for v0; an
optional fast **pre-land validation hook** (§5) is the safety valve.
### 0.4 Design decisions (from review, 2026-06-25)
| Question | Decision |
| --- | --- |
| What is "trunk," and how does work reach the user's real branch? | **A dedicated `nucleic/trunk` branch** shared by all nvrsion sessions. Edits land there instantly; trunk is **promoted to the project's real base only at ship** (§6). A bad edit never touches the real branch directly. |
| Lock hold duration? | **Keep-warm within a turn** (§4). A file's edit lands in trunk immediately (others can *read* the latest), but the lock is *held* across the agent's consecutive edits to that file and released on turn-end / file-switch / a short idle — so a sibling can't interleave between two edits of one logical change. |
| Any gate before an edit lands in trunk? | **Optional fast pre-land hook** (§5), per project, default empty. Truly immediate when unset. |
| Opt-in surface? | **Per-project toggle, default OFF, Beta-labeled, Nucleic-Control-only**, and further requires the shared control container (`usesSharedControlContainer`). |
| Where is the shared trunk on disk? | One checkout at `<repo>/.nucleic/trunk`, on branch `nucleic/trunk`, **bind-mounted RW into the shared control container** that all the project's sessions already share. No per-session worktree. |
| Who performs the commit? | **The host**, via the `GitWorktreeManager` actor, scoped per edit (`git commit -- <paths>`), serialized. The agent writes the file (its container write *is* the host file, via virtiofs); Nucleic commits and releases. Host-mediated commit = a **certain, immediate** release signal — no detection poll needed. |
| Concurrent edits to the same file? | Impossible by the lock. Concurrent edits to *different* files share one trunk index, so commits are **path-scoped and serialized** through the actor; disjoint edits never collide. |
---
## 1. Invariants
1. **One trunk per project, shared by all nvrsion sessions.** Branch `nucleic/trunk`, one checkout,
one index. The trunk is the only working copy — there is **no per-session worktree, no per-session
branch, no fork-point (`baseSHA`)**.
2. **An edit lands the instant it completes.** On the edit-completion signal, Nucleic commits exactly
the edited paths to `nucleic/trunk` (host-mediated, path-scoped). Trunk always reflects every
*completed* edit.
3. **A lock is held for an edit, kept warm for a turn.** Acquired before an edit; retained across the
agent's consecutive edits to that file; released on turn-end, file-switch, or short idle (§4) —
never held to session-ship as today.
4. **No two sessions edit a file at once; the granted session always re-grounds.** The `LockManager`
exclusion (LOCKING §2.2) and the `grantedNeedsReground` handshake are unchanged and are what make
trunk conflicts structurally impossible (§0.3).
5. **Trunk reaches the real branch only by promotion.** The project's actual base branch changes only
when trunk is **promoted** (§6) — an explicit action or autoship-on-completion — never as a side
effect of an edit.
6. **Survivable.** At most one edit's worth of work is ever uncommitted (invariant 2). A small
persisted `nvr_file_state` (§8) plus a launch reconcile rebuild lock state and never strand a file.
7. **Fail toward progress.** A failed pre-land hook (§5) rejects *that edit* back to the agent (it
does not land, the lock stays warm, the agent fixes and re-issues) — it never wedges the trunk or
the queue.
---
## 2. Topology — shared trunk in the shared container
A Nucleic Control project already runs **all its sessions in one shared container** with the whole
control base bind-mounted read-write
([Project.swift:494](../Sources/NucleicCore/Project.swift), `usesSharedControlContainer`;
[SessionController.swift:284](../Sources/NucleicCore/SessionController.swift)). nvrsion reuses exactly
this — it does **not** introduce a new container or mount model.
```
~/.nucleic/control/<repo>/ (the control base, RW-mounted into the
├── .git/ shared "nucleic-control" container)
├── .nucleic/
│ ├── worktrees/<slug>/ ← per-session worktrees (NON-nvrsion sessions, unchanged)
│ └── trunk/ ← THE shared nvrsion checkout, branch `nucleic/trunk`
└── <project files> ← the real base branch checkout (e.g. dev)
nvrsion session A ─┐
nvrsion session B ─┼─ all CWD = .nucleic/trunk, all editing one working copy on `nucleic/trunk`
nvrsion session C ─┘
```
- **`createSession` for an nvrsion project skips worktree creation** and points the session's
`worktreePath` at the shared `.nucleic/trunk` (created lazily on the project's first nvrsion
session via `git worktree add .nucleic/trunk -b nucleic/trunk <defaultBranch>`).
- The trunk dir is host-side under `.nucleic/` (git-excluded), so the **host runs git on it directly**
— commits don't depend on the in-container interceptor (though the interceptor still reports any git
the agent runs itself, as today).
- **Gating.** nvrsion is active for a session iff `project.nvrsionActive` (§10): controlled +
`nvrsion.enabled` + `usesSharedControlContainer` + the container service is on. Per-session
containers (`perSessionContainers`) are **excluded in v0** — a non-shared topology would need
per-container checkouts synced to trunk (deferred, §15).
---
## 3. The edit → land → release loop
The whole feature is one loop layered onto the existing acquire path. The acquire half
([AppStore.arbitrate](../Sources/NucleicCore/AppStore.swift) → `LockManager.acquire`) is **unchanged**;
nvrsion adds the *land + release* half on a new edit-completion signal.
```
agent issues Edit/Write(file F)
├─ arbitrate(S, files:[F]) → LockManager.acquire(S, domain, [F]) # UNCHANGED
│ • domain = nvrsion trunk domain (§10), one per (project, trunk)
│ • all-or-nothing; queues if F is held by another session
│ • on grant: completeGrant → re-ground if trunk moved F under the
│ agent's last Read (existing grantedNeedsReground / diff) # UNCHANGED
├─ agent's native Edit tool writes F (its container write IS the host file via virtiofs)
└─ EDIT-COMPLETE signal (new seam, §3.1)
├─ pre-land hook (§5)? fail → reject this edit to the agent, KEEP lock warm, do not commit
├─ landToTrunk(S, [F]): # host-mediated, serialized
│ git -C .nucleic/trunk add -- F
│ git -C .nucleic/trunk commit -- F -m "<title> (nvrsion: session <S>)"
│ --author "<session author>" --trailer "Nucleic-Session: <S>"
│ # path-scoped: never sweeps in another session's in-flight file
└─ keep F warm (held) — release governed by §4, not here
```
### 3.1 The edit-completion seam (the one genuinely new wiring)
Today there is no "edit done" callback — `finalize()` (stage+commit) lives only at the ship boundary
inside `SessionController.integrate()`
([SessionController.swift:1055](../Sources/NucleicCore/SessionController.swift)). nvrsion needs the
commit to happen *per edit*. The signal already latent in the system: the backend **synthesizes
file-change events from Edit/Write/MultiEdit tool calls** ([ClaudeCodeBackend](../Sources/NucleicCore/Claude/ClaudeCodeBackend.swift),
`emitsFileChangeEvents=false`). nvrsion subscribes to *that* completion, post-approval and
post-tool-result.
**Design rule:** the backend must **not** call git. It emits a structured "edit completed: session S,
paths P" event; `AppStore` (which already owns the `LockManager` and the git collaborators) owns an
`NvrsionTrunk` coordinator that performs `landToTrunk`. This keeps the backend↔git decoupling the
codebase already enforces (the same shape as `LockManager`'s injected collaborators).
### 3.2 Why landing is conflict-free
The lock guaranteed no other session touched F during the edit, and re-ground guaranteed the agent
edited against trunk's current F. So `git add F; git commit -- F` on trunk is always a clean
fast-forward of F's content — there is nothing to merge. Files the agent *read but did not lock* may
have moved; that surfaces through the existing re-ground/diff path on the agent's next edit, as a
*notice*, never a hard trunk conflict.
---
## 4. Lock duration — keep-warm within a turn
Two independent clocks, deliberately decoupled:
| Clock | Fires when | Effect |
| --- | --- | --- |
| **Land** | each edit completes (§3) | commit F to trunk — others can **read** the latest immediately |
| **Release** | turn-end file-switch idle > `keepWarmIdle` | drop the lock — others can **write** F |
So trunk is always current, but the *lock* is held a little longer than a single edit, to protect a
multi-edit logical change from a sibling slipping in between two of its edits.
```
releaseGovernor(session S):
on EDIT-COMPLETE(F): mark F warm, stamp lastTouched[F] = now # keep holding F
on TURN-END(S): releaseAll-nvrsion(S) # drop every warm file
on EDIT-COMPLETE(G≠F): if S no longer intends F → release(S, [F]) # file-switch eviction
idle sweep (timer): for F in warm(S) where now-lastTouched[F] > keepWarmIdle: release(S,[F])
```
- **Released ⇒ re-read on return.** If S releases F then edits it again, it re-acquires; if another
session changed F meanwhile, the existing re-ground hands S the diff. This is the per-file re-read
contract, made explicit.
- **`keepWarmIdle`** is a project config (default 4s). Lower = faster handoff, more re-reads; higher =
fewer re-reads, longer waits. Turn-end always releases regardless.
- **Release is host-certain.** Because the host performs the commit and the host performs the release,
there is no "did it land?" detection lag and no leak surface — the central failure mode LOCKING was
built to prevent simply doesn't exist on the nvrsion path (nothing to detect; we *did* the merge).
---
## 5. Pre-land validation hook *(Phase D)*
Optional per-project command (`ProjectNvrsion.prelandHook`), default empty. It runs **after the edit is
written, before the trunk commit** (`NvrsionTrunk.land`), so a broken edit never poisons the trunk —
and therefore never reaches the real branch (promotion, §6, only ships *committed* trunk work).
```
prelandHook (e.g. "swift -frontend -parse $NUCLEIC_NVR_PATHS" or a project script):
• runs via `/bin/sh -c` on the HOST in the trunk dir, with the edited paths in NUCLEIC_NVR_PATHS,
• OUTSIDE the index gate (it only reads files) — a slow hook can't wedge other sessions' lands,
• with a 10s timeout (NvrsionTrunk.prelandTimeoutSeconds): overrun ⇒ killed ⇒ rejected.
exit 0 → land. non-zero / timeout / spawn-fail → DON'T land (`NvrLandResult.rejected`):
• the edit's content stays on disk (uncommitted) — the agent's work isn't lost,
• the file's lock stays warm (released at turn-end / idle like any held file),
• a transcript note carries the hook's output so the agent (or user) can fix and re-edit;
the next edit to that file re-runs the hook and lands once it passes.
```
Keep it fast — it is on the per-edit hot path. Intended for syntax/format/typecheck of *just the
edited files*, not a full build/test (that belongs at promotion, §6). Unset ⇒ landing is immediate.
> **v0 limitation (honest):** the hook runs on the **host** (not in the agent's container) and the
> rejection is surfaced as a *note*, not folded into the agent's edit tool-result — because the edit's
> result is already sent by the time `.fileChange` fires (§3.1). The safety property still holds (a
> rejected edit never lands/promotes); tighter in-container execution + agent re-prompt is future work.
---
## 6. Trunk → base promotion (ship) *(Phase D)*
Trunk accumulates per-edit, session-attributed commits. The project's **real base branch changes only
by promotion**, which keeps the user in command (NUCLEIC_CONCEPT) and the real history clean despite
per-edit churn.
- **Explicit promotion** *(implemented)*`AppStore.promoteNvrsionTrunk` / `NvrsionTrunk.promote`,
surfaced as the **"Promote trunk → `<base>`"** button in the project's nvrsion settings.
`git merge --squash nucleic/trunk` in the project's root checkout (which is on `base` and untouched
by nvrsion agents) → **one clean commit** on the real branch (trailer `Nucleic-Promote: 1`), then a
best-effort merge of `base` back into the trunk so the *next* promotion squashes only new work.
Conflicts (e.g. `base` edited outside the trunk) reset cleanly and are reported; an unchanged trunk
returns *nothing-to-promote*.
- **Autoship-on-completion** *(deferred — see §15)*: squashing a *single session's* attributed commits
out of an interleaved shared history is ill-defined when sessions edit overlapping files over time
(which commit is "theirs" after a later session touched the same file?). v0 ships the well-defined
**whole-trunk** promotion; per-session autoship is left as future work.
- Promotion is the *only* place a real-branch mutation happens, so it stays the single audit gate.
---
## 7. Coexistence — what the nvrsion path bypasses
When `project.nvrsionActive`, a session takes the trunk path; everything else is unchanged. The two
models never run for the same project at once (gated whole-project).
| Subsystem | nvrsion session |
| --- | --- |
| Per-session worktree / branch / `baseSHA` ([WorktreeManager](../Sources/NucleicCore/Git/WorktreeManager.swift)) | **Not created.** `worktreePath` = shared `.nucleic/trunk`. |
| `LockManager.acquire` + re-ground (LOCKING §4.2, §4.5) | **Reused as-is.** Domain = trunk (§10). |
| Lock *release* (LOCKING §4.4: detect landing) | **Replaced** by host-certain release on commit (§4). No `hasLanded` poll on the nvrsion path. |
| `SessionController.integrate` / `finalize` at ship | **Repurposed** to *trunk→base promotion* only (§6), not per-session merge. |
| Nested worktrees / cascade / `parentRef` / `rootRef` (LOCKING §5) | **Unused** — flat trunk, no tree. |
| Git interceptor → `observeGitOp` | **Still on** (reports agent-run git), but not required for release. |
| Autoship `MergeQueue` | **Reused** only at trunk→base promotion (§6). |
A startup/runtime audit must ensure a project never has *both* live per-session worktrees and nvrsion
on (flip is allowed only when the project is quiescent; §10).
---
## 8. Persistence & recovery
Today lock state is rebuilt at launch purely from `unmergedFiles` (LOCKING §6,
[AppStore.reconstructLocks](../Sources/NucleicCore/AppStore.swift)). On the nvrsion path edits land
immediately, so there are no unmerged files to reconstruct from — the recovery source disappears. But
that loss is **correct**, not a problem to solve: a restart kills every agent process, so any
in-flight nvrsion lock has no agent behind it and *should* vanish. Worktree-less nvrsion sessions are
already skipped by `reconstructLocks`, so no stale lock survives a restart. The only thing that can
carry across a crash is the **trunk's working tree** — hence:
- **`nvrsion_config`** — a JSON column on `project` (migration `v20-nvrsion`), holding the
`ProjectNvrsion` struct (`enabled`, `trunkBranch`, `prelandHook`, `keepWarmIdleSeconds`).
JSON-encoded like `sandbox_config`, so future fields need no new migration. *(Phase A.)*
- **Launch trunk-recovery** (`NvrsionTrunk.recover`, driven by `AppStore.reconcileNvrsionTrunks` after
`reconstructLocks`). Invariant 2 bounds loss to a single edit: a crash can leave a file
written-but-uncommitted on the trunk (the agent wrote it; the app died before `.fileChange` landed
it). On launch, for each nvrsion project, `recover` ensures the trunk exists and **commits any
uncommitted residue** as a `Nucleic-Recovery: 1` commit, so the trunk starts every run clean and no
work is silently lost. The trunk's git history is the durable record. *(Phase C.)*
> **Design note (revised in Phase C):** the earlier sketch proposed an `nvr_file_state` table to
> re-seed warm locks across a restart. It was dropped — re-seeding is pointless because the agents
> that held those locks are gone, and crash safety is fully covered by committing the trunk's dirty
> residue (above). One less table, one less migration, same guarantees.
---
## 9. Failure semantics (truth table)
| Event | Trunk | Lock |
| --- | --- | --- |
| Edit completes, no hook | committed (path-scoped) | kept warm (§4) |
| Pre-land hook fails | **not** committed; edit stays on disk | kept warm; edit re-issued |
| Turn ends | (already committed per edit) | released (all warm) |
| Idle > `keepWarmIdle` | — | that file released |
| Agent runs `git` itself in trunk | its commit recorded; interceptor reports it | reconciled like any commit |
| App/container crash mid-edit | ≤1 edit uncommitted on trunk | re-seeded from `nvr_file_state` at launch |
| Two sessions, same file | — | impossible (lock); second queues |
| Two sessions, different files | both commit (path-scoped, serialized) | independent |
| Promotion to base conflicts | trunk unchanged | n/a (heavier flow, §6, surfaced like autoship conflict) |
---
## 10. Opt-in surface & Beta gating
- **`Project.nvrsion: ProjectNvrsion?`** — `nil`/`enabled:false` ⇒ off (default). Persisted as
`nvrsion_config` JSON (migration `v20-nvrsion`), mirroring `sandbox`.
- **`Project.nvrsionActive: Bool`** — the single gate both spawn and teardown read:
```
isNucleicControlled && (nvrsion?.enabled ?? false) && usesSharedControlContainer
```
(and, like `effectiveSandbox`, subordinate to the app-wide container-service master switch).
- **UI:** a **Beta**-labeled toggle in `ProjectSettingsSheet`
([Sheets.swift](../Sources/NucleicApp/Sheets.swift)), shown only for control projects, disabled
(with an explanatory caption) when `perSessionContainers` is on. Optional `keepWarmIdle` /
`prelandHook` fields behind a disclosure.
- **Flip safety:** toggling nvrsion is only allowed when the project has **no live sessions** (so the
two models never coexist on live work); enforced in `updateProject`.
- **No app-wide default-on for v0** (the chosen rollout is opt-in/off). A
`nvrsionByDefault` UserDefaults key can be added later beside `controlByDefault` if wanted.
---
## 11. Reuse vs replace (summary)
| Component | Verdict |
| --- | --- |
| `LockManager` acquire + queue + re-ground | **Reuse unchanged** (it's the heart; nvrsion only changes *when release fires*). |
| `arbitrate` / `handleApprovalCall` acquire path | **Reuse**; add the edit-completion subscription. |
| Per-session `WorktreeManager` worktree/branch/`baseSHA` | **Bypass** on the nvrsion path. |
| `WorktreeManager.finalize`/`integrate` | **Repurpose** for trunk→base promotion only. |
| Lock-release detection (`hasLanded`, reconcile poll) | **Replace** with host-certain release. |
| Nested worktree / cascade / `parentRef`·`rootRef` | **Unused** on the nvrsion path. |
| Git interceptor `observeGitOp` | **Keep** (agent-run git), not required for release. |
| `reconstructLocks` from `unmergedFiles` | **Replace** with `nvr_file_state` re-seed (§8). |
| `MergeQueue` | **Reuse** at promotion only. |
| `lockDomain` / `isNucleicControlled` gate | **Reuse** as the opt-in gate carrier. |
---
## 12. Swift surface (the stable contract)
```swift
/// Per-project nvrsion config (persisted as `nvrsion_config` JSON, like ProjectSandbox).
public struct ProjectNvrsion: Sendable, Codable, Equatable {
public var enabled: Bool // default false
public var trunkBranch: String // default "nucleic/trunk"
public var prelandHook: String? // default nil → land immediately
public var keepWarmIdleSeconds: Int // default 4
}
extension Project {
/// nvrsion governs this project's sessions (the single spawn/teardown gate).
public var nvrsionActive: Bool { /* §10 */ }
/// Host path of the shared trunk checkout: <repo>/.nucleic/trunk.
public var resolvedTrunkPath: String { /* … */ }
}
/// Host-side coordinator owned by AppStore (mirrors LockManager's injected-collaborator shape).
/// The backend NEVER calls this directly — it emits edit-completion events AppStore routes here.
public actor NvrsionTrunk {
/// Ensure `.nucleic/trunk` exists on `nucleic/trunk`, forked from the base. Idempotent.
public func ensureTrunk(_ project: Project) async throws
/// Land exactly `paths` for `session` as one path-scoped commit (serialized). Runs the
/// pre-land hook first; returns .rejected(stderr) if it fails (lock kept warm by the caller).
public func land(_ session: SessionID, paths: [String]) async -> NvrLandResult
/// Promote trunk → base (squash; §6), optionally scoped to one session's attributed commits.
public func promote(_ project: Project, session: SessionID?) async -> NvrPromoteResult
}
public enum NvrLandResult: Sendable, Equatable { case landed(sha: String); case rejected(String); case failed(String) }
```
Release/keep-warm is driven through the existing `LockManager.release` / `releaseAll`; nvrsion adds a
small `ReleaseGovernor` (§4) in `AppStore` that owns the warm-set + idle timer and calls them.
---
## 13. Phased rollout
Each phase builds + `swift test --build-system native` green before the next.
| Phase | Scope | Status |
| --- | --- | --- |
| **A — Opt-in scaffolding** (§10) | `ProjectNvrsion`, migration `v20-nvrsion`, `ProjectRow` column, `Project.nvrsionActive`/`resolvedTrunkPath`, Beta toggle in `ProjectSettingsSheet`. No behavior change (gate is read nowhere yet). | ✅ **Landed** — `Project.swift`, `GRDBMetadataStore.swift`, `Sheets.swift`; tests in `GRDBMetadataStoreTests`. |
| **B — Trunk topology + land loop** (§2, §3) | `NvrsionTrunk` actor (`ensureTrunk`, `land`, `regroundOnGrant`); skip worktree creation for nvrsion sessions (CWD = shared trunk); `lockDomain` = trunk; the edit-completion seam (§3.1, `.fileChange` → host-mediated path-scoped commit); **release at turn-end** (keep-warm's conservative form — idle/file-switch eviction is C). | ✅ **Landed** — `NvrsionTrunk.swift` + `AppStore` wiring; `NvrsionTrunkTests` + 2 `AppStore` integration tests. |
| **C — Keep-warm eviction + crash-recovery** (§4, §8) | `NvrsionReleaseGovernor` idle eviction on top of B's turn-end release (sweep loop in `AppStore`); launch trunk-recovery (`NvrsionTrunk.recover`); flip-safety guard in `updateProject`. (`nvr_file_state` dropped — see §8.) | ✅ **Landed** — `NvrsionReleaseGovernor.swift`, `NvrsionTrunk.recover`, `AppStore` wiring; `NvrsionReleaseGovernorTests` + trunk-recover + 2 `AppStore` tests. |
| **D — Pre-land hook + promotion** (§5, §6) | Per-edit `prelandHook` (host-side, 10s timeout, `.rejected` → not committed); explicit whole-trunk → base promotion (`promoteNvrsionTrunk`, squash + resync) + a "Promote trunk" button. Per-session autoship-on-completion deferred (§6). | ✅ **Landed** — `NvrsionTrunk.land(prelandHook:)`/`.promote`, `AppStore.promoteNvrsionTrunk`, `Sheets.swift` button; `NvrsionTrunkTests` (hook + promote) + 2 `AppStore` tests. |
**All four phases are landed.** An nvrsion session edits the shared trunk, lands each completed edit
as a path-scoped commit (optionally gated by a pre-land hook), holds a file warm until idle past
`keepWarmIdle` or turn-end, recovers cleanly from a crash, refuses an unsafe mid-flight mode flip, and
the user promotes the trunk's accumulated work into the real branch as one squashed commit on demand.
Everything is behind the per-project Beta opt-in (default off, Control-only).
> **Keep-warm has two tiers now:** a landed file is held until it goes idle past the project's
> `keepWarmIdleSeconds` (the `NvrsionReleaseGovernor` sweep, default 4s) **or** the turn ends
> (`.runFinished` → `releaseAll`), whichever first. Active editing refreshes the idle clock, so a
> multi-edit change to one file is never interrupted; a file the agent finished with is handed off
> within ~`keepWarmIdle`. Explicit file-switch eviction is subsumed by the idle sweep.
---
## 14. Test matrix
Built on the hermetic git-temp-repo harness (`AppStoreTests`, `LockManagerTests`, `MergeQueueTests`).
New: `NvrsionTrunkTests`, `NvrsionReleaseGovernorTests`.
| # | Scenario | Asserts |
| --- | --- | --- |
| 1 | Single edit lands | After edit-complete, F is committed to `nucleic/trunk` (path-scoped); lock warm. |
| 2 | Two sessions, different files | Both land; commits are independent and serialized; no index clobber. |
| 3 | Two sessions, same file | Second queues; granted after first releases; granted session re-grounds to first's content. |
| 4 | Keep-warm protects a multi-edit change | Sibling can't acquire F between two of holder's edits within a turn; gets it at turn-end. |
| 5 | Idle eviction | A warm, untouched file releases after `keepWarmIdle`; sibling acquires. |
| 6 | Re-read on return | Holder releases F (idle), sibling edits F, holder re-acquires → re-ground diff delivered. |
| 7 | Pre-land hook fails | No commit; edit stays on disk; lock warm; agent re-issues; pass → lands. |
| 8 | Crash mid-edit | Launch reconcile: dirty trunk path + `nvr_file_state` row → committed or re-seeded; no strand. |
| 9 | Promotion squashes | Trunk's session-attributed commits squash into base as one commit; trunk preserved. |
| 10 | Gate off-paths | Non-control / per-session-container / disabled → classic worktree path; no trunk created. |
| 11 | Flip safety | Toggling nvrsion with a live session is refused. |
| 12 | No conflict possible | Concurrent disjoint edits + serialized commits never produce a git conflict on trunk. |
---
## 15. Open questions / future work
1. **Per-session containers.** v0 requires the shared container. Supporting `perSessionContainers`
needs per-container trunk checkouts that sync to a host trunk per edit (the explore's "Option B");
deferred.
2. **Read staleness of unlocked context.** Files an agent read for context (not locked) can move
silently; only *edited* files re-ground. Acceptable for v0; a "context moved" advisory is possible
later.
3. **Trunk history volume.** Commit-per-edit makes trunk history noisy; promotion squashes it for the
real branch, but the trunk branch itself grows. Periodic `nucleic/trunk` reset after promotion is a
candidate.
4. **Heavier pre-land gates.** Build/test before *landing* (not just promotion) is intentionally out
of scope (latency); revisit if broken edits poisoning trunk proves painful in practice.
5. **Cross-trunk domains.** A project shipping to multiple bases could want multiple trunks; v0 is one
trunk per project. Domain keying (§10) already allows generalizing later.
6. **iOS parity.** Surface trunk state + per-file warm-locks read-only over the sync protocol
(additive wire fields), as the lock viewer does today.