core: drop empty session files instead of writing meta-only stubs
every zot launch was creating a session file with just a meta line, even when the user exited without prompting. /sessions and ls -la ended up showing dozens of empty entries. now Session tracks messagesAppended and freshFile (true for NewSession, false for OpenSession). Close() removes the file when both conditions hold: this process created it AND no messages were ever appended. resumed sessions are never auto-deleted even if the resume run added nothing, since the prior content is real. PruneEmptySessions sweeps existing meta-only stubs from the cwd's session dir on each interactive launch. cheap (only reads enough of each file to find a 'message' line) and fixes the existing backlog automatically the first time you reopen zot in a project.
- commit
- fdef8ac614c87822cb7fa75dd75c5316fe245a7d
- author
- patriceckhart · · 2026-04-19 09:38 UTC
- parent
- 0a69274
- signature
- unsigned
- refs
- tag: v0.0.11
2 files · +88 -11
M internal/agent/cli.go +4 -0
| @@ -265,6 +265,10 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||
| 265 | 265 | if args.NoSess { | |
| 266 | 266 | return nil, nil | |
| 267 | 267 | } | |
| 268 | + | // Sweep meta-only files left over from older zot versions (and from | |
| 269 | + | // any session that crashed before its first AppendMessage). Cheap; | |
| 270 | + | // reads the first few bytes of each file in the cwd's session dir. | |
| 271 | + | core.PruneEmptySessions(ZotHome(), args.CWD) | |
| 268 | 272 | var ( | |
| 269 | 273 | s *core.Session | |
| 270 | 274 | msgs []provider.Message | |
| @@ -265,6 +265,10 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||||
| 265 | if args.NoSess { | 265 | if args.NoSess { | ||
| 266 | return nil, nil | 266 | return nil, nil | ||
| 267 | } | 267 | } | ||
| 268 | + | // Sweep meta-only files left over from older zot versions (and from | |||
| 269 | + | // any session that crashed before its first AppendMessage). Cheap; | |||
| 270 | + | // reads the first few bytes of each file in the cwd's session dir. | |||
| 271 | + | core.PruneEmptySessions(ZotHome(), args.CWD) | |||
| 268 | var ( | 272 | var ( | ||
| 269 | s *core.Session | 273 | s *core.Session | ||
| 270 | msgs []provider.Message | 274 | msgs []provider.Message | ||
M internal/core/session.go +84 -11
| @@ -23,6 +23,18 @@ type Session struct { | |||
| 23 | 23 | Meta SessionMeta | |
| 24 | 24 | writer *os.File | |
| 25 | 25 | buf *bufio.Writer | |
| 26 | + | ||
| 27 | + | // freshFile is true when the file was created by NewSession (this | |
| 28 | + | // process owns it) and false when OpenSession reopened an existing | |
| 29 | + | // transcript. Used by Close() to delete the file if the run never | |
| 30 | + | // appended any messages — prevents a flood of empty session files | |
| 31 | + | // from sessions the user opens then exits without prompting. | |
| 32 | + | freshFile bool | |
| 33 | + | ||
| 34 | + | // messagesAppended counts AppendMessage calls. Combined with | |
| 35 | + | // freshFile it tells Close() whether the session left any content | |
| 36 | + | // worth keeping. | |
| 37 | + | messagesAppended int | |
| 26 | 38 | } | |
| 27 | 39 | ||
| 28 | 40 | // SessionMeta is written as the first line of every session file. | |
| @@ -72,11 +84,12 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error | |||
| 72 | 84 | return nil, err | |
| 73 | 85 | } | |
| 74 | 86 | s := &Session{ | |
| 75 | - | ID: id, | |
| 76 | - | Path: p, | |
| 77 | - | Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version}, | |
| 78 | - | writer: f, | |
| 79 | - | buf: bufio.NewWriter(f), | |
| 87 | + | ID: id, | |
| 88 | + | Path: p, | |
| 89 | + | Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version}, | |
| 90 | + | writer: f, | |
| 91 | + | buf: bufio.NewWriter(f), | |
| 92 | + | freshFile: true, | |
| 80 | 93 | } | |
| 81 | 94 | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | |
| 82 | 95 | f.Close() | |
| @@ -223,6 +236,51 @@ func firstUserText(line []byte) string { | |||
| 223 | 236 | return "" | |
| 224 | 237 | } | |
| 225 | 238 | ||
| 239 | + | // PruneEmptySessions deletes session files in cwd's session directory | |
| 240 | + | // that contain only a meta line (no messages were ever appended). | |
| 241 | + | // Cleans up the backlog of empty stubs created by old zot versions | |
| 242 | + | // that wrote a meta line at NewSession time and never followed up. | |
| 243 | + | // Errors are swallowed; the caller treats this as best-effort. | |
| 244 | + | func PruneEmptySessions(root, cwd string) { | |
| 245 | + | dir := SessionsDir(root, cwd) | |
| 246 | + | entries, err := os.ReadDir(dir) | |
| 247 | + | if err != nil { | |
| 248 | + | return | |
| 249 | + | } | |
| 250 | + | for _, e := range entries { | |
| 251 | + | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { | |
| 252 | + | continue | |
| 253 | + | } | |
| 254 | + | p := filepath.Join(dir, e.Name()) | |
| 255 | + | if sessionHasNoMessages(p) { | |
| 256 | + | _ = os.Remove(p) | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // sessionHasNoMessages returns true when the file at path contains | |
| 262 | + | // no lines of type "message". Meta-only / usage-only files count as | |
| 263 | + | // empty. Used by PruneEmptySessions and the Describe path. | |
| 264 | + | func sessionHasNoMessages(path string) bool { | |
| 265 | + | f, err := os.Open(path) | |
| 266 | + | if err != nil { | |
| 267 | + | return false | |
| 268 | + | } | |
| 269 | + | defer f.Close() | |
| 270 | + | sc := bufio.NewScanner(f) | |
| 271 | + | sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024) | |
| 272 | + | for sc.Scan() { | |
| 273 | + | var head sessionLineHead | |
| 274 | + | if err := json.Unmarshal(sc.Bytes(), &head); err != nil { | |
| 275 | + | continue | |
| 276 | + | } | |
| 277 | + | if head.Type == "message" { | |
| 278 | + | return false | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | return true | |
| 282 | + | } | |
| 283 | + | ||
| 226 | 284 | // ListSessions returns session file paths for cwd, newest first. | |
| 227 | 285 | func ListSessions(root, cwd string) []string { | |
| 228 | 286 | dir := SessionsDir(root, cwd) | |
| @@ -245,7 +303,11 @@ func (s *Session) AppendMessage(m provider.Message) error { | |||
| 245 | 303 | if s == nil { | |
| 246 | 304 | return nil | |
| 247 | 305 | } | |
| 248 | - | return s.writeLine(sessionLine{Type: "message", Message: &m}) | |
| 306 | + | if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil { | |
| 307 | + | return err | |
| 308 | + | } | |
| 309 | + | s.messagesAppended++ | |
| 310 | + | return nil | |
| 249 | 311 | } | |
| 250 | 312 | ||
| 251 | 313 | // UpdateModel records a provider/model switch in the session file. | |
| @@ -268,16 +330,27 @@ func (s *Session) AppendUsage(u, cum provider.Usage) error { | |||
| 268 | 330 | return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum}) | |
| 269 | 331 | } | |
| 270 | 332 | ||
| 271 | - | // Close flushes and closes the session file. | |
| 333 | + | // Close flushes and closes the session file. If the session was | |
| 334 | + | // freshly created in this process and never had any messages | |
| 335 | + | // appended (the user opened zot, looked around, and exited without | |
| 336 | + | // prompting), the file is deleted on close so the sessions list | |
| 337 | + | // doesn't fill up with empty meta-only stubs. | |
| 272 | 338 | func (s *Session) Close() error { | |
| 273 | 339 | if s == nil { | |
| 274 | 340 | return nil | |
| 275 | 341 | } | |
| 276 | - | if err := s.buf.Flush(); err != nil { | |
| 277 | - | s.writer.Close() | |
| 278 | - | return err | |
| 342 | + | flushErr := s.buf.Flush() | |
| 343 | + | closeErr := s.writer.Close() | |
| 344 | + | if s.freshFile && s.messagesAppended == 0 { | |
| 345 | + | // Best-effort cleanup. We deliberately don't propagate the | |
| 346 | + | // remove error: if it fails (file already gone, perms changed) | |
| 347 | + | // the worst case is one stale empty file in the listing. | |
| 348 | + | _ = os.Remove(s.Path) | |
| 349 | + | } | |
| 350 | + | if flushErr != nil { | |
| 351 | + | return flushErr | |
| 279 | 352 | } | |
| 280 | - | return s.writer.Close() | |
| 353 | + | return closeErr | |
| 281 | 354 | } | |
| 282 | 355 | ||
| 283 | 356 | func (s *Session) writeLine(row sessionLine) error { | |
| @@ -23,6 +23,18 @@ type Session struct { | |||||
| 23 | Meta SessionMeta | 23 | Meta SessionMeta | ||
| 24 | writer *os.File | 24 | writer *os.File | ||
| 25 | buf *bufio.Writer | 25 | buf *bufio.Writer | ||
| 26 | + | ||||
| 27 | + | // freshFile is true when the file was created by NewSession (this | |||
| 28 | + | // process owns it) and false when OpenSession reopened an existing | |||
| 29 | + | // transcript. Used by Close() to delete the file if the run never | |||
| 30 | + | // appended any messages — prevents a flood of empty session files | |||
| 31 | + | // from sessions the user opens then exits without prompting. | |||
| 32 | + | freshFile bool | |||
| 33 | + | ||||
| 34 | + | // messagesAppended counts AppendMessage calls. Combined with | |||
| 35 | + | // freshFile it tells Close() whether the session left any content | |||
| 36 | + | // worth keeping. | |||
| 37 | + | messagesAppended int | |||
| 26 | } | 38 | } | ||
| 27 | 39 | ||||
| 28 | // SessionMeta is written as the first line of every session file. | 40 | // SessionMeta is written as the first line of every session file. | ||
| @@ -72,11 +84,12 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error | |||||
| 72 | return nil, err | 84 | return nil, err | ||
| 73 | } | 85 | } | ||
| 74 | s := &Session{ | 86 | s := &Session{ | ||
| 75 | - | ID: id, | 87 | + | ID: id, |
| 76 | - | Path: p, | 88 | + | Path: p, |
| 77 | - | Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version}, | 89 | + | Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version}, |
| 78 | - | writer: f, | 90 | + | writer: f, |
| 79 | - | buf: bufio.NewWriter(f), | 91 | + | buf: bufio.NewWriter(f), |
| 92 | + | freshFile: true, | |||
| 80 | } | 93 | } | ||
| 81 | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | 94 | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | ||
| 82 | f.Close() | 95 | f.Close() | ||
| @@ -223,6 +236,51 @@ func firstUserText(line []byte) string { | |||||
| 223 | return "" | 236 | return "" | ||
| 224 | } | 237 | } | ||
| 225 | 238 | ||||
| 239 | + | // PruneEmptySessions deletes session files in cwd's session directory | |||
| 240 | + | // that contain only a meta line (no messages were ever appended). | |||
| 241 | + | // Cleans up the backlog of empty stubs created by old zot versions | |||
| 242 | + | // that wrote a meta line at NewSession time and never followed up. | |||
| 243 | + | // Errors are swallowed; the caller treats this as best-effort. | |||
| 244 | + | func PruneEmptySessions(root, cwd string) { | |||
| 245 | + | dir := SessionsDir(root, cwd) | |||
| 246 | + | entries, err := os.ReadDir(dir) | |||
| 247 | + | if err != nil { | |||
| 248 | + | return | |||
| 249 | + | } | |||
| 250 | + | for _, e := range entries { | |||
| 251 | + | if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { | |||
| 252 | + | continue | |||
| 253 | + | } | |||
| 254 | + | p := filepath.Join(dir, e.Name()) | |||
| 255 | + | if sessionHasNoMessages(p) { | |||
| 256 | + | _ = os.Remove(p) | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // sessionHasNoMessages returns true when the file at path contains | |||
| 262 | + | // no lines of type "message". Meta-only / usage-only files count as | |||
| 263 | + | // empty. Used by PruneEmptySessions and the Describe path. | |||
| 264 | + | func sessionHasNoMessages(path string) bool { | |||
| 265 | + | f, err := os.Open(path) | |||
| 266 | + | if err != nil { | |||
| 267 | + | return false | |||
| 268 | + | } | |||
| 269 | + | defer f.Close() | |||
| 270 | + | sc := bufio.NewScanner(f) | |||
| 271 | + | sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024) | |||
| 272 | + | for sc.Scan() { | |||
| 273 | + | var head sessionLineHead | |||
| 274 | + | if err := json.Unmarshal(sc.Bytes(), &head); err != nil { | |||
| 275 | + | continue | |||
| 276 | + | } | |||
| 277 | + | if head.Type == "message" { | |||
| 278 | + | return false | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | return true | |||
| 282 | + | } | |||
| 283 | + | ||||
| 226 | // ListSessions returns session file paths for cwd, newest first. | 284 | // ListSessions returns session file paths for cwd, newest first. | ||
| 227 | func ListSessions(root, cwd string) []string { | 285 | func ListSessions(root, cwd string) []string { | ||
| 228 | dir := SessionsDir(root, cwd) | 286 | dir := SessionsDir(root, cwd) | ||
| @@ -245,7 +303,11 @@ func (s *Session) AppendMessage(m provider.Message) error { | |||||
| 245 | if s == nil { | 303 | if s == nil { | ||
| 246 | return nil | 304 | return nil | ||
| 247 | } | 305 | } | ||
| 248 | - | return s.writeLine(sessionLine{Type: "message", Message: &m}) | 306 | + | if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil { |
| 307 | + | return err | |||
| 308 | + | } | |||
| 309 | + | s.messagesAppended++ | |||
| 310 | + | return nil | |||
| 249 | } | 311 | } | ||
| 250 | 312 | ||||
| 251 | // UpdateModel records a provider/model switch in the session file. | 313 | // UpdateModel records a provider/model switch in the session file. | ||
| @@ -268,16 +330,27 @@ func (s *Session) AppendUsage(u, cum provider.Usage) error { | |||||
| 268 | return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum}) | 330 | return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum}) | ||
| 269 | } | 331 | } | ||
| 270 | 332 | ||||
| 271 | - | // Close flushes and closes the session file. | 333 | + | // Close flushes and closes the session file. If the session was |
| 334 | + | // freshly created in this process and never had any messages | |||
| 335 | + | // appended (the user opened zot, looked around, and exited without | |||
| 336 | + | // prompting), the file is deleted on close so the sessions list | |||
| 337 | + | // doesn't fill up with empty meta-only stubs. | |||
| 272 | func (s *Session) Close() error { | 338 | func (s *Session) Close() error { | ||
| 273 | if s == nil { | 339 | if s == nil { | ||
| 274 | return nil | 340 | return nil | ||
| 275 | } | 341 | } | ||
| 276 | - | if err := s.buf.Flush(); err != nil { | 342 | + | flushErr := s.buf.Flush() |
| 277 | - | s.writer.Close() | 343 | + | closeErr := s.writer.Close() |
| 278 | - | return err | 344 | + | if s.freshFile && s.messagesAppended == 0 { |
| 345 | + | // Best-effort cleanup. We deliberately don't propagate the | |||
| 346 | + | // remove error: if it fails (file already gone, perms changed) | |||
| 347 | + | // the worst case is one stale empty file in the listing. | |||
| 348 | + | _ = os.Remove(s.Path) | |||
| 349 | + | } | |||
| 350 | + | if flushErr != nil { | |||
| 351 | + | return flushErr | |||
| 279 | } | 352 | } | ||
| 280 | - | return s.writer.Close() | 353 | + | return closeErr |
| 281 | } | 354 | } | ||
| 282 | 355 | ||||
| 283 | func (s *Session) writeLine(row sessionLine) error { | 356 | func (s *Session) writeLine(row sessionLine) error { | ||