mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 03:27:02 +08:00
1127 lines
36 KiB
Go
1127 lines
36 KiB
Go
// file_store.go 负责 conversation 的两份持久化事实:state.json 与 context.json。
|
||
package forwarder
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"cursor/gen/agentv1"
|
||
)
|
||
|
||
const (
|
||
conversationStateFileName = "state.json"
|
||
conversationContextFileName = "context.json"
|
||
conversationSchemaVersion = 1
|
||
conversationLockStaleAfter = 30 * time.Minute
|
||
legacyConversationLockStaleAfter = 30 * time.Second
|
||
conversationLockAcquireTimeout = 30 * time.Second
|
||
staleConversationLockRemoveTimeout = 30 * time.Second
|
||
conversationLockRetryInterval = 10 * time.Millisecond
|
||
)
|
||
|
||
var (
|
||
conversationLockProcessStartedAt = time.Now()
|
||
conversationProcessLocksMu sync.Mutex
|
||
conversationProcessLocks = make(map[string]*conversationProcessLock)
|
||
)
|
||
|
||
type conversationProcessLock struct {
|
||
mu sync.Mutex
|
||
refs int
|
||
}
|
||
|
||
type ConversationFileStore struct {
|
||
root string
|
||
}
|
||
|
||
type conversationContextFile struct {
|
||
SchemaVersion int `json:"schema_version"`
|
||
ConversationID string `json:"conversation_id"`
|
||
Version int64 `json:"version"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
Items []HistoryEntry `json:"items"`
|
||
}
|
||
|
||
// NewConversationFileStore 创建 JSON history 文件存储。
|
||
func NewConversationFileStore(historyRoot string) *ConversationFileStore {
|
||
return &ConversationFileStore{root: strings.TrimSpace(historyRoot)}
|
||
}
|
||
|
||
// HistoryDir 返回 history 根路径。
|
||
func (store *ConversationFileStore) HistoryDir() string {
|
||
if store == nil {
|
||
return ""
|
||
}
|
||
return store.root
|
||
}
|
||
|
||
// CreateConversation 确保指定会话对应的 state/context 文件存在并完成元数据初始化。
|
||
func (store *ConversationFileStore) CreateConversation(conversationID string, mode agentv1.AgentMode, parentConversationID string, parentToolCallID string, rootConversationID string) (*ConversationFile, error) {
|
||
if store == nil {
|
||
return nil, fmt.Errorf("conversation file store is nil")
|
||
}
|
||
return store.mutateConversation(conversationID, true, func(conversation *ConversationFile) error {
|
||
if strings.TrimSpace(conversation.ConversationID) != "" {
|
||
if strings.TrimSpace(conversation.Mode) == "" {
|
||
alias, err := modeAlias(mode)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
conversation.Mode = alias
|
||
}
|
||
return nil
|
||
}
|
||
now := time.Now().UTC()
|
||
normalizedConversationID := strings.TrimSpace(conversationID)
|
||
if normalizedConversationID == "" {
|
||
return fmt.Errorf("conversation_id is required")
|
||
}
|
||
conversation.SchemaVersion = conversationSchemaVersion
|
||
conversation.ConversationID = normalizedConversationID
|
||
conversation.RootConversationID = strings.TrimSpace(rootConversationID)
|
||
if conversation.RootConversationID == "" {
|
||
conversation.RootConversationID = normalizedConversationID
|
||
}
|
||
conversation.ParentConversationID = strings.TrimSpace(parentConversationID)
|
||
conversation.ParentToolCallID = strings.TrimSpace(parentToolCallID)
|
||
alias, err := modeAlias(mode)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
conversation.Mode = alias
|
||
conversation.CreatedAt = now
|
||
conversation.UpdatedAt = now
|
||
conversation.NextTurnSeq = 1
|
||
conversation.NextEntrySeq = 1
|
||
conversation.ContextVersion = 0
|
||
conversation.CurrentLoopStatus = "idle"
|
||
conversation.Entries = make([]HistoryEntry, 0, 16)
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// LoadConversation 读取 state.json + context.json。
|
||
func (store *ConversationFileStore) LoadConversation(conversationID string) (*ConversationFile, error) {
|
||
if store == nil {
|
||
return nil, fmt.Errorf("conversation file store is nil")
|
||
}
|
||
return store.mutateConversation(conversationID, false, nil)
|
||
}
|
||
|
||
// AppendEntries 把已经发生的语义事件追加到 context.json,并同步 state.json。
|
||
func (store *ConversationFileStore) AppendEntries(conversationID string, entries []HistoryEntry) (*ConversationFile, []HistoryEntry, error) {
|
||
if store == nil {
|
||
return nil, nil, fmt.Errorf("conversation file store is nil")
|
||
}
|
||
if len(entries) == 0 {
|
||
conversation, err := store.LoadConversation(conversationID)
|
||
return conversation, nil, err
|
||
}
|
||
normalizedConversationID, err := validateConversationID(conversationID)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if err := os.MkdirAll(store.conversationDir(normalizedConversationID), 0o755); err != nil {
|
||
return nil, nil, fmt.Errorf("create conversation directory: %w", err)
|
||
}
|
||
release, err := acquireConversationLock(store.lockPath(normalizedConversationID))
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
defer release()
|
||
|
||
conversation, err := store.readConversationLocked(normalizedConversationID)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if conversation == nil {
|
||
conversation = &ConversationFile{
|
||
SchemaVersion: conversationSchemaVersion,
|
||
ConversationID: normalizedConversationID,
|
||
RootConversationID: normalizedConversationID,
|
||
NextTurnSeq: 1,
|
||
NextEntrySeq: 1,
|
||
Entries: make([]HistoryEntry, 0, 16),
|
||
CreatedAt: time.Now().UTC(),
|
||
}
|
||
alias, err := modeAlias(agentv1.AgentMode_AGENT_MODE_AGENT)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
conversation.Mode = alias
|
||
}
|
||
assigned := appendEntriesInPlace(conversation, entries)
|
||
deriveConversationLoopState(conversation)
|
||
if err := store.writeConversationLocked(normalizedConversationID, conversation); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
return cloneConversationFile(conversation), assigned, nil
|
||
}
|
||
|
||
func (store *ConversationFileStore) SaveConversationWithEntries(conversationID string, source *ConversationFile, entries []HistoryEntry) (*ConversationFile, error) {
|
||
if store == nil {
|
||
return nil, fmt.Errorf("conversation file store is nil")
|
||
}
|
||
normalizedConversationID, err := validateConversationID(conversationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.MkdirAll(store.conversationDir(normalizedConversationID), 0o755); err != nil {
|
||
return nil, fmt.Errorf("create conversation directory: %w", err)
|
||
}
|
||
release, err := acquireConversationLock(store.lockPath(normalizedConversationID))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer release()
|
||
|
||
conversation, err := store.readConversationLocked(normalizedConversationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if conversation == nil {
|
||
conversation = &ConversationFile{
|
||
SchemaVersion: conversationSchemaVersion,
|
||
ConversationID: normalizedConversationID,
|
||
RootConversationID: normalizedConversationID,
|
||
NextTurnSeq: 1,
|
||
NextEntrySeq: 1,
|
||
Entries: make([]HistoryEntry, 0, len(entries)),
|
||
CreatedAt: time.Now().UTC(),
|
||
}
|
||
}
|
||
mergeConversationMetadata(conversation, source)
|
||
appendEntriesInPlace(conversation, resetEntrySequences(entries))
|
||
deriveConversationLoopState(conversation)
|
||
if err := store.writeConversationLocked(normalizedConversationID, conversation); err != nil {
|
||
return nil, err
|
||
}
|
||
return cloneConversationFile(conversation), nil
|
||
}
|
||
|
||
// UpdateConversationMeta 更新 state.json;context.json 保持不变。
|
||
func (store *ConversationFileStore) UpdateConversationMeta(conversationID string, update func(*ConversationFile) error) (*ConversationFile, error) {
|
||
if store == nil {
|
||
return nil, fmt.Errorf("conversation file store is nil")
|
||
}
|
||
normalizedConversationID, err := validateConversationID(conversationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.MkdirAll(store.conversationDir(normalizedConversationID), 0o755); err != nil {
|
||
return nil, fmt.Errorf("create conversation directory: %w", err)
|
||
}
|
||
release, err := acquireConversationLock(store.lockPath(normalizedConversationID))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer release()
|
||
|
||
conversation, err := store.readConversationLocked(normalizedConversationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if conversation == nil {
|
||
conversation = &ConversationFile{
|
||
SchemaVersion: conversationSchemaVersion,
|
||
ConversationID: normalizedConversationID,
|
||
RootConversationID: normalizedConversationID,
|
||
NextTurnSeq: 1,
|
||
NextEntrySeq: 1,
|
||
Entries: make([]HistoryEntry, 0, 16),
|
||
CreatedAt: time.Now().UTC(),
|
||
}
|
||
}
|
||
if update != nil {
|
||
if err := update(conversation); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if err := store.writeConversationMetaLocked(normalizedConversationID, conversation); err != nil {
|
||
return nil, err
|
||
}
|
||
return cloneConversationFile(conversation), nil
|
||
}
|
||
|
||
// ReplaceEntries 原子替换 context.json,并同步 state.json 中的 sequence/version 状态。
|
||
func (store *ConversationFileStore) ReplaceEntries(conversationID string, entries []HistoryEntry, update func(*ConversationFile) error) (*ConversationFile, error) {
|
||
if store == nil {
|
||
return nil, fmt.Errorf("conversation file store is nil")
|
||
}
|
||
normalizedConversationID, err := validateConversationID(conversationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.MkdirAll(store.conversationDir(normalizedConversationID), 0o755); err != nil {
|
||
return nil, fmt.Errorf("create conversation directory: %w", err)
|
||
}
|
||
release, err := acquireConversationLock(store.lockPath(normalizedConversationID))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer release()
|
||
|
||
conversation, err := store.readConversationLocked(normalizedConversationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if conversation == nil {
|
||
conversation = &ConversationFile{
|
||
SchemaVersion: conversationSchemaVersion,
|
||
ConversationID: normalizedConversationID,
|
||
RootConversationID: normalizedConversationID,
|
||
NextTurnSeq: 1,
|
||
NextEntrySeq: 1,
|
||
Entries: make([]HistoryEntry, 0, len(entries)),
|
||
CreatedAt: time.Now().UTC(),
|
||
}
|
||
}
|
||
conversation.Entries = nil
|
||
conversation.NextEntrySeq = 1
|
||
conversation.NextTurnSeq = 1
|
||
appendEntriesInPlace(conversation, resetEntrySequences(entries))
|
||
if update != nil {
|
||
if err := update(conversation); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
deriveConversationLoopState(conversation)
|
||
if err := store.writeConversationLocked(normalizedConversationID, conversation); err != nil {
|
||
return nil, err
|
||
}
|
||
return cloneConversationFile(conversation), nil
|
||
}
|
||
|
||
// GetConversationSummary 返回轻量会话摘要。
|
||
func (store *ConversationFileStore) GetConversationSummary(conversationID string) (ConversationSummary, error) {
|
||
conversation, err := store.LoadConversation(conversationID)
|
||
if err != nil || conversation == nil {
|
||
return ConversationSummary{}, err
|
||
}
|
||
return ConversationSummary{
|
||
ConversationID: conversation.ConversationID,
|
||
Mode: conversation.Mode,
|
||
EntriesCount: len(conversation.Entries),
|
||
NextTurnSeq: conversation.NextTurnSeq,
|
||
NextEntrySeq: conversation.NextEntrySeq,
|
||
UpdatedAt: conversation.UpdatedAt,
|
||
}, nil
|
||
}
|
||
|
||
// ListConversationIDs 返回 history 根目录下包含 state/context 的 conversation id。
|
||
func (store *ConversationFileStore) ListConversationIDs() ([]string, error) {
|
||
if store == nil {
|
||
return nil, fmt.Errorf("conversation file store is nil")
|
||
}
|
||
entries, err := os.ReadDir(store.root)
|
||
if err != nil {
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return nil, nil
|
||
}
|
||
return nil, fmt.Errorf("scan history directory: %w", err)
|
||
}
|
||
ids := make([]string, 0, len(entries))
|
||
for _, entry := range entries {
|
||
if !entry.IsDir() {
|
||
continue
|
||
}
|
||
conversationID := strings.TrimSpace(entry.Name())
|
||
if conversationID == "" {
|
||
continue
|
||
}
|
||
if ok, err := fileExists(store.statePath(conversationID)); err != nil {
|
||
return nil, err
|
||
} else if ok {
|
||
ids = append(ids, conversationID)
|
||
}
|
||
}
|
||
sort.Strings(ids)
|
||
return ids, nil
|
||
}
|
||
|
||
func (store *ConversationFileStore) mutateConversation(conversationID string, createIfMissing bool, update func(*ConversationFile) error) (*ConversationFile, error) {
|
||
normalizedConversationID, err := validateConversationID(conversationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.MkdirAll(store.conversationDir(normalizedConversationID), 0o755); err != nil {
|
||
return nil, fmt.Errorf("create conversation directory: %w", err)
|
||
}
|
||
release, err := acquireConversationLock(store.lockPath(normalizedConversationID))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer release()
|
||
|
||
conversation, err := store.readConversationLocked(normalizedConversationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if conversation == nil {
|
||
if !createIfMissing {
|
||
return nil, nil
|
||
}
|
||
conversation = &ConversationFile{
|
||
SchemaVersion: conversationSchemaVersion,
|
||
ConversationID: normalizedConversationID,
|
||
RootConversationID: normalizedConversationID,
|
||
NextTurnSeq: 1,
|
||
NextEntrySeq: 1,
|
||
Entries: make([]HistoryEntry, 0, 16),
|
||
CreatedAt: time.Now().UTC(),
|
||
}
|
||
}
|
||
if update == nil {
|
||
return cloneConversationFile(conversation), nil
|
||
}
|
||
if err := update(conversation); err != nil {
|
||
return nil, err
|
||
}
|
||
deriveConversationLoopState(conversation)
|
||
if err := store.writeConversationLocked(normalizedConversationID, conversation); err != nil {
|
||
return nil, err
|
||
}
|
||
return cloneConversationFile(conversation), nil
|
||
}
|
||
|
||
func (store *ConversationFileStore) readConversationLocked(conversationID string) (*ConversationFile, error) {
|
||
stateBody, err := os.ReadFile(store.statePath(conversationID))
|
||
if err != nil {
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return nil, nil
|
||
}
|
||
return nil, fmt.Errorf("read conversation state: %w", err)
|
||
}
|
||
var conversation ConversationFile
|
||
if err := json.Unmarshal(stateBody, &conversation); err != nil {
|
||
return nil, fmt.Errorf("decode conversation state %q: %w", conversationID, err)
|
||
}
|
||
context, err := store.readContextLocked(conversationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
conversation.Entries = context
|
||
normalizeLoadedConversation(conversationID, &conversation)
|
||
return &conversation, nil
|
||
}
|
||
|
||
func (store *ConversationFileStore) readContextLocked(conversationID string) ([]HistoryEntry, error) {
|
||
body, err := os.ReadFile(store.contextPath(conversationID))
|
||
if err != nil {
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return make([]HistoryEntry, 0, 16), nil
|
||
}
|
||
return nil, fmt.Errorf("read conversation context: %w", err)
|
||
}
|
||
var context conversationContextFile
|
||
if err := json.Unmarshal(body, &context); err != nil {
|
||
return nil, fmt.Errorf("decode conversation context %q: %w", conversationID, err)
|
||
}
|
||
return append([]HistoryEntry(nil), context.Items...), nil
|
||
}
|
||
|
||
func (store *ConversationFileStore) writeConversationLocked(conversationID string, conversation *ConversationFile) error {
|
||
if conversation == nil {
|
||
return fmt.Errorf("conversation is nil")
|
||
}
|
||
normalizeLoadedConversation(conversationID, conversation)
|
||
if err := store.writeContextLocked(conversationID, conversation); err != nil {
|
||
return err
|
||
}
|
||
if err := store.writeConversationMetaLocked(conversationID, conversation); err != nil {
|
||
return err
|
||
}
|
||
store.syncCursorTranscriptBestEffort(conversationID, conversation)
|
||
return nil
|
||
}
|
||
|
||
func (store *ConversationFileStore) writeConversationMetaLocked(conversationID string, conversation *ConversationFile) error {
|
||
if conversation == nil {
|
||
return fmt.Errorf("conversation is nil")
|
||
}
|
||
currentLoopID := conversation.CurrentLoopID
|
||
currentLoopStatus := conversation.CurrentLoopStatus
|
||
currentRequestID := conversation.CurrentRequestID
|
||
currentTurnSeq := conversation.CurrentTurnSeq
|
||
normalizeLoadedConversation(conversationID, conversation)
|
||
if strings.TrimSpace(currentLoopStatus) != "" && (strings.TrimSpace(currentRequestID) == "" || conversationHasRequestEntry(conversation.Entries, currentRequestID, currentTurnSeq)) {
|
||
conversation.CurrentLoopID = currentLoopID
|
||
conversation.CurrentLoopStatus = currentLoopStatus
|
||
conversation.CurrentRequestID = currentRequestID
|
||
conversation.CurrentTurnSeq = currentTurnSeq
|
||
}
|
||
if err := refreshConversationRuntimeState(conversation); err != nil {
|
||
return err
|
||
}
|
||
metadata := cloneConversationFile(conversation)
|
||
metadata.SchemaVersion = conversationSchemaVersion
|
||
metadata.ContextVersion = contextVersionForEntries(conversation.Entries)
|
||
metadata.Entries = nil
|
||
return writeJSONFileAtomic(store.statePath(conversationID), metadata)
|
||
}
|
||
|
||
func (store *ConversationFileStore) writeContextLocked(conversationID string, conversation *ConversationFile) error {
|
||
context := conversationContextFile{
|
||
SchemaVersion: conversationSchemaVersion,
|
||
ConversationID: strings.TrimSpace(conversationID),
|
||
Version: contextVersionForEntries(conversation.Entries),
|
||
UpdatedAt: time.Now().UTC(),
|
||
Items: append([]HistoryEntry(nil), conversation.Entries...),
|
||
}
|
||
return writeJSONFileAtomic(store.contextPath(conversationID), context)
|
||
}
|
||
|
||
func (store *ConversationFileStore) syncCursorTranscriptBestEffort(conversationID string, conversation *ConversationFile) {
|
||
if store == nil || conversation == nil {
|
||
return
|
||
}
|
||
folder := normalizeAgentTranscriptsFolder(conversation.AgentTranscriptsFolder)
|
||
if folder == "" {
|
||
return
|
||
}
|
||
if err := store.syncCursorTranscript(conversationID, conversation, folder); err != nil {
|
||
log.Printf("forwarder transcript sync failed conversation_id=%s err=%v", strings.TrimSpace(conversationID), err)
|
||
}
|
||
}
|
||
|
||
func (store *ConversationFileStore) syncCursorTranscript(conversationID string, conversation *ConversationFile, transcriptsFolder string) error {
|
||
return store.syncCursorTranscriptWithLatestStatus(conversationID, conversation, transcriptsFolder, false)
|
||
}
|
||
|
||
func (store *ConversationFileStore) syncCursorTranscriptWithLatestStatus(conversationID string, conversation *ConversationFile, transcriptsFolder string, includeLatestStatus bool) error {
|
||
if store == nil || conversation == nil {
|
||
return nil
|
||
}
|
||
path, err := cursorTranscriptPath(transcriptsFolder, conversationID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
data, err := projectCursorTranscriptJSONLWithLatestStatus(conversation, includeLatestStatus)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(data) == 0 {
|
||
return nil
|
||
}
|
||
data = preserveCursorAppendedTurnEnded(path, data)
|
||
return writeCursorTranscriptAtomic(path, data)
|
||
}
|
||
|
||
func (store *ConversationFileStore) SyncAllCursorTranscriptsBestEffort() {
|
||
if store == nil {
|
||
return
|
||
}
|
||
conversationIDs, err := store.ListConversationIDs()
|
||
if err != nil {
|
||
log.Printf("forwarder transcript backfill scan failed err=%v", err)
|
||
return
|
||
}
|
||
for _, conversationID := range conversationIDs {
|
||
conversation, err := store.LoadConversation(conversationID)
|
||
if err != nil {
|
||
log.Printf("forwarder transcript backfill load failed conversation_id=%s err=%v", conversationID, err)
|
||
continue
|
||
}
|
||
if conversation == nil || conversation.AgentTranscriptsFolder == "" {
|
||
continue
|
||
}
|
||
info, err := os.Stat(conversation.AgentTranscriptsFolder)
|
||
if err != nil || !info.IsDir() {
|
||
continue
|
||
}
|
||
if err := store.syncCursorTranscriptWithLatestStatus(conversationID, conversation, conversation.AgentTranscriptsFolder, true); err != nil {
|
||
log.Printf("forwarder transcript backfill failed conversation_id=%s err=%v", conversationID, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func contextVersionForEntries(entries []HistoryEntry) int64 {
|
||
var version int64
|
||
for _, entry := range entries {
|
||
if entry.Seq > version {
|
||
version = entry.Seq
|
||
}
|
||
}
|
||
return version
|
||
}
|
||
|
||
func deriveConversationLoopState(conversation *ConversationFile) {
|
||
if conversation == nil {
|
||
return
|
||
}
|
||
conversation.SchemaVersion = conversationSchemaVersion
|
||
conversation.ContextVersion = contextVersionForEntries(conversation.Entries)
|
||
fallbackStatus := firstNonEmpty(strings.TrimSpace(conversation.CurrentLoopStatus), "idle")
|
||
requestID := strings.TrimSpace(conversation.CurrentRequestID)
|
||
turnSeq := conversation.CurrentTurnSeq
|
||
if requestID != "" && !conversationHasRequestEntry(conversation.Entries, requestID, turnSeq) {
|
||
requestID = ""
|
||
turnSeq = 0
|
||
fallbackStatus = "idle"
|
||
}
|
||
for index := len(conversation.Entries) - 1; index >= 0; index-- {
|
||
entry := conversation.Entries[index]
|
||
if strings.TrimSpace(entry.RequestID) == "" {
|
||
continue
|
||
}
|
||
if requestID == "" {
|
||
requestID = strings.TrimSpace(entry.RequestID)
|
||
turnSeq = entry.TurnSeq
|
||
}
|
||
break
|
||
}
|
||
status := deriveRequestLoopStatus(conversation.Entries, requestID, turnSeq, fallbackStatus)
|
||
conversation.CurrentRequestID = requestID
|
||
conversation.CurrentTurnSeq = turnSeq
|
||
if requestID != "" {
|
||
conversation.CurrentLoopID = fmt.Sprintf("%d:%s", turnSeq, requestID)
|
||
}
|
||
conversation.CurrentLoopStatus = status
|
||
}
|
||
|
||
func conversationHasRequestEntry(entries []HistoryEntry, requestID string, turnSeq int64) bool {
|
||
requestID = strings.TrimSpace(requestID)
|
||
if requestID == "" {
|
||
return false
|
||
}
|
||
for _, entry := range entries {
|
||
if strings.TrimSpace(entry.RequestID) != requestID {
|
||
continue
|
||
}
|
||
if turnSeq > 0 && entry.TurnSeq > 0 && entry.TurnSeq != turnSeq {
|
||
continue
|
||
}
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func deriveRequestLoopStatus(entries []HistoryEntry, requestID string, turnSeq int64, fallbackStatus string) string {
|
||
if strings.TrimSpace(requestID) == "" {
|
||
return firstNonEmpty(strings.TrimSpace(fallbackStatus), "idle")
|
||
}
|
||
openToolCalls := make(map[string]struct{})
|
||
terminalStatus := ""
|
||
seenActivity := false
|
||
for _, entry := range entries {
|
||
if strings.TrimSpace(entry.RequestID) != strings.TrimSpace(requestID) {
|
||
continue
|
||
}
|
||
if turnSeq > 0 && entry.TurnSeq > 0 && entry.TurnSeq != turnSeq {
|
||
continue
|
||
}
|
||
switch strings.TrimSpace(entry.Kind) {
|
||
case "tool_call":
|
||
seenActivity = true
|
||
toolCallID := historyEntryToolCallID(entry)
|
||
if toolCallID == "" {
|
||
toolCallID = fmt.Sprintf("entry:%d", entry.Seq)
|
||
}
|
||
openToolCalls[toolCallID] = struct{}{}
|
||
case "tool_result", "assistant_text", "prompt_context", "request_context", "user_message":
|
||
seenActivity = true
|
||
if strings.TrimSpace(entry.Kind) == "tool_result" {
|
||
if toolCallID := historyEntryToolCallID(entry); toolCallID != "" {
|
||
delete(openToolCalls, toolCallID)
|
||
}
|
||
}
|
||
case "metadata":
|
||
var payload metadataPayload
|
||
if err := json.Unmarshal(entry.Payload, &payload); err == nil {
|
||
switch strings.TrimSpace(payload.Type) {
|
||
case "turn_completed":
|
||
terminalStatus = "completed"
|
||
case "provider_error":
|
||
terminalStatus = "provider_error"
|
||
case "failed":
|
||
terminalStatus = "failed"
|
||
case "control":
|
||
if strings.TrimSpace(readStringValue(payload.Value["status"])) == "canceled" {
|
||
terminalStatus = "canceled"
|
||
}
|
||
case "run_request":
|
||
seenActivity = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if terminalStatus != "" {
|
||
return terminalStatus
|
||
}
|
||
if len(openToolCalls) > 0 {
|
||
return "waiting_tool"
|
||
}
|
||
if seenActivity {
|
||
return "running"
|
||
}
|
||
return firstNonEmpty(strings.TrimSpace(fallbackStatus), "idle")
|
||
}
|
||
|
||
func (store *ConversationFileStore) conversationDir(conversationID string) string {
|
||
return filepath.Join(store.root, conversationID)
|
||
}
|
||
|
||
func (store *ConversationFileStore) statePath(conversationID string) string {
|
||
return filepath.Join(store.conversationDir(conversationID), conversationStateFileName)
|
||
}
|
||
|
||
func (store *ConversationFileStore) contextPath(conversationID string) string {
|
||
return filepath.Join(store.conversationDir(conversationID), conversationContextFileName)
|
||
}
|
||
|
||
func (store *ConversationFileStore) lockPath(conversationID string) string {
|
||
return filepath.Join(store.conversationDir(conversationID), "conversation.lock")
|
||
}
|
||
|
||
func appendEntriesInPlace(conversation *ConversationFile, entries []HistoryEntry) []HistoryEntry {
|
||
if conversation == nil || len(entries) == 0 {
|
||
return nil
|
||
}
|
||
now := time.Now().UTC()
|
||
assigned := make([]HistoryEntry, 0, len(entries))
|
||
maxTurnSeq := conversation.NextTurnSeq - 1
|
||
for _, entry := range entries {
|
||
next := entry
|
||
if next.CreatedAt.IsZero() {
|
||
next.CreatedAt = now
|
||
}
|
||
if next.Seq <= 0 {
|
||
next.Seq = conversation.NextEntrySeq
|
||
conversation.NextEntrySeq++
|
||
} else if next.Seq >= conversation.NextEntrySeq {
|
||
conversation.NextEntrySeq = next.Seq + 1
|
||
}
|
||
if next.TurnSeq > maxTurnSeq {
|
||
maxTurnSeq = next.TurnSeq
|
||
}
|
||
conversation.Entries = append(conversation.Entries, next)
|
||
assigned = append(assigned, next)
|
||
}
|
||
if maxTurnSeq+1 > conversation.NextTurnSeq {
|
||
conversation.NextTurnSeq = maxTurnSeq + 1
|
||
}
|
||
if conversation.CreatedAt.IsZero() {
|
||
conversation.CreatedAt = now
|
||
}
|
||
conversation.UpdatedAt = now
|
||
conversation.ContextVersion = contextVersionForEntries(conversation.Entries)
|
||
return assigned
|
||
}
|
||
|
||
func mergeConversationMetadata(target *ConversationFile, source *ConversationFile) {
|
||
if target == nil || source == nil {
|
||
return
|
||
}
|
||
if strings.TrimSpace(source.ConversationID) != "" {
|
||
target.ConversationID = strings.TrimSpace(source.ConversationID)
|
||
}
|
||
if strings.TrimSpace(source.RootConversationID) != "" {
|
||
target.RootConversationID = strings.TrimSpace(source.RootConversationID)
|
||
}
|
||
target.ParentConversationID = strings.TrimSpace(source.ParentConversationID)
|
||
target.ParentToolCallID = strings.TrimSpace(source.ParentToolCallID)
|
||
target.SubagentTypeName = strings.TrimSpace(source.SubagentTypeName)
|
||
if folder := normalizeAgentTranscriptsFolder(source.AgentTranscriptsFolder); folder != "" {
|
||
target.AgentTranscriptsFolder = folder
|
||
}
|
||
if strings.TrimSpace(source.Mode) != "" {
|
||
target.Mode = strings.TrimSpace(source.Mode)
|
||
}
|
||
target.TokenDetailsUsedTokens = source.TokenDetailsUsedTokens
|
||
if source.TokenDetailsMaxTokens > 0 {
|
||
target.TokenDetailsMaxTokens = source.TokenDetailsMaxTokens
|
||
}
|
||
target.AutoCompactionPending = source.AutoCompactionPending
|
||
target.AutoCompactionPromptTokens = source.AutoCompactionPromptTokens
|
||
target.AutoCompactionReserveTokens = source.AutoCompactionReserveTokens
|
||
target.AutoCompactionTriggeredAt = source.AutoCompactionTriggeredAt
|
||
target.AutoCompactionSourceModelCallID = source.AutoCompactionSourceModelCallID
|
||
target.CurrentPlanText = source.CurrentPlanText
|
||
target.CurrentPlans = clonePlanRegistryEntries(source.CurrentPlans)
|
||
target.CurrentTodos = cloneTodoItems(source.CurrentTodos)
|
||
target.LatestRequestPrefix = cloneConversationRequestPrefix(source.LatestRequestPrefix)
|
||
target.LastProviderCall = cloneConversationProviderCall(source.LastProviderCall)
|
||
if !source.CreatedAt.IsZero() && (target.CreatedAt.IsZero() || source.CreatedAt.Before(target.CreatedAt)) {
|
||
target.CreatedAt = source.CreatedAt
|
||
}
|
||
if !source.UpdatedAt.IsZero() && source.UpdatedAt.After(target.UpdatedAt) {
|
||
target.UpdatedAt = source.UpdatedAt
|
||
}
|
||
if source.NextTurnSeq > target.NextTurnSeq {
|
||
target.NextTurnSeq = source.NextTurnSeq
|
||
}
|
||
if source.NextEntrySeq > target.NextEntrySeq {
|
||
target.NextEntrySeq = source.NextEntrySeq
|
||
}
|
||
target.CurrentLoopID = strings.TrimSpace(source.CurrentLoopID)
|
||
target.CurrentLoopStatus = strings.TrimSpace(source.CurrentLoopStatus)
|
||
target.CurrentRequestID = strings.TrimSpace(source.CurrentRequestID)
|
||
target.CurrentTurnSeq = source.CurrentTurnSeq
|
||
}
|
||
|
||
func normalizeLoadedConversation(conversationID string, conversation *ConversationFile) {
|
||
if conversation == nil {
|
||
return
|
||
}
|
||
conversation.SchemaVersion = conversationSchemaVersion
|
||
if strings.TrimSpace(conversation.ConversationID) == "" {
|
||
conversation.ConversationID = conversationID
|
||
}
|
||
if strings.TrimSpace(conversation.RootConversationID) == "" {
|
||
conversation.RootConversationID = conversation.ConversationID
|
||
}
|
||
if conversation.NextTurnSeq <= 0 {
|
||
conversation.NextTurnSeq = 1
|
||
}
|
||
if conversation.NextEntrySeq <= 0 {
|
||
conversation.NextEntrySeq = 1
|
||
}
|
||
if conversation.Entries == nil {
|
||
conversation.Entries = make([]HistoryEntry, 0, 16)
|
||
}
|
||
conversation.AgentTranscriptsFolder = normalizeAgentTranscriptsFolder(conversation.AgentTranscriptsFolder)
|
||
if conversation.AgentTranscriptsFolder == "" {
|
||
conversation.AgentTranscriptsFolder = agentTranscriptsFolderFromEntries(conversation.Entries)
|
||
}
|
||
for _, entry := range conversation.Entries {
|
||
if entry.Seq >= conversation.NextEntrySeq {
|
||
conversation.NextEntrySeq = entry.Seq + 1
|
||
}
|
||
if entry.TurnSeq >= conversation.NextTurnSeq {
|
||
conversation.NextTurnSeq = entry.TurnSeq + 1
|
||
}
|
||
if conversation.CreatedAt.IsZero() || (!entry.CreatedAt.IsZero() && entry.CreatedAt.Before(conversation.CreatedAt)) {
|
||
conversation.CreatedAt = entry.CreatedAt
|
||
}
|
||
if !entry.CreatedAt.IsZero() && entry.CreatedAt.After(conversation.UpdatedAt) {
|
||
conversation.UpdatedAt = entry.CreatedAt
|
||
}
|
||
}
|
||
if conversation.CreatedAt.IsZero() {
|
||
conversation.CreatedAt = time.Now().UTC()
|
||
}
|
||
if conversation.UpdatedAt.IsZero() {
|
||
conversation.UpdatedAt = conversation.CreatedAt
|
||
}
|
||
deriveConversationLoopState(conversation)
|
||
}
|
||
|
||
func validateConversationID(conversationID string) (string, error) {
|
||
normalized := strings.TrimSpace(conversationID)
|
||
if normalized == "" {
|
||
return "", fmt.Errorf("conversation_id is required")
|
||
}
|
||
if strings.Contains(normalized, "/") || strings.Contains(normalized, string(os.PathSeparator)) {
|
||
return "", fmt.Errorf("conversation_id must not contain path separators")
|
||
}
|
||
return normalized, nil
|
||
}
|
||
|
||
func writeJSONFileAtomic(path string, payload any) error {
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
return fmt.Errorf("create parent directory: %w", err)
|
||
}
|
||
data, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return fmt.Errorf("marshal json: %w", err)
|
||
}
|
||
var pretty bytes.Buffer
|
||
if err := json.Indent(&pretty, data, "", " "); err == nil {
|
||
data = pretty.Bytes()
|
||
}
|
||
file, tempPath, err := openUniqueArtifactTempFile(path)
|
||
if err != nil {
|
||
return fmt.Errorf("open temp file: %w", err)
|
||
}
|
||
renamed := false
|
||
defer func() {
|
||
if !renamed {
|
||
_ = os.Remove(tempPath)
|
||
}
|
||
}()
|
||
if _, err := file.Write(append(data, '\n')); err != nil {
|
||
file.Close()
|
||
return fmt.Errorf("write temp file: %w", err)
|
||
}
|
||
if err := file.Close(); err != nil {
|
||
return fmt.Errorf("close temp file: %w", err)
|
||
}
|
||
if err := renameArtifactTempFile(tempPath, path); err != nil {
|
||
return fmt.Errorf("rename temp file: %w", err)
|
||
}
|
||
renamed = true
|
||
return syncDirectory(filepath.Dir(path))
|
||
}
|
||
|
||
func fileExists(path string) (bool, error) {
|
||
_, err := os.Stat(path)
|
||
if err == nil {
|
||
return true, nil
|
||
}
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return false, nil
|
||
}
|
||
return false, err
|
||
}
|
||
|
||
func cloneConversationFile(conversation *ConversationFile) *ConversationFile {
|
||
if conversation == nil {
|
||
return nil
|
||
}
|
||
cloned := *conversation
|
||
cloned.CurrentPlans = clonePlanRegistryEntries(conversation.CurrentPlans)
|
||
cloned.CurrentTodos = cloneTodoItems(conversation.CurrentTodos)
|
||
cloned.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix)
|
||
cloned.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall)
|
||
cloned.Entries = append([]HistoryEntry(nil), conversation.Entries...)
|
||
return &cloned
|
||
}
|
||
|
||
func cloneConversationRequestPrefix(prefix *ConversationRequestPrefix) *ConversationRequestPrefix {
|
||
if prefix == nil {
|
||
return nil
|
||
}
|
||
cloned := *prefix
|
||
return &cloned
|
||
}
|
||
|
||
func cloneConversationProviderCall(call *ConversationProviderCall) *ConversationProviderCall {
|
||
if call == nil {
|
||
return nil
|
||
}
|
||
cloned := *call
|
||
return &cloned
|
||
}
|
||
|
||
func cloneByteSlices(items [][]byte) [][]byte {
|
||
if len(items) == 0 {
|
||
return nil
|
||
}
|
||
cloned := make([][]byte, 0, len(items))
|
||
for _, item := range items {
|
||
cloned = append(cloned, append([]byte(nil), item...))
|
||
}
|
||
return cloned
|
||
}
|
||
|
||
func cloneStringSlice(items []string) []string {
|
||
if len(items) == 0 {
|
||
return nil
|
||
}
|
||
return append([]string(nil), items...)
|
||
}
|
||
|
||
func acquireConversationLock(lockPath string) (func(), error) {
|
||
releaseProcessLock := acquireConversationProcessLock(lockPath)
|
||
releaseFileLock, err := acquireConversationFileLock(lockPath)
|
||
if err != nil {
|
||
releaseProcessLock()
|
||
return nil, err
|
||
}
|
||
return func() {
|
||
releaseFileLock()
|
||
releaseProcessLock()
|
||
}, nil
|
||
}
|
||
|
||
func acquireConversationProcessLock(lockPath string) func() {
|
||
key := filepath.Clean(lockPath)
|
||
conversationProcessLocksMu.Lock()
|
||
lock := conversationProcessLocks[key]
|
||
if lock == nil {
|
||
lock = &conversationProcessLock{}
|
||
conversationProcessLocks[key] = lock
|
||
}
|
||
lock.refs++
|
||
conversationProcessLocksMu.Unlock()
|
||
|
||
lock.mu.Lock()
|
||
return func() {
|
||
lock.mu.Unlock()
|
||
conversationProcessLocksMu.Lock()
|
||
lock.refs--
|
||
if lock.refs <= 0 {
|
||
delete(conversationProcessLocks, key)
|
||
}
|
||
conversationProcessLocksMu.Unlock()
|
||
}
|
||
}
|
||
|
||
func acquireConversationFileLock(lockPath string) (func(), error) {
|
||
deadline := time.Now().Add(conversationLockAcquireTimeout)
|
||
var staleRemoveDeadline time.Time
|
||
var lastStaleRemoveErr error
|
||
for {
|
||
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||
if err == nil {
|
||
owner := conversationLockOwnerToken()
|
||
_, _ = file.WriteString(fmt.Sprintf("pid=%d\nowner=%s\ncreated_at=%s\n", os.Getpid(), owner, time.Now().UTC().Format(time.RFC3339Nano)))
|
||
_ = file.Close()
|
||
return func() {
|
||
removeConversationLockIfOwner(lockPath, owner)
|
||
}, nil
|
||
}
|
||
if !errors.Is(err, os.ErrExist) {
|
||
return nil, fmt.Errorf("create history lock: %w", err)
|
||
}
|
||
if stale, staleErr := conversationLockIsStale(lockPath); staleErr != nil {
|
||
return nil, staleErr
|
||
} else if stale {
|
||
removeErr := os.Remove(lockPath)
|
||
if removeErr == nil || errors.Is(removeErr, os.ErrNotExist) {
|
||
lastStaleRemoveErr = nil
|
||
staleRemoveDeadline = time.Time{}
|
||
continue
|
||
}
|
||
if staleRemoveDeadline.IsZero() {
|
||
staleRemoveDeadline = time.Now().Add(staleConversationLockRemoveTimeout)
|
||
}
|
||
lastStaleRemoveErr = removeErr
|
||
} else {
|
||
lastStaleRemoveErr = nil
|
||
staleRemoveDeadline = time.Time{}
|
||
}
|
||
waitDeadline := deadline
|
||
if lastStaleRemoveErr != nil && staleRemoveDeadline.After(waitDeadline) {
|
||
waitDeadline = staleRemoveDeadline
|
||
}
|
||
remaining := time.Until(waitDeadline)
|
||
if remaining <= 0 {
|
||
break
|
||
}
|
||
if remaining > conversationLockRetryInterval {
|
||
remaining = conversationLockRetryInterval
|
||
}
|
||
time.Sleep(remaining)
|
||
}
|
||
if lastStaleRemoveErr != nil {
|
||
return nil, fmt.Errorf("timeout acquiring history lock %q (stale lock remove failed: %w)", lockPath, lastStaleRemoveErr)
|
||
}
|
||
return nil, fmt.Errorf("timeout acquiring history lock %q", lockPath)
|
||
}
|
||
|
||
func conversationLockIsStale(lockPath string) (bool, error) {
|
||
info, err := os.Stat(lockPath)
|
||
if err != nil {
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return true, nil
|
||
}
|
||
return false, err
|
||
}
|
||
if time.Since(info.ModTime()) > conversationLockStaleAfter {
|
||
return true, nil
|
||
}
|
||
pid := readConversationLockPID(lockPath)
|
||
if pid <= 0 {
|
||
return time.Since(info.ModTime()) > legacyConversationLockStaleAfter, nil
|
||
}
|
||
if pid == os.Getpid() {
|
||
if lockCreatedBeforeCurrentProcess(lockPath, info.ModTime()) {
|
||
return true, nil
|
||
}
|
||
return false, nil
|
||
}
|
||
return !processLooksAlive(pid), nil
|
||
}
|
||
|
||
func lockCreatedBeforeCurrentProcess(lockPath string, modTime time.Time) bool {
|
||
startedAt := conversationLockProcessStartedAt.Add(-time.Second)
|
||
if createdAt := readConversationLockCreatedAt(lockPath); !createdAt.IsZero() {
|
||
return createdAt.Before(startedAt)
|
||
}
|
||
return !modTime.IsZero() && modTime.Before(startedAt)
|
||
}
|
||
|
||
func readConversationLockPID(lockPath string) int {
|
||
pid, _, _ := readConversationLockMetadata(lockPath)
|
||
return pid
|
||
}
|
||
|
||
func readConversationLockCreatedAt(lockPath string) time.Time {
|
||
_, createdAt, _ := readConversationLockMetadata(lockPath)
|
||
return createdAt
|
||
}
|
||
|
||
func readConversationLockOwner(lockPath string) string {
|
||
_, _, owner := readConversationLockMetadata(lockPath)
|
||
return owner
|
||
}
|
||
|
||
func readConversationLockMetadata(lockPath string) (int, time.Time, string) {
|
||
body, err := os.ReadFile(lockPath)
|
||
if err != nil {
|
||
return 0, time.Time{}, ""
|
||
}
|
||
var pid int
|
||
var createdAt time.Time
|
||
var owner string
|
||
for _, line := range strings.Split(string(body), "\n") {
|
||
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||
if !ok {
|
||
continue
|
||
}
|
||
switch strings.TrimSpace(key) {
|
||
case "pid":
|
||
parsedPID, err := strconv.Atoi(strings.TrimSpace(value))
|
||
if err == nil && parsedPID > 0 {
|
||
pid = parsedPID
|
||
}
|
||
case "created_at":
|
||
parsedCreatedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value))
|
||
if err == nil {
|
||
createdAt = parsedCreatedAt
|
||
}
|
||
case "owner":
|
||
owner = strings.TrimSpace(value)
|
||
}
|
||
}
|
||
return pid, createdAt, owner
|
||
}
|
||
|
||
func conversationLockOwnerToken() string {
|
||
return fmt.Sprintf("%d-%d", os.Getpid(), time.Now().UnixNano())
|
||
}
|
||
|
||
func removeConversationLockIfOwner(lockPath string, owner string) {
|
||
if strings.TrimSpace(owner) != "" {
|
||
currentOwner := readConversationLockOwner(lockPath)
|
||
if currentOwner != "" && currentOwner != owner {
|
||
return
|
||
}
|
||
}
|
||
_ = os.Remove(lockPath)
|
||
}
|
||
|
||
func processLooksAlive(pid int) bool {
|
||
if pid <= 0 {
|
||
return false
|
||
}
|
||
if pid == os.Getpid() {
|
||
return true
|
||
}
|
||
return processExists(pid)
|
||
}
|
||
|
||
func syncDirectory(path string) error {
|
||
if runtime.GOOS == "windows" {
|
||
return nil
|
||
}
|
||
dir, err := os.Open(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer dir.Close()
|
||
return dir.Sync()
|
||
}
|