返回 DeepSeek-Reasonix
turn_events.go
根目录 / internal / control / turn_events.go
1 package control
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "sync"
9 "sync/atomic"
10 "time"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/event"
14 "reasonix/internal/evidence"
15 "reasonix/internal/session"
16 "reasonix/internal/sessioninbox"
17 "reasonix/internal/transcript"
18 "reasonix/internal/turnevent"
19 )
20
21 // turnEventSink persists lifecycle envelopes before frontend publication.
22 // Provider-facing transcript messages remain a separate artifact.
23 type turnEventSink struct {
24 event.AuditForwarder
25 innerMu sync.RWMutex
26 inner event.Sink
27 stream event.Sink
28 c *Controller
29 publish atomic.Int32
30 }
31
32 type turnEventDurableSink struct{ owner *turnEventSink }
33
34 // turnEventState has an independent lock so ledger I/O never holds c.mu.
35 type turnEventState struct {
36 mu sync.RWMutex
37 ledger *turnevent.Ledger
38 err error
39 v3 *session.Session
40 v3Path string
41 // v3Runtime pins the session instance the cached store belongs to. A
42 // reclaim closes the old runtime and a later takeover re-opens the same
43 // identity, so the path key alone would keep serving the closed store.
44 v3Runtime *session.Runtime
45 v3Release func(context.Context) error
46 v3Err error
47 projection *transcript.Projection
48 projectionErr error
49 commitMu sync.Mutex
50 persistMu sync.Mutex
51 projectionPath string
52 pendingCheckpoint *transcript.Checkpoint
53 projectionPersistedThrough uint64
54 projectionWriteErr error
55 volatileTodos []event.Todo
56 volatileTodoWritten bool
57 // pendingExecutionCommit is prepared by an unpublished hot-rebuild
58 // candidate and consumed atomically with Runtime execution activation.
59 // commitMu owns it and its queue reservation.
60 pendingExecutionCommit *session.PreparedBatch
61 pendingTermination *TerminationPlan
62 turnMessageIDs map[string]bool
63 finalizedTurn string
64 terminationBoundary *terminationBoundary
65 }
66
67 // projectVolatileTodo keeps the same event-derived projection for controllers
68 // that have not acquired a session path yet. It is a cache of successful
69 // lifecycle events, never a second writable todo state machine.
70 func (c *Controller) projectVolatileTodo(e event.Event) {
71 if c == nil {
72 return
73 }
74 c.turnEvents.mu.Lock()
75 defer c.turnEvents.mu.Unlock()
76 switch {
77 case e.Kind == event.TurnStarted:
78 c.turnEvents.volatileTodos = []event.Todo{}
79 c.turnEvents.volatileTodoWritten = false
80 case e.Kind == event.ToolResult && e.Tool.TodoWritten:
81 c.turnEvents.volatileTodos = append([]event.Todo(nil), e.Tool.Todos...)
82 c.turnEvents.volatileTodoWritten = true
83 }
84 }
85
86 func (c *Controller) volatileTodoState() ([]event.Todo, bool) {
87 if c == nil {
88 return []event.Todo{}, false
89 }
90 c.turnEvents.mu.RLock()
91 defer c.turnEvents.mu.RUnlock()
92 return append([]event.Todo(nil), c.turnEvents.volatileTodos...), c.turnEvents.volatileTodoWritten
93 }
94
95 func newTurnEventSink(inner event.Sink, c *Controller) *turnEventSink {
96 s := &turnEventSink{inner: inner, c: c}
97 s.stream = event.Coalesce(&turnEventDurableSink{owner: s}, event.DefaultStreamDeltaWindow)
98 s.AuditForwarder = event.AuditForwarder{Inner: s.stream}
99 return s
100 }
101
102 func (s *turnEventSink) InboxChanged(snap sessioninbox.InboxSnapshot) {
103 if s != nil {
104 notifyInboxChanged(s.innerSnapshot(), snap)
105 }
106 }
107
108 var _ event.OptionalSinkCapabilities = (*turnEventSink)(nil)
109 var _ event.CheckedSink = (*turnEventSink)(nil)
110 var _ event.OptionalSinkCapabilities = (*turnEventDurableSink)(nil)
111 var _ event.CheckedSink = (*turnEventDurableSink)(nil)
112
113 func (s *turnEventSink) Emit(e event.Event) {
114 if s == nil {
115 return
116 }
117 s.observe(e)
118 if turnEventSynchronousBarrier(e.Kind) {
119 if err := event.EmitChecked(s.stream, e); err != nil {
120 s.fail(err)
121 }
122 return
123 }
124 s.stream.Emit(e)
125 }
126
127 // observe feeds every raw event to the ledger's routing and to the liveness
128 // tracker before ordering, so silence is measured from real emission time.
129 func (s *turnEventSink) observe(e event.Event) {
130 if s.c == nil {
131 return
132 }
133 if ledger := s.c.turnEventLedger(); ledger != nil {
134 ledger.ObserveRawEvent(e)
135 }
136 s.c.liveness.observe(e, time.Now())
137 }
138
139 func turnEventSynchronousBarrier(kind event.Kind) bool {
140 switch kind {
141 case event.ToolDispatch, event.ToolStarted, event.ToolResult, event.AskRequest, event.ApprovalRequest,
142 event.MCPInteractionRequest, event.PromptAnswered, event.TurnStatusChanged,
143 event.TurnStarted, event.TurnDone:
144 return true
145 default:
146 return false
147 }
148 }
149
150 func (s *turnEventSink) EmitChecked(e event.Event) error {
151 if s == nil {
152 return nil
153 }
154 s.observe(e)
155 var err error
156 if s.publish.Load() > 0 && e.Kind == event.PromptAnswered {
157 // A frontend may answer during prompt publication, so the coalescer cannot
158 // wait on itself. Only that already-ordered PromptAnswered barrier may use
159 // this re-entrant path; other checked events preserve coalescer ordering.
160 err = (&turnEventDurableSink{owner: s}).EmitChecked(e)
161 } else {
162 err = event.EmitChecked(s.stream, e)
163 }
164 if err != nil {
165 s.fail(err)
166 }
167 return err
168 }
169
170 func (s *turnEventSink) fail(err error) {
171 if s != nil && s.c != nil && err != nil {
172 s.c.failTurnEventLedger(err)
173 }
174 }
175
176 func (s *turnEventSink) innerSnapshot() event.Sink {
177 if s == nil {
178 return nil
179 }
180 s.innerMu.RLock()
181 defer s.innerMu.RUnlock()
182 return s.inner
183 }
184
185 func (s *turnEventSink) setInner(inner event.Sink) {
186 if s == nil {
187 return
188 }
189 s.innerMu.Lock()
190 s.inner = inner
191 s.innerMu.Unlock()
192 }
193
194 func (s *turnEventSink) publishInner(e event.Event) {
195 inner := s.innerSnapshot()
196 if inner == nil {
197 return
198 }
199 s.publish.Add(1)
200 defer s.publish.Add(-1)
201 inner.Emit(e)
202 }
203
204 // emitChecked persists before publish and returns durability failures to the
205 // admission boundary. It also suppresses the executor's duplicate TurnStarted
206 // because the controller has already committed that transition before the
207 // provider goroutine is launched.
208 func (s *turnEventSink) persistAndPublish(e event.Event) error {
209 if s == nil || s.c == nil {
210 return nil
211 }
212 if e.RecoveryCheckpoint {
213 return s.c.CheckpointSession(context.Background(), agent.CheckpointBeforeTopTool)
214 }
215 if err := s.c.stampToolRecoveryEvent(e); err != nil {
216 return err
217 }
218 ledger := s.c.turnEventLedger()
219 if ledger == nil {
220 s.c.projectVolatileTodo(e)
221 s.c.refreshRuntimeState(e)
222 s.publishInner(e)
223 return nil
224 }
225 if staleTurnStatus(e, ledger) {
226 return nil
227 }
228 // Outside-turn notices are not lifecycle records and must pass through after
229 // bootstrap or a terminal event.
230 if ledger.ActiveTurnID() == "" {
231 return s.publishOutsideTurn(ledger, e)
232 }
233 if e.Kind == event.TurnStarted && ledger.CurrentStatus() == event.TurnInProgress {
234 return nil
235 }
236 status := e.Status
237 if status == "" {
238 status = ledger.CurrentStatus()
239 }
240 switch e.Kind {
241 case event.TurnStarted:
242 status = event.TurnInProgress
243 case event.AskRequest, event.ApprovalRequest, event.MCPInteractionRequest:
244 status = event.TurnWaitingUser
245 case event.TurnDone:
246 status = terminalTurnStatus(e)
247 case event.TurnStatusChanged:
248 // The emitter supplied the exact transition in e.Status.
249 }
250 if e.WriteIntent {
251 return nil
252 }
253 // No frontend callback runs while commitMu is held. Prompt publication
254 // can synchronously reenter this sink to append PromptAnswered.
255 stamped, envelope, ok, err := s.commitEnvelope(ledger, e, status)
256 if err != nil {
257 return err
258 }
259 if !ok {
260 return nil
261 }
262 if err := s.c.flushSubmissionStart(s.c.submissionAdmissionContext(), e.Kind); err != nil {
263 return err
264 }
265 projectionSaved := true
266 if e.Kind == event.TurnDone {
267 if store := s.c.sessionEventStore(); store != nil {
268 if _, err := store.Flush(context.Background()); err != nil {
269 return err
270 }
271 }
272 s.c.captureTranscriptCheckpoint(ledger, envelope.TranscriptDigest)
273 if err := s.c.persistTranscriptCheckpoint(ledger); err != nil {
274 projectionSaved = false
275 slog.Warn("controller: persist transcript display checkpoint", "err", err)
276 }
277 }
278 if _, runtime, exclusive := s.c.v3Binding(); exclusive && runtime != nil {
279 if err := runtime.PublishTranscriptFrame(envelope); err != nil {
280 return err
281 }
282 }
283 s.c.recordTurnLifecycle(stamped)
284 s.c.refreshRuntimeState(stamped)
285 s.publishInner(stamped)
286 if e.Kind == event.TurnDone && !ledger.ProjectionAckRequired() && projectionSaved {
287 if err := ledger.AcknowledgeProjection(stamped.TurnID); err != nil {
288 return err
289 }
290 }
291 return nil
292 }
293
294 func lateBusinessEvent(kind event.Kind) bool {
295 switch kind {
296 case event.ToolDispatch, event.ToolStarted, event.ToolProgress, event.ToolResult,
297 event.AskRequest, event.ApprovalRequest, event.MCPInteractionRequest,
298 event.PromptAnswered, event.TurnStarted, event.TurnStatusChanged, event.TurnDone:
299 return true
300 default:
301 return false
302 }
303 }
304
305 func (s *turnEventSink) commitEnvelope(ledger *turnevent.Ledger, e event.Event, status event.TurnStatus) (event.Event, turnevent.Envelope, bool, error) {
306 if e.Kind == event.TurnDone {
307 s.c.snapshotMu.Lock()
308 defer s.c.snapshotMu.Unlock()
309 }
310 s.c.turnEvents.commitMu.Lock()
311 defer s.c.turnEvents.commitMu.Unlock()
312 if s.c.discardLateTurnEvent(e) {
313 slog.Info("controller: discarded late turn event", "kind", e.Kind, "turnId", e.TurnID)
314 return e, turnevent.Envelope{}, false, nil
315 }
316 ctx := context.Background()
317 if e.Kind == event.TurnDone {
318 var cancel context.CancelFunc
319 ctx, cancel = context.WithTimeout(ctx, terminationFlushTimeout)
320 defer cancel()
321 }
322 if e.Kind == event.Notice && e.Code == event.NoticeCodeMCPToolsList && e.MessageID == "" {
323 if store := s.c.sessionEventStore(); store != nil {
324 e.MessageID = fmt.Sprintf("notice:%s:%d", store.ID(), store.EventSequence()+1)
325 }
326 }
327 if err := s.c.appendSessionEventLocked(ctx, e); err != nil {
328 return e, turnevent.Envelope{}, false, err
329 }
330 if e.Kind == event.TurnDone {
331 e.ReadCompletion = s.c.updateTurnLedgerTranscript(ledger)
332 }
333 stamped, envelope, ok, err := ledger.AppendEnvelope(e, status)
334 if err != nil || !ok || stamped.Sequence == 0 {
335 return stamped, envelope, ok, err
336 }
337 s.c.turnEvents.mu.RLock()
338 projection := s.c.turnEvents.projection
339 s.c.turnEvents.mu.RUnlock()
340 _, _, exclusive := s.c.v3Binding()
341 if projection != nil && !exclusive {
342 if projectionErr := projection.Apply(envelope); projectionErr != nil {
343 s.c.turnEvents.mu.Lock()
344 s.c.turnEvents.projectionErr = projectionErr
345 s.c.turnEvents.mu.Unlock()
346 }
347 }
348 return stamped, envelope, true, nil
349 }
350
351 func (s *turnEventDurableSink) Emit(e event.Event) {
352 _ = s.EmitChecked(e)
353 }
354
355 func (s *turnEventDurableSink) EmitChecked(e event.Event) error {
356 if s == nil || s.owner == nil {
357 return nil
358 }
359 err := s.owner.persistAndPublish(e)
360 if err == nil {
361 return nil
362 }
363 if classifyCommitError(err) == commitLifecycle {
364 slog.Info("controller: lifecycle event commit", "err", err, "kind", e.Kind)
365 return nil
366 }
367 if e.Kind == event.TurnDone && classifyCommitError(err) != commitOwnership {
368 s.owner.c.disarmGoalLifecycle("persistence-error")
369 s.owner.c.mu.Lock()
370 s.owner.c.enterRecoveryLocked("terminal_commit_failed")
371 s.owner.c.mu.Unlock()
372 if _, runtime, exclusive := s.owner.c.v3Binding(); exclusive && runtime != nil {
373 runtime.Transcript().PersistenceFailed()
374 }
375 }
376 // Async stream callers cannot observe checked errors. Fail the Turn here so
377 // a poisoned WAL immediately cancels provider, prompt, and process work.
378 slog.Error("controller: append turn event ledger", "err", err, "kind", e.Kind)
379 s.owner.fail(err)
380 if e.Kind == event.TurnDone {
381 // The durable terminal failed, so publish a sequence-free control-plane
382 // failure only to release UI state. It is never treated as ledger truth.
383 e.Err = errors.Join(e.Err, err)
384 e.Status = event.TurnFailed
385 if inner := s.owner.innerSnapshot(); inner != nil {
386 inner.Emit(e)
387 }
388 }
389 return err
390 }
391
392 func (s *turnEventDurableSink) inner() event.Sink {
393 if s == nil || s.owner == nil {
394 return nil
395 }
396 return s.owner.innerSnapshot()
397 }
398
399 func (s *turnEventDurableSink) RecordDelegationAudit(a evidence.DelegationAudit) {
400 event.RecordDelegationAudit(s.inner(), a)
401 }
402 func (s *turnEventDurableSink) RecordReadinessAudit(a evidence.ReadinessAudit) {
403 event.RecordReadinessAudit(s.inner(), a)
404 }
405 func (s *turnEventDurableSink) RecordAnchorSafetyAudit(a event.AnchorSafetyAudit) {
406 event.RecordAnchorSafetyAudit(s.inner(), a)
407 }
408 func (s *turnEventDurableSink) RecordTurnCompletion() { event.RecordTurnCompletion(s.inner()) }
409 func (s *turnEventDurableSink) RecordContractShadow(a event.ContractShadowAudit) {
410 event.RecordContractShadow(s.inner(), a)
411 }
412 func (s *turnEventDurableSink) RecordCompletionReport(a event.CompletionReportAudit) {
413 event.RecordCompletionReport(s.inner(), a)
414 }
415 func (s *turnEventDurableSink) RecordMemoryRecall(a event.MemoryRecallAudit) {
416 event.RecordMemoryRecall(s.inner(), a)
417 }
418 func (s *turnEventDurableSink) RecordDelegationAdmission(a event.DelegationAdmissionAudit) {
419 event.RecordDelegationAdmission(s.inner(), a)
420 }
421 func (s *turnEventDurableSink) RecordOutcomeProgress(a evidence.OutcomeSample) {
422 event.RecordOutcomeProgress(s.inner(), a)
423 }
424 func (s *turnEventDurableSink) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) {
425 event.RecordProtocolRecovery(s.inner(), a)
426 }
427 func (s *turnEventDurableSink) RecordWorkspaceMutation(a event.WorkspaceMutation) {
428 event.RecordWorkspaceMutation(s.inner(), a)
429 }
430 func (s *turnEventDurableSink) RecordRunBudget(a event.RunBudgetSample) {
431 event.RecordRunBudget(s.inner(), a)
432 }
433 func (s *turnEventDurableSink) RecordSubagentLifecycle(a event.SubagentLifecycleInfo) {
434 event.RecordSubagentLifecycle(s.inner(), a)
435 }
436
437 func terminalTurnStatus(e event.Event) event.TurnStatus {
438 if e.Recovery != nil && e.Recovery.State == "recovery_required" {
439 return event.TurnRecoveryRequired
440 }
441 if e.Cancelled || errors.Is(e.Err, context.Canceled) {
442 return event.TurnInterrupted
443 }
444 if e.Err != nil {
445 return event.TurnFailed
446 }
447 return event.TurnCompleted
448 }
449
450 func (c *Controller) turnEventLedger() *turnevent.Ledger {
451 if c == nil {
452 return nil
453 }
454 c.turnEvents.mu.RLock()
455 defer c.turnEvents.mu.RUnlock()
456 return c.turnEvents.ledger
457 }
458
459 func (c *Controller) turnEventLedgerError() error {
460 if c == nil {
461 return nil
462 }
463 c.turnEvents.mu.RLock()
464 defer c.turnEvents.mu.RUnlock()
465 return c.turnEvents.err
466 }
467
468 func (c *Controller) applyTurnDoneProtocol(done event.Event, cancelRequested bool) event.Event {
469 if cancelRequested {
470 // Interruption is a terminal state, not a send failure; partial text is
471 // already display-only by this point.
472 done.Err = nil
473 }
474 return done
475 }
476
477 func (c *Controller) turnEventRuntimeStatus() (string, event.TurnStatus, uint64, uint64) {
478 ledger := c.turnEventLedger()
479 if ledger == nil {
480 return "", "", 0, 0
481 }
482 latest, replayAfter := ledger.ProjectionCursor()
483 return ledger.ActiveTurnID(), ledger.CurrentStatus(), latest, replayAfter
484 }
485
486 func (c *Controller) rebindTurnEvents(sessionPath string) {
487 defer c.refreshRuntimeState(event.Event{})
488 if c == nil {
489 return
490 }
491 desiredV3Path := sessionDirectory(sessionPath)
492 ledgerID := agent.BranchID(sessionPath)
493 var desiredRuntime *session.Runtime
494 if _, runtime, _ := c.v3Binding(); runtime != nil {
495 ref := runtime.Ref()
496 desiredV3Path = "session:" + ref.HostID + "/" + ref.SessionID
497 ledgerID = ref.SessionID
498 desiredRuntime = runtime
499 }
500 c.turnEvents.mu.RLock()
501 currentV3, currentV3Path := c.turnEvents.v3, c.turnEvents.v3Path
502 currentV3Runtime := c.turnEvents.v3Runtime
503 c.turnEvents.mu.RUnlock()
504 v3, releaseV3, v3Err := currentV3, (func(context.Context) error)(nil), error(nil)
505 // The runtime pin matters for exclusive sessions: a reclaim closes the
506 // old instance and the takeover re-opens the same identity, so the path
507 // alone cannot tell a live store from the closed one it replaced.
508 if currentV3 == nil || currentV3Path != desiredV3Path || currentV3Runtime != desiredRuntime {
509 v3, releaseV3, v3Err = c.openSessionEventStore(sessionPath)
510 }
511 ledger := turnevent.NewMemory(ledgerID)
512 err := v3Err
513 if err != nil {
514 // Normalize platform-specific open errors behind the same storage
515 // sentinel used by append failures. Keep the original error in the
516 // chain so unsupported-schema callers can still inspect its type.
517 err = fmt.Errorf("%w: %w", turnevent.ErrTurnLedgerUnavailable, err)
518 slog.Warn("controller: open v3 session event store", "err", err, "session", agent.BranchID(sessionPath))
519 c.turnEvents.mu.Lock()
520 previousV3, previousRelease := c.turnEvents.v3, c.turnEvents.v3Release
521 previousLedger := c.turnEvents.ledger
522 c.turnEvents.ledger = nil
523 c.turnEvents.err = err
524 c.turnEvents.v3 = nil
525 c.turnEvents.v3Path = ""
526 c.turnEvents.v3Runtime = nil
527 c.turnEvents.v3Release = nil
528 c.turnEvents.v3Err = err
529 c.turnEvents.mu.Unlock()
530 if previousLedger != nil {
531 if closeErr := previousLedger.Close(); closeErr != nil {
532 slog.Warn("controller: close ledger after failed rebind", "err", closeErr)
533 }
534 }
535 // Fail admission closed without losing the compatibility writer's
536 // cleanup owner. Service-backed runtimes remain host-owned.
537 if previousV3 != nil && !c.sessionEngineEnabled() {
538 var closeErr error
539 if previousRelease != nil {
540 closeErr = previousRelease(context.Background())
541 } else {
542 closeErr = previousV3.Close(context.Background())
543 }
544 if closeErr != nil {
545 slog.Warn("controller: close session after failed rebind", "err", closeErr)
546 }
547 }
548 return
549 }
550 c.turnEvents.mu.Lock()
551 c.turnEvents.volatileTodos = []event.Todo{}
552 c.turnEvents.volatileTodoWritten = false
553 previous := c.turnEvents.ledger
554 previousV3 := c.turnEvents.v3
555 previousV3Release := c.turnEvents.v3Release
556 c.turnEvents.ledger = ledger
557 c.turnEvents.err = nil
558 c.turnEvents.v3 = v3
559 c.turnEvents.v3Path = desiredV3Path
560 c.turnEvents.v3Runtime = desiredRuntime
561 if releaseV3 != nil {
562 c.turnEvents.v3Release = releaseV3
563 }
564 c.turnEvents.v3Err = nil
565 c.turnEvents.projection = nil
566 c.turnEvents.projectionErr = nil
567 c.turnEvents.projectionPath = sessionPath
568 c.turnEvents.pendingCheckpoint = nil
569 c.turnEvents.projectionPersistedThrough = 0
570 c.turnEvents.projectionWriteErr = nil
571 c.turnEvents.mu.Unlock()
572 if !c.sessionEngineEnabled() {
573 c.bindAttachmentService()
574 }
575 var projection *transcript.Projection
576 var projectionErr error
577 if !c.sessionEngineEnabled() {
578 projection, projectionErr = c.restoreTranscriptProjection(sessionPath, ledger)
579 }
580 c.turnEvents.mu.Lock()
581 c.turnEvents.projection, c.turnEvents.projectionErr = projection, projectionErr
582 c.turnEvents.mu.Unlock()
583 if previous != nil && previous != ledger {
584 if closeErr := previous.Close(); closeErr != nil {
585 slog.Warn("controller: close previous turn event ledger", "err", closeErr)
586 }
587 }
588 // An exclusive v3 handle belongs to SessionRuntime. Runtime publication
589 // closes the exact previous instance through SessionService after the new
590 // binding is visible; this compatibility cleanup must never close it early.
591 if previousV3 != nil && previousV3 != v3 && !c.sessionEngineEnabled() {
592 var closeErr error
593 if previousV3Release != nil {
594 closeErr = previousV3Release(context.Background())
595 } else {
596 closeErr = previousV3.Close(context.Background())
597 }
598 if closeErr != nil {
599 slog.Warn("controller: flush and close previous v3 session", "err", closeErr)
600 }
601 }
602 }
603
604 func classifyCommitError(err error) commitFailureKind {
605 if err == nil {
606 return commitOK
607 }
608 if errors.Is(err, errTerminationDurability) {
609 return commitUnexpected
610 }
611 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
612 return commitLifecycle
613 }
614 if errors.Is(err, session.ErrStaleActivity) || errors.Is(err, session.ErrOperationConflict) {
615 return commitLifecycle
616 }
617 if errors.Is(err, session.ErrSessionNotRunning) || errors.Is(err, session.ErrStaleGeneration) ||
618 errors.Is(err, session.ErrStaleExecution) || errors.Is(err, session.ErrReadOnly) || errors.Is(err, session.ErrRuntimeRetiring) {
619 return commitOwnership
620 }
621 return commitUnexpected
622 }
623
624 type commitFailureKind int
625
626 const (
627 commitOK commitFailureKind = iota
628 commitLifecycle
629 commitOwnership
630 commitUnexpected
631 )
632
633 func (c *Controller) failTurnEventLedger(err error) {
634 defer c.refreshRuntimeState(event.Event{})
635 if c == nil || err == nil {
636 return
637 }
638 switch classifyCommitError(err) {
639 case commitLifecycle:
640 slog.Info("controller: lifecycle commit result", "err", err)
641 return
642 case commitOwnership:
643 c.turnEvents.mu.Lock()
644 if c.turnEvents.err == nil {
645 c.turnEvents.err = err
646 }
647 c.turnEvents.mu.Unlock()
648 c.signalTurnCancel()
649 c.promptOwner.CancelAll()
650 c.approval.clearAll()
651 return
652 }
653 c.turnEvents.mu.Lock()
654 if c.turnEvents.err == nil {
655 c.turnEvents.err = err
656 }
657 c.turnEvents.mu.Unlock()
658 c.signalTurnCancel()
659 c.promptOwner.CancelAll()
660 c.approval.clearAll()
661 }
662
663 // staleTurnStatus reports a status stamped for a turn that has since reached
664 // its terminal event; cancelling is sticky, so it must not reach the next turn.
665 func staleTurnStatus(e event.Event, ledger *turnevent.Ledger) bool {
666 return e.Kind == event.TurnStatusChanged && e.TurnID != "" && e.TurnID != ledger.ActiveTurnID()
667 }
668
669 // emitTurnStatus stamps the transition with the turn that requested it so the
670 // ledger can drop it if that turn already reached its terminal event.
671 func (c *Controller) emitTurnStatus(status event.TurnStatus, turnID string) {
672 if c == nil || status == "" {
673 return
674 }
675 c.sink.Emit(event.Event{Kind: event.TurnStatusChanged, Status: status, TurnID: turnID})
676 }
677
678 // emitTurnEventChecked reaches the lifecycle sink below the inbox observer so
679 // admission can fail closed on disk errors instead of starting an unledgered
680 // provider request. Lifecycle events do not participate in inbox notice logic.
681 func (c *Controller) emitTurnEventChecked(e event.Event) error {
682 if c == nil {
683 return nil
684 }
685 if e.ItemID != "" && e.TurnID == "" {
686 if identity, ok := c.promptOwner.Identity(e.ItemID); ok {
687 e.TurnID = identity.TurnID
688 e.PromptKind = string(identity.Kind)
689 }
690 } else if e.ItemID != "" && e.PromptKind == "" {
691 if identity, ok := c.promptOwner.Identity(e.ItemID); ok {
692 e.PromptKind = string(identity.Kind)
693 }
694 }
695 return event.EmitChecked(c.sink, e)
696 }
697
698 // SetTurnEventRoutingMetadata attaches desktop routing identity to lifecycle
699 // envelopes only. It never changes provider-visible prompts or tool schemas.
700 func (c *Controller) SetTurnEventRoutingMetadata(runtimeEpoch, submissionID string) {
701 c.promptEpochMu.Lock()
702 c.promptRuntimeEpoch = runtimeEpoch
703 c.promptEpochMu.Unlock()
704 if ledger := c.turnEventLedger(); ledger != nil {
705 ledger.RequireProjectionAck(true)
706 ledger.SetRoutingMetadata(runtimeEpoch, submissionID)
707 }
708 c.BindTranscriptRuntimeEpoch(runtimeEpoch)
709 }
710
711 // TurnEventsAfter returns the durable lifecycle suffix used by reconnecting
712 // frontends to close sequence gaps.
713 func (c *Controller) TurnEventsAfter(after uint64) ([]turnevent.Envelope, error) {
714 ledger := c.turnEventLedger()
715 if ledger == nil {
716 return []turnevent.Envelope{}, nil
717 }
718 return ledger.EventsAfter(after)
719 }
720
721 func (c *Controller) TurnEventReplay(after uint64) (turnevent.ReplayView, error) {
722 ledger := c.turnEventLedger()
723 if ledger == nil {
724 return turnevent.ReplayView{Events: []turnevent.Envelope{}}, nil
725 }
726 return ledger.Replay(after)
727 }
728
729 func (c *Controller) AcknowledgeTurnProjection(turnID string) error {
730 ledger := c.turnEventLedger()
731 if ledger == nil {
732 return nil
733 }
734 if err := c.persistTranscriptCheckpoint(ledger); err != nil {
735 return err
736 }
737 return ledger.AcknowledgeProjection(turnID)
738 }
739
740 func (c *Controller) ObserveTurnProjectionRetry() {
741 if ledger := c.turnEventLedger(); ledger != nil {
742 ledger.ObserveProjectionRetry()
743 }
744 }
745
746 func (c *Controller) PendingTurnProjections() []turnevent.PendingProjection {
747 ledger := c.turnEventLedger()
748 if ledger == nil {
749 return []turnevent.PendingProjection{}
750 }
751 return ledger.PendingProjections()
752 }
753
754 func (c *Controller) TurnEventMetrics() turnevent.MetricsSnapshot {
755 ledger := c.turnEventLedger()
756 if ledger == nil {
757 return turnevent.MetricsSnapshot{}
758 }
759 return ledger.MetricsSnapshot()
760 }
761
762 func (c *Controller) DrainTurnEventMetrics() turnevent.MetricsSnapshot {
763 ledger := c.turnEventLedger()
764 if ledger == nil {
765 return turnevent.MetricsSnapshot{}
766 }
767 return ledger.DrainMetrics()
768 }
769
769 lines GO