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.
This commit is contained in:
patriceckhart 2026-04-19 11:38:57 +02:00
parent 0a69274cd6
commit fdef8ac614
2 changed files with 88 additions and 11 deletions

View file

@ -265,6 +265,10 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
if args.NoSess {
return nil, nil
}
// Sweep meta-only files left over from older zot versions (and from
// any session that crashed before its first AppendMessage). Cheap;
// reads the first few bytes of each file in the cwd's session dir.
core.PruneEmptySessions(ZotHome(), args.CWD)
var (
s *core.Session
msgs []provider.Message

View file

@ -23,6 +23,18 @@ type Session struct {
Meta SessionMeta
writer *os.File
buf *bufio.Writer
// freshFile is true when the file was created by NewSession (this
// process owns it) and false when OpenSession reopened an existing
// transcript. Used by Close() to delete the file if the run never
// appended any messages — prevents a flood of empty session files
// from sessions the user opens then exits without prompting.
freshFile bool
// messagesAppended counts AppendMessage calls. Combined with
// freshFile it tells Close() whether the session left any content
// worth keeping.
messagesAppended int
}
// 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
return nil, err
}
s := &Session{
ID: id,
Path: p,
Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
writer: f,
buf: bufio.NewWriter(f),
ID: id,
Path: p,
Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
writer: f,
buf: bufio.NewWriter(f),
freshFile: true,
}
if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
f.Close()
@ -223,6 +236,51 @@ func firstUserText(line []byte) string {
return ""
}
// PruneEmptySessions deletes session files in cwd's session directory
// that contain only a meta line (no messages were ever appended).
// Cleans up the backlog of empty stubs created by old zot versions
// that wrote a meta line at NewSession time and never followed up.
// Errors are swallowed; the caller treats this as best-effort.
func PruneEmptySessions(root, cwd string) {
dir := SessionsDir(root, cwd)
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
continue
}
p := filepath.Join(dir, e.Name())
if sessionHasNoMessages(p) {
_ = os.Remove(p)
}
}
}
// sessionHasNoMessages returns true when the file at path contains
// no lines of type "message". Meta-only / usage-only files count as
// empty. Used by PruneEmptySessions and the Describe path.
func sessionHasNoMessages(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
for sc.Scan() {
var head sessionLineHead
if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
continue
}
if head.Type == "message" {
return false
}
}
return true
}
// ListSessions returns session file paths for cwd, newest first.
func ListSessions(root, cwd string) []string {
dir := SessionsDir(root, cwd)
@ -245,7 +303,11 @@ func (s *Session) AppendMessage(m provider.Message) error {
if s == nil {
return nil
}
return s.writeLine(sessionLine{Type: "message", Message: &m})
if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {
return err
}
s.messagesAppended++
return nil
}
// UpdateModel records a provider/model switch in the session file.
@ -268,16 +330,27 @@ func (s *Session) AppendUsage(u, cum provider.Usage) error {
return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})
}
// Close flushes and closes the session file.
// Close flushes and closes the session file. If the session was
// freshly created in this process and never had any messages
// appended (the user opened zot, looked around, and exited without
// prompting), the file is deleted on close so the sessions list
// doesn't fill up with empty meta-only stubs.
func (s *Session) Close() error {
if s == nil {
return nil
}
if err := s.buf.Flush(); err != nil {
s.writer.Close()
return err
flushErr := s.buf.Flush()
closeErr := s.writer.Close()
if s.freshFile && s.messagesAppended == 0 {
// Best-effort cleanup. We deliberately don't propagate the
// remove error: if it fails (file already gone, perms changed)
// the worst case is one stale empty file in the listing.
_ = os.Remove(s.Path)
}
return s.writer.Close()
if flushErr != nil {
return flushErr
}
return closeErr
}
func (s *Session) writeLine(row sessionLine) error {