Files
nucleic/Sources/NucleicCore/NvrsionReleaseGovernor.swift
T
NucleicandClaude Opus 4.8 174da0301e 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]>
2026-06-25 21:32:09 -07:00

54 lines
2.9 KiB
Swift

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()
}
}