返回 DeepSeek-Reasonix
runtime_state.go
根目录 / internal / control / runtime_state.go
1 package control
2
3 import (
4 "crypto/rand"
5 "encoding/hex"
6 "log/slog"
7 "reflect"
8 "sync"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/event"
12 "reasonix/internal/jobs"
13 "reasonix/internal/session"
14 "reasonix/internal/turnevent"
15 )
16
17 // RuntimeStateReader is optional so older embedders of SessionAPI keep working.
18 type RuntimeStateReader interface {
19 RuntimeStateSnapshot() event.RuntimeStateSnapshot
20 }
21
22 type controllerRuntimeState struct {
23 mu sync.Mutex // serializes sampling, commit and publication order; never held by observers
24 snapshot event.RuntimeStateSnapshot
25 ledger *turnevent.Ledger
26 path string
27 activity string
28 sink event.Sink
29 pending *event.RuntimeStateSnapshot
30 draining bool
31 jobUnsubscribe func()
32 }
33
34 func newRuntimeStateEpoch() string {
35 var bytes [16]byte
36 if _, err := rand.Read(bytes[:]); err != nil {
37 panic(err)
38 }
39 return hex.EncodeToString(bytes[:])
40 }
41
42 // RuntimeStateSnapshot first commits a fresh projection from the current owners
43 // and then returns that immutable boundary. This closes the small callback lag
44 // after a background job starts or exits without making readers combine fields
45 // from separate snapshots.
46 func (c *Controller) RuntimeStateSnapshot() event.RuntimeStateSnapshot {
47 if c == nil {
48 return event.RuntimeStateSnapshot{Todos: []event.Todo{}, Interactions: []event.PendingInteraction{}}
49 }
50 c.refreshRuntimeState(event.Event{})
51 c.runtimeState.mu.Lock()
52 defer c.runtimeState.mu.Unlock()
53 return cloneRuntimeState(c.runtimeState.snapshot)
54 }
55
56 func cloneRuntimeState(in event.RuntimeStateSnapshot) event.RuntimeStateSnapshot {
57 out := in
58 out.Todos = append([]event.Todo{}, in.Todos...)
59 out.Interactions = append([]event.PendingInteraction{}, in.Interactions...)
60 if in.Recovery != nil {
61 recovery := *in.Recovery
62 out.Recovery = &recovery
63 }
64 if in.Goal != nil {
65 goal := *in.Goal
66 if in.Goal.MaxGoalRounds != nil {
67 limit := *in.Goal.MaxGoalRounds
68 goal.MaxGoalRounds = &limit
69 }
70 if in.Goal.BlockedReason != nil {
71 reason := *in.Goal.BlockedReason
72 goal.BlockedReason = &reason
73 }
74 out.Goal = &goal
75 }
76 return out
77 }
78
79 func (c *Controller) initializeRuntimeState() {
80 c.runtimeState.mu.Lock()
81 c.runtimeState.sink = c.sink
82 c.runtimeState.mu.Unlock()
83 c.refreshRuntimeState(event.Event{})
84 if c.jobs != nil {
85 // A manager may be shared across a controller rebuild. Subscribe to all
86 // session transitions and filter against the current committed binding.
87 _, stop := c.jobs.SubscribeRuntime("", func(state jobs.RuntimeState) {
88 c.refreshRuntimeState(event.Event{})
89 })
90 c.runtimeState.mu.Lock()
91 c.runtimeState.jobUnsubscribe = stop
92 c.runtimeState.mu.Unlock()
93 c.refreshRuntimeState(event.Event{})
94 }
95 }
96
97 // refreshRuntimeState is a commit boundary, not a read-side workaround. Every
98 // lifecycle and job boundary calls it after releasing its owning locks. A
99 // single sampler re-reads current owners instead of replaying stale booleans.
100 func (c *Controller) refreshRuntimeState(e event.Event) {
101 c.refreshRuntimeStateAttempt(e, 0)
102 }
103
104 // refreshRuntimeStateAttempt retries an unstable multi-owner sample at most
105 // three times. Lifecycle callbacks will sample again after later transitions;
106 // a perpetually changing runtime must not grow the stack or monopolize a caller.
107 func (c *Controller) refreshRuntimeStateAttempt(e event.Event, attempt int) {
108 if c == nil {
109 return
110 }
111 r := &c.runtimeState
112 r.mu.Lock()
113 if r.sink == nil {
114 r.mu.Unlock()
115 return
116 } // construction has not finished
117 c.mu.Lock()
118 running, finishing, closed, cancelling, path := c.bodyActiveLocked(), c.finalizingLocked(), c.closed, c.cancelRequestedLocked(), c.sessionPath
119 c.mu.Unlock()
120 _, v3Runtime, exclusiveSession := c.v3Binding()
121 var v3RuntimeSnapshot session.RuntimeSnapshot
122 if exclusiveSession && v3Runtime != nil {
123 v3RuntimeSnapshot = v3Runtime.StateSnapshot()
124 }
125 ledger := c.turnEventLedger()
126 initialized := r.snapshot.SchemaVersion == 1
127 base, activity := r.snapshot, r.activity
128 if r.snapshot.ProjectionEpoch == "" || r.path != path || r.ledger != ledger {
129 base = event.RuntimeStateSnapshot{ProjectionEpoch: newRuntimeStateEpoch(), RuntimeEpoch: newRuntimeStateEpoch()}
130 activity = ""
131 }
132 next := base
133 next.SchemaVersion = 1
134 goalView, goalErr := c.goalLifecycleView()
135 next.Goal = goalView
136 next.GoalError = ""
137 if goalErr != nil {
138 next.GoalError = goalErr.Error()
139 }
140 if ref, ok := c.SessionRef(); ok {
141 next.HostID = ref.HostID
142 next.SessionID = ref.SessionID
143 next.SessionCodec = session.Codec
144 next.RuntimeEpoch = v3RuntimeSnapshot.Epoch
145 next.ActivityRevision = v3RuntimeSnapshot.ActivityRevision
146 } else {
147 next.HostID = ""
148 next.SessionID = ""
149 next.SessionCodec = ""
150 }
151 if ledger != nil {
152 next.TurnID, next.TurnStatus, next.TurnEventSeq = ledger.RuntimeIdentity()
153 }
154 v3Snapshot, hasV3Snapshot := c.sessionStateSnapshot()
155 applyRuntimeSessionState(&next, v3Snapshot, hasV3Snapshot)
156 next.HeadID = agent.BranchID(path)
157 if exclusiveSession {
158 next.HeadID = ""
159 }
160 if hasV3Snapshot {
161 // The typed v3 projection above is authoritative.
162 } else if ledger != nil {
163 next.Todos, next.TodoWritten = ledger.TodoState()
164 } else {
165 next.Todos, next.TodoWritten = c.volatileTodoState()
166 }
167 // Keep the empty wire shape stable. Ledger projections intentionally use a
168 // nil backing slice internally, while every public snapshot promises [].
169 // Normalizing before the semantic comparison prevents a read from creating
170 // a new revision solely because nil and an empty slice differ to reflect.
171 if next.Todos == nil {
172 next.Todos = []event.Todo{}
173 }
174 setRuntimePhase(&next, exclusiveSession, v3Runtime, v3RuntimeSnapshot, running, finishing, closed, cancelling)
175 // Close is immediately authoritative for the public controller view even
176 // while the session runtime remains in its private finalizing barrier. The
177 // latter keeps commit authority alive until TurnDone is durable; exposing it
178 // here would make a closed controller look runnable again.
179 next.Running = (running || finishing) && !closed
180 next.CancelRequested = cancelling && !closed
181 identities, promptRevision := c.promptOwner.IdentitiesRevision()
182 next.PendingPrompt = len(identities) > 0
183 next.Interactions = make([]event.PendingInteraction, len(identities))
184 for i, identity := range identities {
185 next.Interactions[i] = event.PendingInteraction{RequestID: identity.PromptID, ToolCallID: identity.ToolCallID, Kind: string(identity.Kind), HeadID: next.HeadID, TurnID: identity.TurnID, RuntimeEpoch: identity.RuntimeEpoch}
186 }
187 // Compatibility consumers may still use Cancellable as a button-state
188 // hint. Derive it only from the authoritative phase/request projection so a
189 // worker crossing into the finishing window cannot make an accepted cancel
190 // briefly look unavailable.
191 next.Cancellable = next.Phase == "executing" || next.Phase == "cancelling" || next.PendingPrompt
192 next.BackgroundJobs = 0
193 if c.jobs != nil {
194 next.BackgroundJobs = len(c.jobs.RunningForSession(agent.BranchID(path)))
195 }
196 // Sampling owners is off their locks. Do not commit a mixture if the
197 // admission/close/binding boundary advanced while another owner was read.
198 stable := c.runtimeBoundaryStable(running, finishing, closed, cancelling, path)
199 currentGoal, currentGoalErr := c.goalLifecycleView()
200 stable = stable && reflect.DeepEqual(goalView, currentGoal)
201 stable = stable && ((goalErr == nil && currentGoalErr == nil) || (goalErr != nil && currentGoalErr != nil && goalErr.Error() == currentGoalErr.Error()))
202 if exclusiveSession && v3Runtime != nil {
203 _, currentRuntime, currentExclusive := c.v3Binding()
204 stable = stable && currentExclusive && currentRuntime == v3Runtime && currentRuntime.StateSnapshot().ActivityRevision == v3RuntimeSnapshot.ActivityRevision
205 }
206 if !stable || ledger != c.turnEventLedger() || promptRevision != c.promptOwner.Revision() {
207 r.mu.Unlock()
208 if attempt < 2 {
209 c.refreshRuntimeStateAttempt(event.Event{}, attempt+1)
210 }
211 return
212 }
213 if closed && !running && next.BackgroundJobs == 0 && r.jobUnsubscribe != nil {
214 stop := r.jobUnsubscribe
215 r.jobUnsubscribe = nil
216 defer stop()
217 }
218 activity = runtimeActivity(next, e, activity)
219 next.Activity = activity
220 setRuntimeRecovery(&next, v3Snapshot, hasV3Snapshot, ledger, activity)
221 // Token deltas do not need runtime notifications. Keep the last published
222 // watermark until a semantic state changes, avoiding a second token stream.
223 compare := next
224 compare.TurnEventSeq = r.snapshot.TurnEventSeq
225 if reflect.DeepEqual(compare, r.snapshot) {
226 r.mu.Unlock()
227 return
228 }
229 next.Revision++
230 r.snapshot = cloneRuntimeState(next)
231 r.path, r.ledger, r.activity = path, ledger, activity
232 defer slog.Debug("runtime state committed", "source", "controller", "epoch", next.RuntimeEpoch[:8], "revision", next.Revision, "phase", next.Phase)
233 if !initialized {
234 r.mu.Unlock()
235 return
236 }
237 pending := cloneRuntimeState(next)
238 r.pending = &pending
239 if r.draining {
240 r.mu.Unlock()
241 return
242 }
243 r.draining = true
244 r.mu.Unlock()
245 go c.publishRuntimeState()
246 }
247
248 func (c *Controller) publishRuntimeState() {
249 r := &c.runtimeState
250 for {
251 r.mu.Lock()
252 if r.pending == nil {
253 r.draining = false
254 r.mu.Unlock()
255 return
256 }
257 snapshot, sink := cloneRuntimeState(*r.pending), r.sink
258 r.pending = nil
259 r.mu.Unlock()
260 event.PublishRuntimeState(sink, snapshot)
261 }
262 }
263
264 func runtimeActivity(state event.RuntimeStateSnapshot, e event.Event, activity string) string {
265 if state.PendingPrompt {
266 return "waiting_input"
267 }
268 if state.Phase == "cancelling" {
269 return "cancelling"
270 }
271 if state.Phase == "recovery_required" {
272 return "recovery_required"
273 }
274 if state.Phase == "executing" {
275 if e.TurnID == "" || e.TurnID == state.TurnID {
276 switch e.Kind {
277 case event.Text, event.Message:
278 activity = "streaming"
279 case event.TurnStarted, event.Reasoning, event.ToolDispatch, event.ToolProgress, event.ToolResult, event.CompactionStarted, event.Retrying:
280 activity = "thinking"
281 }
282 }
283 if activity == "" {
284 activity = "thinking"
285 }
286 } else {
287 activity = ""
288 }
289 return activity
290 }
291
292 func setRuntimePhase(next *event.RuntimeStateSnapshot, exclusiveSession bool, v3Runtime *session.Runtime, v3RuntimeSnapshot session.RuntimeSnapshot, running, finishing, closed, cancelling bool) {
293 next.Phase = "idle"
294 if closed {
295 next.Phase = "closed"
296 return
297 }
298 if exclusiveSession && v3Runtime != nil {
299 switch v3RuntimeSnapshot.Phase {
300 case session.RuntimeRunning:
301 next.Phase = "executing"
302 case session.RuntimeCancelling:
303 next.Phase = "cancelling"
304 case session.RuntimeFinalizing:
305 next.Phase = "finishing"
306 case session.RuntimeRecoveryRequired:
307 next.Phase = "recovery_required"
308 case session.RuntimeClosed:
309 next.Phase = "closed"
310 }
311 } else {
312 switch {
313 case next.TurnStatus == event.TurnRecoveryRequired:
314 next.Phase = "recovery_required"
315 case cancelling:
316 next.Phase = "cancelling"
317 case running:
318 next.Phase = "executing"
319 case finishing:
320 next.Phase = "finishing"
321 case closed:
322 next.Phase = "closed"
323 }
324 }
325 }
326
327 func applyRuntimeSessionState(next *event.RuntimeStateSnapshot, v3Snapshot session.Snapshot, hasV3Snapshot bool) {
328 if hasV3Snapshot {
329 snapshot := v3Snapshot
330 next.CommittedSeq = snapshot.EventSequence
331 next.DurableSeq = snapshot.DurableSequence
332 next.Persistence = string(snapshot.PersistenceStatus)
333 next.PersistenceErr = snapshot.PersistenceError
334 next.Todos = append([]event.Todo(nil), snapshot.Projection.Todos...)
335 next.TodoWritten = snapshot.Projection.TodoWritten
336 if snapshot.Projection.TurnID != "" {
337 next.TurnID = snapshot.Projection.TurnID
338 next.TurnStatus = snapshot.Projection.TurnStatus
339 }
340 if snapshot.Projection.Recovery != nil && snapshot.Projection.Recovery.State == "recovery_required" {
341 next.TurnStatus = event.TurnRecoveryRequired
342 }
343 } else {
344 next.CommittedSeq = next.TurnEventSeq
345 next.DurableSeq = next.TurnEventSeq
346 next.Persistence = "unavailable"
347 }
348 }
349
350 func setRuntimeRecovery(next *event.RuntimeStateSnapshot, v3Snapshot session.Snapshot, hasV3Snapshot bool, ledger *turnevent.Ledger, activity string) {
351 next.Recovery = nil
352 if next.Phase == "recovery_required" {
353 if hasV3Snapshot && v3Snapshot.Projection.Recovery != nil {
354 recovery := *v3Snapshot.Projection.Recovery
355 next.Recovery = &recovery
356 } else if ledger != nil {
357 next.Recovery = ledger.RecoveryStatus()
358 }
359 if next.Recovery == nil {
360 next.Recovery = &event.RecoveryStatus{State: "recovery_required", Phase: activity, Reason: "runtime state requires recovery"}
361 } else if next.Recovery.Phase == "" {
362 next.Recovery.Phase = activity
363 }
364 }
365 }
366
367 func (c *Controller) runtimeBoundaryStable(running, finishing, closed, cancelling bool, path string) bool {
368 c.mu.Lock()
369 defer c.mu.Unlock()
370 return running == c.bodyActiveLocked() && finishing == c.finalizingLocked() && closed == c.closed && cancelling == c.cancelRequestedLocked() && path == c.sessionPath
371 }
372
372 lines GO