Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
8fc019e
feat(sessions): prune old sessions, never one another process has open
Vasanthdev2004 Sep 26, 2026
ac1efe3
feat(cli): add zero sessions prune, with a retention setting read fro…
Vasanthdev2004 Sep 26, 2026
9aabaf6
test(sessions): a dry run reports a session another process has open …
Vasanthdev2004 Sep 26, 2026
316c898
test(sessions): pin the dry run for undated sessions and the lease a …
Vasanthdev2004 Sep 26, 2026
4f1dbd9
test(sessions): report every failure when a session is written while …
Vasanthdev2004 Sep 26, 2026
5fe047a
docs(readme): list sessions prune in the Chinese README as well
Vasanthdev2004 Sep 26, 2026
c0f4742
fix(sessions): a fork or child holds its parent, and is refused while…
Vasanthdev2004 Sep 26, 2026
4cfbb88
docs(sessions): say that nothing calls Release yet, and what it is for
Vasanthdev2004 Sep 26, 2026
2bacb7e
fix(sessions): let go of the lease before removing a pruned session's…
Vasanthdev2004 Sep 26, 2026
ff28d8f
test(cli): an empty --older-than is a usage error, not the retention …
Vasanthdev2004 Sep 26, 2026
7fa2017
fix(sessions): refuse to continue a session prune holds, rather than …
Vasanthdev2004 Sep 26, 2026
dc3ec65
test(sessions): a lease that cannot be taken keeps the session, and d…
Vasanthdev2004 Sep 26, 2026
94c9087
fix(sessions): keep a parent forked after the plan, and refuse sessio…
Vasanthdev2004 Sep 28, 2026
ff04d2f
fix(sessions): make a dry run decide at removal time like a real prune
Vasanthdev2004 Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ zero context context-budget report
zero repo-map deterministic repository map
zero repo-info local repository summary
zero search | find search local session history
zero sessions inspect, resume, fork, and rewind sessions
zero sessions inspect, resume, fork, rewind, and prune sessions
zero spec manage spec-mode drafts
zero specialist manage specialist subagents
zero skills manage markdown instruction skills
Expand Down
2 changes: 1 addition & 1 deletion README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ zero context 上下文预算报告
zero repo-map 确定性仓库映射
zero repo-info 本地仓库摘要
zero search | find 搜索本地会话历史
zero sessions 检查、恢复、分叉和回滚会话
zero sessions 检查、恢复、分叉、回滚和清理会话
zero spec 管理规范模式草稿
zero specialist 管理专业子智能体
zero skills 管理 Markdown 指令技能
Expand Down
17 changes: 17 additions & 0 deletions internal/acp/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,12 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
if operation == persistedSessionResume && historyErr != nil {
return nil, RPCError(codeInternalError, "restore session history: "+historyErr.Error())
}
// Load stays best effort about a history it cannot read, but not about one
// prune holds: publishing it would leave the client using a session prune may
// be removing, one this process could not hold open.
if errors.Is(historyErr, sessions.ErrPruning) {
return nil, RPCError(codeInternalError, historyErr.Error())
}
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
if err != nil {
return nil, RPCError(codeInternalError, "config: "+err.Error())
Expand Down Expand Up @@ -908,9 +914,20 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
// enough: rehydration substitutes the compaction event in place of the events
// it replaced, so a loop that skips everything but EventMessage would drop the
// summary exactly as before. It is projected below. Reported by @jatmn.
//
// The session was picked from its metadata a moment ago. Make sure it is
// still there, and held, before restoring it: see sessions.HoldToContinue.
if err := a.deps.Store.HoldToContinue(sessionID); err != nil {
return nil, nil, nil, err
}
events, eventLogPresent, err := a.deps.Store.ReadRehydratedEventsWithPresence(sessionID)
var rehydrateWarning error
if err != nil {
if errors.Is(err, sessions.ErrPruning) {
// Not a rehydration failure: the raw read would continue the session
// without holding it while zero sessions prune may be removing it.
return nil, nil, nil, err
}
rehydrateWarning = err
events, eventLogPresent, err = a.deps.Store.ReadEventsWithPresence(sessionID)
if err != nil {
Expand Down
65 changes: 65 additions & 0 deletions internal/acp/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2418,3 +2418,68 @@ func payloadString(payload any, key string) string {
value, _ := decoded[key].(string)
return value
}

// Load is best effort about a history it cannot read, but a session that zero
// sessions prune holds is refused by load and resume alike, and not published:
// this process could not hold it open while prune may be removing it.
func TestACPLoadAndResumeAreRefusedWhilePruneHoldsTheSession(t *testing.T) {
deps := testDeps(t)
cwd := t.TempDir()
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
if err != nil {
t.Fatalf("create session: %v", err)
}
// Created by an earlier process, which has since exited.
deps.Store.Release(meta.SessionID)

release, locked, err := sessions.NewStore(sessions.StoreOptions{RootDir: deps.Store.RootDir}).HoldExclusive(meta.SessionID)
if err != nil || !locked {
t.Fatalf("take the lease the way prune does: locked=%v, %v", locked, err)
}
defer release()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHarness(t, deps)
defer h.stop()
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err == nil || !strings.Contains(err.Error(), "locked by zero sessions prune") {
t.Errorf("session/load while prune holds the session: err = %v, want it refused", err)
}
if err := h.client.Call(ctx, MethodSessionResume, ResumeSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &ResumeSessionResult{}); err == nil || !strings.Contains(err.Error(), "locked by zero sessions prune") {
t.Errorf("session/resume while prune holds the session: err = %v, want it refused", err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: meta.SessionID,
Prompt: []ContentBlock{TextBlock("carry on")},
}, &PromptResult{}); err == nil {
t.Fatal("a session refused while prune held it was still promptable")
}
}

// Activation reads the session's metadata before it restores the history. A
// session prune removes in between is refused as removed, which activation then
// refuses to publish for load as well as resume, instead of restoring it as an
// empty conversation.
func TestACPLoadHistoryRefusesASessionRemovedAfterItWasPicked(t *testing.T) {
deps := testDeps(t)
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: t.TempDir()})
if err != nil {
t.Fatalf("create session: %v", err)
}
deps.Store.Release(meta.SessionID)
dir := filepath.Join(deps.Store.RootDir, meta.SessionID)
if err := os.Remove(filepath.Join(dir, sessions.MetadataFile)); err != nil {
t.Fatal(err)
}

a := &Agent{deps: deps}
if _, _, _, err := a.loadHistory(meta.SessionID, false); !errors.Is(err, sessions.ErrPruning) {
t.Errorf("load history of a session prune is removing: err = %v, want it refused", err)
}
if err := os.RemoveAll(dir); err != nil {
t.Fatal(err)
}
if _, _, _, err := a.loadHistory(meta.SessionID, false); !errors.Is(err, sessions.ErrPruning) {
t.Errorf("load history of a session prune removed: err = %v, want it refused", err)
}
}
36 changes: 35 additions & 1 deletion internal/cli/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ type sessionCommandOptions struct {
excludeTarget bool
preserveLast int
maxPromptChars int
olderThan string
dryRun bool
}

func runSessions(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
Expand Down Expand Up @@ -72,6 +74,11 @@ func runSessions(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return writeExecUsageError(stderr, "sessions compact-plan requires a session id")
}
return runSessionsCompactPlan(store, remaining[0], options, stdout, stderr)
case "prune":
if len(remaining) != 0 {
return writeExecUsageError(stderr, "sessions prune does not accept positional arguments")
}
return runSessionsPrune(store, options, stdout, stderr, deps)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown sessions command %q", command))
}
Expand All @@ -91,6 +98,8 @@ func parseSessionsArgs(args []string) (string, []string, sessionCommandOptions,
options.json = true
case "--exclude-target":
options.excludeTarget = true
case "--dry-run":
options.dryRun = true
case "--kind":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
Expand Down Expand Up @@ -168,6 +177,21 @@ func parseSessionsArgs(args []string) (string, []string, sessionCommandOptions,
}
options.preserveLast = preserveLast
continue
case arg == "--older-than":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return command, remaining, options, false, err
}
options.olderThan = value
index = next
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case strings.HasPrefix(arg, "--older-than="):
value, err := parseNonEmptySessionsFlag("--older-than", strings.TrimPrefix(arg, "--older-than="))
if err != nil {
return command, remaining, options, false, err
}
options.olderThan = value
continue
case arg == "--max-prompt-chars":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
Expand Down Expand Up @@ -225,7 +249,7 @@ func parseSessionKindFlag(value string) (sessions.SessionKind, error) {

func isSessionsCommand(command string) bool {
switch command {
case "list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan":
case "list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan", "prune":
return true
default:
return false
Expand All @@ -244,6 +268,9 @@ func validateSessionCommandFlags(command string, options sessionCommandOptions)
if hasCompactionFlag && command != "compact-plan" {
return execUsageError{"--preserve-last and --max-prompt-chars are only valid for sessions compact-plan"}
}
if (options.olderThan != "" || options.dryRun) && command != "prune" {
return execUsageError{"--older-than and --dry-run are only valid for sessions prune"}
}
return nil
}

Expand Down Expand Up @@ -532,6 +559,7 @@ Commands:
rewind-plan <id> Preview events kept and dropped by a rewind
rewind <id> Restore workspace files and truncate the log to a checkpoint
compact-plan <id> Preview events compacted and preserved by compaction
prune Remove sessions last updated before a cutoff

Flags:
--json Print JSON output
Expand All @@ -541,7 +569,13 @@ Flags:
--exclude-target Drop the target event (rewind-plan, rewind)
--preserve-last <n> Keep recent events in compact-plan
--max-prompt-chars <n> Limit compact-plan summary prompt
--older-than <age> Prune cutoff, as days (30d) or a duration (720h); at least 1d.
Without it, prune uses sessions.retentionDays from your user config.
--dry-run List what prune would remove, and why it keeps the rest
-h, --help Show this help

prune only runs when you run it. It never removes a session another Zero process
has open, or an ancestor of a session it keeps, and --dry-run shows why.
`)
return err
}
171 changes: 171 additions & 0 deletions internal/cli/sessions_prune.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package cli

import (
"fmt"
"io"
"strconv"
"strings"
"time"

"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sessions"
)

// maxPruneDays keeps a day count from overflowing time.Duration.
const maxPruneDays = 36500

// runSessionsPrune removes sessions last updated before a cutoff, or with
// --dry-run lists what would go and why. It runs only when asked (#971).
func runSessionsPrune(store *sessions.Store, options sessionCommandOptions, stdout io.Writer, stderr io.Writer, deps appDeps) int {
olderThan, err := pruneCutoff(options, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
report, err := store.Prune(sessions.PruneOptions{OlderThan: olderThan, DryRun: options.dryRun})
if err != nil {
return writeSessionCommandError(stderr, err)
}
if options.json {
if err := writePrettyJSON(stdout, redaction.RedactValue(report, redaction.Options{})); err != nil {
return exitCrash
}
} else if _, err := fmt.Fprint(stdout, formatPruneReport(report)); err != nil {
return exitCrash
}
if len(report.Failed) > 0 {
return exitCrash
}
return exitSuccess
}

// pruneCutoff is --older-than, or else sessions.retentionDays from the user's
// own config, and never below sessions.MinimumPruneAge. Project config has no
// say in it: see config.SessionsConfig.
func pruneCutoff(options sessionCommandOptions, deps appDeps) (time.Duration, error) {
var age time.Duration
if options.olderThan != "" {
parsed, err := parsePruneAge(options.olderThan)
if err != nil {
return 0, err
}
age = parsed
} else {
days := 0
if deps.userConfigPath != nil {
path, err := deps.userConfigPath()
if err != nil {
return 0, fmt.Errorf("sessions prune could not find your config: %w", err)
}
settings, err := config.ReadSessionsConfig(path)
if err != nil {
return 0, fmt.Errorf("sessions prune could not read sessions.retentionDays: %w", err)
}
days = settings.RetentionDays
}
if days == 0 {
return 0, execUsageError{"sessions prune needs a cutoff: pass --older-than (for example --older-than 30d) or set sessions.retentionDays in your user config"}
}
if days > maxPruneDays {
return 0, execUsageError{fmt.Sprintf("sessions.retentionDays %d is more than %d", days, maxPruneDays)}
}
age = time.Duration(days) * 24 * time.Hour
}
if age < sessions.MinimumPruneAge {
return 0, execUsageError{fmt.Sprintf("sessions prune does not remove anything updated in the last %d hours; use --older-than 1d or more", int(sessions.MinimumPruneAge.Hours()))}
}
return age, nil
}

// parsePruneAge accepts a whole number of days ("30d") or a Go duration
// ("720h").
func parsePruneAge(value string) (time.Duration, error) {
invalid := execUsageError{fmt.Sprintf("invalid --older-than %q: expected days like 30d or a duration like 720h", value)}
trimmed := strings.TrimSpace(value)
if days, ok := strings.CutSuffix(trimmed, "d"); ok {
count, err := strconv.Atoi(days)
if err != nil || count <= 0 || count > maxPruneDays {
return 0, invalid
}
return time.Duration(count) * 24 * time.Hour, nil
}
age, err := time.ParseDuration(trimmed)
if err != nil || age <= 0 {
return 0, invalid
}
return age, nil
}

func formatPruneReport(report sessions.PruneReport) string {
var out strings.Builder
verb := "Removed"
if report.DryRun {
verb = "Would remove"
}
if len(report.Removed) == 0 {
fmt.Fprintf(&out, "No sessions to remove: nothing last updated before %s can go.\n", report.Cutoff)
} else {
var total int64
for _, entry := range report.Removed {
total += entry.Bytes
}
fmt.Fprintf(&out, "%s %d %s last updated before %s (%s):\n", verb, len(report.Removed), plural(len(report.Removed), "session", "sessions"), report.Cutoff, formatPruneBytes(total))
for _, entry := range report.Removed {
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, redact(entry.Title)))
}
}
if len(report.Kept) > 0 {
fmt.Fprintf(&out, "Kept %d old enough to remove:\n", len(report.Kept))
for _, entry := range report.Kept {
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, entry.Reason))
}
}
if len(report.Failed) > 0 {
failed := "Could not remove"
if report.DryRun {
failed = "Could not check"
}
fmt.Fprintf(&out, "%s %d:\n", failed, len(report.Failed))
for _, entry := range report.Failed {
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, redact(entry.Reason)))
}
}
if report.DryRun {
out.WriteString("Dry run: nothing was removed.\n")
}
return out.String()
}

func formatPruneEntry(entry sessions.PruneEntry, note string) string {
updated := entry.UpdatedAt
if len(updated) >= len("2006-01-02") {
updated = updated[:len("2006-01-02")]
}
line := redact(entry.SessionID) + " " + updated
if note = strings.TrimSpace(note); note != "" {
line += " " + note
}
return line
}

func formatPruneBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
value, suffix := float64(bytes)/unit, "KB"
for _, next := range []string{"MB", "GB", "TB"} {
if value < unit {
break
}
value, suffix = value/unit, next
}
return fmt.Sprintf("%.1f %s", value, suffix)
}

func plural(count int, one, many string) string {
if count == 1 {
return one
}
return many
}
Loading
Loading