Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

632 lines
19 KiB
Go

package agentsessionstore
import (
"context"
"log/slog"
"sort"
"strings"
"sync"
"time"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
"github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical"
)
type State struct {
Presences []WorkspaceAgentPresence `json:"presences"`
Sessions []ProviderActivitySessionProjection `json:"sessions"`
}
type Messages struct {
AgentSessionID string
Messages []WorkspaceAgentMessageUpdate
}
const (
WorkspaceAgentSyncStatusPending = "pending"
WorkspaceAgentSyncStatusSynced = "synced"
WorkspaceAgentSyncStatusFailed = "failed"
)
type agentSessionSyncState struct {
state WorkspaceAgentSyncState
pendingReports int
failedReports int
}
type sessionEntry struct {
mu sync.Mutex
refCount int
state State
sessionMessages map[string][]WorkspaceAgentSessionMessage
messageVersionBySession map[string]uint64
remoteMessageVersionBySession map[string]uint64
syncStates map[string]*agentSessionSyncState
hiddenSessions map[string]struct{}
}
type Store struct {
mu sync.RWMutex
rooms map[string]*sessionEntry
client ReadRepository
syncer *sessionSyncer
syncStateStore SyncStateStore
messageCursorStore MessageCursorStore
syncBackoff SyncBackoffConfig
updateListener func(roomID string, snapshot WorkspaceAgentSnapshot)
startOnce sync.Once
}
type Option func(*Store)
// WithSyncStateStore injects a persistence backend for per-session activity
// sync states. Loaded states seed TrackRoom, updates are written through on
// every sync-state transition, and entries are deleted when a session is
// hidden. Without this option sync states live in memory only.
//
// Store keys are the scope identifier passed to TrackRoom: tutti side =
// workspace ID, external daemons (tsh) = control-plane room ID; workspace ≡
// room, one-to-one, no implicit translation.
func WithSyncStateStore(store SyncStateStore) Option {
return func(svc *Store) {
if svc != nil {
svc.syncStateStore = store
}
}
}
// WithMessageCursorStore injects a persistence backend for per-session message
// sync cursors. Loaded cursors seed TrackRoom so the syncer resumes message
// pulls where it left off, cursor advances are written through, and entries
// are deleted when a session is hidden. Without this option cursors live in
// memory only.
//
// Store keys are the scope identifier passed to TrackRoom: tutti side =
// workspace ID, external daemons (tsh) = control-plane room ID; workspace ≡
// room, one-to-one, no implicit translation.
func WithMessageCursorStore(store MessageCursorStore) Option {
return func(svc *Store) {
if svc != nil {
svc.messageCursorStore = store
}
}
}
// WithSyncBackoff enables per-session exponential backoff for failed message
// syncs in the background syncer. The zero config disables backoff, which is
// the default and matches historical behavior (every sync tick retries
// immediately). Use DefaultSyncBackoffConfig for field-proven values.
func WithSyncBackoff(cfg SyncBackoffConfig) Option {
return func(svc *Store) {
if svc != nil {
svc.syncBackoff = cfg
}
}
}
func New(client ReadRepository, opts ...Option) *Store {
svc := &Store{
rooms: make(map[string]*sessionEntry),
client: client,
}
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
svc.syncer = newSessionSyncer(svc, client)
return svc
}
func (s *Store) Start(ctx context.Context) {
if s == nil || s.syncer == nil {
return
}
s.startOnce.Do(func() {
go s.syncer.run(ctx)
})
}
func (s *Store) TrackRoom(roomID string) {
roomID = strings.TrimSpace(roomID)
if s == nil || roomID == "" {
return
}
loadedSyncStates := s.loadStoredSyncStates(roomID)
loadedCursors := s.loadStoredMessageCursors(roomID)
s.mu.Lock()
defer s.mu.Unlock()
entry := s.rooms[roomID]
if entry == nil {
entry = &sessionEntry{
sessionMessages: make(map[string][]WorkspaceAgentSessionMessage),
messageVersionBySession: make(map[string]uint64),
remoteMessageVersionBySession: make(map[string]uint64),
syncStates: make(map[string]*agentSessionSyncState),
hiddenSessions: make(map[string]struct{}),
}
for agentSessionID, syncState := range loadedSyncStates {
entry.syncStates[agentSessionID] = syncEntryFromState(syncState)
}
for agentSessionID, cursor := range loadedCursors {
agentSessionID = strings.TrimSpace(agentSessionID)
if agentSessionID == "" || cursor == 0 {
continue
}
entry.remoteMessageVersionBySession[agentSessionID] = cursor
}
s.rooms[roomID] = entry
}
entry.refCount++
}
func (s *Store) SetUpdateListener(listener func(roomID string, snapshot WorkspaceAgentSnapshot)) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.updateListener = listener
}
func (s *Store) UntrackRoom(roomID string) {
roomID = strings.TrimSpace(roomID)
if s == nil || roomID == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
entry := s.rooms[roomID]
if entry == nil {
return
}
entry.refCount--
if entry.refCount <= 0 {
delete(s.rooms, roomID)
}
}
func (s *Store) TriggerSync(roomID string) {
if s == nil || s.syncer == nil {
return
}
s.syncer.triggerRoom(roomID)
}
func (s *Store) GetAgentState(roomID string) (State, bool) {
entry := s.roomEntry(roomID)
if entry == nil {
return State{}, false
}
entry.mu.Lock()
defer entry.mu.Unlock()
return State{
Presences: clonePresences(entry.state.Presences),
Sessions: sessionsWithSyncStates(entry.state.Sessions, entry.syncStates),
}, true
}
func (s *Store) GetAgentSnapshot(roomID string) (WorkspaceAgentSnapshot, bool) {
entry := s.roomEntry(roomID)
if entry == nil {
return WorkspaceAgentSnapshot{}, false
}
entry.mu.Lock()
defer entry.mu.Unlock()
return snapshotFromEntryLocked(entry), true
}
func (s *Store) RestoreSnapshot(roomID string, snapshot WorkspaceAgentSnapshot) {
roomID = strings.TrimSpace(roomID)
if s == nil || roomID == "" {
return
}
s.updateState(roomID, snapshot)
}
func (s *Store) GetAgentMessages(roomID, agentSessionID string) (Messages, bool) {
agentSessionID = strings.TrimSpace(agentSessionID)
if agentSessionID == "" {
return Messages{}, false
}
entry := s.roomEntry(roomID)
if entry == nil {
return Messages{}, false
}
entry.mu.Lock()
defer entry.mu.Unlock()
return Messages{
AgentSessionID: agentSessionID,
Messages: sessionMessageUpdatesForLegacyReads(entry.sessionMessages[agentSessionID]),
}, true
}
func (s *Store) ListSessionMessages(
roomID string,
agentSessionID string,
afterVersion uint64,
limit int,
) (ListSessionMessagesReply, bool) {
agentSessionID = strings.TrimSpace(agentSessionID)
if agentSessionID == "" {
return ListSessionMessagesReply{}, false
}
entry := s.roomEntry(roomID)
if entry == nil {
return ListSessionMessagesReply{}, false
}
entry.mu.Lock()
defer entry.mu.Unlock()
all := entry.sessionMessages[agentSessionID]
latestVersion := entry.messageVersionBySession[agentSessionID]
if latestVersion == 0 {
latestVersion = maxSessionMessageVersion(0, all)
}
filtered := make([]WorkspaceAgentSessionMessage, 0, len(all))
for _, message := range all {
if message.Version <= afterVersion {
continue
}
filtered = append(filtered, cloneSessionMessage(message))
}
hasMore := false
if limit > 0 && len(filtered) > limit {
// Page membership must be contiguous in version space: deliver the
// lowest `limit` undelivered versions. Stored order is display order
// (occurredAt), so truncating it directly could return version N while
// omitting an undelivered version < N; cursor-based consumers would
// then advance past the omitted row and lose it permanently.
sort.SliceStable(filtered, func(i, j int) bool {
return filtered[i].Version < filtered[j].Version
})
filtered = filtered[:limit]
hasMore = true
// Advertise the page boundary, not the store head, so cursors advance
// exactly to the end of this page.
latestVersion = maxSessionMessageVersion(0, filtered)
}
return ListSessionMessagesReply{
Messages: sortSessionMessages(filtered),
LatestVersion: latestVersion,
HasMore: hasMore,
}, true
}
func (s *Store) ApplyEvents(roomID string, source EventSource, events []activityshared.Event) {
roomID = strings.TrimSpace(roomID)
if s == nil || roomID == "" || len(events) == 0 {
return
}
var ok bool
source, ok = normalizeRuntimeEventSource(source)
if !ok {
return
}
entry := s.roomEntry(roomID)
if entry == nil {
return
}
entry.mu.Lock()
before := snapshotFromEntryLocked(entry)
now := time.Now().UnixMilli()
for _, event := range events {
s.applyEventLocked(entry, roomID, source, event, now)
}
changed := !workspaceAgentSnapshotBusinessEqual(before, snapshotFromEntryLocked(entry))
entry.mu.Unlock()
if changed {
s.notifyRoomUpdate(roomID)
}
}
func (s *Store) ApplyActivity(
roomID string,
source EventSource,
timelineItems []WorkspaceAgentTimelineItem,
statePatches []WorkspaceAgentStatePatch,
messageUpdateBatches ...[]WorkspaceAgentMessageUpdate,
) {
var messageUpdates []WorkspaceAgentMessageUpdate
if len(messageUpdateBatches) > 0 {
messageUpdates = messageUpdateBatches[0]
}
roomID = strings.TrimSpace(roomID)
if s == nil || roomID == "" || (len(timelineItems) == 0 && len(statePatches) == 0 && len(messageUpdates) == 0) {
return
}
var ok bool
source, ok = normalizeRuntimeEventSource(source)
if !ok {
return
}
entry := s.roomEntry(roomID)
if entry == nil {
return
}
entry.mu.Lock()
before := snapshotFromEntryLocked(entry)
now := time.Now().UnixMilli()
for _, patch := range statePatches {
applyStatePatchLocked(entry, source, patch, now)
}
appendMessageUpdatesLocked(entry, source, messageUpdates)
changed := !workspaceAgentSnapshotBusinessEqual(before, snapshotFromEntryLocked(entry))
entry.mu.Unlock()
if changed {
s.notifyRoomUpdate(roomID)
}
}
func (s *Store) ApplySessionState(
roomID string,
source EventSource,
agentSessionID string,
state WorkspaceAgentSessionStateUpdate,
) {
roomID = strings.TrimSpace(roomID)
agentSessionID = strings.TrimSpace(firstNonEmptyString(agentSessionID, source.AgentID, source.ProviderSessionID))
if s == nil || roomID == "" || agentSessionID == "" {
return
}
var ok bool
source, ok = normalizeRuntimeEventSource(source)
if !ok {
return
}
entry := s.roomEntry(roomID)
if entry == nil {
return
}
if strings.TrimSpace(source.AgentID) == "" {
source.AgentID = agentSessionID
}
entry.mu.Lock()
before := snapshotFromEntryLocked(entry)
applyStatePatchLocked(entry, source, statePatchFromSessionState(agentSessionID, state), time.Now().UnixMilli())
applySessionStateTimesLocked(entry, agentSessionID, state)
changed := !workspaceAgentSnapshotBusinessEqual(before, snapshotFromEntryLocked(entry))
entry.mu.Unlock()
if changed {
s.notifyRoomUpdate(roomID)
}
}
func (s *Store) ApplySessionMessages(
roomID string,
source EventSource,
agentSessionID string,
updates []WorkspaceAgentSessionMessageUpdate,
) {
roomID = strings.TrimSpace(roomID)
agentSessionID = strings.TrimSpace(firstNonEmptyString(agentSessionID, source.AgentID, source.ProviderSessionID))
if s == nil || roomID == "" || agentSessionID == "" || len(updates) == 0 {
return
}
var ok bool
source, ok = normalizeRuntimeEventSource(source)
if !ok {
return
}
entry := s.roomEntry(roomID)
if entry == nil {
return
}
if strings.TrimSpace(source.AgentID) == "" {
source.AgentID = agentSessionID
}
entry.mu.Lock()
changed := appendMessageUpdatesLocked(entry, source, messageUpdatesFromSessionMessages(agentSessionID, updates))
entry.mu.Unlock()
if changed {
s.notifyRoomUpdate(roomID)
}
}
func normalizeRuntimeEventSource(source EventSource) (EventSource, bool) {
origin := NormalizeSessionOrigin(source.SessionOrigin)
if origin == "" {
return EventSource{}, false
}
source.SessionOrigin = origin
return source, true
}
func (s *Store) HideAgentSession(roomID string, agentSessionID string) {
agentSessionID = strings.TrimSpace(agentSessionID)
if s == nil || agentSessionID == "" {
return
}
entry := s.roomEntry(roomID)
if entry == nil {
return
}
entry.mu.Lock()
before := snapshotFromEntryLocked(entry)
if entry.hiddenSessions == nil {
entry.hiddenSessions = make(map[string]struct{})
}
entry.hiddenSessions[agentSessionID] = struct{}{}
entry.state.Sessions = removeSessionByID(entry.state.Sessions, agentSessionID)
delete(entry.sessionMessages, agentSessionID)
delete(entry.messageVersionBySession, agentSessionID)
delete(entry.remoteMessageVersionBySession, agentSessionID)
delete(entry.syncStates, agentSessionID)
if s.syncStateStore != nil {
if err := s.syncStateStore.DeleteAgentSyncState(context.Background(), roomID, agentSessionID); err != nil {
slog.Warn("agent activity sync state delete failed",
"event", "agent_activity.sync_state.delete_failed",
"room_id", roomID,
"agent_session_id", agentSessionID,
"error", err,
)
}
}
if s.messageCursorStore != nil {
if err := s.messageCursorStore.DeleteMessageCursor(context.Background(), roomID, agentSessionID); err != nil {
slog.Warn("agent activity message cursor delete failed",
"event", "agent_activity.message_cursor.delete_failed",
"room_id", roomID,
"agent_session_id", agentSessionID,
"error", err,
)
}
}
changed := !workspaceAgentSnapshotBusinessEqual(before, snapshotFromEntryLocked(entry))
entry.mu.Unlock()
if changed {
s.notifyRoomUpdate(roomID)
}
}
func statePatchFromSessionState(agentSessionID string, state WorkspaceAgentSessionStateUpdate) WorkspaceAgentStatePatch {
patch := WorkspaceAgentStatePatch{
AgentSessionID: strings.TrimSpace(agentSessionID),
Kind: strings.TrimSpace(state.Kind),
RootAgentSessionID: strings.TrimSpace(state.RootAgentSessionID),
RootTurnID: strings.TrimSpace(state.RootTurnID),
ParentAgentSessionID: strings.TrimSpace(state.ParentAgentSessionID),
ParentTurnID: strings.TrimSpace(state.ParentTurnID),
ParentToolCallID: strings.TrimSpace(state.ParentToolCallID),
AgentTargetID: strings.TrimSpace(state.AgentTargetID),
DeviceID: strings.TrimSpace(state.DeviceID),
Provider: strings.TrimSpace(state.Provider),
ProviderSessionID: strings.TrimSpace(state.ProviderSessionID),
Model: strings.TrimSpace(state.Model),
Settings: clonePayloadMap(state.Settings),
Capabilities: canonical.CloneCapabilitySnapshot(state.Capabilities),
RuntimeContext: clonePayloadMap(state.RuntimeContext),
RuntimeContextPatch: canonical.CloneRuntimeContextPatch(state.RuntimeContextPatch),
TurnLifecycle: cloneTurnLifecycle(state.TurnLifecycle),
SubmitAvailability: cloneSubmitAvailability(state.SubmitAvailability),
InteractionTransition: cloneInteractionTransition(state.InteractionTransition),
CWD: strings.TrimSpace(state.CWD),
Title: strings.TrimSpace(state.Title),
LifecycleStatus: strings.TrimSpace(state.LifecycleStatus),
CurrentPhase: strings.TrimSpace(state.CurrentPhase),
OccurredAtUnixMS: state.OccurredAtUnixMS,
RootProviderTurn: cloneRootProviderTurnTransition(state.RootProviderTurn),
}
if state.Turn != nil {
patch.Turn = &WorkspaceAgentTurnPatch{
TurnID: strings.TrimSpace(state.Turn.TurnID),
CapabilityRefs: cloneCapabilityReferences(state.Turn.CapabilityRefs),
Origin: strings.TrimSpace(state.Turn.Origin),
SourceGoalOperationID: strings.TrimSpace(state.Turn.SourceGoalOperationID),
SourceGoalRevision: state.Turn.SourceGoalRevision,
SourceGoalRepairEpoch: state.Turn.SourceGoalRepairEpoch,
ActiveTurnID: cloneStringPointer(state.Turn.ActiveTurnID),
Phase: strings.TrimSpace(state.Turn.Phase),
Outcome: strings.TrimSpace(state.Turn.Outcome),
Settling: state.Turn.Settling,
CompletedCommand: cloneCompletedCommand(state.Turn.CompletedCommand),
SubmitAvailability: cloneSubmitAvailability(state.Turn.SubmitAvailability),
FileChanges: clonePayloadMap(state.Turn.FileChanges),
StartedAtUnixMS: state.Turn.StartedAtUnixMS,
CompletedAtUnixMS: state.Turn.CompletedAtUnixMS,
}
}
return patch
}
func messageUpdatesFromSessionMessages(agentSessionID string, updates []WorkspaceAgentSessionMessageUpdate) []WorkspaceAgentMessageUpdate {
if len(updates) == 0 {
return nil
}
out := make([]WorkspaceAgentMessageUpdate, 0, len(updates))
for _, update := range updates {
out = append(out, WorkspaceAgentMessageUpdate{
AgentSessionID: strings.TrimSpace(agentSessionID),
MessageID: strings.TrimSpace(update.MessageID),
TurnID: strings.TrimSpace(update.TurnID),
Role: strings.TrimSpace(update.Role),
Kind: strings.TrimSpace(update.Kind),
Status: strings.TrimSpace(update.Status),
Semantics: cloneMessageSemantics(update.Semantics),
Payload: clonePayloadMap(update.Payload),
OccurredAtUnixMS: update.OccurredAtUnixMS,
StartedAtUnixMS: update.StartedAtUnixMS,
CompletedAtUnixMS: update.CompletedAtUnixMS,
})
}
return out
}
func applySessionStateTimesLocked(entry *sessionEntry, agentSessionID string, state WorkspaceAgentSessionStateUpdate) {
if entry == nil || (state.StartedAtUnixMS <= 0 && state.EndedAtUnixMS <= 0) {
return
}
agentSessionID = strings.TrimSpace(agentSessionID)
for index := range entry.state.Sessions {
if strings.TrimSpace(entry.state.Sessions[index].AgentSessionID) != agentSessionID {
continue
}
if state.StartedAtUnixMS > 0 {
entry.state.Sessions[index].StartedAtUnixMS = state.StartedAtUnixMS
}
if state.EndedAtUnixMS > 0 {
entry.state.Sessions[index].EndedAtUnixMS = state.EndedAtUnixMS
}
return
}
}
func (s *Store) MarkActivitySyncPending(
roomID string,
agentSessionID string,
timelineItemCount int,
statePatchCount int,
messageUpdateCount int,
) (WorkspaceAgentSyncState, bool) {
return s.updateActivitySyncState(roomID, agentSessionID, func(current *agentSessionSyncState, now int64) WorkspaceAgentSyncState {
return applyActivitySyncPending(current, agentSessionID, timelineItemCount, statePatchCount, messageUpdateCount, now)
})
}
func (s *Store) MarkActivitySyncSucceeded(
roomID string,
agentSessionID string,
timelineItemCount int,
statePatchCount int,
messageUpdateCount int,
) (WorkspaceAgentSyncState, bool) {
return s.updateActivitySyncState(roomID, agentSessionID, func(current *agentSessionSyncState, now int64) WorkspaceAgentSyncState {
return applyActivitySyncSucceeded(current, agentSessionID, timelineItemCount, statePatchCount, messageUpdateCount, now)
})
}
func (s *Store) MarkActivitySyncFailed(
roomID string,
agentSessionID string,
_ int,
_ int,
_ int,
err error,
) (WorkspaceAgentSyncState, bool) {
return s.updateActivitySyncState(roomID, agentSessionID, func(current *agentSessionSyncState, now int64) WorkspaceAgentSyncState {
return applyActivitySyncFailed(current, agentSessionID, err, now)
})
}