セッションシステム¶
このドキュメントでは、clinvoker のセッション永続化システムを包括的に説明します。保存形式、アトミック書き込み、プロセス間ファイルロック、インメモリのメタデータインデックス、ライフサイクル管理を扱います。
セッション概要¶
セッションにより次が可能になります。
- 継続性: CLI 実行をまたいで会話を再開
- コンテキスト: 作業ディレクトリや状態を維持
- 監査: 使用量とトークン消費を追跡
- 共同作業: チームメンバー間でセッションを共有
- フォーク: 既存セッションからブランチを作成
セッション構造¶
Session 構造体(internal/session/session.go:45-93)は、AI とのやり取りのセッション全体を表します。
type Session struct {
ID string `json:"id"`
Backend string `json:"backend"`
CreatedAt time.Time `json:"created_at"`
LastUsed time.Time `json:"last_used"`
WorkingDir string `json:"working_dir"`
BackendSessionID string `json:"backend_session_id,omitempty"`
Model string `json:"model,omitempty"`
InitialPrompt string `json:"initial_prompt,omitempty"`
Status SessionStatus `json:"status,omitempty"`
TurnCount int `json:"turn_count,omitempty"`
TokenUsage *TokenUsage `json:"token_usage,omitempty"`
Tags []string `json:"tags,omitempty"`
Title string `json:"title,omitempty"`
ParentID string `json:"parent_id,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
セッション ID 生成¶
セッション ID は crypto/rand による 128-bit エントロピーで生成します。
const SessionIDBytes = 16 // 128-bit entropy
func generateID() (string, error) {
bytes := make([]byte, SessionIDBytes)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
この方式の特徴:
- 衝突耐性: 50% の衝突確率に 2^64 回程度の試行が必要
- セキュリティ: 暗号学的乱数で予測困難
- 長さ: 32 文字の hex で扱いやすい
保存形式¶
ファイル構成¶
セッションは ~/.clinvk/sessions/ に保存されます。
~/.clinvk/sessions/
├── a1b2c3d4e5f6789012345678abcdef01.json
├── b2c3d4e5f6g7890123456789abcdef12.json
├── c3d4e5f6g7h8901234567890abcdef23.json
└── index.json
セッションファイル形式¶
各セッションは 0600 権限で JSON ファイルとして保存されます。
{
"id": "a1b2c3d4e5f6789012345678abcdef01",
"backend": "claude",
"created_at": "2025-01-15T10:30:00Z",
"last_used": "2025-01-15T11:45:00Z",
"working_dir": "/home/user/projects/myapp",
"backend_session_id": "claude-sess-abc123",
"model": "claude-sonnet-4",
"initial_prompt": "Refactor auth middleware",
"status": "active",
"turn_count": 5,
"token_usage": {
"input_tokens": 1500,
"output_tokens": 2300,
"cached_tokens": 500
},
"tags": ["refactoring", "auth"],
"title": "Auth Middleware Refactoring"
}
アトミック書き込み機構¶
セッションの書き込みは、データ破損を防ぐためにアトミックなファイル操作で行います。
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, path); err != nil {
_ = os.Remove(path)
if err2 := os.Rename(tmpName, path); err2 != nil {
return err
}
}
cleanup = false
return nil
}
アトミック書き込みの流れ¶
sequenceDiagram
participant Writer as 書き込み側
participant TempFile as 一時ファイル
participant TargetFile as ターゲットファイル
Writer->>TempFile: 同一ディレクトリに一時ファイル作成
Writer->>TempFile: 権限設定(0600)
Writer->>TempFile: データ書き込み
Writer->>TempFile: fsync() でディスクへ同期
Writer->>TempFile: ファイルを閉じる
Writer->>TargetFile: rename()(アトミック)
Note over Writer,TargetFile: 読み手は古い/新しいのどちらかを見て、途中状態は見ない
この方式により次が保証されます。
- 原子性: 読み手は「完全な古い/完全な新しい」しか観測しない
- 永続性: rename 前に fsync でディスクへ同期
- 権限: 生成時点から制限された権限(0600)
プロセス間ファイルロック¶
セッションストアはプロセス間同期のためにファイルロックを使います。
flowchart TB
subgraph FileLock["FileLock(internal/session/filelock.go)"]
INTERFACE[FileLockInterface]
UNIX[Unix 実装
flock syscall]
WINDOWS[Windows 実装
LockFileEx]
end
subgraph Operations["ロック操作"]
LOCK[Lock(排他)]
SHARED[LockShared(共有)]
TRY[TryLock(ノンブロッキング)]
TIMEOUT[LockWithTimeout]
end
INTERFACE --> UNIX
INTERFACE --> WINDOWS
LOCK --> INTERFACE
SHARED --> INTERFACE
TRY --> INTERFACE
TIMEOUT --> INTERFACE
Store でのロック利用¶
func (s *Store) Save(sess *Session) error {
// Acquire cross-process lock for write operation
if err := s.fileLock.Lock(); err != nil {
return fmt.Errorf("failed to acquire store lock: %w", err)
}
defer func() {
_ = s.fileLock.Unlock()
}()
s.mu.Lock()
defer s.mu.Unlock()
if err := s.saveLocked(sess); err != nil {
return err
}
s.updateIndex(sess)
_ = s.persistIndex()
return nil
}
二重ロック戦略¶
セッションストアは 2 レベルのロックを使用します。
- プロセス内: goroutine 安全性のための
sync.RWMutex - プロセス間: CLI/サーバー併用を想定した
FileLock
flowchart TB
subgraph WriteOperation["書き込み操作"]
START[Save 開始]
FLOCK[FileLock 取得
プロセス間]
MLOCK[Mutex 取得
プロセス内]
WRITE[セッション書き込み]
UPDATE[インデックス更新]
MUNLOCK[Mutex 解放]
FUNLOCK[FileLock 解放]
END[終了]
end
START --> FLOCK
FLOCK --> MLOCK
MLOCK --> WRITE
WRITE --> UPDATE
UPDATE --> MUNLOCK
MUNLOCK --> FUNLOCK
FUNLOCK --> END
インメモリのメタデータインデックス¶
すべてのセッションを毎回読み込むことなく高速操作できるよう、ストアは軽量なインメモリインデックスを保持します。
type SessionMeta struct {
ID string
Backend string
Status SessionStatus
LastUsed time.Time
Model string
WorkDir string
Title string
Tags []string
CreatedAt time.Time
}
type Store struct {
mu sync.RWMutex
dir string
index map[string]*SessionMeta // Lightweight metadata cache
dirty bool
fileLock *FileLock
indexModTime time.Time
}
インデックスの利点¶
- 高速な一覧: フル JSON を読み込まずに一覧表示
- 効率的なフィルタ: backend/status/tags でディスク I/O なしに絞り込み
- ページング: offset/limit を全件読み込みなしで対応
- メモリ効率: 必要なときだけフルセッションをロード
インデックスの永続化¶
起動を速くするため、インデックスはディスクへ永続化されます。
func (s *Store) persistIndex() error {
data, err := json.Marshal(&persistedIndex{
Version: 1,
Index: s.index,
})
if err != nil {
return fmt.Errorf("failed to marshal index: %w", err)
}
indexPath := filepath.Join(s.dir, indexFileName)
return writeFileAtomic(indexPath, data, 0600)
}
インデックスの復旧¶
起動時は永続化インデックスの読み込みを試み、失敗した場合はスキャンへフォールバックします。
func (s *Store) rebuildIndex() error {
// Fast path: try to load persisted index
if s.loadPersistedIndex() {
return nil
}
// Slow path: scan session files
entries, err := os.ReadDir(s.dir)
// ... build index from files
// Persist for next startup
_ = s.persistIndex()
return nil
}
セッションのライフサイクル状態¶
stateDiagram-v2
[*] --> Active: 作成
Active --> Active: 継続
Active --> Paused: タイムアウト
Paused --> Active: 再開
Active --> Completed: 完了
Active --> Error: 失敗
Completed --> [*]: クリーンアップ
Error --> [*]: クリーンアップ
状態定義¶
| 状態 | 説明 |
|---|---|
active |
使用中で、再開可能 |
paused |
タイムアウトしたが再開可能 |
completed |
正常終了 |
error |
エラーで終了 |
状態遷移¶
func (s *Session) MarkUsed() {
s.LastUsed = time.Now()
}
func (s *Session) SetStatus(status SessionStatus) {
s.Status = status
}
func (s *Session) SetError(msg string) {
s.Status = StatusError
s.ErrorMessage = msg
}
func (s *Session) Complete() {
s.Status = StatusCompleted
}
並行制御戦略¶
読み取り操作¶
func (s *Store) Get(id string) (*Session, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.getLocked(id)
}
読み取り操作の流れ:
- 読み取りロック取得
- インデックスを確認(高速経路)
- 必要ならセッションファイルをロード
- 読み取りロック解放
書き込み操作¶
func (s *Store) Create(backend, workDir string) (*Session, error) {
// Cross-process lock first
if err := s.fileLock.Lock(); err != nil {
return nil, err
}
defer func() { _ = s.fileLock.Unlock() }()
s.mu.Lock()
defer s.mu.Unlock()
// ... create and save session
}
書き込み操作の流れ:
- プロセス間ファイルロック取得
- プロセス内の書き込みロック取得
- 書き込み実行
- インデックス更新
- インデックス永続化
- ロック解放(逆順)
読み取り時のインデックス再ロード¶
ストアは外部変更を検出し、必要ならインデックスを再ロードします。
func (s *Store) indexModifiedExternally() bool {
if s.indexModTime.IsZero() {
return true
}
indexPath := filepath.Join(s.dir, indexFileName)
info, err := os.Stat(indexPath)
if err != nil {
return true
}
return info.ModTime().After(s.indexModTime)
}
性能面の考慮¶
遅延ロード¶
フルセッションは必要になったときだけ読み込みます。
func (s *Store) ListMeta() ([]*SessionMeta, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if err := s.ensureIndexLoadedForRead(); err != nil {
return nil, err
}
// Return metadata without loading full sessions
metas := make([]*SessionMeta, 0, len(s.index))
for _, meta := range s.index {
metas = append(metas, meta)
}
return metas, nil
}
ページング¶
インデックスを用いて効率的なページングが可能です。
func (s *Store) ListPaginated(filter *ListFilter) (*ListResult, error) {
// Filter using index only
var matchingIDs []string
for id, meta := range s.index {
if s.metaMatchesFilter(meta, filter) {
matchingIDs = append(matchingIDs, id)
}
}
// Apply offset and limit
if filter.Offset > 0 {
matchingIDs = matchingIDs[filter.Offset:]
}
if filter.Limit > 0 && len(matchingIDs) > filter.Limit {
matchingIDs = matchingIDs[:filter.Limit]
}
// Load full sessions only for matches
sessions := make([]*Session, 0, len(matchingIDs))
for _, id := range matchingIDs {
sess, err := s.getLocked(id)
if err != nil {
continue
}
sessions = append(sessions, sess)
}
return &ListResult{
Sessions: sessions,
Total: total,
Limit: filter.Limit,
Offset: filter.Offset,
}, nil
}
フォークとクリーンアップ機構¶
セッションのフォーク¶
セッションはフォークしてブランチを作成できます。
func (s *Session) Fork() (*Session, error) {
newSess, err := NewSessionWithOptions(s.Backend, s.WorkingDir, &SessionOptions{
Model: s.Model,
ParentID: s.ID,
Tags: append([]string{}, s.Tags...),
})
if err != nil {
return nil, err
}
// Copy metadata
for k, v := range s.Metadata {
newSess.SetMetadata(k, v)
}
return newSess, nil
}
クリーンアップ¶
古いセッションは経過時間に基づいて削除できます。
func (s *Store) Clean(maxAge time.Duration) (int, error) {
if err := s.fileLock.Lock(); err != nil {
return 0, err
}
defer func() { _ = s.fileLock.Unlock() }()
s.mu.Lock()
defer s.mu.Unlock()
cutoff := time.Now().Add(-maxAge)
var deleted int
for id, meta := range s.index {
if meta.LastUsed.Before(cutoff) {
if err := s.deleteLocked(id); err == nil {
s.removeFromIndex(id)
deleted++
}
}
}
if deleted > 0 {
_ = s.persistIndex()
}
return deleted, nil
}
セキュリティ考慮¶
ファイル権限¶
セッションファイルは 0600(所有者のみ読み書き)で作成されます。
// Use 0600 to protect potentially sensitive prompt data
if err := writeFileAtomic(path, data, 0600); err != nil {
return fmt.Errorf("failed to write session file: %w", err)
}
ディレクトリ権限¶
セッションディレクトリは 0700 です。
func (s *Store) ensureStoreDirLocked() error {
// Use 0700 for security - only owner can access session data
return os.MkdirAll(s.dir, 0700)
}
パストラバーサル防止¶
セッション ID を検証して、パストラバーサルを防ぎます。
func validateSessionID(id string) error {
if id == "" {
return fmt.Errorf("session ID cannot be empty")
}
// Check for path traversal attempts
if strings.Contains(id, "/") || strings.Contains(id, "\\") || strings.Contains(id, "..") {
return fmt.Errorf("invalid session ID: contains path characters")
}
// Validate format
if (len(id) == 16 || len(id) == 32) && !sessionIDPattern.MatchString(id) {
return fmt.Errorf("invalid session ID format")
}
return nil
}
関連ドキュメント¶
- アーキテクチャ概要 - システム全体像
- バックエンドシステム - バックエンド抽象化レイヤ
- API 設計 - REST API アーキテクチャ