返回 DeepSeek-Reasonix
inbox.go
根目录 / internal / control / inbox.go
1 package control
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "maps"
9 "path/filepath"
10 "strings"
11 "sync"
12 "time"
13
14 "reasonix/internal/event"
15 "reasonix/internal/sessioninbox"
16 "reasonix/internal/sessiontemp"
17 )
18
19 // TurnAdmission is the exported classification of TrySubmitInboxItem /
20 // TrySteerInboxItem results.
21 type TurnAdmission string
22
23 const (
24 AdmissionStarted TurnAdmission = "started"
25 AdmissionSteerAccepted TurnAdmission = "steer_accepted"
26 AdmissionQueuedFollowup TurnAdmission = "queued_followup"
27 AdmissionRejectedBusy TurnAdmission = "rejected_busy"
28 AdmissionRejectedRotating TurnAdmission = "rejected_rotating"
29 AdmissionRejectedClosed TurnAdmission = "rejected_closed"
30 AdmissionRejectedCapacity TurnAdmission = "rejected_capacity"
31 )
32
33 // InboxRequest is the frontend-facing enqueue payload.
34 type InboxRequest struct {
35 ExpectedSessionPath string // optional exact-session fence; never persisted
36 Intent sessioninbox.InboxIntent
37 Display string
38 Raw string
39 Submit string
40 Format string
41 Source string
42 Idempotency string
43 Invocations []InvocationRequest
44 Extra map[string]string
45 // FreezeRefs lists workspace-relative paths to freeze at enqueue time.
46 FreezeRefs []string
47 Attachments []SubmissionAttachment
48 }
49
50 // Inbox port on SessionAPI.
51 type Inbox interface {
52 EnqueueInbox(req InboxRequest) (sessioninbox.InboxReceipt, error)
53 InboxSnapshot() sessioninbox.InboxSnapshot
54 ReadInboxItem(id string) (sessioninbox.InboxItemMeta, sessioninbox.PromptEnvelope, error)
55 UpdateInboxItem(id string, display, raw, submit string) (sessioninbox.InboxItemMeta, error)
56 AppendInboxItem(id, text, idempotency string, extra map[string]string) (sessioninbox.InboxItemMeta, error)
57 DeleteInboxItem(id string) error
58 CancelWithInboxItems(ids []string, source string) error
59 CancelWithInboxItemsResult(ids []string, source string) (InboxCancelResult, error)
60 MoveInboxItem(id string, toIndex int) error
61 SetInboxPaused(paused bool) error
62 RetryInboxItem(id string) error
63 RefreshInboxReferences(id string) error
64 TrySubmitInboxItem(id string) (sessioninbox.InboxReceipt, error)
65 RunInboxTurn(ctx context.Context, id string) error
66 TrySteerInboxItem(id string) (sessioninbox.InboxReceipt, error)
67 TryEnqueueAndSteer(req InboxRequest) (sessioninbox.InboxReceipt, error)
68 TryEnqueueFollowup(req InboxRequest) (sessioninbox.InboxReceipt, error)
69 }
70
71 // Compile-time port satisfaction.
72 var _ Inbox = (*Controller)(nil)
73
74 // inboxState is controller-owned inbox wiring (disk store + active items).
75 type inboxState struct {
76 prepareMu sync.Mutex
77 // admissionMu serializes competing admission state machines. Snapshot
78 // recovery and completion never hold it across Store I/O.
79 admissionMu sync.Mutex
80 // scanMu joins autonomous sidecar reads at shutdown without waiting for a
81 // dispatcher that may itself retire this controller during host admission.
82 scanMu sync.Mutex
83 mu sync.Mutex
84 store *sessioninbox.Store
85 // tempLease pins the process-local inbox used by an exclusive v3 Runtime.
86 // It is not recovery state and is deleted with the session temp generation.
87 tempLease *sessiontemp.Lease
88 closed bool // seals new sidecar opens when controller teardown starts
89 // activeItemIDs includes the running follow-up and every accepted steer.
90 // TurnDone durable-acks the set so multi-steer rounds leave no orphans.
91 activeItemIDs map[string]struct{}
92 // activeOwnership mirrors activeItemIDs for lock-free recovery checks while
93 // the Store owns its transaction lock. admittingOwnership covers the narrow
94 // durable-claim -> active-registration transition.
95 activeOwnership sync.Map
96 admittingOwnership sync.Map
97 dispatching bool
98 dispatchPending bool
99 // Retry bookkeeping is guarded by mu. Retries are bounded so a persistent
100 // disk or materialization failure cannot create a hot background loop.
101 dispatchRetryAttempts int
102 dispatchRetryScheduled bool
103 // beforePreparedAdmission is a deterministic test hook for the gap between
104 // durable preparation and Controller admission. Production leaves it nil.
105 beforePreparedAdmission func()
106 // beforeCompletionSnapshot exposes the slow snapshot boundary without
107 // changing production behavior.
108 beforeCompletionSnapshot func()
109 // beforeCompletionAck exposes the ownership-to-ack boundary to race tests.
110 beforeCompletionAck func()
111 // beforeSnapshotRead exposes the final Store snapshot boundary to lock tests.
112 beforeSnapshotRead func()
113 // afterDispatchScan exposes the empty-scan boundary for lost-wakeup tests.
114 afterDispatchScan func(found bool)
115 // beforeDispatchSubmit injects a transient owner-level dispatch failure.
116 beforeDispatchSubmit func(itemID string) error
117 // scheduleDispatchRetry replaces the production timer in deterministic tests.
118 scheduleDispatchRetry func(delay time.Duration, retry func())
119 }
120
121 func (s *inboxState) trackActive(id string) {
122 if s == nil || id == "" {
123 return
124 }
125 if s.activeItemIDs == nil {
126 s.activeItemIDs = make(map[string]struct{})
127 }
128 s.activeOwnership.Store(id, struct{}{})
129 s.activeItemIDs[id] = struct{}{}
130 }
131
132 func (s *inboxState) untrackActive(id string) {
133 if s == nil || id == "" {
134 return
135 }
136 if s.activeItemIDs != nil {
137 delete(s.activeItemIDs, id)
138 }
139 s.activeOwnership.Delete(id)
140 }
141
142 func (s *inboxState) untrackActiveSet(ids []string) {
143 if s == nil {
144 return
145 }
146 for _, id := range ids {
147 if s.activeItemIDs != nil {
148 delete(s.activeItemIDs, id)
149 }
150 s.activeOwnership.Delete(id)
151 }
152 }
153
154 func (s *inboxState) clearActive() {
155 if s == nil {
156 return
157 }
158 s.activeItemIDs = nil
159 s.activeOwnership.Clear()
160 }
161
162 func (s *inboxState) trackAdmission(id string) {
163 if s != nil && id != "" {
164 s.admittingOwnership.Store(id, struct{}{})
165 }
166 }
167
168 func (s *inboxState) untrackAdmission(id string) {
169 if s != nil && id != "" {
170 s.admittingOwnership.Delete(id)
171 }
172 }
173
174 // ownsItem is intentionally lock-free: Store recovery calls it while holding
175 // its own transaction lock, and no Store -> Controller lock edge is allowed.
176 func (s *inboxState) ownsItem(id string) bool {
177 if s == nil || id == "" {
178 return false
179 }
180 if _, ok := s.admittingOwnership.Load(id); ok {
181 return true
182 }
183 _, ok := s.activeOwnership.Load(id)
184 return ok
185 }
186
187 func (s *inboxState) activeIDs() []string {
188 if s == nil || len(s.activeItemIDs) == 0 {
189 return nil
190 }
191 out := make([]string, 0, len(s.activeItemIDs))
192 for id := range s.activeItemIDs {
193 out = append(out, id)
194 }
195 return out
196 }
197
198 func (c *Controller) bindInboxStoreNotifications(st *sessioninbox.Store) {
199 if c == nil || st == nil {
200 return
201 }
202 st.OnChange(func(snap sessioninbox.InboxSnapshot) {
203 notifyInboxChanged(c.sink, snap)
204 })
205 }
206
207 func (c *Controller) ensureInbox() (*sessioninbox.Store, error) {
208 path := c.SessionPath()
209 c.inbox.mu.Lock()
210 defer c.inbox.mu.Unlock()
211 if path == "" && c.sessionEngineEnabled() {
212 if c.inbox.tempLease == nil {
213 lease, err := c.sessionTemp.Acquire()
214 if err != nil {
215 return nil, fmt.Errorf("open v3 runtime inbox: %w", err)
216 }
217 c.inbox.tempLease = lease
218 }
219 path = filepath.Join(c.inbox.tempLease.Dir(), "runtime-inbox.jsonl")
220 }
221 if path == "" {
222 return nil, fmt.Errorf("inbox requires a session identity")
223 }
224 if c.inbox.store != nil && c.inbox.store.SessionPath() == path {
225 return c.inbox.store, nil
226 }
227 if c.inbox.closed {
228 return nil, fmt.Errorf("controller inbox is closed")
229 }
230 if c.inbox.store != nil {
231 c.inbox.store.Close()
232 c.inbox.store = nil
233 }
234 st, err := sessioninbox.Open(path, sessioninbox.Limits{})
235 if err != nil {
236 return nil, err
237 }
238 c.bindInboxStoreNotifications(st)
239 c.inbox.store = st
240 snap := st.Snapshot()
241 if snap.Recovered && snap.RecoveredN > 0 {
242 c.sink.Emit(event.Event{
243 Kind: event.Notice,
244 Level: event.LevelWarn,
245 Code: "inbox_recovered",
246 Text: fmt.Sprintf("Recovered %d pending instruction(s). Inbox is paused — review with /queue before resuming.", snap.RecoveredN),
247 })
248 sessioninbox.NoteRecovered(snap.RecoveredN)
249 }
250 return st, nil
251 }
252
253 // rebindInbox opens the inbox for the current session path. Safe across
254 // NewSession/Resume/SetSessionPath; does not copy items on fork.
255 func (c *Controller) rebindInbox() {
256 path := c.SessionPath()
257 c.inbox.mu.Lock()
258 defer c.inbox.mu.Unlock()
259 if c.inbox.closed {
260 return
261 }
262 if c.inbox.store != nil {
263 if path != "" && c.inbox.store.SessionPath() == path {
264 return
265 }
266 // Pending work must remain inspectable if this session is reopened.
267 _ = c.inbox.store.PauseIfPending()
268 c.inbox.store.Close()
269 c.inbox.store = nil
270 c.inbox.clearActive()
271 }
272 if c.inbox.tempLease != nil {
273 c.inbox.tempLease.Release()
274 c.inbox.tempLease = nil
275 }
276 if path == "" && c.sessionEngineEnabled() {
277 lease, err := c.sessionTemp.Acquire()
278 if err != nil {
279 slog.Warn("controller: open v3 runtime inbox", "err", err)
280 return
281 }
282 c.inbox.tempLease = lease
283 path = filepath.Join(lease.Dir(), "runtime-inbox.jsonl")
284 }
285 if path == "" {
286 return
287 }
288 st, err := sessioninbox.Open(path, sessioninbox.Limits{})
289 if err != nil {
290 slog.Warn("controller: open session inbox", "err", err, "path", path)
291 return
292 }
293 c.bindInboxStoreNotifications(st)
294 c.inbox.store = st
295 snap := st.Snapshot()
296 if snap.Recovered && snap.RecoveredN > 0 {
297 // Emit after unlock via deferred sink call would race; emit here.
298 go func(n int) {
299 c.sink.Emit(event.Event{
300 Kind: event.Notice,
301 Level: event.LevelWarn,
302 Code: "inbox_recovered",
303 Text: fmt.Sprintf("Recovered %d pending instruction(s). Inbox is paused — review with /queue before resuming.", n),
304 })
305 }(snap.RecoveredN)
306 sessioninbox.NoteRecovered(snap.RecoveredN)
307 }
308 }
309
310 func (c *Controller) pauseInboxOnRotate() {
311 c.inbox.mu.Lock()
312 st := c.inbox.store
313 c.inbox.mu.Unlock()
314 if st != nil {
315 _ = st.PauseIfPending()
316 }
317 }
318
319 func (c *Controller) InboxSnapshot() sessioninbox.InboxSnapshot {
320 st, err := c.ensureInbox()
321 if err != nil {
322 return sessioninbox.InboxSnapshot{}
323 }
324 if recovered, recoverErr := st.RecoverOrphanedInFlightOwnedBy(c.inbox.ownsItem); recoverErr != nil {
325 slog.Warn("controller: recover orphaned inbox items", "err", recoverErr)
326 } else if recovered > 0 {
327 sessioninbox.NoteRecovered(recovered)
328 }
329 c.inbox.mu.Lock()
330 beforeSnapshotRead := c.inbox.beforeSnapshotRead
331 c.inbox.mu.Unlock()
332 if beforeSnapshotRead != nil {
333 beforeSnapshotRead()
334 }
335 return st.Snapshot()
336 }
337
338 func (c *Controller) ReadInboxItem(id string) (sessioninbox.InboxItemMeta, sessioninbox.PromptEnvelope, error) {
339 st, err := c.ensureInbox()
340 if err != nil {
341 return sessioninbox.InboxItemMeta{}, sessioninbox.PromptEnvelope{}, err
342 }
343 return st.ReadItem(id)
344 }
345
346 func (c *Controller) UpdateInboxItem(id, display, raw, submit string) (sessioninbox.InboxItemMeta, error) {
347 st, err := c.ensureInbox()
348 if err != nil {
349 return sessioninbox.InboxItemMeta{}, err
350 }
351 submit = strings.TrimSpace(firstNonEmptyStr(submit, raw, display))
352 display = firstNonEmptyStr(display, submit)
353 raw = firstNonEmptyStr(raw, submit)
354 _, previous, err := st.ReadItem(id)
355 if err != nil {
356 return sessioninbox.InboxItemMeta{}, err
357 }
358 env := sessioninbox.PromptEnvelope{
359 DisplayText: display,
360 RawText: raw,
361 SubmitText: submit,
362 Format: previous.Format,
363 ImageInputs: previous.ImageInputs,
364 ImageSourceRefs: maps.Clone(previous.ImageSourceRefs),
365 AttachmentIdentities: previous.AttachmentIdentities,
366 Source: previous.Source,
367 ExplicitRefs: append([]string(nil), previous.ExplicitRefs...),
368 Invocation: previous.Invocation,
369 Invocations: append([]sessioninbox.StructuredInvocation(nil), previous.Invocations...),
370 Attachments: append([]string(nil), previous.Attachments...),
371 Extra: maps.Clone(previous.Extra),
372 }
373 if err := c.freezeInboxEnvelopeReferences(context.Background(), &env, submit, env.ExplicitRefs); err != nil {
374 return sessioninbox.InboxItemMeta{}, err
375 }
376 updated, err := st.UpdateItem(id, env)
377 if err != nil {
378 return sessioninbox.InboxItemMeta{}, err
379 }
380 if len(env.ReferenceErrors) > 0 {
381 reason := strings.Join(env.ReferenceErrors, "; ")
382 if err := st.SetState(id, sessioninbox.StateBlocked, reason); err != nil {
383 return sessioninbox.InboxItemMeta{}, err
384 }
385 _ = st.SetPaused(true)
386 updated.State = sessioninbox.StateBlocked
387 updated.BlockReason = reason
388 }
389 return updated, nil
390 }
391
392 // AppendInboxItem atomically merges collect-mode text and binds the inbound
393 // platform message ID as an idempotency alias for the existing durable item.
394 func (c *Controller) AppendInboxItem(id, text, idempotency string, extra map[string]string) (sessioninbox.InboxItemMeta, error) {
395 st, err := c.ensureInbox()
396 if err != nil {
397 return sessioninbox.InboxItemMeta{}, err
398 }
399 _, previous, err := st.ReadItem(id)
400 if err != nil {
401 return sessioninbox.InboxItemMeta{}, err
402 }
403 text = strings.TrimSpace(text)
404 if text == "" {
405 return sessioninbox.InboxItemMeta{}, sessioninbox.ErrEmpty
406 }
407 merged := strings.TrimSpace(previous.SubmitText)
408 if merged != "" {
409 merged += "\n" + text
410 } else {
411 merged = text
412 }
413 env := previous
414 env.DisplayText = merged
415 env.RawText = merged
416 env.SubmitText = merged
417 if len(extra) > 0 {
418 env.Extra = maps.Clone(extra)
419 }
420 if err := c.freezeInboxEnvelopeReferences(context.Background(), &env, merged, env.ExplicitRefs); err != nil {
421 return sessioninbox.InboxItemMeta{}, err
422 }
423 aliasEnv := sessioninbox.PromptEnvelope{
424 DisplayText: text,
425 RawText: text,
426 SubmitText: text,
427 Source: previous.Source,
428 Extra: maps.Clone(extra),
429 }
430 updated, err := st.UpdateItemWithIdempotency(id, env, idempotency, aliasEnv)
431 if err != nil {
432 return sessioninbox.InboxItemMeta{}, err
433 }
434 if len(env.ReferenceErrors) > 0 {
435 reason := strings.Join(env.ReferenceErrors, "; ")
436 if err := st.SetState(id, sessioninbox.StateBlocked, reason); err != nil {
437 return sessioninbox.InboxItemMeta{}, err
438 }
439 _ = st.SetPaused(true)
440 updated.State = sessioninbox.StateBlocked
441 updated.BlockReason = reason
442 }
443 return updated, nil
444 }
445
446 func (c *Controller) DeleteInboxItem(id string) error {
447 c.inbox.admissionMu.Lock()
448 defer c.inbox.admissionMu.Unlock()
449 st, err := c.ensureInbox()
450 if err != nil {
451 return err
452 }
453 if _, recoverErr := st.RecoverOrphanedInFlightOwnedBy(c.inbox.ownsItem); recoverErr != nil {
454 slog.Warn("controller: recover inbox item before delete", "err", recoverErr, "id", id)
455 }
456 err = st.DeletePendingOrAcceptedItem(id)
457 if err == nil || errors.Is(err, sessioninbox.ErrNotFound) {
458 return nil
459 }
460 return err
461 }
462
463 func (c *Controller) MoveInboxItem(id string, toIndex int) error {
464 st, err := c.ensureInbox()
465 if err != nil {
466 return err
467 }
468 return st.MoveItem(id, toIndex)
469 }
470
471 func (c *Controller) SetInboxPaused(paused bool) error {
472 return c.setInboxPaused(paused, true)
473 }
474
475 // SetInboxPausedPassive changes pause state without starting a background turn.
476 // Blocking transports such as Bot own their render sink and drain explicitly.
477 func (c *Controller) SetInboxPausedPassive(paused bool) error {
478 return c.setInboxPaused(paused, false)
479 }
480
481 func (c *Controller) setInboxPaused(paused, dispatch bool) error {
482 st, err := c.ensureInbox()
483 if err != nil {
484 return err
485 }
486 if err := st.SetPaused(paused); err != nil {
487 return err
488 }
489 if paused {
490 sessioninbox.NotePaused()
491 } else if dispatch {
492 // On resume, try to dispatch if idle.
493 c.maybeDispatchInbox()
494 }
495 return nil
496 }
497
498 func (c *Controller) RetryInboxItem(id string) error {
499 return c.retryInboxItem(id, true)
500 }
501
502 // RetryInboxItemPassive requeues an item without detached background dispatch.
503 func (c *Controller) RetryInboxItemPassive(id string) error {
504 return c.retryInboxItem(id, false)
505 }
506
507 func (c *Controller) retryInboxItem(id string, dispatch bool) error {
508 st, err := c.ensureInbox()
509 if err != nil {
510 return err
511 }
512 if err := st.RetryItem(id); err != nil {
513 return err
514 }
515 if dispatch {
516 c.maybeDispatchInbox()
517 }
518 return nil
519 }
520
521 // TrySubmitInboxItem admits a queued item as a new turn when the session is idle.
522 func (c *Controller) TrySubmitInboxItem(id string) (sessioninbox.InboxReceipt, error) {
523 c.mu.Lock()
524 beforeDispatch := c.modelSettings.beforeInboxDispatch
525 c.mu.Unlock()
526 if beforeDispatch != nil {
527 release, err := beforeDispatch(c)
528 if err != nil {
529 return sessioninbox.InboxReceipt{}, err
530 }
531 if release != nil {
532 defer release()
533 }
534 }
535 c.inbox.admissionMu.Lock()
536 defer c.inbox.admissionMu.Unlock()
537 st, err := c.ensureInbox()
538 if err != nil {
539 return sessioninbox.InboxReceipt{}, err
540 }
541 meta, env, err := st.ReadItem(id)
542 if err != nil {
543 return sessioninbox.InboxReceipt{}, err
544 }
545 if meta.State != sessioninbox.StateQueued {
546 return sessioninbox.InboxReceipt{}, sessioninbox.ErrInvalidState
547 }
548 if st.Snapshot().Paused {
549 return sessioninbox.InboxReceipt{}, sessioninbox.ErrPaused
550 }
551 run, block, materializeErr := c.prepareInboxRun(env)
552 if materializeErr != nil {
553 return sessioninbox.InboxReceipt{}, materializeErr
554 }
555 if block != "" {
556 _ = st.SetState(id, sessioninbox.StateBlocked, block)
557 _ = st.SetPaused(true)
558 return sessioninbox.InboxReceipt{}, fmt.Errorf("%w: %s", sessioninbox.ErrInvalidState, block)
559 }
560 // Persist the in-flight state before admission. Active tracking is installed
561 // only after Controller admission is reserved and before the turn can finish.
562 c.inbox.trackAdmission(id)
563 defer c.inbox.untrackAdmission(id)
564 if err := st.ClaimItem(id); err != nil {
565 return sessioninbox.InboxReceipt{}, err
566 }
567 c.inbox.mu.Lock()
568 beforeAdmission := c.inbox.beforePreparedAdmission
569 c.inbox.mu.Unlock()
570 if beforeAdmission != nil {
571 beforeAdmission()
572 }
573 // Start the classified envelope directly. Submit would parse @tokens again
574 // and mix live workspace bytes with the enqueue-time snapshot.
575 result := c.submitPreparedInboxTurn(id, run)
576 if result != turnStarted {
577 if err := st.SetState(id, sessioninbox.StateQueued, ""); err != nil {
578 _ = st.ForcePause(true, 1)
579 return sessioninbox.InboxReceipt{}, err
580 }
581 return c.receiptForAdmissionResult(id, st, result), nil
582 }
583 return sessioninbox.InboxReceipt{
584 ItemID: id,
585 Disposition: sessioninbox.DispositionStarted,
586 Capacity: st.Snapshot().Capacity,
587 }, nil
588 }
589
590 func (c *Controller) receiptForAdmissionResult(id string, st *sessioninbox.Store, result admissionResult) sessioninbox.InboxReceipt {
591 disposition := sessioninbox.DispositionRejectedBusy
592 switch result {
593 case turnDroppedClosed:
594 disposition = sessioninbox.DispositionRejectedClosed
595 case turnDroppedRotating:
596 disposition = sessioninbox.DispositionRejectedRotating
597 }
598 return sessioninbox.InboxReceipt{ItemID: id, Disposition: disposition, Capacity: st.Snapshot().Capacity}
599 }
600
601 // onInboxTurnDone acknowledges durable completion of every active inbox item
602 // (running follow-up + all steers accepted this turn). Dispatch of the next
603 // item is deferred until the finishing window closes so admission is not
604 // rejected as busy.
605 func (c *Controller) onInboxTurnDone() {
606 c.inbox.mu.Lock()
607 // Keep these IDs published as live ownership while SnapshotActivity runs.
608 // Inbox recovery can therefore proceed without waiting on extension hooks,
609 // transcript I/O, or the session file lock and will preserve this turn.
610 ids := c.inbox.activeIDs()
611 st := c.inbox.store
612 beforeSnapshot := c.inbox.beforeCompletionSnapshot
613 beforeAck := c.inbox.beforeCompletionAck
614 c.inbox.mu.Unlock()
615 if st == nil || len(ids) == 0 {
616 return
617 }
618 if beforeSnapshot != nil {
619 beforeSnapshot()
620 }
621 // Transcript snapshot is the durable receipt boundary for the whole set.
622 if err := c.SnapshotActivity(); err != nil {
623 slog.Warn("controller: inbox turn snapshot", "err", err)
624 for _, id := range ids {
625 _ = st.SetState(id, sessioninbox.StateUncertain, "turn completed but transcript snapshot failed")
626 }
627 _ = st.SetPaused(true)
628 c.inbox.mu.Lock()
629 c.inbox.untrackActiveSet(ids)
630 c.inbox.mu.Unlock()
631 sessioninbox.NoteUncertain()
632 return
633 }
634 // Keep ownership published through every durable acknowledgement. Recovery
635 // can run concurrently, sees these IDs as live without a Controller lock,
636 // and ownership is removed only after dequeue or uncertain state is durable.
637 if beforeAck != nil {
638 beforeAck()
639 }
640 ackFailed := false
641 for _, id := range ids {
642 if err := st.AckDequeue(id); err != nil {
643 if errors.Is(err, sessioninbox.ErrNotFound) {
644 continue
645 }
646 slog.Warn("controller: inbox ack dequeue", "err", err, "id", id)
647 _ = st.SetState(id, sessioninbox.StateUncertain, "turn completed but inbox acknowledgement failed")
648 ackFailed = true
649 }
650 }
651 if ackFailed {
652 _ = st.SetPaused(true)
653 sessioninbox.NoteUncertain()
654 }
655 c.inbox.mu.Lock()
656 c.inbox.untrackActiveSet(ids)
657 c.inbox.mu.Unlock()
658 }
659
660 // onInboxUnappliedSteer keeps accepted-but-unapplied steers for inspection.
661 func (c *Controller) onInboxUnappliedSteer(itemID string) {
662 if itemID == "" {
663 return
664 }
665 st, err := c.ensureInbox()
666 if err != nil {
667 return
668 }
669 if err := st.MarkAcceptedSteerUncertain(itemID, "steer accepted but unapplied before turn exit"); err != nil {
670 if errors.Is(err, sessioninbox.ErrNotFound) {
671 c.inbox.mu.Lock()
672 c.inbox.untrackActive(itemID)
673 c.inbox.mu.Unlock()
674 }
675 return
676 }
677 _ = st.SetPaused(true)
678 c.inbox.mu.Lock()
679 c.inbox.untrackActive(itemID)
680 c.inbox.mu.Unlock()
681 sessioninbox.NoteUncertain()
682 }
683
684 // TryEnqueueAndSteer is a convenience for frontends: durable steer then TrySteer.
685 func (c *Controller) TryEnqueueAndSteer(req InboxRequest) (sessioninbox.InboxReceipt, error) {
686 return c.tryEnqueueAndSteerForTurn("", req)
687 }
688
689 // TryEnqueueAndSteerForTurn preserves the durable fallback semantics while
690 // fencing the mid-turn steer against the exact lifecycle turn observed by the
691 // caller. If that turn has already ended, the instruction remains a queued
692 // follow-up and is never injected into a replacement turn.
693 func (c *Controller) TryEnqueueAndSteerForTurn(turnID string, req InboxRequest) (sessioninbox.InboxReceipt, error) {
694 turnID = strings.TrimSpace(turnID)
695 if turnID == "" {
696 return sessioninbox.InboxReceipt{}, fmt.Errorf("turnId is required")
697 }
698 return c.tryEnqueueAndSteerForTurn(turnID, req)
699 }
700
701 func (c *Controller) tryEnqueueAndSteerForTurn(turnID string, req InboxRequest) (sessioninbox.InboxReceipt, error) {
702 req.Intent = sessioninbox.IntentSteer
703 rec, err := c.EnqueueInbox(req)
704 if err != nil {
705 return rec, err
706 }
707 steered, err := c.trySteerInboxItem(rec.ItemID, turnID)
708 if errors.Is(err, sessioninbox.ErrPaused) {
709 rec.Disposition = sessioninbox.DispositionQueuedFollowup
710 rec.Paused = true
711 return rec, nil
712 }
713 if err != nil {
714 return rec, err
715 }
716 return steered, nil
717 }
718
719 // TryEnqueueFollowup durably queues a follow-up and may dispatch if idle.
720 func (c *Controller) TryEnqueueFollowup(req InboxRequest) (sessioninbox.InboxReceipt, error) {
721 return c.TryEnqueueFollowupContext(c.attachmentContext(), req)
722 }
723
724 func (c *Controller) TryEnqueueFollowupContext(ctx context.Context, req InboxRequest) (sessioninbox.InboxReceipt, error) {
725 req.Intent = sessioninbox.IntentFollowup
726 rec, err := c.EnqueueInboxContext(ctx, req)
727 if err != nil {
728 return rec, err
729 }
730 if !c.Running() {
731 c.maybeDispatchInbox()
732 }
733 return rec, nil
734 }
735
736 func firstNonEmptyStr(vals ...string) string {
737 for _, v := range vals {
738 if strings.TrimSpace(v) != "" {
739 return strings.TrimSpace(v)
740 }
741 }
742 return ""
743 }
744
744 lines GO