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}