返回 DeepSeek-Reasonix
coalesce.go
根目录 / internal / event / coalesce.go
1 package event
2
3 import (
4 "reflect"
5 "strings"
6 "sync"
7 "time"
8
9 "reasonix/internal/evidence"
10 "reasonix/internal/nilutil"
11 )
12
13 // coalesceMaxBytes bounds a merged delta so one event never carries an
14 // unbounded payload across a frontend bridge.
15 const coalesceMaxBytes = 16 << 10
16
17 // DefaultStreamDeltaWindow caps how often coalesced streaming deltas cross
18 // into a frontend: at most one merged event per window under load — about one
19 // per display frame — so a fast provider (hundreds of chunks/sec) cannot
20 // flood a webview bridge, an SSE stream, or a terminal redraw loop.
21 const DefaultStreamDeltaWindow = 16 * time.Millisecond
22
23 // Coalesce wraps inner so bursts of consecutive streaming deltas — Text or
24 // Reasoning events carrying nothing but a Text payload — merge into one event.
25 // The first delta of a burst forwards immediately (time-to-first-token is
26 // unchanged); later deltas buffer at most window, flushing earlier on any
27 // other event (total order preserved), a kind switch, or coalesceMaxBytes.
28 func Coalesce(inner Sink, window time.Duration) Sink {
29 if nilutil.IsNil(inner) {
30 return Discard
31 }
32 if window <= 0 {
33 return inner
34 }
35 return &coalescer{inner: inner, window: window}
36 }
37
38 type coalescer struct {
39 inner Sink
40 window time.Duration
41
42 // mu guards buffering state and the outbound queue; inner.Emit is never
43 // called under mu. A single drainer forwards FIFO, so a sink that
44 // synchronously re-enters Emit enqueues and returns instead of deadlocking.
45 mu sync.Mutex
46 kind Kind
47 source string
48 messageID string
49 attemptID string
50 buf strings.Builder
51 pending bool
52 timer *time.Timer
53 lastForward time.Time
54 queue []coalescedEvent
55 draining bool
56 }
57
58 type coalescedEvent struct {
59 event Event
60 done chan error
61 forward func()
62 }
63
64 var _ OptionalSinkCapabilities = (*coalescer)(nil)
65 var _ CheckedSink = (*coalescer)(nil)
66
67 // isStreamDelta reports whether e is a pure streaming delta: merging is only
68 // safe when no other field carries meaning. The zero-probe comparison keeps
69 // this true by construction as Event grows fields.
70 func isStreamDelta(e Event) bool {
71 if (e.Kind != Text && e.Kind != Reasoning) || e.Text == "" {
72 return false
73 }
74 probe := e
75 probe.Text = ""
76 probe.Source = ""
77 probe.MessageID = ""
78 probe.AttemptID = ""
79 return reflect.DeepEqual(probe, Event{Kind: e.Kind})
80 }
81
82 func (c *coalescer) Emit(e Event) {
83 _ = c.enqueue(e, false)
84 }
85
86 // EmitChecked is a synchronous ordering barrier. Buffered deltas are written
87 // before e, and it returns only after the durable inner sink has acknowledged
88 // e. Regular streaming Emit calls remain non-blocking while a drainer is
89 // active; they learn asynchronous failures through the lifecycle sink's
90 // poisoned-ledger state.
91 func (c *coalescer) EmitChecked(e Event) error {
92 return c.enqueue(e, true)
93 }
94
95 func (c *coalescer) enqueue(e Event, checked bool) error {
96 var done chan error
97 if checked {
98 done = make(chan error, 1)
99 }
100 c.mu.Lock()
101 if checked && isStreamDelta(e) {
102 c.enqueueFlushLocked()
103 c.queue = append(c.queue, coalescedEvent{event: e, done: done})
104 c.drainAndUnlock()
105 return <-done
106 }
107 if !isStreamDelta(e) {
108 c.enqueueFlushLocked()
109 c.queue = append(c.queue, coalescedEvent{event: e, done: done})
110 c.drainAndUnlock()
111 if done != nil {
112 return <-done
113 }
114 return nil
115 }
116 if c.pending && (c.kind != e.Kind || c.source != e.Source || c.messageID != e.MessageID || c.attemptID != e.AttemptID) {
117 c.enqueueFlushLocked()
118 }
119 if !c.pending && time.Since(c.lastForward) >= c.window {
120 c.lastForward = time.Now()
121 c.queue = append(c.queue, coalescedEvent{event: e, done: done})
122 c.drainAndUnlock()
123 if done != nil {
124 return <-done
125 }
126 return nil
127 }
128 if !c.pending {
129 c.pending = true
130 c.kind = e.Kind
131 c.source = e.Source
132 c.messageID = e.MessageID
133 c.attemptID = e.AttemptID
134 if c.timer == nil {
135 c.timer = time.AfterFunc(c.window, c.flush)
136 } else {
137 c.timer.Reset(c.window)
138 }
139 }
140 c.buf.WriteString(e.Text)
141 if c.buf.Len() >= coalesceMaxBytes {
142 c.enqueueFlushLocked()
143 }
144 c.drainAndUnlock()
145 if done != nil {
146 return <-done
147 }
148 return nil
149 }
150
151 func (c *coalescer) flush() {
152 c.mu.Lock()
153 c.enqueueFlushLocked()
154 c.drainAndUnlock()
155 }
156
157 // enqueueFlushLocked moves the buffered delta (if any) onto the outbound queue.
158 func (c *coalescer) enqueueFlushLocked() {
159 if !c.pending {
160 return
161 }
162 c.timer.Stop()
163 c.queue = append(c.queue, coalescedEvent{event: Event{Kind: c.kind, Text: c.buf.String(), Source: c.source, MessageID: c.messageID, AttemptID: c.attemptID}})
164 c.buf.Reset()
165 c.pending = false
166 c.source = ""
167 c.lastForward = time.Now()
168 }
169
170 // drainAndUnlock forwards queued events in FIFO order and releases mu. Exactly
171 // one goroutine drains at a time; others enqueue and return.
172 func (c *coalescer) drainAndUnlock() {
173 if c.draining || len(c.queue) == 0 {
174 c.mu.Unlock()
175 return
176 }
177 c.draining = true
178 for len(c.queue) > 0 {
179 batch := c.queue
180 c.queue = nil
181 c.mu.Unlock()
182 for _, item := range batch {
183 if item.forward != nil {
184 item.forward()
185 continue
186 }
187 err := EmitChecked(c.inner, item.event)
188 if item.done != nil {
189 item.done <- err
190 close(item.done)
191 }
192 }
193 c.mu.Lock()
194 }
195 c.draining = false
196 c.mu.Unlock()
197 }
198
199 // Optional sink capabilities flush first so audits never overtake a buffered
200 // delta, then forward to inner sinks that opt in.
201
202 func (c *coalescer) enqueueCapability(forward func()) {
203 c.mu.Lock()
204 c.enqueueFlushLocked()
205 c.queue = append(c.queue, coalescedEvent{forward: forward})
206 c.drainAndUnlock()
207 }
208
209 func (c *coalescer) RecordDelegationAudit(a evidence.DelegationAudit) {
210 c.enqueueCapability(func() { RecordDelegationAudit(c.inner, a) })
211 }
212
213 func (c *coalescer) RecordReadinessAudit(a evidence.ReadinessAudit) {
214 c.enqueueCapability(func() { RecordReadinessAudit(c.inner, a) })
215 }
216
217 func (c *coalescer) RecordAnchorSafetyAudit(a AnchorSafetyAudit) {
218 c.enqueueCapability(func() { RecordAnchorSafetyAudit(c.inner, a) })
219 }
220
221 func (c *coalescer) RecordTurnCompletion() {
222 c.enqueueCapability(func() { RecordTurnCompletion(c.inner) })
223 }
224
225 func (c *coalescer) RecordProtocolRecovery(a ProtocolRecoveryAudit) {
226 c.enqueueCapability(func() { RecordProtocolRecovery(c.inner, a) })
227 }
228
229 func (c *coalescer) RecordContractShadow(a ContractShadowAudit) {
230 c.enqueueCapability(func() { RecordContractShadow(c.inner, a) })
231 }
232
233 func (c *coalescer) RecordCompletionReport(a CompletionReportAudit) {
234 c.enqueueCapability(func() { RecordCompletionReport(c.inner, a) })
235 }
236
237 func (c *coalescer) RecordOutcomeProgress(sample evidence.OutcomeSample) {
238 c.enqueueCapability(func() { RecordOutcomeProgress(c.inner, sample) })
239 }
240
241 func (c *coalescer) RecordMemoryRecall(a MemoryRecallAudit) {
242 c.enqueueCapability(func() { RecordMemoryRecall(c.inner, a) })
243 }
244
245 func (c *coalescer) RecordDelegationAdmission(a DelegationAdmissionAudit) {
246 c.enqueueCapability(func() { RecordDelegationAdmission(c.inner, a) })
247 }
248
249 func (c *coalescer) RecordWorkspaceMutation(m WorkspaceMutation) {
250 c.enqueueCapability(func() { RecordWorkspaceMutation(c.inner, m) })
251 }
252
253 func (c *coalescer) RecordRunBudget(sample RunBudgetSample) {
254 c.enqueueCapability(func() { RecordRunBudget(c.inner, sample) })
255 }
256
257 func (c *coalescer) RecordSubagentLifecycle(info SubagentLifecycleInfo) {
258 c.enqueueCapability(func() { RecordSubagentLifecycle(c.inner, info) })
259 }
260
260 lines GO