191 lines
7.6 KiB
Swift
191 lines
7.6 KiB
Swift
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: 10–12 commits/s open, 2–4 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 }
|
||
}
|