Skip to main content

agentstatedeveloper_cli/commands/
gc.rs

1//! `asd gc` — preview, and on request perform, a garbage-collection sweep of
2//! the ASG store behind this repository.
3//!
4//! Every `asd index` commits a fresh state tree. Content addressing shares the
5//! unchanged parts, but the interior nodes on each changed path are new, so the
6//! store grows with every run. Until AgentStateGraph v1.2.2 each of those
7//! checkpoints also pinned its snapshot as a milestone, which kept nearly all of
8//! it reachable: measured on one store, 7.9 GB whose live state was 3.4% of its
9//! objects. `asd gc` is how that space comes back.
10//!
11//! **By default it only previews.** Nothing is deleted without `--sweep`, and
12//! the preview reports exactly what a sweep under the same policy would keep and
13//! reclaim. When mutating, the steps run in this order:
14//!
15//! 1. Distil any not-yet-extracted history. A sweep refuses while any commit is
16//!    undistilled, so its signal is never lost with its state.
17//! 2. `--unpin-legacy`: stop pinning milestones distilled before v1.2.2. One
18//!    time, deletes nothing; without it a sweep of an older store reclaims little.
19//! 3. `--sweep`: delete every object outside the retention policy's keep-set.
20//!    AgentStateGraph holds the store's write lock from computing that keep-set
21//!    to the last delete, so another ASD process committing meanwhile — a git
22//!    hook, `asd-serve`, an MCP server — waits or fails rather than losing data.
23//! 4. `--vacuum`: shrink the file. A sweep frees pages inside the database;
24//!    only a vacuum returns them to the OS.
25//!
26//! Mutation is deliberately CLI-only: `asd-serve` exposes the preview, never
27//! the sweep. Run this on the machine that owns the database.
28
29use anyhow::{Context, Result, bail};
30use clap::Args;
31use serde_json::{Value, json};
32
33use agentstatedeveloper_core::Engine;
34use agentstatedeveloper_core::gc::{GC_CHECKPOINT_EVERY, GC_KEEP_RECENT, gc_policy};
35
36use crate::config::Config;
37
38/// Batch size for the history extractor — matches `asd index`.
39const HISTORY_EXTRACT_BATCH: usize = 5_000;
40
41#[derive(Debug, Args)]
42pub struct GcArgs {
43    /// Delete every object outside the retention policy's keep-set. Without
44    /// this flag `asd gc` only reports what a sweep would reclaim.
45    #[arg(long)]
46    pub sweep: bool,
47
48    /// Compact the database file afterwards, returning freed pages to the OS.
49    /// Needs free disk roughly equal to the database's size while it runs. On
50    /// its own (without `--sweep`) it compacts without deleting anything.
51    #[arg(long)]
52    pub vacuum: bool,
53
54    /// Stop pinning the snapshots of milestones distilled before
55    /// AgentStateGraph v1.2.2, which pinned every checkpoint. One-time and
56    /// deletes nothing itself; milestones stay on the timeline. Checkpoints
57    /// deliberately tagged to pin their state are left alone.
58    #[arg(long)]
59    pub unpin_legacy: bool,
60
61    /// Keep the state of the N most recent commits in full.
62    #[arg(long, default_value_t = GC_KEEP_RECENT)]
63    pub keep_recent: usize,
64
65    /// Also keep every Kth older commit's state as a sparse checkpoint. The
66    /// default, 0, keeps none: ASD rebuilds derived state from source at the
67    /// git revision a milestone records rather than restoring a retained
68    /// snapshot, and at one pin per hundred commits a busy store would keep
69    /// thousands of full snapshots.
70    #[arg(long, default_value_t = GC_CHECKPOINT_EVERY)]
71    pub checkpoint_every: usize,
72}
73
74pub fn run(cfg: &Config, args: GcArgs) -> Result<()> {
75    let engine = Engine::open_sqlite(&cfg.db_path)?;
76    let repo = &engine.repo;
77
78    let distilled = repo
79        .extract_history(HISTORY_EXTRACT_BATCH)
80        .context("could not distil history before GC")?
81        .commits_processed;
82
83    let unpinned = if args.unpin_legacy {
84        Some(
85            repo.history_unpin_legacy_milestones()
86                .context("could not unpin legacy milestones")?,
87        )
88    } else {
89        None
90    };
91
92    let policy = gc_policy(args.keep_recent, args.checkpoint_every);
93
94    let result = if args.sweep {
95        repo.gc_sweep(policy, true, args.vacuum).context(
96            "sweep failed — if the database is locked, another ASD process \
97             (a git hook, asd-serve, an MCP server) is writing; retry when it finishes",
98        )?
99    } else if args.vacuum {
100        json!({ "vacuum": repo.gc_vacuum().context("vacuum failed")? })
101    } else {
102        repo.gc_sweep(policy, false, false)?
103    };
104
105    let out = json!({
106        "db": cfg.db_path.display().to_string(),
107        "distilled_now": distilled,
108        "unpinned_legacy_milestones": unpinned,
109        "result": result,
110    });
111    println!("{}", serde_json::to_string_pretty(&out)?);
112    eprintln!("{}", summary(&args, &result));
113
114    if result["refused"] == true {
115        // Distillation ran first, so this means a commit landed between it and
116        // the sweep taking its lock. Correct to refuse; say so and fail loudly.
117        bail!(
118            "sweep refused: {}",
119            result["reason"].as_str().unwrap_or("unsafe")
120        );
121    }
122    Ok(())
123}
124
125/// One human-readable line for stderr; stdout stays machine-readable JSON.
126fn summary(args: &GcArgs, result: &Value) -> String {
127    let n = |v: &Value| v.as_i64().unwrap_or(0);
128    if result["dry_run"] == true {
129        let w = &result["would_reclaim"];
130        return format!(
131            "preview: a sweep would delete {} of {} objects ({:.1}%), keeping {} live{}. \
132             Nothing was deleted — re-run with --sweep to reclaim.",
133            n(&w["reclaimable_objects"]),
134            n(&w["total_objects"]),
135            w["reclaimable_pct"].as_f64().unwrap_or(0.0),
136            n(&w["live_objects"]),
137            if result["safe"] == true {
138                ""
139            } else {
140                " (a sweep would currently be refused: history is not fully distilled)"
141            },
142        );
143    }
144    if result["mutated"] == true {
145        let mut line = format!(
146            "swept: deleted {} of {} objects, {} remain.",
147            n(&result["objects_deleted"]),
148            n(&result["objects_before"]),
149            n(&result["objects_after"]),
150        );
151        if let Some(v) = result.get("vacuum") {
152            line.push_str(&format!(
153                " vacuum reclaimed {} bytes on disk.",
154                n(&v["bytes_reclaimed"])
155            ));
156        } else if !args.vacuum {
157            line.push_str(" The file only shrinks after --vacuum.");
158        }
159        return line;
160    }
161    if let Some(v) = result.get("vacuum") {
162        return format!(
163            "vacuumed: reclaimed {} bytes on disk.",
164            n(&v["bytes_reclaimed"])
165        );
166    }
167    "no change.".to_string()
168}