返回 DeepSeek-Reasonix
session_events.go
根目录 / internal / control / session_events.go
1 package control
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "reflect"
12 "runtime"
13 "strings"
14 "sync"
15
16 "reasonix/internal/agent"
17 "reasonix/internal/event"
18 "reasonix/internal/provider"
19 "reasonix/internal/session"
20 "reasonix/internal/turnevent"
21 )
22
23 func sessionDirectory(sessionPath string) string {
24 sessionPath = filepath.Clean(strings.TrimSpace(sessionPath))
25 if sessionPath == "." || sessionPath == "" {
26 return ""
27 }
28 // Windows spellings of one path must retain one shared writer identity.
29 if runtime.GOOS == "windows" {
30 sessionPath = strings.ToLower(sessionPath)
31 }
32 parent := filepath.Dir(sessionPath)
33 root := filepath.Join(parent, "sessions-v4")
34 if filepath.Base(parent) == "sessions" {
35 root = filepath.Join(filepath.Dir(parent), "sessions-v4")
36 }
37 id := agent.BranchID(sessionPath)
38 if id == "" {
39 return ""
40 }
41 return filepath.Join(root, id)
42 }
43
44 type sharedSessionEventStore struct {
45 store *session.Session
46 refs int
47 }
48
49 var processSessionEventStores = struct {
50 sync.Mutex
51 stores map[string]*sharedSessionEventStore
52 }{stores: map[string]*sharedSessionEventStore{}}
53
54 func acquireSessionEventStore(dir, id string) (*session.Session, func(context.Context) error, bool, error) {
55 processSessionEventStores.Lock()
56 defer processSessionEventStores.Unlock()
57 if entry := processSessionEventStores.stores[dir]; entry != nil {
58 entry.refs++
59 return entry.store, releaseSessionEventStore(dir, entry), true, nil
60 }
61 store, err := session.Open(dir, id)
62 if errors.Is(err, session.ErrSessionNotFound) {
63 store, err = session.CreateStore(dir, id)
64 }
65 if err != nil {
66 return nil, nil, false, err
67 }
68 entry := &sharedSessionEventStore{store: store, refs: 1}
69 processSessionEventStores.stores[dir] = entry
70 return store, releaseSessionEventStore(dir, entry), false, nil
71 }
72
73 func releaseSessionEventStore(dir string, entry *sharedSessionEventStore) func(context.Context) error {
74 var once sync.Once
75 var releaseErr error
76 return func(ctx context.Context) error {
77 once.Do(func() {
78 // Every ownership handoff is a semantic checkpoint even when another
79 // in-process controller already retains the physical writer.
80 _, releaseErr = entry.store.Flush(ctx)
81 processSessionEventStores.Lock()
82 current := processSessionEventStores.stores[dir]
83 if current != entry {
84 processSessionEventStores.Unlock()
85 return
86 }
87 entry.refs--
88 last := entry.refs == 0
89 if last {
90 delete(processSessionEventStores.stores, dir)
91 }
92 processSessionEventStores.Unlock()
93 if last {
94 releaseErr = errors.Join(releaseErr, entry.store.Close(ctx))
95 }
96 })
97 return releaseErr
98 }
99 }
100
101 func (c *Controller) openSessionEventStore(sessionPath string) (*session.Session, func(context.Context) error, error) {
102 if service, runtime, exclusive := c.v3Binding(); runtime != nil {
103 return runtime.Session(), nil, nil
104 } else if exclusive && service != nil {
105 // A service-backed controller needs a published Runtime before admission.
106 // Creating a path-derived sidecar here would reintroduce a second producer
107 // outside the v3.1 ownership boundary.
108 return nil, nil, nil
109 }
110 dir := sessionDirectory(sessionPath)
111 if dir == "" {
112 return nil, nil, nil
113 }
114 id := agent.BranchID(sessionPath)
115 store, release, shared, err := acquireSessionEventStore(dir, id)
116 if err != nil {
117 return nil, nil, err
118 }
119 if !shared {
120 if _, _, err := store.RecoverInterrupted(context.Background()); err != nil {
121 _ = release(context.Background())
122 return nil, nil, err
123 }
124 }
125 snapshot := store.StateSnapshot()
126 if snapshot.EventSequence != 0 {
127 return store, release, nil
128 }
129 return store, release, nil
130 }
131
132 // releaseLegacyEventStoreForImport stops this controller's retired path-bound
133 // event producer before the importer takes a shared freeze lock. Other
134 // controllers keep their own reference; in that case the freeze correctly
135 // refuses to race an active producer instead of copying a moving prefix.
136 func (c *Controller) releaseLegacyEventStoreForImport(ctx context.Context) (func(), error) {
137 if c == nil {
138 return func() {}, nil
139 }
140 path := c.SessionPath()
141 c.turnEvents.mu.Lock()
142 if c.turnEvents.v3 == nil || strings.HasPrefix(c.turnEvents.v3Path, "session:") {
143 c.turnEvents.mu.Unlock()
144 return func() {}, nil
145 }
146 store := c.turnEvents.v3
147 release := c.turnEvents.v3Release
148 c.turnEvents.v3 = nil
149 c.turnEvents.v3Path = ""
150 c.turnEvents.v3Runtime = nil
151 c.turnEvents.v3Release = nil
152 c.turnEvents.mu.Unlock()
153 var err error
154 if release != nil {
155 err = release(ctx)
156 } else {
157 err = store.Close(ctx)
158 }
159 restore := func() {
160 if _, runtime, _ := c.v3Binding(); runtime == nil {
161 c.rebindTurnEvents(path)
162 }
163 }
164 return restore, err
165 }
166
167 // SuspendLegacyEventStoreForImport closes this controller's path-derived
168 // compatibility producer while an idle host prepares a replacement runtime.
169 // The caller must serialize turn admission for the controller. Calling the
170 // returned function restores the producer when candidate preparation fails;
171 // after a successful publication the old controller is retired instead.
172 func (c *Controller) SuspendLegacyEventStoreForImport(ctx context.Context) (func(), error) {
173 return c.releaseLegacyEventStoreForImport(ctx)
174 }
175
176 func (c *Controller) seedSessionEventsFromExecutor(reason string) error {
177 if !c.sessionEventCommitAllowed() {
178 return nil
179 }
180 if snapshot, ok := c.sessionEventSnapshot(); !ok || snapshot.EventSequence != 0 {
181 return nil
182 }
183 if c == nil || c.executor == nil {
184 return nil
185 }
186 // Existing legacy transcripts are loaded by Resume after Controller
187 // construction. Seeding the constructor's system-only placeholder here
188 // would make it look authoritative and discard the loaded history.
189 if reason == "session-open" {
190 if info, err := os.Stat(c.SessionPath()); err == nil && info.Size() > 0 {
191 return nil
192 }
193 }
194 messages := c.executor.Session().Snapshot()
195 for i := range messages {
196 if strings.TrimSpace(messages[i].ID) == "" {
197 messages[i].ID = agent.NewMessageID()
198 }
199 }
200 if err := c.RecordSessionMessages(context.Background(), reason, messages); err != nil {
201 return err
202 }
203 return nil
204 }
205
206 // importLegacyResumeOverPlaceholder repairs the only safe pre-migration
207 // overlap: an older build may have created a system-only v3 sidecar before it
208 // loaded an existing legacy transcript. No executed turn can be present in
209 // that projection, so replacing it with the frozen loaded history is lossless.
210 func (c *Controller) importLegacyResumeOverPlaceholder(incoming *agent.Session) error {
211 if c == nil || incoming == nil {
212 return nil
213 }
214 if !c.sessionEventCommitAllowed() {
215 return nil
216 }
217 snapshot, ok := c.sessionEventSnapshot()
218 if !ok || len(snapshot.Projection.ModelMessages) != 1 || len(incoming.Snapshot()) <= 1 {
219 return nil
220 }
221 messages := incoming.Snapshot()
222 for i := range messages {
223 if strings.TrimSpace(messages[i].ID) == "" {
224 messages[i].ID = agent.NewMessageID()
225 }
226 }
227 return c.replaceSessionEventProjection(context.Background(), "legacy-resume-placeholder", messages)
228 }
229
230 func (c *Controller) restoreExecutorFromSessionEvents() {
231 if c == nil || c.executor == nil {
232 return
233 }
234 snapshot, ok := c.sessionEventSnapshot()
235 if !ok || len(snapshot.Projection.ModelMessages) == 0 {
236 return
237 }
238 if reflect.DeepEqual(c.executor.Session().Snapshot(), snapshot.Projection.ModelMessages) {
239 return
240 }
241 // Called only at construction or explicit Resume. Path rewrites and recovery
242 // rebinds must keep their prepared candidate session instead of adopting the
243 // source log again.
244 c.executor.Session().Replace(append([]provider.Message(nil), snapshot.Projection.ModelMessages...))
245 }
246
247 // replaceSessionModelContext records an Agent/configuration rebuild without
248 // rewriting the UI transcript. The exact serialized model context becomes a
249 // typed event, while the original messages remain available for history.
250 func (c *Controller) replaceSessionModelContext(ctx context.Context, messages []provider.Message, reason string) error {
251 store := c.sessionEventStore()
252 if store == nil {
253 return errors.New("exclusive v3 controller has no session store")
254 }
255 snapshot := store.ExecutionSnapshot()
256 modelMessages := provider.ModelMessages(messages)
257 if modelMessages == nil {
258 modelMessages = []provider.Message{}
259 }
260 payload, err := json.Marshal(map[string]any{
261 "messages": modelMessages,
262 "reason": reason,
263 "sourceSequences": []uint64{snapshot.EventSequence},
264 })
265 if err != nil {
266 return err
267 }
268 events := []session.Event{{Kind: "model/context-replace", Payload: payload}}
269 if strings.TrimSpace(c.ModelRef()) != "" {
270 configPayload, err := json.Marshal(map[string]string{"modelRef": c.ModelRef(), "modelIdentity": c.ModelSelectionIdentity()})
271 if err != nil {
272 return err
273 }
274 events = append(events, session.Event{Kind: "session/config", Payload: configPayload})
275 }
276 digest := sha256.Sum256(payload)
277 c.turnEvents.commitMu.Lock()
278 batch := session.Batch{
279 OperationID: fmt.Sprintf("model-context:%x", digest[:16]),
280 TurnID: snapshot.Projection.TurnID,
281 Events: events,
282 }
283 _, runtime, exclusive := c.v3Binding()
284 if exclusive && runtime != nil && runtime.Session() == store && !runtime.OwnsExecution(c.ExecutionGeneration()) {
285 prepared, prepareErr := store.PrepareBatchContext(ctx, batch.OperationID, batch)
286 if prepareErr == nil {
287 if previous := c.turnEvents.pendingExecutionCommit; previous != nil {
288 previous.Release()
289 }
290 c.turnEvents.pendingExecutionCommit = &prepared
291 }
292 err = prepareErr
293 } else {
294 _, err = c.appendSessionBatch(ctx, store, batch)
295 }
296 c.turnEvents.commitMu.Unlock()
297 if err != nil {
298 return err
299 }
300 if c.executor != nil {
301 c.executor.Session().Replace(append([]provider.Message(nil), modelMessages...))
302 }
303 return nil
304 }
305
306 func (c *Controller) adoptResumeSystemPrompt(incoming *agent.Session) error {
307 if !c.sessionEventCommitAllowed() {
308 return nil
309 }
310 store := c.sessionEventStore()
311 if store == nil || incoming == nil {
312 return nil
313 }
314 snapshot := store.ExecutionSnapshot()
315 persisted, current := snapshot.Projection.ModelMessages, incoming.Snapshot()
316 if len(persisted) == 0 || len(current) == 0 || persisted[0].Role != provider.RoleSystem || current[0].Role != provider.RoleSystem || reflect.DeepEqual(persisted[0], current[0]) {
317 return nil
318 }
319 replaced := append([]provider.Message(nil), persisted...)
320 replaced[0] = current[0]
321 payload, err := json.Marshal(map[string]any{"messages": replaced, "reason": "system-prompt-refresh"})
322 if err != nil {
323 return err
324 }
325 c.turnEvents.commitMu.Lock()
326 defer c.turnEvents.commitMu.Unlock()
327 _, err = c.appendSessionBatch(context.Background(), store, session.Batch{
328 OperationID: fmt.Sprintf("system-prompt-refresh:%d", snapshot.EventSequence+1),
329 TurnID: snapshot.Projection.TurnID,
330 Events: []session.Event{{Kind: "history/replace", Payload: payload}},
331 })
332 return err
333 }
334
335 func (c *Controller) sessionEventStore() *session.Session {
336 if c == nil {
337 return nil
338 }
339 c.turnEvents.mu.RLock()
340 defer c.turnEvents.mu.RUnlock()
341 return c.turnEvents.v3
342 }
343
344 // sessionEventCommitAllowed fences unpublished Desktop replacement runtimes.
345 // Those candidates intentionally share the active process store so they can
346 // inspect the latest in-memory prefix, but they do not own mutation authority
347 // until the tab's final lease handoff succeeds.
348 func (c *Controller) sessionEventCommitAllowed() bool {
349 if _, runtime, _ := c.v3Binding(); runtime != nil {
350 return runtime.OwnsExecution(c.ExecutionGeneration())
351 }
352 if c == nil || !c.managedSessionEvents.Load() {
353 return true
354 }
355 if c.executor == nil || c.executor.Session() == nil {
356 return false
357 }
358 path := c.SessionPath()
359 if path == "" {
360 return true
361 }
362 auth := c.executor.Session().WriteAuthority()
363 return auth != nil && auth.Covers(path)
364 }
365
366 func (c *Controller) sessionEventSnapshot() (session.Snapshot, bool) {
367 store := c.sessionEventStore()
368 if store == nil {
369 return session.Snapshot{}, false
370 }
371 return store.ExecutionSnapshot(), true
372 }
373
374 func (c *Controller) sessionStateSnapshot() (session.Snapshot, bool) {
375 store := c.sessionEventStore()
376 if store == nil {
377 return session.Snapshot{}, false
378 }
379 return store.StateSnapshot(), true
380 }
381
382 // appendSessionEventLocked mirrors the existing event.Sink lifecycle into the
383 // single typed business log. turnEvents.commitMu must be held, which preserves
384 // the exact order assigned by the compatibility envelope adapter.
385 func (c *Controller) appendSessionEventLocked(ctx context.Context, e event.Event) error {
386 if !c.sessionEventCommitAllowed() {
387 return nil
388 }
389 store := c.sessionEventStore()
390 if store == nil {
391 if c.sessionEngineEnabled() {
392 return session.ErrSessionNotRunning
393 }
394 return nil
395 }
396 snapshot := store.ExecutionSnapshot()
397 projection := snapshot.Projection
398 if projection.Recovery != nil && projection.Recovery.State == "recovery_required" && e.Kind != event.TurnDone {
399 // Recovery has sealed business-state mutation. The watchdog observes
400 // the uncooperative worker; late semantic output must never reactivate
401 // tools, interactions, Goal, or Todo.
402 return nil
403 }
404 if e.TurnID == "" {
405 if _, turnID, active := c.currentTurnToken(); active {
406 e.TurnID = turnID
407 }
408 if e.TurnID == "" {
409 if ledger := c.turnEventLedger(); ledger != nil {
410 e.TurnID = ledger.ActiveTurnID()
411 }
412 }
413 if e.TurnID == "" {
414 e.TurnID = projection.TurnID
415 }
416 }
417 events, err := c.sessionEventsFor(e, projection)
418 if err != nil || len(events) == 0 {
419 return err
420 }
421 if e.Kind == event.TurnDone {
422 if err := c.appendTerminationLocked(ctx, e, store, events); err != nil {
423 return fmt.Errorf("%w: %w", turnevent.ErrTurnLedgerUnavailable, err)
424 }
425 return nil
426 }
427 op := fmt.Sprintf("runtime:%s:%d:%d", e.TurnID, snapshot.EventSequence+1, e.Kind)
428 _, err = c.appendSessionBatch(ctx, store, session.Batch{OperationID: op, TurnID: e.TurnID, Events: events})
429 if err != nil {
430 return fmt.Errorf("%w: %w", turnevent.ErrTurnLedgerUnavailable, err)
431 }
432 c.noteCommittedMessagesLocked(events)
433 return nil
434 }
435
436 func (c *Controller) sessionEventsFor(e event.Event, projection session.Projection) ([]session.Event, error) {
437 if e.Kind == event.Notice {
438 return mcpDisplayNoticeEvents(e)
439 }
440 return c.v3EventsFor(e, projection)
441 }
442
443 func (c *Controller) v3EventsFor(e event.Event, projection session.Projection) ([]session.Event, error) {
444 makePayload := func(value any) (json.RawMessage, error) { return json.Marshal(value) }
445 var out []session.Event
446 switch e.Kind {
447 case event.TurnStarted:
448 payload, err := makePayload(map[string]any{"status": event.TurnInProgress})
449 if err != nil {
450 return nil, err
451 }
452 out = append(out, session.Event{Kind: "turn/start", Payload: payload})
453 out = c.appendSubmissionEvent(out, e.TurnID)
454 if e.DomainKind != "" {
455 if e.DomainKind != "goal/state" || len(e.DomainPayload) == 0 {
456 return nil, fmt.Errorf("unsupported turn admission domain event %q", e.DomainKind)
457 }
458 out = append(out, session.Event{Kind: e.DomainKind, Payload: append(json.RawMessage(nil), e.DomainPayload...)})
459 }
460 case event.ToolDispatch:
461 payload, err := makePayload(v3ToolPayload(e.Tool, false))
462 if err != nil {
463 return nil, err
464 }
465 out = append(out, session.Event{Kind: "tool/call", Payload: payload})
466 case event.ToolStarted:
467 payload, err := makePayload(map[string]any{"id": e.Tool.ID, "name": e.Tool.Name})
468 if err != nil {
469 return nil, err
470 }
471 out = append(out, session.Event{Kind: "tool/start", Payload: payload})
472 case event.ToolResult:
473 // Tool results are emitted before Agent mutates its derived conversation
474 // cache. Commit the exact message and structured result together so a
475 // recovered prefix cannot contain only one half.
476 if e.CommittedMessage != nil {
477 messagePayload, err := makePayload(map[string]any{"message": *e.CommittedMessage})
478 if err != nil {
479 return nil, err
480 }
481 out = append(out, session.Event{Kind: "message/complete", Payload: messagePayload})
482 }
483 payload, err := makePayload(v3ToolPayload(e.Tool, true))
484 if err != nil {
485 return nil, err
486 }
487 if e.Tool.TodoWritten {
488 todoPayload, err := makePayload(map[string]any{"todos": e.Tool.Todos})
489 if err != nil {
490 return nil, err
491 }
492 out = append(out, session.Event{Kind: "todo/write", Payload: todoPayload})
493 }
494 out = append(out, session.Event{Kind: "tool/result", Payload: payload})
495 case event.StreamAttempt:
496 payload, err := makePayload(map[string]any{
497 "id": e.StreamAttempt.ID, "messageId": e.MessageID,
498 "action": e.StreamAttempt.Action, "attempt": e.StreamAttempt.Attempt,
499 "max": e.StreamAttempt.Max, "reason": e.StreamAttempt.Reason,
500 })
501 if err != nil {
502 return nil, err
503 }
504 out = append(out, session.Event{Kind: "assistant/attempt", Payload: payload})
505 case event.AskRequest, event.ApprovalRequest, event.MCPInteractionRequest:
506 kind := strings.TrimSpace(e.PromptKind)
507 if kind == "" {
508 switch e.Kind {
509 case event.AskRequest:
510 kind = "ask"
511 case event.MCPInteractionRequest:
512 kind = "mcp"
513 default:
514 kind = "approval"
515 }
516 }
517 payload, err := makePayload(map[string]any{
518 "id": e.ItemID, "toolCallId": e.ItemID, "kind": kind, "state": "pending",
519 "sessionId": e.SessionID, "headId": agent.BranchID(c.SessionPath()),
520 "turnId": e.TurnID, "runtimeEpoch": e.RuntimeEpoch,
521 })
522 if err != nil {
523 return nil, err
524 }
525 out = append(out, session.Event{Kind: "interaction/created", Payload: payload})
526 case event.PromptAnswered:
527 state := strings.TrimSpace(e.InteractionState)
528 if state == "" {
529 state = "answered"
530 }
531 payload, err := makePayload(map[string]any{"id": e.ItemID, "state": state})
532 if err != nil {
533 return nil, err
534 }
535 out = append(out, session.Event{Kind: "interaction/resolved", Payload: payload})
536 if e.DomainKind != "" {
537 out = append(out, session.Event{Kind: e.DomainKind, Payload: append(json.RawMessage(nil), e.DomainPayload...)})
538 }
539 case event.TurnStatusChanged:
540 if e.Status == event.TurnRecoveryRequired && e.Recovery != nil {
541 payload, err := makePayload(e.Recovery)
542 if err != nil {
543 return nil, err
544 }
545 out = append(out, session.Event{Kind: "runtime/recovery", Payload: payload})
546 }
547 case event.CompactionDone:
548 // Context-maintenance commits persist their exact model projection before
549 // this notification is emitted. CompactionDone is presentation-only.
550 case event.TurnDone:
551 interactionState := "unavailable"
552 if e.Cancelled || e.Status == event.TurnInterrupted {
553 interactionState = "cancelled"
554 }
555 out = append(out, session.ClosureEvents(projection, interactionState, "turn ended before recording a result")...)
556 if e.Recovery != nil && e.Recovery.State == "recovery_required" {
557 recoveryPayload, marshalErr := makePayload(e.Recovery)
558 if marshalErr != nil {
559 return nil, marshalErr
560 }
561 out = append(out, session.Event{Kind: "runtime/recovery", Payload: recoveryPayload})
562 }
563 payload, err := makePayload(map[string]any{"status": terminalTurnStatus(e)})
564 if err != nil {
565 return nil, err
566 }
567 out = append(out, session.Event{Kind: "turn/end", Payload: payload})
568 }
569 return out, nil
570 }
571
572 // v3ToolPayload retains the structured execution and presentation metadata
573 // emitted by the tool runtime. UI truncation may choose a smaller view later;
574 // the authoritative event must not collapse a result to name plus text.
575 func v3ToolPayload(tool event.Tool, result bool) map[string]any {
576 payload := map[string]any{
577 "id": tool.ID, "name": tool.Name, "args": tool.Args,
578 "runState": tool.RunState, "diagnostic": tool.Diagnostic,
579 "resolvedName": tool.ResolvedName, "capabilityId": tool.CapabilityID,
580 "readOnly": tool.ReadOnly, "truncated": tool.Truncated,
581 "durationMs": tool.DurationMs, "startedAt": tool.StartedAt, "endedAt": tool.EndedAt,
582 "partial": tool.Partial, "argChars": tool.ArgChars, "refreshed": tool.Refreshed,
583 "parentId": tool.ParentID, "attemptId": tool.AttemptID,
584 "subagentRef": tool.SubagentRef, "subagentStatus": tool.SubagentStatus,
585 "subagentErrorCode": tool.SubagentErrorCode, "subagentRetryable": tool.SubagentRetryable,
586 "diff": tool.Diff, "added": tool.Added, "removed": tool.Removed,
587 "profile": tool.Profile, "execution": tool.Execution,
588 "presentedFiles": tool.PresentedFiles,
589 "workspaceMutation": tool.WorkspaceMutation, "workspacePaths": tool.WorkspacePaths,
590 "workspaceAllPaths": tool.WorkspaceAllPaths,
591 }
592 if result {
593 payload["output"] = tool.Output
594 payload["error"] = tool.Err
595 payload["todos"] = tool.Todos
596 payload["todoWritten"] = tool.TodoWritten
597 }
598 return payload
599 }
600
601 // RecordSessionMessages implements agent.SessionEventRecorder. Runtime message
602 // commits arrive here directly; this code never diffs the mutable legacy
603 // transcript to infer missing business events.
604 func (c *Controller) RecordSessionMessages(ctx context.Context, reason string, messages []provider.Message) error {
605 if !c.sessionEventCommitAllowed() {
606 return nil
607 }
608 store := c.sessionEventStore()
609 if store == nil || len(messages) == 0 {
610 return nil
611 }
612 if recovery := store.ExecutionSnapshot().Projection.Recovery; recovery != nil && recovery.State == "recovery_required" {
613 return nil
614 }
615 c.turnEvents.commitMu.Lock()
616 defer c.turnEvents.commitMu.Unlock()
617 if !c.messageCommitAllowedLocked(ctx, store) {
618 return nil
619 }
620 events := make([]session.Event, 0, len(messages))
621 for _, message := range messages {
622 if strings.TrimSpace(message.ID) == "" {
623 return errors.New("record session message: missing stable message id")
624 }
625 payload, err := json.Marshal(map[string]any{"message": message})
626 if err != nil {
627 return err
628 }
629 events = append(events, session.Event{Kind: "message/complete", Payload: payload})
630 }
631 snapshot := store.ExecutionSnapshot()
632 op := fmt.Sprintf("messages:%s:%d", reason, snapshot.EventSequence+1)
633 _, err := c.appendSessionBatch(ctx, store, session.Batch{OperationID: op, TurnID: snapshot.Projection.TurnID, Events: events})
634 if err == nil {
635 c.noteCommittedMessagesLocked(events)
636 }
637 return err
638 }
639
640 // RecordSessionModelContext implements agent.SessionModelContextRecorder. It
641 // persists the exact provider-visible projection without mutating the Agent or
642 // the canonical history. A successful return means the accepted commit is on
643 // stable storage through its final sequence.
644 func (c *Controller) RecordSessionModelContext(ctx context.Context, request agent.SessionModelContextCommit) (agent.SessionModelContextCommitResult, error) {
645 var result agent.SessionModelContextCommitResult
646 if !c.sessionEventCommitAllowed() {
647 return result, session.ErrStaleExecution
648 }
649 store := c.sessionEventStore()
650 if store == nil {
651 return result, nil
652 }
653 operationID := strings.TrimSpace(request.OperationID)
654 if operationID == "" {
655 return result, errors.New("record session model context: missing operation id")
656 }
657 modelMessages := provider.ModelMessages(request.Messages)
658 if modelMessages == nil {
659 modelMessages = []provider.Message{}
660 }
661 payload, err := json.Marshal(map[string]any{
662 "messages": modelMessages,
663 "reason": strings.TrimSpace(request.Reason),
664 })
665 if err != nil {
666 return result, err
667 }
668
669 c.turnEvents.commitMu.Lock()
670 if !c.messageCommitAllowedLocked(ctx, store) {
671 c.turnEvents.commitMu.Unlock()
672 return result, session.ErrStaleExecution
673 }
674 commit, err := c.appendSessionBatch(ctx, store, session.Batch{
675 OperationID: "model-context-maintenance:" + operationID,
676 Events: []session.Event{{Kind: "model/context-replace", Payload: payload}},
677 })
678 c.turnEvents.commitMu.Unlock()
679 if err != nil {
680 return result, err
681 }
682 result.Accepted = true
683 receipt, err := store.Flush(ctx)
684 if err != nil {
685 return result, err
686 }
687 if receipt.DurableSequence < commit.LastSequence() {
688 return result, fmt.Errorf("record session model context: durable sequence %d is before commit %d", receipt.DurableSequence, commit.LastSequence())
689 }
690 result.Durable = true
691 return result, nil
692 }
693
694 // RecordSessionMessageUpsert records one explicit stable-message mutation.
695 // This is used for local metadata such as protocol recovery receipts, whose
696 // provider-visible bytes may not change but whose durable UI/business fact must
697 // survive without falling back to a full legacy transcript rewrite.
698 func (c *Controller) RecordSessionMessageUpsert(ctx context.Context, reason string, message provider.Message) error {
699 if !c.sessionEventCommitAllowed() {
700 return nil
701 }
702 store := c.sessionEventStore()
703 if store == nil {
704 return nil
705 }
706 if strings.TrimSpace(message.ID) == "" {
707 return errors.New("record session message upsert: missing stable message id")
708 }
709 payload, err := json.Marshal(map[string]any{"message": message})
710 if err != nil {
711 return err
712 }
713 c.turnEvents.commitMu.Lock()
714 defer c.turnEvents.commitMu.Unlock()
715 if !c.messageCommitAllowedLocked(ctx, store) {
716 return nil
717 }
718 snapshot := store.ExecutionSnapshot()
719 op := fmt.Sprintf("message-upsert:%s:%s:%d", reason, message.ID, snapshot.EventSequence+1)
720 _, err = c.appendSessionBatch(ctx, store, session.Batch{OperationID: op, TurnID: snapshot.Projection.TurnID, Events: []session.Event{{Kind: "message/upsert", Payload: payload}}})
721 return err
722 }
723
724 // replaceSessionEventProjection records an intentional transcript rewrite as
725 // an explicit context event. Recovery, cancellation and host prompt changes use
726 // this path so the typed log remains authoritative without diffing the legacy
727 // message cache at a later checkpoint.
728 func (c *Controller) replaceSessionEventProjection(ctx context.Context, reason string, messages []provider.Message) error {
729 if !c.sessionEventCommitAllowed() {
730 return nil
731 }
732 store := c.sessionEventStore()
733 if store == nil {
734 return nil
735 }
736 payload, err := json.Marshal(map[string]any{
737 "messages": append([]provider.Message(nil), messages...),
738 "reason": reason,
739 })
740 if err != nil {
741 return err
742 }
743 c.turnEvents.commitMu.Lock()
744 defer c.turnEvents.commitMu.Unlock()
745 snapshot := store.ExecutionSnapshot()
746 op := fmt.Sprintf("context-replace:%s:%d", reason, snapshot.EventSequence+1)
747 _, err = c.appendSessionBatch(ctx, store, session.Batch{
748 OperationID: op,
749 TurnID: snapshot.Projection.TurnID,
750 Events: []session.Event{{Kind: "history/replace", Payload: payload}},
751 })
752 return err
753 }
754
755 func (c *Controller) flushSessionEvents(ctx context.Context) (session.DurableReceipt, error) {
756 store := c.sessionEventStore()
757 if store == nil {
758 return session.DurableReceipt{}, nil
759 }
760 return store.Flush(ctx)
761 }
762
763 func (c *Controller) appendDomainState(kind string, payload json.RawMessage, reason string) error {
764 if !c.sessionEventCommitAllowed() {
765 return nil
766 }
767 store := c.sessionEventStore()
768 if store == nil || len(payload) == 0 {
769 return nil
770 }
771 if recovery := store.ExecutionSnapshot().Projection.Recovery; recovery != nil && recovery.State == "recovery_required" {
772 return nil
773 }
774 c.turnEvents.commitMu.Lock()
775 defer c.turnEvents.commitMu.Unlock()
776 if !c.messageCommitAllowedLocked(context.Background(), store) {
777 return nil
778 }
779 snapshot := store.ExecutionSnapshot()
780 op := fmt.Sprintf("domain:%s:%s:%d", kind, reason, snapshot.EventSequence+1)
781 _, err := c.appendSessionBatch(context.Background(), store, session.Batch{OperationID: op, TurnID: snapshot.Projection.TurnID, Events: []session.Event{{Kind: kind, Payload: append(json.RawMessage(nil), payload...)}}})
782 if err != nil {
783 return fmt.Errorf("%w: %w", turnevent.ErrTurnLedgerUnavailable, err)
784 }
785 return nil
786 }
787
787 lines GO