Files
nucleic/Sources/NucleicCore/SessionUIProjector.swift
T

191 lines
7.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import NucleicProtocol
/// Per-session reduction of the canonical event stream into batched UI commits
/// (docs/MAIN_THREAD_PERFORMANCE_PLAN.md item 2).
///
/// One projector runs per live session, on its own actor executor — not the main actor.
/// It consumes the `SessionController.subscribe()` stream, appends every event to a
/// pending batch in arrival order, and hands the batch to the main actor:
///
/// - **immediately** when an event is a semantic boundary (approval requested/resolved,
/// user text, error, run/session status transition, terminal event, note, quota push).
/// The pending streaming deltas ride along in front of it, in order, so a terminal
/// event can never outrun the text it concludes.
/// - **on a cadence** for ordinary streaming traffic (assistant text, thinking, tool
/// lifecycle and input deltas, usage): ~11 commits/s for the open chat, ~3/s for a
/// background session. The first event after a quiet spell flushes with no added
/// latency; only events landing inside a live window coalesce.
///
/// The projector never drops, reorders, or rewrites events — canonical transcript
/// durability, seq order, and sync delivery are untouched; only the *cadence* of
/// main-actor commits changes. On cancellation (re-observe, session teardown) the
/// pending tail is still delivered; after a session delete the main-actor side finds
/// no controller and drops it — that's the delete's decision, not a coalescing loss.
public actor SessionUIProjector {
/// How urgently coalescable events reach the main actor.
public enum Cadence: Sendable {
/// The chat on screen: ~11 batched commits/s keeps text visibly streaming.
case open
/// Sidebar-only session: a few commits/s is plenty for summary rows.
case background
}
private let openInterval: Duration
private let backgroundInterval: Duration
private var cadence: Cadence
private let deliver: @MainActor @Sendable ([AgentEvent]) async -> Void
/// Events awaiting the next flush, in arrival order.
private var pending: [AgentEvent] = []
/// A semantic event is pending — the next flush skips the coalesce window.
private var urgent = false
/// When the last flush happened; nil until the first (which is always immediate).
private var lastFlush: ContinuousClock.Instant?
/// Parks the drain loop while no events are pending.
private var wake: CheckedContinuation<Void, Never>?
/// The live coalesce-window sleep, cancellable by an urgent enqueue.
private var coalesceSleep: Task<Void, Never>?
/// `openInterval`/`backgroundInterval` are injectable for tests; production uses the
/// defaults (plan targets: 1012 commits/s open, 24 background).
public init(
cadence: Cadence,
openInterval: Duration = .milliseconds(90),
backgroundInterval: Duration = .milliseconds(300),
deliver: @escaping @MainActor @Sendable ([AgentEvent]) async -> Void
) {
self.cadence = cadence
self.openInterval = openInterval
self.backgroundInterval = backgroundInterval
self.deliver = deliver
}
/// Retarget the flush cadence (the open chat changed). Cuts any in-flight coalesce
/// window short so a session promoted to `.open` starts committing at UI cadence
/// now, not after one last background-length window.
public func setCadence(_ new: Cadence) {
guard cadence != new else { return }
cadence = new
coalesceSleep?.cancel()
}
/// Consume the stream until it ends (session shutdown) or the surrounding task is
/// cancelled (re-observe, teardown), then deliver any pending tail.
public func run(_ stream: AsyncStream<AgentEvent>) async {
await withTaskGroup(of: Void.self) { group in
group.addTask { await self.pump(stream) }
group.addTask { await self.drainLoop() }
// The pump ends when the stream terminates; the drain loop only ends by
// cancellation — either way, wind the other one down too.
await group.next()
group.cancelAll()
}
// Events that arrived before the end still reach the UI — a coalesce window
// must never turn into event loss.
await flushNow()
}
// MARK: - Stream side
private func pump(_ stream: AsyncStream<AgentEvent>) async {
for await event in stream { enqueue(event) }
}
private func enqueue(_ event: AgentEvent) {
pending.append(event)
if Self.flushesImmediately(event.kind) {
urgent = true
coalesceSleep?.cancel()
}
wake?.resume()
wake = nil
}
/// Semantic boundaries the user must see with no coalescing latency. Everything
/// else is streaming-progress traffic whose only observable effect is a display
/// refresh, which the next flush covers.
static func flushesImmediately(_ kind: AgentEvent.Kind) -> Bool {
switch kind {
case .assistantText, .thinking, .toolCallStarted, .toolCallInputDelta,
.toolCallCompleted, .toolResult, .fileChange, .usage, .raw:
return false
case .sessionStarted, .userText, .approvalRequested, .approvalResolved,
.rateLimit, .codexUsage, .turnCompleted, .runFinished, .error, .note:
return true
}
}
// MARK: - Flush side
private var flushInterval: Duration {
switch cadence {
case .open: openInterval
case .background: backgroundInterval
}
}
private func drainLoop() async {
while !Task.isCancelled {
await parkUntilEvent()
if Task.isCancelled { return }
// Coalesce: the window is measured from the last flush, so the first event
// after a quiet spell flushes immediately; an urgent event skips the wait.
if !urgent, let last = lastFlush {
let target = last + flushInterval
if ContinuousClock.now < target { await coalesce(until: target) }
}
if Task.isCancelled { return }
await flushNow()
}
}
/// Sleep until `target`, waking early when an urgent event lands (its enqueue
/// cancels the sleep) or the drain loop is cancelled.
private func coalesce(until target: ContinuousClock.Instant) async {
let sleep = Task { () -> Void in
try? await Task.sleep(until: target, clock: .continuous)
}
coalesceSleep = sleep
await withTaskCancellationHandler {
_ = await sleep.value
} onCancel: {
sleep.cancel()
}
coalesceSleep = nil
}
private func flushNow() async {
guard !pending.isEmpty else { return }
let batch = pending
pending = []
urgent = false
lastFlush = .now
await deliver(batch)
}
/// Park until at least one event is pending. Cancellation-safe: a cancel while
/// parked resumes the continuation instead of stranding the drain loop.
private func parkUntilEvent() async {
await withTaskCancellationHandler {
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
if !pending.isEmpty || Task.isCancelled {
cont.resume()
} else {
wake = cont
}
}
} onCancel: {
Task { await self.releaseWake() }
}
}
private func releaseWake() {
wake?.resume()
wake = nil
}
/// Test hook: how many events sit in the pending batch right now.
func pendingCount() -> Int { pending.count }
}