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:
co-authored by
Claude Opus 4.8
parent
7c45577365
commit
174da0301e
@@ -145,6 +145,11 @@ struct ProjectSettingsSheet: View {
|
|||||||
@State private var image: String
|
@State private var image: String
|
||||||
@State private var idleMinutes: Int
|
@State private var idleMinutes: Int
|
||||||
@State private var allowHostExec: Bool
|
@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
|
@State private var showMove = false
|
||||||
/// Drives the "Convert to Nucleic Control" confirmation popup (the conversion is permanent —
|
/// Drives the "Convert to Nucleic Control" confirmation popup (the conversion is permanent —
|
||||||
/// a Control project can't be released — so it always asks first).
|
/// a Control project can't be released — so it always asks first).
|
||||||
@@ -168,6 +173,16 @@ struct ProjectSettingsSheet: View {
|
|||||||
_image = State(initialValue: sandbox.image ?? "")
|
_image = State(initialValue: sandbox.image ?? "")
|
||||||
_idleMinutes = State(initialValue: max(1, sandbox.idleTimeoutSeconds / 60))
|
_idleMinutes = State(initialValue: max(1, sandbox.idleTimeoutSeconds / 60))
|
||||||
_allowHostExec = State(initialValue: sandbox.allowHostExec)
|
_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 {
|
var body: some View {
|
||||||
@@ -282,6 +297,82 @@ struct ProjectSettingsSheet: View {
|
|||||||
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
|
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
|
||||||
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
|
.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) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -390,6 +481,18 @@ struct ProjectSettingsSheet: View {
|
|||||||
allowHostExec: allowHostExec,
|
allowHostExec: allowHostExec,
|
||||||
// Per-session containers aren't user-settable yet; preserve any existing choice.
|
// Per-session containers aren't user-settable yet; preserve any existing choice.
|
||||||
perSessionContainers: project.sandbox?.perSessionContainers ?? false)
|
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)
|
await store.updateProject(updated)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -447,6 +447,19 @@ public final class AppStore: ConflictArbiter {
|
|||||||
/// replace the old `unmergedFiles`-derived "lock". Acquired via `arbitrate` on edit, released
|
/// 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.
|
/// by `reconcileLocks` once work lands in the parent (any merger), plus mediated/lifecycle.
|
||||||
private let lockManager = LockManager()
|
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).
|
/// The release-reconcile loop; nil when nothing holds a lock (LOCKING §4.4).
|
||||||
private var lockReconcileTask: Task<Void, Never>?
|
private var lockReconcileTask: Task<Void, Never>?
|
||||||
/// Re-check cadence for `reconcileLocks` — a slow backstop, since release is also event-driven
|
/// 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.containerManager = containerManager
|
||||||
self.conflictCoordinator = conflictCoordinator
|
self.conflictCoordinator = conflictCoordinator
|
||||||
self.now = now
|
self.now = now
|
||||||
|
self.nvrsionGovernor = NvrsionReleaseGovernor(now: now)
|
||||||
self.backendFactory = backendFactory
|
self.backendFactory = backendFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,6 +633,8 @@ public final class AppStore: ConflictArbiter {
|
|||||||
await containerManager?.reconcile(activeSessions: Array(controllers.keys))
|
await containerManager?.reconcile(activeSessions: Array(controllers.keys))
|
||||||
// Rebuild held locks from authoritative git state now that controllers exist (LOCKING §6).
|
// Rebuild held locks from authoritative git state now that controllers exist (LOCKING §6).
|
||||||
await reconstructLocks()
|
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 —
|
/// 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
|
/// 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.
|
/// in-memory list. Sandbox changes take effect on the next turn started in the project.
|
||||||
public func updateProject(_ project: Project) async {
|
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 {
|
do {
|
||||||
try await database.saveProject(project)
|
try await database.saveProject(project)
|
||||||
await loadProjects()
|
await loadProjects()
|
||||||
@@ -1910,10 +1939,15 @@ public final class AppStore: ConflictArbiter {
|
|||||||
/// control container (the `git` interceptor shim, [[git-event]]). Outside it we couldn't
|
/// 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.
|
/// reliably unlock, so we don't lock at all rather than strand files.
|
||||||
private func lockDomain(for session: Session) -> LockDomain? {
|
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 root = session.rootRef { return LockDomain(root) }
|
||||||
if let parent = session.parentRef { return LockDomain(parent) }
|
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
|
/// 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.
|
/// proceeding. `.clean` if gone; `.changed` when the parent moved one of those files.
|
||||||
private func runUpdateOnGrant(_ id: SessionID, files: [String]) async -> LockUpdateResult {
|
private func runUpdateOnGrant(_ id: SessionID, files: [String]) async -> LockUpdateResult {
|
||||||
guard let controller = controllers[id] else { return .clean }
|
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)
|
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.
|
/// 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
|
/// 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.
|
/// this no longer decides when to stop — it just does the work.
|
||||||
|
/// The nvrsion edit loop (NVRSION §3–4), 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 {
|
private func reconcileLocks() async {
|
||||||
for sid in await lockManager.sessionsHoldingLocks() {
|
for sid in await lockManager.sessionsHoldingLocks() {
|
||||||
let heldPaths = await lockManager.heldPaths(sid)
|
let heldPaths = await lockManager.heldPaths(sid)
|
||||||
@@ -2664,13 +2828,23 @@ public final class AppStore: ConflictArbiter {
|
|||||||
let backendID = BackendID.forModel(resolvedModel) ?? project.defaultBackend ?? .claudeCode
|
let backendID = BackendID.forModel(resolvedModel) ?? project.defaultBackend ?? .claudeCode
|
||||||
let resolvedBase = base ?? project.defaultBranch
|
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.
|
// 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
|
// Without one, it runs directly in the project's main checkout on the chosen
|
||||||
// branch — no isolation, so merges/discards have nothing to remove.
|
// 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)
|
? try await worktrees.create(for: sessionID, in: project, base: resolvedBase, slug: title)
|
||||||
: nil
|
: nil
|
||||||
let worktreePath = worktree?.path ?? project.rootPath
|
let worktreePath = nvrsionTrunkPath ?? worktree?.path ?? project.rootPath
|
||||||
|
|
||||||
let transcriptURL = transcriptsDir
|
let transcriptURL = transcriptsDir
|
||||||
.appendingPathComponent(sessionID.rawValue)
|
.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
|
// 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.
|
// this base *is* the root of a new tree.
|
||||||
let parentSession = await liveSession(whoseBranchIs: resolvedBase.value)
|
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(
|
let session = Session(
|
||||||
id: sessionID, projectID: project.id, backend: backendID,
|
id: sessionID, projectID: project.id, backend: backendID,
|
||||||
title: title, status: trimmedPrompt.isEmpty ? .awaitingInput : .running,
|
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,
|
baseSHA: worktree?.baseSHA,
|
||||||
parentRef: resolvedBase.value,
|
parentRef: nvrsionBranch ?? resolvedBase.value,
|
||||||
rootRef: parentSession?.rootRef ?? resolvedBase.value,
|
rootRef: nvrsionBranch ?? parentSession?.rootRef ?? resolvedBase.value,
|
||||||
parentSessionID: parentSession?.id,
|
parentSessionID: nvrsionBranch != nil ? nil : parentSession?.id,
|
||||||
model: resolvedModel, effort: effort ?? defaultEffort,
|
model: resolvedModel, effort: effort ?? defaultEffort,
|
||||||
transcriptPath: transcriptURL.path, auto: resolvedAuto,
|
transcriptPath: transcriptURL.path, auto: resolvedAuto,
|
||||||
autoShip: resolvedAutoShip,
|
autoShip: resolvedAutoShip,
|
||||||
@@ -3163,6 +3340,7 @@ public final class AppStore: ConflictArbiter {
|
|||||||
|
|
||||||
private func ingestUI(_ sessionID: SessionID, _ event: AgentEvent) async {
|
private func ingestUI(_ sessionID: SessionID, _ event: AgentEvent) async {
|
||||||
guard let controller = controllers[sessionID] else { return }
|
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
|
// 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.
|
// latest one as the coarse fallback for the global quota indicator.
|
||||||
if case .rateLimit(let rl) = event.kind { latestRateLimit = rl }
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 §2–3). 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 add→commit 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(
|
try db.execute(
|
||||||
sql: "ALTER TABLE session ADD COLUMN auto_ship_conflict INTEGER NOT NULL DEFAULT 0;")
|
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
|
return migrator
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +296,7 @@ private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
|
|||||||
var setup_script: String?
|
var setup_script: String?
|
||||||
var setup_policy: String
|
var setup_policy: String
|
||||||
var sandbox_config: String?
|
var sandbox_config: String?
|
||||||
|
var nvrsion_config: String?
|
||||||
var created_at: Date
|
var created_at: Date
|
||||||
var archived_at: Date?
|
var archived_at: Date?
|
||||||
|
|
||||||
@@ -307,6 +313,9 @@ private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
|
|||||||
sandbox_config = p.sandbox.flatMap { sandbox in
|
sandbox_config = p.sandbox.flatMap { sandbox in
|
||||||
(try? JSONEncoder().encode(sandbox)).map { String(decoding: $0, as: UTF8.self) }
|
(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
|
created_at = p.createdAt
|
||||||
archived_at = p.archivedAt
|
archived_at = p.archivedAt
|
||||||
}
|
}
|
||||||
@@ -325,6 +334,9 @@ private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
|
|||||||
sandbox: sandbox_config.flatMap { json in
|
sandbox: sandbox_config.flatMap { json in
|
||||||
try? JSONDecoder().decode(ProjectSandbox.self, from: Data(json.utf8))
|
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,
|
createdAt: created_at,
|
||||||
archivedAt: archived_at)
|
archivedAt: archived_at)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// App-wide container preferences, persisted in `UserDefaults`. These gate and seed the
|
||||||
/// *per-project* `ProjectSandbox` config above — they never sandbox sessions on their own.
|
/// *per-project* `ProjectSandbox` config above — they never sandbox sessions on their own.
|
||||||
/// Surfaced in Settings → General; read here so `NucleicCore` (e.g. `SessionController`,
|
/// 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
|
public var setupPolicy: SetupPolicy
|
||||||
/// Execution-sandbox settings. `nil` → sessions run on the host (default).
|
/// Execution-sandbox settings. `nil` → sessions run on the host (default).
|
||||||
public var sandbox: ProjectSandbox?
|
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
|
public var createdAt: Date
|
||||||
/// When the project was archived, or `nil` if it's active. Archiving hides the project
|
/// 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
|
/// 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,
|
setupScript: String? = nil,
|
||||||
setupPolicy: SetupPolicy = .block,
|
setupPolicy: SetupPolicy = .block,
|
||||||
sandbox: ProjectSandbox? = nil,
|
sandbox: ProjectSandbox? = nil,
|
||||||
|
nvrsion: ProjectNvrsion? = nil,
|
||||||
createdAt: Date = Date(),
|
createdAt: Date = Date(),
|
||||||
archivedAt: Date? = nil
|
archivedAt: Date? = nil
|
||||||
) {
|
) {
|
||||||
@@ -449,6 +511,7 @@ public struct Project: Identifiable, Sendable, Codable, Equatable {
|
|||||||
self.setupScript = setupScript
|
self.setupScript = setupScript
|
||||||
self.setupPolicy = setupPolicy
|
self.setupPolicy = setupPolicy
|
||||||
self.sandbox = sandbox
|
self.sandbox = sandbox
|
||||||
|
self.nvrsion = nvrsion
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.archivedAt = archivedAt
|
self.archivedAt = archivedAt
|
||||||
}
|
}
|
||||||
@@ -495,6 +558,22 @@ public struct Project: Identifiable, Sendable, Codable, Equatable {
|
|||||||
isNucleicControlled && !(effectiveSandbox?.perSessionContainers ?? false)
|
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.
|
/// `<repoSlug>` used in the worktree path and branch derivation.
|
||||||
public var repoSlug: String {
|
public var repoSlug: String {
|
||||||
let last = (rootPath as NSString).lastPathComponent
|
let last = (rootPath as NSString).lastPathComponent
|
||||||
|
|||||||
@@ -177,6 +177,166 @@ struct AppStoreTests {
|
|||||||
.appendingPathComponent(".nucleic/worktrees/make-a-file/made.txt")))
|
.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 {
|
@Test func archivedWorktreeCleanupReclaimsPastThresholdThenRestoresOnUnarchive() async throws {
|
||||||
let repo = try await GitTestRepo()
|
let repo = try await GitTestRepo()
|
||||||
defer { repo.cleanup() }
|
defer { repo.cleanup() }
|
||||||
|
|||||||
@@ -32,6 +32,60 @@ struct GRDBMetadataStoreTests {
|
|||||||
#expect(loaded == [project])
|
#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() {
|
@Test func shipDestinationResolutionPrecedence() {
|
||||||
var project = makeProject() // defaultBranch == "main", no autoship override
|
var project = makeProject() // defaultBranch == "main", no autoship override
|
||||||
var session = makeSession(projectID: project.id)
|
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 §2–3)")
|
||||||
|
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
@@ -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.
|
||||||
Reference in New Issue
Block a user