返回 DeepSeek-Reasonix
inbox_dispatch.go
根目录 / internal / control / inbox_dispatch.go
1 package control
2
3 import (
4 "errors"
5 "log/slog"
6 "time"
7
8 "reasonix/internal/sessioninbox"
9 )
10
11 const maxInboxDispatchRetryAttempts = 3
12
13 // ErrInboxRuntimeUnpublished means the host owns the next dispatch kick:
14 // either this runtime is a candidate or it was replaced before admission.
15 var ErrInboxRuntimeUnpublished = errors.New("inbox runtime is not published")
16
17 // NotifyInboxRuntimeReady is called after a host publishes a complete runtime.
18 func (c *Controller) NotifyInboxRuntimeReady() { c.maybeDispatchInbox() }
19
20 func (c *Controller) SetBeforeInboxDispatch(before func(*Controller) (func(), error)) {
21 c.mu.Lock()
22 c.modelSettings.beforeInboxDispatch = before
23 c.mu.Unlock()
24 }
25
26 type inboxDispatchResult int
27
28 const (
29 inboxDispatchIdle inboxDispatchResult = iota
30 inboxDispatchStarted
31 inboxDispatchRetry
32 )
33
34 // endRotation releases the admission gate and republishes durable queue work.
35 func (c *Controller) endRotation() {
36 c.mu.Lock()
37 c.rotating = false
38 c.mu.Unlock()
39 c.maybeDispatchInbox()
40 }
41
42 // maybeDispatchInbox is a level-triggered kick, not a one-shot edge. Every
43 // caller publishes pending work before checking whether a dispatcher is live.
44 // The active dispatcher clears dispatching only while holding the same lock
45 // after observing no pending kick, so a completion, rotation release, or steer
46 // rejection can never disappear in the handoff window.
47 func (c *Controller) maybeDispatchInbox() {
48 c.inbox.mu.Lock()
49 if c.inbox.closed {
50 c.inbox.mu.Unlock()
51 return
52 }
53 c.inbox.dispatchPending = true
54 if c.inbox.dispatching {
55 c.inbox.mu.Unlock()
56 return
57 }
58 c.inbox.dispatching = true
59 c.inbox.mu.Unlock()
60 c.mu.Lock()
61 hostAdmission := c.modelSettings.beforeInboxDispatch != nil
62 c.mu.Unlock()
63 if hostAdmission {
64 // Enqueue/resume can be called with the host's publication lock held.
65 // Never synchronously reenter that lock through its admission callback.
66 c.autosaveWG.Go(c.drainInboxDispatch)
67 return
68 }
69 c.drainInboxDispatch()
70 }
71
72 func (c *Controller) drainInboxDispatch() {
73 for {
74 c.inbox.mu.Lock()
75 if !c.inbox.dispatchPending {
76 c.inbox.dispatching = false
77 c.inbox.mu.Unlock()
78 return
79 }
80 c.inbox.dispatchPending = false
81 c.inbox.mu.Unlock()
82
83 switch c.dispatchInboxOnce() {
84 case inboxDispatchRetry:
85 c.scheduleInboxDispatchRetry()
86 case inboxDispatchStarted, inboxDispatchIdle:
87 c.resetInboxDispatchRetries()
88 }
89 }
90 }
91
92 // dispatchInboxOnce admits one FIFO item when every runtime gate is open. A
93 // started turn owns the next kick through finishGuardedTurn; this method never
94 // loops over multiple items while that turn is active.
95 func (c *Controller) dispatchInboxOnce() inboxDispatchResult {
96 if c.PendingPrompt() {
97 return inboxDispatchIdle
98 }
99 c.mu.Lock()
100 busy := c.bodyActiveLocked() || c.finalizingLocked() || c.maintenance != nil || c.rotating || c.closed
101 c.mu.Unlock()
102 if busy {
103 return inboxDispatchIdle
104 }
105 // Controllers without persistence cannot own a durable inbox. Rotation and
106 // turn-completion hooks are shared with those controllers, so treat the
107 // missing path as an empty queue instead of retrying a permanent condition.
108 if c.SessionPath() == "" && !c.sessionEngineEnabled() {
109 return inboxDispatchIdle
110 }
111 meta, ok, err := c.nextInboxDispatchItem()
112 if err != nil {
113 slog.Warn("controller: open inbox for dispatch", "err", err)
114 return inboxDispatchRetry
115 }
116 c.inbox.mu.Lock()
117 beforeSubmit := c.inbox.beforeDispatchSubmit
118 c.inbox.mu.Unlock()
119 if !ok {
120 return inboxDispatchIdle
121 }
122 if beforeSubmit != nil {
123 if err := beforeSubmit(meta.ID); err != nil {
124 slog.Warn("controller: inbox dispatch hook", "err", err, "id", meta.ID)
125 return inboxDispatchRetry
126 }
127 }
128 receipt, err := c.TrySubmitInboxItem(meta.ID)
129 if err != nil {
130 if errors.Is(err, sessioninbox.ErrContentChanged) || errors.Is(err, sessioninbox.ErrOrderChanged) || errors.Is(err, sessioninbox.ErrNotFound) {
131 // A prepared candidate was invalidated. Reselect from the authoritative
132 // queue through the existing level-triggered loop, without a timer.
133 c.inbox.mu.Lock()
134 c.inbox.dispatchPending = true
135 c.inbox.mu.Unlock()
136 return inboxDispatchIdle
137 }
138 if errors.Is(err, ErrInboxRuntimeUnpublished) || errors.Is(err, ErrTurnRunning) {
139 return inboxDispatchIdle
140 }
141 slog.Warn("controller: dispatch inbox item", "err", err, "id", meta.ID)
142 return inboxDispatchRetry
143 }
144 if receipt.Disposition == sessioninbox.DispositionStarted {
145 return inboxDispatchStarted
146 }
147 // A competing turn or rotation owns the next kick when its gate releases.
148 return inboxDispatchIdle
149 }
150
151 func (c *Controller) nextInboxDispatchItem() (sessioninbox.InboxItemMeta, bool, error) {
152 c.inbox.scanMu.Lock()
153 defer c.inbox.scanMu.Unlock()
154 c.inbox.mu.Lock()
155 closed := c.inbox.closed
156 afterScan := c.inbox.afterDispatchScan
157 c.inbox.mu.Unlock()
158 if closed {
159 return sessioninbox.InboxItemMeta{}, false, nil
160 }
161 st, err := c.ensureInbox()
162 if err != nil {
163 return sessioninbox.InboxItemMeta{}, false, err
164 }
165 // NextQueued refreshes disk state and may create its transaction-lock
166 // directory. Keep that access inside the same shutdown boundary as Open.
167 meta, ok := st.NextQueued()
168 if afterScan != nil {
169 afterScan(ok)
170 }
171 return meta, ok, nil
172 }
173
174 func (c *Controller) scheduleInboxDispatchRetry() {
175 c.inbox.mu.Lock()
176 if c.inbox.dispatchRetryScheduled || c.inbox.dispatchRetryAttempts >= maxInboxDispatchRetryAttempts {
177 c.inbox.mu.Unlock()
178 return
179 }
180 attempt := c.inbox.dispatchRetryAttempts
181 c.inbox.dispatchRetryAttempts++
182 c.inbox.dispatchRetryScheduled = true
183 schedule := c.inbox.scheduleDispatchRetry
184 c.inbox.mu.Unlock()
185
186 delay := [...]time.Duration{50 * time.Millisecond, 200 * time.Millisecond, 500 * time.Millisecond}[attempt]
187 retry := func() {
188 c.inbox.mu.Lock()
189 c.inbox.dispatchRetryScheduled = false
190 c.inbox.mu.Unlock()
191 c.maybeDispatchInbox()
192 }
193 if schedule != nil {
194 schedule(delay, retry)
195 return
196 }
197 time.AfterFunc(delay, retry)
198 }
199
200 func (c *Controller) resetInboxDispatchRetries() {
201 c.inbox.mu.Lock()
202 c.inbox.dispatchRetryAttempts = 0
203 c.inbox.mu.Unlock()
204 }
205
205 lines GO