Files
nucleic/Sources/NucleicCore/MergeQueue.swift
T

190 lines
8.1 KiB
Swift

import Foundation
// MARK: - Autoship merge queue
//
// Autoship lets a session merge its own branch into the project's default branch the
// moment the agent finishes its work (see AppStore.shipIfCompleted). Many sessions run
// in parallel, so their ship requests must land one-at-a-time per target branch — never
// concurrently — or two `git checkout target` + merge sequences would race on the same
// main working copy.
//
// The git-level race is *already* impossible: GitWorktreeManager is an actor (structural
// ops serialized) and `integrate` merges against the live target tip and never
// force-updates, so a stale branch can't overwrite newer work and a conflict is aborted
// with the branch left intact. This queue layers the *observable* guarantees on top:
// • FIFO ordering and fairness across sessions targeting the same branch,
// • strict per-target serialization (different targets/repos still run in parallel),
// • per-session dedupe so a re-fired `.completed` turn can't double-enqueue,
// • a single place that reports queued / merging / merged / conflicted / failed.
//
// The queue owns *ordering*; the injected integrator closure owns the *git work*. That
// split keeps the queue pure and unit-testable with no git dependency.
/// Why a request is in the queue — which decides what "still eligible?" means when the
/// integrator re-validates it (`AppStore.performShip`).
public enum ShipKind: Sendable, Equatable {
/// The chat armed autoship and classified its own turn done (`AppStore.shipIfCompleted`).
/// Eligibility is the session's own state: autoship on, idle, last turn `.completed`.
case autoship
/// A finished subagent handing its worktree's work back to the chat that spawned it
/// (LOCKING §11.4). Not user-armed autoship: the worker's run task is the authority that
/// the turn is over, and it enqueues this *before* archiving the worker — so the session
/// checks autoship asks about (`autoShip`, the classified disposition, `archived`) don't
/// apply. What still applies: Nucleic Control, a real worktree, and a non-empty footprint.
case subagentHandoff
}
/// A request to merge one session's branch into a target branch.
public struct ShipRequest: Sendable, Equatable {
public let sessionID: SessionID
public let projectID: ProjectID
public let target: GitRef
public let strategy: IntegrationStrategy
public let kind: ShipKind
public init(
sessionID: SessionID, projectID: ProjectID, target: GitRef,
strategy: IntegrationStrategy, kind: ShipKind = .autoship
) {
self.sessionID = sessionID
self.projectID = projectID
self.target = target
self.strategy = strategy
self.kind = kind
}
/// Serialization key: requests sharing a key are processed strictly one-at-a-time.
var targetKey: TargetKey { TargetKey(projectID: projectID, target: target) }
}
struct TargetKey: Hashable, Sendable {
let projectID: ProjectID
let target: GitRef
}
/// The lifecycle of a ship request, fanned out on `MergeQueue.updates`.
public enum ShipStatus: Sendable, Equatable {
/// Waiting behind `position` other requests for the same target (0 = next up).
case queued(position: Int)
/// The merge is running now.
case merging
/// Landed cleanly; `commit` is the new target tip.
case merged(commit: String)
/// Aborted on conflict — branch intact, nothing overwritten. Paths are the clashes.
case conflicted([String])
/// The integrator threw (e.g. the main tree was dirty). Branch intact.
case failed(String)
/// Nothing to do (session no longer eligible, or empty diff).
case skipped(String)
/// A status that ends a request's life in the queue (frees the per-session slot).
var isTerminal: Bool {
switch self {
case .queued, .merging: return false
case .merged, .conflicted, .failed, .skipped: return true
}
}
}
/// One status change for a session, delivered on the `updates` stream.
public struct ShipUpdate: Sendable, Equatable {
public let sessionID: SessionID
public let status: ShipStatus
public init(sessionID: SessionID, status: ShipStatus) {
self.sessionID = sessionID
self.status = status
}
}
/// Serializes autoship merges per target branch and reports their progress.
///
/// Fail-safe: with no integrator attached (tests, early startup) an enqueue is reported
/// `.failed` rather than silently dropped, so a wiring gap is visible, never a lost merge.
public actor MergeQueue {
/// Does the actual merge for one request. Injected by `AppStore` after construction
/// so the queue carries no git/SessionController dependency.
public typealias Integrator = @Sendable (ShipRequest) async -> ShipStatus
private var integrator: Integrator?
/// Pending requests per target, in FIFO order. The head of each array is in flight
/// (or about to be) once that target is draining.
private var pending: [TargetKey: [ShipRequest]] = [:]
/// Targets with a live drain task, so we never start a second worker for one target.
private var draining: Set<TargetKey> = []
/// Sessions currently queued or merging — the dedupe set.
private var liveSessions: Set<SessionID> = []
private var subscribers: [UUID: AsyncStream<ShipUpdate>.Continuation] = [:]
public init(integrator: Integrator? = nil) {
self.integrator = integrator
}
public func setIntegrator(_ integrator: @escaping Integrator) {
self.integrator = integrator
}
/// Live fan-out of status changes. Each subscriber gets every update appended after
/// it subscribes (mirrors `SessionController.subscribe()`).
public func updates() -> AsyncStream<ShipUpdate> {
let id = UUID()
return AsyncStream { continuation in
subscribers[id] = continuation
continuation.onTermination = { [weak self] _ in
Task { await self?.removeSubscriber(id) }
}
}
}
private func removeSubscriber(_ id: UUID) {
subscribers[id] = nil
}
/// Enqueue a request. Idempotent per session: a session already queued or merging is
/// ignored (returns `false`), so a repeated `.completed` turn can't double-ship.
/// Returns `true` if the request was accepted.
@discardableResult
public func enqueue(_ request: ShipRequest) -> Bool {
guard !liveSessions.contains(request.sessionID) else { return false }
liveSessions.insert(request.sessionID)
let key = request.targetKey
pending[key, default: []].append(request)
emit(request.sessionID, .queued(position: pending[key]!.count - 1))
if !draining.contains(key) {
draining.insert(key)
Task { await self.drain(key) }
}
return true
}
/// True if the session has a request queued or in flight (used by callers to avoid
/// re-enqueuing). Cheap, actor-isolated read.
public func isLive(_ sessionID: SessionID) -> Bool { liveSessions.contains(sessionID) }
/// Drain one target's FIFO, one request at a time, until empty.
private func drain(_ key: TargetKey) async {
defer { draining.remove(key) }
while let request = pending[key]?.first {
emit(request.sessionID, .merging)
let status: ShipStatus
if let integrator {
status = await integrator(request)
} else {
status = .failed("merge queue has no integrator attached")
}
// Pop the head only now — after the integrator returns — so the live target
// tip the *next* request merges against already reflects this merge.
pending[key]?.removeFirst()
if pending[key]?.isEmpty == true { pending[key] = nil }
liveSessions.remove(request.sessionID)
emit(request.sessionID, status)
}
}
private func emit(_ sessionID: SessionID, _ status: ShipStatus) {
let update = ShipUpdate(sessionID: sessionID, status: status)
for continuation in subscribers.values { continuation.yield(update) }
}
}