| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "sort" |
| 9 | "strings" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/fileutil" |
| 13 | fileencoding "reasonix/internal/fileutil/encoding" |
| 14 | "reasonix/internal/store" |
| 15 | ) |
| 16 | |
| 17 | // BranchMeta is the small sidecar record that turns flat session files into a |
| 18 | // navigable conversation tree. The conversation itself remains in the .jsonl |
| 19 | // file; metadata lives beside it at <session>.meta. |
| 20 | type BranchMeta struct { |
| 21 | ID string `json:"id"` |
| 22 | Name string `json:"name,omitempty"` |
| 23 | ParentID string `json:"parent_id,omitempty"` |
| 24 | ForkTurn int `json:"fork_turn,omitempty"` |
| 25 | ForkMessageIndex int `json:"fork_message_index,omitempty"` |
| 26 | CreatedAt time.Time `json:"created_at"` |
| 27 | UpdatedAt time.Time `json:"updated_at"` |
| 28 | Scope string `json:"scope,omitempty"` |
| 29 | WorkspaceRoot string `json:"workspace_root,omitempty"` |
| 30 | TopicID string `json:"topic_id,omitempty"` |
| 31 | TopicTitle string `json:"topic_title,omitempty"` |
| 32 | CustomTitle string `json:"custom_title,omitempty"` |
| 33 | Model string `json:"model,omitempty"` |
| 34 | TokenMode string `json:"token_mode,omitempty"` |
| 35 | Mode string `json:"mode,omitempty"` |
| 36 | ToolApprovalMode string `json:"tool_approval_mode,omitempty"` |
| 37 | Goal string `json:"goal,omitempty"` |
| 38 | Recovered bool `json:"recovered,omitempty"` |
| 39 | RecoveryReason string `json:"recovery_reason,omitempty"` |
| 40 | RecoveryDigest string `json:"recovery_digest,omitempty"` |
| 41 | // RecoveryDepth counts how many recovery forks separate this branch from a |
| 42 | // normal session (1 = forked from a normal session). SaveRecoveryBranch |
| 43 | // refuses to fork past SessionRecoveryMaxDepth so a conflict loop cannot |
| 44 | // spawn unbounded nested recovery chains (#5993 reached 8 levels). Legacy |
| 45 | // recovery metas without the field are treated as depth 1. |
| 46 | RecoveryDepth int `json:"recovery_depth,omitempty"` |
| 47 | Revision int64 `json:"revision,omitempty"` |
| 48 | ContentDigest string `json:"content_digest,omitempty"` |
| 49 | WriterID string `json:"writer_id,omitempty"` |
| 50 | // SchemaVersion records the BranchMeta version that last wrote the listing |
| 51 | // fields (Turns/Preview) FROM the session's content. It is stamped only by the |
| 52 | // writers that actually derive those counts — Controller.snapshot's |
| 53 | // UpdateSessionMeta and Fork/Branch — never by EnsureBranchMeta / TouchBranchMeta |
| 54 | // / rename / set-model, which don't know the turn count. So ListSessions can |
| 55 | // tell a meta whose counts are authoritative (>= BranchMetaCountsVersion: trust |
| 56 | // Turns even when 0 = genuinely empty) from a legacy/contentless one |
| 57 | // (< version: decode once, then backfill + stamp). |
| 58 | SchemaVersion int `json:"schema_version,omitempty"` |
| 59 | // Turns and Preview are listing-only fields the desktop sidebar and CLI |
| 60 | // pickers show ("5 turns · 'help me debug…'") without decoding the whole |
| 61 | // .jsonl. The autosave path (Controller.snapshot) keeps them fresh from the |
| 62 | // in-memory conversation, so ListSessions stays O(1) per session instead of |
| 63 | // O(file size). Gated by SchemaVersion (above), not Turns == 0, so a |
| 64 | // genuinely-empty session is recorded once and never re-decoded. |
| 65 | Turns int `json:"turns,omitempty"` |
| 66 | Preview string `json:"preview,omitempty"` |
| 67 | InFlightTurn *InFlightTurnMeta `json:"in_flight_turn,omitempty"` |
| 68 | } |
| 69 | |
| 70 | // BranchMetaCountsVersion is stamped into BranchMeta.SchemaVersion whenever a |
| 71 | // writer records Turns/Preview from session content (UpdateSessionMeta, |
| 72 | // Fork/Branch). Bump it when the meaning of those listing fields changes so |
| 73 | // existing listings re-derive them instead of trusting a stale cache. |
| 74 | const BranchMetaCountsVersion = 1 |
| 75 | |
| 76 | // InFlightTurnMeta records the message-log boundary for a foreground turn that |
| 77 | // has started but not yet reached TurnDone. If the process exits mid-turn, a |
| 78 | // later resume can strip the partial assistant/tool tail without guessing. |
| 79 | type InFlightTurnMeta struct { |
| 80 | StartMessageIndex int `json:"start_message_index"` |
| 81 | PreserveUser bool `json:"preserve_user"` |
| 82 | StartedAt time.Time `json:"started_at"` |
| 83 | } |
| 84 | |
| 85 | func (m BranchMeta) DefaultScope() string { |
| 86 | switch m.Scope { |
| 87 | case "project": |
| 88 | return "project" |
| 89 | default: |
| 90 | return "global" |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // BranchInfo combines sidecar metadata with the session file details needed for |
| 95 | // pickers and tree rendering. |
| 96 | type BranchInfo struct { |
| 97 | BranchMeta |
| 98 | Path string |
| 99 | ModTime time.Time |
| 100 | Preview string |
| 101 | Turns int |
| 102 | } |
| 103 | |
| 104 | func BranchID(path string) string { |
| 105 | if path == "" { |
| 106 | return "" |
| 107 | } |
| 108 | base := filepath.Base(path) |
| 109 | if ext := filepath.Ext(base); ext != "" { |
| 110 | base = strings.TrimSuffix(base, ext) |
| 111 | } |
| 112 | return base |
| 113 | } |
| 114 | |
| 115 | func BranchMetaPath(sessionPath string) string { |
| 116 | return store.SessionMeta(sessionPath) |
| 117 | } |
| 118 | |
| 119 | func LoadBranchMeta(sessionPath string) (BranchMeta, bool, error) { |
| 120 | metaPath := BranchMetaPath(sessionPath) |
| 121 | if metaPath == "" { |
| 122 | return BranchMeta{}, false, nil |
| 123 | } |
| 124 | b, err := fileencoding.ReadFileUTF8(metaPath) |
| 125 | if err != nil { |
| 126 | if os.IsNotExist(err) { |
| 127 | return BranchMeta{}, false, nil |
| 128 | } |
| 129 | return BranchMeta{}, false, err |
| 130 | } |
| 131 | var m BranchMeta |
| 132 | if err := json.Unmarshal(b, &m); err != nil { |
| 133 | return BranchMeta{}, false, fmt.Errorf("decode branch meta %s: %w", metaPath, err) |
| 134 | } |
| 135 | if m.ID == "" { |
| 136 | m.ID = BranchID(sessionPath) |
| 137 | } |
| 138 | m.sanitizeDisplayFields() |
| 139 | return m, true, nil |
| 140 | } |
| 141 | |
| 142 | // sanitizeDisplayFields cleans persisted display strings that older builds |
| 143 | // polluted with internal wrappers (memory-compiler execution contracts, |
| 144 | // transient blocks) — #5666. Every reader goes through LoadBranchMeta, so this |
| 145 | // is the single boundary; UserPreviewText is a no-op on clean text, and a |
| 146 | // field that was pure wrapper falls back to empty so callers use their normal |
| 147 | // fallbacks (preview, default title). |
| 148 | func (m *BranchMeta) sanitizeDisplayFields() { |
| 149 | m.TopicTitle = sanitizeStoredDisplayText(m.TopicTitle) |
| 150 | m.CustomTitle = sanitizeStoredDisplayText(m.CustomTitle) |
| 151 | m.Preview = sanitizeStoredDisplayText(m.Preview) |
| 152 | } |
| 153 | |
| 154 | func sanitizeStoredDisplayText(s string) string { |
| 155 | if strings.TrimSpace(s) == "" { |
| 156 | return strings.TrimSpace(s) |
| 157 | } |
| 158 | return UserPreviewText(s) |
| 159 | } |
| 160 | |
| 161 | // branchMetaReadBackoffs paces the re-reads of a branch-meta sidecar that |
| 162 | // failed to load. On Windows fileutil.ReplaceFile can fall back to a |
| 163 | // non-atomic in-place copy, so a concurrent reader may catch the sidecar |
| 164 | // half-written (an open/read error or truncated JSON). Those tears heal in |
| 165 | // milliseconds; a few short retries separate them from real corruption. |
| 166 | var branchMetaReadBackoffs = []time.Duration{20 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond} |
| 167 | |
| 168 | // loadBranchMetaRetry reads the branch-meta sidecar like LoadBranchMeta but |
| 169 | // retries transient failures (I/O errors and undecodable JSON) before giving |
| 170 | // up. A missing sidecar is a legitimate state — a session that has never |
| 171 | // recorded meta — and returns ok=false immediately without retrying. |
| 172 | func loadBranchMetaRetry(sessionPath string) (BranchMeta, bool, error) { |
| 173 | var lastErr error |
| 174 | for attempt := 0; ; attempt++ { |
| 175 | meta, ok, err := LoadBranchMeta(sessionPath) |
| 176 | if err == nil { |
| 177 | return meta, ok, nil |
| 178 | } |
| 179 | lastErr = err |
| 180 | if attempt >= len(branchMetaReadBackoffs) { |
| 181 | return BranchMeta{}, false, lastErr |
| 182 | } |
| 183 | time.Sleep(branchMetaReadBackoffs[attempt]) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func SaveBranchMeta(sessionPath string, m BranchMeta) error { |
| 188 | return saveBranchMeta(sessionPath, m, true) |
| 189 | } |
| 190 | |
| 191 | func SaveBranchMetaPreserveUpdated(sessionPath string, m BranchMeta) error { |
| 192 | return saveBranchMeta(sessionPath, m, false) |
| 193 | } |
| 194 | |
| 195 | func saveBranchMeta(sessionPath string, m BranchMeta, touchUpdated bool) error { |
| 196 | metaPath := BranchMetaPath(sessionPath) |
| 197 | if metaPath == "" { |
| 198 | return fmt.Errorf("empty session path") |
| 199 | } |
| 200 | now := time.Now().UTC() |
| 201 | if m.ID == "" { |
| 202 | m.ID = BranchID(sessionPath) |
| 203 | } |
| 204 | if m.CreatedAt.IsZero() { |
| 205 | m.CreatedAt = now |
| 206 | } |
| 207 | if touchUpdated || m.UpdatedAt.IsZero() { |
| 208 | m.UpdatedAt = now |
| 209 | } |
| 210 | if existing, ok, err := LoadBranchMeta(sessionPath); err == nil && ok { |
| 211 | preserveBranchMetaPersistence(&m, existing) |
| 212 | } |
| 213 | if err := os.MkdirAll(filepath.Dir(metaPath), 0o755); err != nil { |
| 214 | return err |
| 215 | } |
| 216 | b, err := json.MarshalIndent(m, "", " ") |
| 217 | if err != nil { |
| 218 | return err |
| 219 | } |
| 220 | b = append(b, '\n') |
| 221 | tmp, err := os.CreateTemp(filepath.Dir(metaPath), ".branch.*.tmp") |
| 222 | if err != nil { |
| 223 | return err |
| 224 | } |
| 225 | tmpPath := tmp.Name() |
| 226 | if _, err := tmp.Write(b); err != nil { |
| 227 | tmp.Close() |
| 228 | os.Remove(tmpPath) |
| 229 | return err |
| 230 | } |
| 231 | if err := tmp.Close(); err != nil { |
| 232 | os.Remove(tmpPath) |
| 233 | return err |
| 234 | } |
| 235 | if err := fileutil.ReplaceFile(tmpPath, metaPath); err != nil { |
| 236 | os.Remove(tmpPath) |
| 237 | return err |
| 238 | } |
| 239 | return nil |
| 240 | } |
| 241 | |
| 242 | func preserveBranchMetaPersistence(next *BranchMeta, existing BranchMeta) { |
| 243 | if next == nil { |
| 244 | return |
| 245 | } |
| 246 | if existing.Revision > next.Revision { |
| 247 | next.Revision = existing.Revision |
| 248 | next.ContentDigest = existing.ContentDigest |
| 249 | next.WriterID = existing.WriterID |
| 250 | return |
| 251 | } |
| 252 | if existing.Revision == next.Revision { |
| 253 | if strings.TrimSpace(next.ContentDigest) == "" { |
| 254 | next.ContentDigest = existing.ContentDigest |
| 255 | } |
| 256 | if strings.TrimSpace(next.WriterID) == "" { |
| 257 | next.WriterID = existing.WriterID |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | func EnsureBranchMeta(sessionPath string) (BranchMeta, error) { |
| 263 | if sessionPath == "" { |
| 264 | return BranchMeta{}, fmt.Errorf("empty session path") |
| 265 | } |
| 266 | if m, ok, err := LoadBranchMeta(sessionPath); err != nil || ok { |
| 267 | return m, err |
| 268 | } |
| 269 | when := time.Now().UTC() |
| 270 | if info, err := os.Stat(sessionPath); err == nil { |
| 271 | when = info.ModTime().UTC() |
| 272 | } |
| 273 | m := BranchMeta{ |
| 274 | ID: BranchID(sessionPath), |
| 275 | CreatedAt: when, |
| 276 | UpdatedAt: when, |
| 277 | } |
| 278 | return m, saveBranchMeta(sessionPath, m, false) |
| 279 | } |
| 280 | |
| 281 | func TouchBranchMeta(sessionPath string) error { |
| 282 | unlock := lockSessionSavePath(sessionPath) |
| 283 | defer unlock() |
| 284 | m, err := EnsureBranchMeta(sessionPath) |
| 285 | if err != nil { |
| 286 | return err |
| 287 | } |
| 288 | m.UpdatedAt = time.Now().UTC() |
| 289 | return saveBranchMeta(sessionPath, m, false) |
| 290 | } |
| 291 | |
| 292 | func MarkSessionInFlightTurn(sessionPath string, startMessageIndex int, preserveUser bool) error { |
| 293 | return SetSessionInFlightTurn(sessionPath, InFlightTurnMeta{ |
| 294 | StartMessageIndex: startMessageIndex, |
| 295 | PreserveUser: preserveUser, |
| 296 | StartedAt: time.Now().UTC(), |
| 297 | }) |
| 298 | } |
| 299 | |
| 300 | // SetSessionInFlightTurn writes an existing in-flight marker verbatim. It is |
| 301 | // used when a running turn moves to a recovery branch: preserving StartedAt is |
| 302 | // what lets crash recovery relocate the turn after an in-turn compaction has |
| 303 | // rewritten its original message index. |
| 304 | func SetSessionInFlightTurn(sessionPath string, marker InFlightTurnMeta) error { |
| 305 | startMessageIndex := marker.StartMessageIndex |
| 306 | if startMessageIndex < 0 { |
| 307 | startMessageIndex = 0 |
| 308 | } |
| 309 | // The sidecar is read-modify-write; the per-path save lock keeps concurrent |
| 310 | // writers (autosave's UpdateSessionMeta, listing backfill) from dropping |
| 311 | // each other's fields. |
| 312 | unlock := lockSessionSavePath(sessionPath) |
| 313 | defer unlock() |
| 314 | m, err := EnsureBranchMeta(sessionPath) |
| 315 | if err != nil { |
| 316 | return err |
| 317 | } |
| 318 | marker.StartMessageIndex = startMessageIndex |
| 319 | if marker.StartedAt.IsZero() { |
| 320 | marker.StartedAt = time.Now().UTC() |
| 321 | } |
| 322 | m.InFlightTurn = &marker |
| 323 | return SaveBranchMetaPreserveUpdated(sessionPath, m) |
| 324 | } |
| 325 | |
| 326 | func ClearSessionInFlightTurn(sessionPath string) error { |
| 327 | unlock := lockSessionSavePath(sessionPath) |
| 328 | defer unlock() |
| 329 | m, ok, err := LoadBranchMeta(sessionPath) |
| 330 | if err != nil || !ok { |
| 331 | return err |
| 332 | } |
| 333 | if m.InFlightTurn == nil { |
| 334 | return nil |
| 335 | } |
| 336 | m.InFlightTurn = nil |
| 337 | return SaveBranchMetaPreserveUpdated(sessionPath, m) |
| 338 | } |
| 339 | |
| 340 | func ListBranches(dir string) ([]BranchInfo, error) { |
| 341 | entries, err := os.ReadDir(dir) |
| 342 | if err != nil { |
| 343 | if os.IsNotExist(err) { |
| 344 | return nil, nil |
| 345 | } |
| 346 | return nil, err |
| 347 | } |
| 348 | var out []BranchInfo |
| 349 | for _, e := range entries { |
| 350 | if e.IsDir() || !store.IsSessionTranscriptName(e.Name()) { |
| 351 | continue |
| 352 | } |
| 353 | info, err := e.Info() |
| 354 | if err != nil { |
| 355 | continue |
| 356 | } |
| 357 | path := filepath.Join(dir, e.Name()) |
| 358 | if !IsVisibleSession(path) { |
| 359 | continue |
| 360 | } |
| 361 | preview, turns := previewSession(path) |
| 362 | if turns == 0 { |
| 363 | continue |
| 364 | } |
| 365 | meta, ok, err := LoadBranchMeta(path) |
| 366 | if err != nil { |
| 367 | continue |
| 368 | } |
| 369 | if !ok { |
| 370 | meta = BranchMeta{ |
| 371 | ID: BranchID(path), |
| 372 | CreatedAt: info.ModTime().UTC(), |
| 373 | UpdatedAt: info.ModTime().UTC(), |
| 374 | } |
| 375 | } |
| 376 | if meta.ID == "" { |
| 377 | meta.ID = BranchID(path) |
| 378 | } |
| 379 | out = append(out, BranchInfo{ |
| 380 | BranchMeta: meta, |
| 381 | Path: path, |
| 382 | ModTime: info.ModTime(), |
| 383 | Preview: preview, |
| 384 | Turns: turns, |
| 385 | }) |
| 386 | } |
| 387 | sort.Slice(out, func(i, j int) bool { |
| 388 | if out[i].CreatedAt.Equal(out[j].CreatedAt) { |
| 389 | return out[i].ID < out[j].ID |
| 390 | } |
| 391 | return out[i].CreatedAt.Before(out[j].CreatedAt) |
| 392 | }) |
| 393 | return out, nil |
| 394 | } |
| 395 | |
| 396 | // RenameSession updates the user-chosen display title in the session's |
| 397 | // .jsonl.meta sidecar file. If no meta file exists yet, one is created. The |
| 398 | // topic title remains a separate grouping label, so explicit session names do |
| 399 | // not fight topic auto-titling. |
| 400 | func RenameSession(sessionPath string, title string) error { |
| 401 | if sessionPath == "" { |
| 402 | return fmt.Errorf("empty session path") |
| 403 | } |
| 404 | // Read-modify-write on the sidecar: hold the per-path meta lock so a |
| 405 | // concurrent save (recordSessionContentRevision) can't have its Revision |
| 406 | // bump clobbered by a stale read-back here. |
| 407 | unlock := lockSessionSavePath(sessionPath) |
| 408 | defer unlock() |
| 409 | m, err := EnsureBranchMeta(sessionPath) |
| 410 | if err != nil { |
| 411 | return err |
| 412 | } |
| 413 | m.CustomTitle = strings.TrimSpace(title) |
| 414 | return SaveBranchMetaPreserveUpdated(sessionPath, m) |
| 415 | } |
| 416 | |
| 417 | // LoadSessionModel reads the canonical provider/model ref saved beside a |
| 418 | // session transcript. |
| 419 | func LoadSessionModel(sessionPath string) (string, bool) { |
| 420 | meta, ok, err := LoadBranchMeta(sessionPath) |
| 421 | if err != nil || !ok { |
| 422 | return "", false |
| 423 | } |
| 424 | model := strings.TrimSpace(meta.Model) |
| 425 | if model == "" { |
| 426 | return "", false |
| 427 | } |
| 428 | return model, true |
| 429 | } |
| 430 | |
| 431 | // SetBranchModelPreserveUpdated stores the canonical provider/model ref without |
| 432 | // changing the session activity timestamp. |
| 433 | func SetBranchModelPreserveUpdated(sessionPath, model string) error { |
| 434 | if sessionPath == "" { |
| 435 | return fmt.Errorf("empty session path") |
| 436 | } |
| 437 | unlock := lockSessionSavePath(sessionPath) |
| 438 | defer unlock() |
| 439 | meta, err := EnsureBranchMeta(sessionPath) |
| 440 | if err != nil { |
| 441 | return err |
| 442 | } |
| 443 | meta.Model = strings.TrimSpace(model) |
| 444 | return SaveBranchMetaPreserveUpdated(sessionPath, meta) |
| 445 | } |
| 446 | |
| 447 | // UpdateSessionMeta refreshes the listing-only sidecar fields (model, preview, |
| 448 | // user-turn count) the sidebar and pickers read without decoding the .jsonl. |
| 449 | // markActivity bumps UpdatedAt (the autosave path passes true on a real turn); |
| 450 | // false preserves it (used to backfill legacy sessions during a read). An empty |
| 451 | // model leaves the stored model untouched. |
| 452 | func UpdateSessionMeta(sessionPath, model, preview string, turns int, markActivity bool) error { |
| 453 | if sessionPath == "" { |
| 454 | return fmt.Errorf("empty session path") |
| 455 | } |
| 456 | unlock := lockSessionSavePath(sessionPath) |
| 457 | defer unlock() |
| 458 | m, err := EnsureBranchMeta(sessionPath) |
| 459 | if err != nil { |
| 460 | return err |
| 461 | } |
| 462 | if strings.TrimSpace(model) != "" { |
| 463 | m.Model = strings.TrimSpace(model) |
| 464 | } |
| 465 | m.Preview = preview |
| 466 | m.Turns = turns |
| 467 | // These counts were derived from the current content, so mark them |
| 468 | // authoritative — listing can then trust Turns (even 0) without re-decoding. |
| 469 | m.SchemaVersion = BranchMetaCountsVersion |
| 470 | return saveBranchMeta(sessionPath, m, markActivity) |
| 471 | } |
| 472 |