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]>
54 lines
2.9 KiB
Swift
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()
|
|
}
|
|
}
|