feat: Time Travel shadow-repo retention (orphan cleanup, optional age-out, stale-lock repair)

Time Travel keeps a hidden git repository per workspace under /a0/usr/.time_travel/workspaces/<id>/repo.git and snapshots on every file change, but ships no retention: nothing deletes a shadow repository when its chat or project is removed (chat_remove never touches .time_travel), there is no delete/prune endpoint in the API or web UI, and a workspace whose 'git add' ever exceeds GIT_TIMEOUT_SECONDS strands repo.git/index.lock, failing every later snapshot with 'index.lock: File exists'. Observed on a live instance: 518 shadow repositories / 12 GB, most belonging to long-deleted chats, plus a permanently wedged workspace. This adds a throttled retention sweep driven from job_loop: - Orphans: live workspace paths (project folders, the configured workdir, per-chat workdirs) are forward-enumerated and hashed with the existing workspace_id_for derivation; a shadow directory matching none of them is unreachable from the UI forever and is removed after a grace window. - Optional age-out: retention_max_age_days (default 0 = keep forever) removes a live workspace's history when it has had no snapshot in N days. Time Travel lazily re-initializes an empty history on the next snapshot, so this is always safe for the feature. - Stale locks: repo.git/index.lock older than retention_stale_lock_minutes is removed (git subprocesses are killed at GIT_TIMEOUT_SECONDS, so no legitimate lock lives that long), un-wedging future snapshots. - Corrupt-repo set-asides (repo.git.invalid*) past the grace window. Deletion is refused for any path outside the shadow root. Settings render in the existing plugin-config pattern (default_config.yaml + webui/config.html, settings_sections: agent): enable toggle, sweep interval, age-out days, orphan grace, stale-lock age. Durable evidence lives next to the workspaces dir: retention.json (running totals + last sweep stamp) and retention.log (one JSON line per sweep naming everything removed, tail-capped at 1000). Sweeps run in a worker thread off the event loop, at most one in flight, default every 6 hours. Tests: tests/test_time_travel_retention.py (config defaults/clamps, the full sweep matrix, keep-forever default, disabled no-op, deletion guard, marker accrual, per-sweep history + cap, throttle, and workspace_id_for parity).

King0James0 committed Jul 17, 2026 at 10:00 UTC c42dffa54e046bbbfa339632c1274678395afdf9
6 files changed +717 -1
plugins/_time_travel/default_config.yaml new
+5
@@ -0,0 +1,5 @@
1 +retention_enabled: true
2 +retention_sweep_interval_hours: 6
3 +retention_max_age_days: 0 # 0 = keep workspace history forever
4 +retention_orphan_grace_hours: 24
5 +retention_stale_lock_minutes: 30
plugins/_time_travel/extensions/python/job_loop/_50_retention.py new
+39
@@ -0,0 +1,39 @@
1 +"""Throttled Time Travel retention sweep (see helpers/retention.py)."""
2 +
3 +import asyncio
4 +from typing import Any
5 +
6 +from helpers.extension import Extension
7 +from helpers.print_style import PrintStyle
8 +
9 +_in_flight = False
10 +
11 +
12 +class TimeTravelRetention(Extension):
13 +
14 + async def execute(self, **kwargs: Any) -> None:
15 + global _in_flight
16 + if _in_flight:
17 + return
18 + try:
19 + from plugins._time_travel.helpers import retention
20 + except Exception:
21 + return
22 + try:
23 + if not retention.due():
24 + return
25 + _in_flight = True
26 + try:
27 + stats = await asyncio.to_thread(retention.sweep)
28 + finally:
29 + _in_flight = False
30 + if any(stats.values()):
31 + PrintStyle().print(
32 + "Time Travel retention: "
33 + f"orphans={stats['orphans_removed']} aged={stats['aged_removed']} "
34 + f"locks={stats['stale_locks_removed']} "
35 + f"invalid={stats['invalid_backups_removed']} "
36 + f"reclaimed={stats['bytes_reclaimed']}b"
37 + )
38 + except Exception:
39 + return
plugins/_time_travel/helpers/retention.py new
+341
@@ -0,0 +1,341 @@
1 +"""Retention for Time Travel shadow repositories.
2 +
3 +Time Travel keeps one hidden git repository per workspace under
4 +``/a0/usr/.time_travel/workspaces/<workspace_id>/repo.git`` and snapshots it on every file
5 +change. Without retention those repositories accumulate unboundedly: a removed chat or project
6 +leaves its shadow repository orphaned forever (nothing cleans it up), and a workspace whose
7 +``git add`` ever exceeded ``GIT_TIMEOUT_SECONDS`` strands a ``repo.git/index.lock`` that makes
8 +every later snapshot fail with "index.lock: File exists".
9 +
10 +The sweep (driven from ``job_loop``, throttled by config) removes:
11 +
12 +- ORPHANS — shadow directories whose id matches no live workspace path. Live paths are
13 + forward-enumerated (project folders, the configured workdir, per-chat workdirs) and hashed
14 + with the same ``workspace_id_for`` derivation; anything outside that set has no owner and can
15 + never be shown in the UI again. Deleted once last activity is past a grace window.
16 +- AGED repositories — no snapshot in ``retention_max_age_days`` (0 = keep forever, the
17 + default).
18 +- STALE LOCKS — ``repo.git/index.lock`` older than ``retention_stale_lock_minutes``; Time
19 + Travel kills its git subprocesses at ``GIT_TIMEOUT_SECONDS``, so no legitimate lock lives
20 + that long. Removing it un-wedges future snapshots.
21 +- INVALID BACKUPS — ``repo.git.invalid*`` set-asides made for corrupt repositories, past the
22 + same grace window.
23 +
24 +Deleting a live workspace's shadow repository is always safe for the feature itself: the next
25 +snapshot lazily re-initializes an empty history. Deletion is refused for any path outside the
26 +shadow root.
27 +
28 +Durable state next to the workspaces dir: ``retention.json`` (running totals + last sweep
29 +stamp) and ``retention.log`` (one JSON line per sweep with the names of everything removed,
30 +tail-capped).
31 +"""
32 +
33 +from __future__ import annotations
34 +
35 +import datetime
36 +import json
37 +import os
38 +import shutil
39 +import time
40 +from typing import Any, Optional
41 +
42 +PLUGIN_NAME = "_time_travel"
43 +
44 +MARKER_FILE = "retention.json"
45 +HISTORY_FILE = "retention.log"
46 +HISTORY_MAX_LINES = 1000
47 +
48 +DEFAULT_CONFIG: dict[str, Any] = {
49 + "retention_enabled": True,
50 + "retention_sweep_interval_hours": 6,
51 + "retention_max_age_days": 0,
52 + "retention_orphan_grace_hours": 24,
53 + "retention_stale_lock_minutes": 30,
54 +}
55 +
56 +
57 +def _int_at_least(value: Any, minimum: int, fallback: int) -> int:
58 + try:
59 + return max(int(value), minimum)
60 + except Exception:
61 + return fallback
62 +
63 +
64 +def effective_config(cfg: Optional[dict[str, Any]] = None) -> dict[str, Any]:
65 + """Plugin config with defaults filled in and values clamped to sane minimums."""
66 + if cfg is None:
67 + try:
68 + from helpers import plugins
69 +
70 + cfg = plugins.get_plugin_config(PLUGIN_NAME) or {}
71 + except Exception:
72 + cfg = {}
73 + merged = dict(DEFAULT_CONFIG)
74 + merged.update({k: v for k, v in cfg.items() if k in DEFAULT_CONFIG and v is not None})
75 + merged["retention_enabled"] = bool(merged["retention_enabled"])
76 + merged["retention_sweep_interval_hours"] = _int_at_least(
77 + merged["retention_sweep_interval_hours"], 1, 6
78 + )
79 + merged["retention_max_age_days"] = _int_at_least(merged["retention_max_age_days"], 0, 0)
80 + merged["retention_orphan_grace_hours"] = _int_at_least(
81 + merged["retention_orphan_grace_hours"], 1, 24
82 + )
83 + merged["retention_stale_lock_minutes"] = _int_at_least(
84 + merged["retention_stale_lock_minutes"], 5, 30
85 + )
86 + return merged
87 +
88 +
89 +def _state_dir() -> str:
90 + from plugins._time_travel.helpers import time_travel
91 +
92 + return str(time_travel.real_path_for_display("/a0/usr/.time_travel"))
93 +
94 +
95 +def _shadow_root() -> str:
96 + from plugins._time_travel.helpers import time_travel
97 +
98 + return str(time_travel.real_path_for_display(time_travel.SHADOW_DISPLAY_ROOT))
99 +
100 +
101 +def live_workspace_ids() -> set[str]:
102 + """Every workspace id resolvable from a path that exists right now: project folders, the
103 + configured workdir, and per-chat workdirs (custom projects resolvers may mint workspaces
104 + there; including them only makes the sweep more conservative)."""
105 + from plugins._time_travel.helpers import time_travel
106 +
107 + ids: set[str] = set()
108 +
109 + projects_root = time_travel.real_path_for_display("/a0/usr/projects")
110 + try:
111 + for name in os.listdir(projects_root):
112 + if os.path.isdir(os.path.join(projects_root, name)):
113 + ids.add(time_travel.workspace_id_for(f"/a0/usr/projects/{name}"))
114 + except Exception:
115 + pass
116 +
117 + try:
118 + ids.add(time_travel.workspace_id_for(time_travel.configured_workdir_display_path()))
119 + except Exception:
120 + ids.add(time_travel.workspace_id_for("/a0/usr/workdir"))
121 +
122 + chats_root = time_travel.real_path_for_display("/a0/usr/chats")
123 + try:
124 + for name in os.listdir(chats_root):
125 + if os.path.isdir(os.path.join(chats_root, name, "workdir")):
126 + ids.add(time_travel.workspace_id_for(f"/a0/usr/chats/{name}/workdir"))
127 + except Exception:
128 + pass
129 +
130 + return ids
131 +
132 +
133 +def _read_json(path: str) -> dict[str, Any]:
134 + try:
135 + with open(path, "r", encoding="utf-8") as f:
136 + return json.load(f)
137 + except Exception:
138 + return {}
139 +
140 +
141 +def _write_marker(state_dir: str, sweep_stats: dict[str, int], stamp: str) -> None:
142 + try:
143 + os.makedirs(state_dir, exist_ok=True)
144 + path = os.path.join(state_dir, MARKER_FILE)
145 + payload = _read_json(path)
146 + payload["sweeps"] = int(payload.get("sweeps", 0)) + 1
147 + for key, value in sweep_stats.items():
148 + payload[key] = int(payload.get(key, 0)) + int(value)
149 + payload["last_sweep_at"] = stamp
150 + tmp = path + ".tmp"
151 + with open(tmp, "w", encoding="utf-8") as f:
152 + json.dump(payload, f, indent=2, sort_keys=True)
153 + os.replace(tmp, path)
154 + except Exception:
155 + pass
156 +
157 +
158 +def _append_history(state_dir: str, entry: dict[str, Any]) -> None:
159 + try:
160 + os.makedirs(state_dir, exist_ok=True)
161 + path = os.path.join(state_dir, HISTORY_FILE)
162 + lines: list[str] = []
163 + try:
164 + with open(path, "r", encoding="utf-8") as f:
165 + lines = [ln for ln in f.read().splitlines() if ln.strip()]
166 + except Exception:
167 + lines = []
168 + lines.append(json.dumps(entry, sort_keys=True))
169 + if len(lines) > HISTORY_MAX_LINES:
170 + lines = lines[-HISTORY_MAX_LINES:]
171 + tmp = path + ".tmp"
172 + with open(tmp, "w", encoding="utf-8") as f:
173 + f.write("\n".join(lines) + "\n")
174 + os.replace(tmp, path)
175 + except Exception:
176 + pass
177 +
178 +
179 +def read_history(limit: int = 50, state_dir: Optional[str] = None) -> list[dict[str, Any]]:
180 + try:
181 + base = state_dir if state_dir is not None else _state_dir()
182 + with open(os.path.join(base, HISTORY_FILE), "r", encoding="utf-8") as f:
183 + lines = [ln for ln in f.read().splitlines() if ln.strip()]
184 + return [json.loads(ln) for ln in lines[-limit:]]
185 + except Exception:
186 + return []
187 +
188 +
189 +def _last_activity(entry_path: str) -> float:
190 + candidates = [
191 + os.path.join(entry_path, "repo.git", "refs", "heads", "current"),
192 + os.path.join(entry_path, "repo.git", "packed-refs"),
193 + os.path.join(entry_path, "repo.git", "HEAD"),
194 + os.path.join(entry_path, "repo.git"),
195 + entry_path,
196 + ]
197 + newest = 0.0
198 + for candidate in candidates:
199 + try:
200 + newest = max(newest, os.stat(candidate).st_mtime)
201 + except Exception:
202 + continue
203 + return newest
204 +
205 +
206 +def _tree_bytes(path: str) -> int:
207 + total = 0
208 + try:
209 + for root, _dirs, names in os.walk(path):
210 + for name in names:
211 + try:
212 + total += os.stat(os.path.join(root, name)).st_size
213 + except Exception:
214 + pass
215 + except Exception:
216 + pass
217 + return total
218 +
219 +
220 +def _remove_tree(path: str, shadow_root: str) -> int:
221 + """rmtree guarded to the shadow root; returns bytes reclaimed (0 on refusal/failure)."""
222 + real = os.path.realpath(path)
223 + root = os.path.realpath(shadow_root)
224 + if not real.startswith(root + os.sep):
225 + return 0
226 + size = _tree_bytes(real)
227 + try:
228 + shutil.rmtree(real)
229 + return size
230 + except Exception:
231 + return 0
232 +
233 +
234 +def sweep(
235 + cfg: Optional[dict[str, Any]] = None,
236 + shadow_root: Optional[str] = None,
237 + live_ids: Optional[set[str]] = None,
238 + now_ts: Optional[float] = None,
239 + state_dir: Optional[str] = None,
240 +) -> dict[str, int]:
241 + """One retention pass. All inputs are injectable for tests; production callers pass
242 + nothing and everything resolves from the plugin runtime."""
243 + stats = {
244 + "orphans_removed": 0,
245 + "aged_removed": 0,
246 + "stale_locks_removed": 0,
247 + "invalid_backups_removed": 0,
248 + "bytes_reclaimed": 0,
249 + }
250 + config = effective_config(cfg)
251 + if not config["retention_enabled"]:
252 + return stats
253 + root = shadow_root if shadow_root is not None else _shadow_root()
254 + if not os.path.isdir(root):
255 + return stats
256 + ids = live_ids if live_ids is not None else live_workspace_ids()
257 + base = state_dir if state_dir is not None else _state_dir()
258 + now = time.time() if now_ts is None else now_ts
259 +
260 + max_age_s = config["retention_max_age_days"] * 86400
261 + grace_s = config["retention_orphan_grace_hours"] * 3600
262 + lock_s = config["retention_stale_lock_minutes"] * 60
263 +
264 + detail: dict[str, list[str]] = {"orphans": [], "aged": [], "locks": [], "invalid": []}
265 +
266 + try:
267 + entries = os.listdir(root)
268 + except Exception:
269 + return stats
270 +
271 + for name in entries:
272 + entry = os.path.join(root, name)
273 + if not os.path.isdir(entry):
274 + continue
275 + last = _last_activity(entry)
276 +
277 + if name not in ids:
278 + if now - last > grace_s:
279 + stats["bytes_reclaimed"] += _remove_tree(entry, root)
280 + stats["orphans_removed"] += 1
281 + detail["orphans"].append(name)
282 + continue
283 +
284 + if max_age_s and now - last > max_age_s:
285 + stats["bytes_reclaimed"] += _remove_tree(entry, root)
286 + stats["aged_removed"] += 1
287 + detail["aged"].append(name)
288 + continue
289 +
290 + lock = os.path.join(entry, "repo.git", "index.lock")
291 + try:
292 + if os.path.isfile(lock) and now - os.stat(lock).st_mtime > lock_s:
293 + os.remove(lock)
294 + stats["stale_locks_removed"] += 1
295 + detail["locks"].append(name)
296 + except Exception:
297 + pass
298 +
299 + try:
300 + for sub in os.listdir(entry):
301 + if sub.startswith("repo.git.invalid"):
302 + backup = os.path.join(entry, sub)
303 + if now - os.stat(backup).st_mtime > grace_s:
304 + stats["bytes_reclaimed"] += _remove_tree(backup, root)
305 + stats["invalid_backups_removed"] += 1
306 + detail["invalid"].append(f"{name}/{sub}")
307 + except Exception:
308 + pass
309 +
310 + stamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
311 + _write_marker(base, stats, stamp)
312 + _append_history(base, {"at": stamp, **stats, "removed": detail})
313 + return stats
314 +
315 +
316 +def due(
317 + cfg: Optional[dict[str, Any]] = None,
318 + now_ts: Optional[float] = None,
319 + state_dir: Optional[str] = None,
320 +) -> bool:
321 + """True when retention is enabled and the configured interval has elapsed since the last
322 + sweep (or no sweep ever ran)."""
323 + config = effective_config(cfg)
324 + if not config["retention_enabled"]:
325 + return False
326 + base = state_dir if state_dir is not None else _state_dir()
327 + marker = _read_json(os.path.join(base, MARKER_FILE))
328 + last = str(marker.get("last_sweep_at") or "")
329 + if not last:
330 + return True
331 + try:
332 + last_dt = datetime.datetime.fromisoformat(last)
333 + now = (
334 + datetime.datetime.now(datetime.timezone.utc)
335 + if now_ts is None
336 + else datetime.datetime.fromtimestamp(now_ts, datetime.timezone.utc)
337 + )
338 + interval_s = config["retention_sweep_interval_hours"] * 3600
339 + return (now - last_dt).total_seconds() >= interval_s
340 + except Exception:
341 + return True
plugins/_time_travel/plugin.yaml
+2 -1
@@ -3,6 +3,7 @@ title: Time Travel
3 description: Agent Zero-owned workdir/project history, diff inspection, travel, and revert for /a0/usr workspaces.
4 version: 0.1.0
5 always_enabled: false
6 -settings_sections: []
6 +settings_sections:
7 + - agent
8 per_project_config: false
9 per_agent_config: false
plugins/_time_travel/webui/config.html new
+104
@@ -0,0 +1,104 @@
1 +<html>
2 +
3 +<head>
4 + <title>Time Travel</title>
5 +</head>
6 +
7 +<body>
8 + <div x-data>
9 + <template x-if="config">
10 + <div>
11 + <div class="section-title">Time Travel retention</div>
12 + <div class="section-description">
13 + Time Travel keeps a hidden history of every workspace it snapshots. Retention
14 + keeps that storage bounded by cleaning up histories nothing can reach anymore
15 + (deleted chats and projects) and, optionally, aging out old history.
16 + </div>
17 +
18 + <div class="field">
19 + <div class="field-label">
20 + <div class="field-title">Enable retention</div>
21 + <div class="field-description">
22 + Periodically clean up orphaned workspace histories, stale snapshot
23 + locks, and corrupt-repository backups. Turning this off means Time
24 + Travel storage grows without bound.
25 + </div>
26 + </div>
27 + <div class="field-control">
28 + <label class="toggle">
29 + <input type="checkbox" x-model="config.retention_enabled"
30 + x-init="if (config.retention_enabled === undefined || config.retention_enabled === null) config.retention_enabled = true" />
31 + <span class="toggler"></span>
32 + </label>
33 + </div>
34 + </div>
35 +
36 + <div class="field">
37 + <div class="field-label">
38 + <div class="field-title">Sweep interval (hours)</div>
39 + <div class="field-description">
40 + How often the cleanup runs.
41 + </div>
42 + </div>
43 + <div class="field-control">
44 + <input type="number" min="1" step="1"
45 + x-init="if (config.retention_sweep_interval_hours === undefined || config.retention_sweep_interval_hours === null) config.retention_sweep_interval_hours = 6"
46 + x-model.number="config.retention_sweep_interval_hours" />
47 + </div>
48 + </div>
49 +
50 + <div class="field">
51 + <div class="field-label">
52 + <div class="field-title">Delete history older than (days)</div>
53 + <div class="field-description">
54 + Leave at 0 (the default) and workspace history is kept forever. Set a
55 + number — say 30 — and a workspace with no snapshot in that many days
56 + has its history deleted. Time Travel simply starts a fresh history on
57 + the workspace's next change.
58 + </div>
59 + </div>
60 + <div class="field-control">
61 + <input type="number" min="0" step="1"
62 + x-init="if (config.retention_max_age_days === undefined || config.retention_max_age_days === null) config.retention_max_age_days = 0"
63 + x-model.number="config.retention_max_age_days" />
64 + </div>
65 + </div>
66 +
67 + <div class="field">
68 + <div class="field-label">
69 + <div class="field-title">Orphan grace period (hours)</div>
70 + <div class="field-description">
71 + History belonging to a deleted chat or project is unreachable from the
72 + UI and gets cleaned up — but only after it has been inactive this long,
73 + so recent work is never raced.
74 + </div>
75 + </div>
76 + <div class="field-control">
77 + <input type="number" min="1" step="1"
78 + x-init="if (config.retention_orphan_grace_hours === undefined || config.retention_orphan_grace_hours === null) config.retention_orphan_grace_hours = 24"
79 + x-model.number="config.retention_orphan_grace_hours" />
80 + </div>
81 + </div>
82 +
83 + <div class="field">
84 + <div class="field-label">
85 + <div class="field-title">Stale snapshot lock age (minutes)</div>
86 + <div class="field-description">
87 + A leftover git index.lock older than this is removed so snapshots stop
88 + failing with "index.lock: File exists". Time Travel's own git
89 + operations are killed after 20 seconds, so no legitimate lock lives
90 + this long.
91 + </div>
92 + </div>
93 + <div class="field-control">
94 + <input type="number" min="5" step="1"
95 + x-init="if (config.retention_stale_lock_minutes === undefined || config.retention_stale_lock_minutes === null) config.retention_stale_lock_minutes = 30"
96 + x-model.number="config.retention_stale_lock_minutes" />
97 + </div>
98 + </div>
99 + </div>
100 + </template>
101 + </div>
102 +</body>
103 +
104 +</html>
tests/test_time_travel_retention.py new
+226
@@ -0,0 +1,226 @@
1 +import hashlib
2 +import importlib.util
3 +import json
4 +import os
5 +import sys
6 +import time
7 +from pathlib import Path
8 +
9 +import pytest
10 +
11 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
12 +if str(PROJECT_ROOT) not in sys.path:
13 + sys.path.insert(0, str(PROJECT_ROOT))
14 +
15 +MOD_PATH = PROJECT_ROOT / "plugins" / "_time_travel" / "helpers" / "retention.py"
16 +_spec = importlib.util.spec_from_file_location("time_travel_retention", MOD_PATH)
17 +retention = importlib.util.module_from_spec(_spec)
18 +assert _spec and _spec.loader
19 +_spec.loader.exec_module(retention)
20 +
21 +DAY = 86400
22 +HOUR = 3600
23 +NOW = time.time()
24 +
25 +CFG_AGING = {"retention_max_age_days": 30}
26 +
27 +
28 +def _hex_id(seed: str) -> str:
29 + return hashlib.sha256(seed.encode()).hexdigest()[:32]
30 +
31 +
32 +def _mk_repo(shadow_root, name, age_s, lock_age_s=None, invalid_age_s=None):
33 + entry = os.path.join(shadow_root, name)
34 + ref = os.path.join(entry, "repo.git", "refs", "heads")
35 + os.makedirs(ref, exist_ok=True)
36 + cur = os.path.join(ref, "current")
37 + with open(cur, "w") as f:
38 + f.write("deadbeef")
39 + stamp = NOW - age_s
40 + for path in (cur, os.path.join(entry, "repo.git"), entry):
41 + os.utime(path, (stamp, stamp))
42 + if lock_age_s is not None:
43 + lock = os.path.join(entry, "repo.git", "index.lock")
44 + with open(lock, "w") as f:
45 + f.write("")
46 + os.utime(lock, (NOW - lock_age_s, NOW - lock_age_s))
47 + if invalid_age_s is not None:
48 + backup = os.path.join(entry, "repo.git.invalid-20260101")
49 + os.makedirs(backup, exist_ok=True)
50 + with open(os.path.join(backup, "junk"), "w") as f:
51 + f.write("x" * 100)
52 + os.utime(backup, (NOW - invalid_age_s, NOW - invalid_age_s))
53 + return entry
54 +
55 +
56 +def test_effective_config_defaults_and_clamps():
57 + cfg = retention.effective_config({})
58 + assert cfg["retention_enabled"] is True
59 + assert cfg["retention_max_age_days"] == 0
60 + assert cfg["retention_sweep_interval_hours"] == 6
61 + clamped = retention.effective_config(
62 + {
63 + "retention_sweep_interval_hours": 0,
64 + "retention_max_age_days": -5,
65 + "retention_orphan_grace_hours": 0,
66 + "retention_stale_lock_minutes": 1,
67 + "retention_enabled": 1,
68 + }
69 + )
70 + assert clamped["retention_sweep_interval_hours"] == 1
71 + assert clamped["retention_max_age_days"] == 0
72 + assert clamped["retention_orphan_grace_hours"] == 1
73 + assert clamped["retention_stale_lock_minutes"] == 5
74 + assert clamped["retention_enabled"] is True
75 + garbage = retention.effective_config({"retention_sweep_interval_hours": "nope"})
76 + assert garbage["retention_sweep_interval_hours"] == 6
77 +
78 +
79 +def test_sweep_matrix(tmp_path):
80 + shadow = str(tmp_path / "workspaces")
81 + state = str(tmp_path / "state")
82 + os.makedirs(shadow)
83 +
84 + live_recent = _hex_id("alpha")
85 + live_aged = _hex_id("beta")
86 + live_locked = _hex_id("gamma")
87 + live_fresh_lock = _hex_id("delta")
88 + live_invalid = _hex_id("epsilon")
89 + live_ids = {live_recent, live_aged, live_locked, live_fresh_lock, live_invalid}
90 +
91 + _mk_repo(shadow, live_recent, age_s=1 * HOUR)
92 + _mk_repo(shadow, live_aged, age_s=40 * DAY)
93 + _mk_repo(shadow, live_locked, age_s=1 * HOUR, lock_age_s=1 * HOUR)
94 + _mk_repo(shadow, live_fresh_lock, age_s=1 * HOUR, lock_age_s=60)
95 + _mk_repo(shadow, live_invalid, age_s=1 * HOUR, invalid_age_s=48 * HOUR)
96 + _mk_repo(shadow, "0" * 32, age_s=48 * HOUR) # orphan past grace
97 + _mk_repo(shadow, "1" * 32, age_s=1 * HOUR) # orphan inside grace
98 + with open(os.path.join(shadow, "stray-file"), "w") as f:
99 + f.write("ignore me")
100 +
101 + stats = retention.sweep(
102 + cfg=CFG_AGING, shadow_root=shadow, live_ids=live_ids, now_ts=NOW, state_dir=state
103 + )
104 +
105 + assert os.path.isdir(os.path.join(shadow, live_recent))
106 + assert not os.path.exists(os.path.join(shadow, live_aged))
107 + assert stats["aged_removed"] == 1
108 + assert not os.path.exists(os.path.join(shadow, "0" * 32))
109 + assert os.path.isdir(os.path.join(shadow, "1" * 32))
110 + assert stats["orphans_removed"] == 1
111 + assert os.path.isdir(os.path.join(shadow, live_locked))
112 + assert not os.path.exists(os.path.join(shadow, live_locked, "repo.git", "index.lock"))
113 + assert os.path.exists(os.path.join(shadow, live_fresh_lock, "repo.git", "index.lock"))
114 + assert stats["stale_locks_removed"] == 1
115 + assert os.path.isdir(os.path.join(shadow, live_invalid))
116 + assert not os.path.exists(
117 + os.path.join(shadow, live_invalid, "repo.git.invalid-20260101")
118 + )
119 + assert stats["invalid_backups_removed"] == 1
120 + assert stats["bytes_reclaimed"] > 0
121 + assert os.path.isfile(os.path.join(shadow, "stray-file"))
122 +
123 +
124 +def test_max_age_zero_keeps_history_forever(tmp_path):
125 + shadow = str(tmp_path / "workspaces")
126 + state = str(tmp_path / "state")
127 + os.makedirs(shadow)
128 + ancient = _hex_id("ancient")
129 + _mk_repo(shadow, ancient, age_s=400 * DAY)
130 +
131 + stats = retention.sweep(
132 + cfg={"retention_max_age_days": 0},
133 + shadow_root=shadow,
134 + live_ids={ancient},
135 + now_ts=NOW,
136 + state_dir=state,
137 + )
138 + assert stats["aged_removed"] == 0
139 + assert os.path.isdir(os.path.join(shadow, ancient))
140 +
141 +
142 +def test_disabled_sweep_is_noop(tmp_path):
143 + shadow = str(tmp_path / "workspaces")
144 + os.makedirs(shadow)
145 + _mk_repo(shadow, "0" * 32, age_s=48 * HOUR)
146 + stats = retention.sweep(
147 + cfg={"retention_enabled": False},
148 + shadow_root=shadow,
149 + live_ids=set(),
150 + now_ts=NOW,
151 + state_dir=str(shadow),
152 + )
153 + assert stats["orphans_removed"] == 0
154 + assert os.path.isdir(os.path.join(shadow, "0" * 32))
155 +
156 +
157 +def test_remove_tree_refuses_outside_root(tmp_path):
158 + shadow = str(tmp_path / "workspaces")
159 + outside = str(tmp_path / "outside")
160 + os.makedirs(shadow)
161 + os.makedirs(outside)
162 + assert retention._remove_tree(outside, shadow) == 0
163 + assert os.path.isdir(outside)
164 +
165 +
166 +def test_marker_and_history(tmp_path):
167 + shadow = str(tmp_path / "workspaces")
168 + state = str(tmp_path / "state")
169 + os.makedirs(shadow)
170 + _mk_repo(shadow, "0" * 32, age_s=48 * HOUR)
171 + retention.sweep(cfg={}, shadow_root=shadow, live_ids=set(), now_ts=NOW, state_dir=state)
172 + _mk_repo(shadow, "2" * 32, age_s=48 * HOUR)
173 + retention.sweep(cfg={}, shadow_root=shadow, live_ids=set(), now_ts=NOW, state_dir=state)
174 +
175 + marker = json.load(open(os.path.join(state, retention.MARKER_FILE)))
176 + assert marker["sweeps"] == 2
177 + assert marker["orphans_removed"] == 2
178 + assert marker["last_sweep_at"]
179 +
180 + history = retention.read_history(state_dir=state)
181 + assert len(history) == 2
182 + assert history[0]["removed"]["orphans"] == ["0" * 32]
183 + assert history[1]["removed"]["orphans"] == ["2" * 32]
184 + assert history[0]["at"]
185 +
186 +
187 +def test_history_tail_cap(tmp_path):
188 + state = str(tmp_path / "state")
189 + os.makedirs(state)
190 + with open(os.path.join(state, retention.HISTORY_FILE), "w") as f:
191 + for i in range(retention.HISTORY_MAX_LINES + 20):
192 + f.write('{"at": "old-%d"}\n' % i)
193 + retention._append_history(state, {"at": "newest"})
194 + history = retention.read_history(limit=retention.HISTORY_MAX_LINES + 100, state_dir=state)
195 + assert len(history) == retention.HISTORY_MAX_LINES
196 + assert history[-1]["at"] == "newest"
197 + assert history[0]["at"] != "old-0"
198 +
199 +
200 +def test_due_throttle(tmp_path):
201 + state = str(tmp_path / "state")
202 + shadow = str(tmp_path / "workspaces")
203 + os.makedirs(shadow)
204 + assert retention.due(cfg={}, state_dir=state)
205 + assert not retention.due(cfg={"retention_enabled": False}, state_dir=state)
206 +
207 + retention.sweep(cfg={}, shadow_root=shadow, live_ids=set(), now_ts=NOW, state_dir=state)
208 + assert not retention.due(cfg={}, now_ts=time.time(), state_dir=state)
209 + assert retention.due(cfg={}, now_ts=time.time() + 7 * HOUR, state_dir=state)
210 + assert not retention.due(
211 + cfg={"retention_sweep_interval_hours": 12},
212 + now_ts=time.time() + 7 * HOUR,
213 + state_dir=state,
214 + )
215 +
216 +
217 +def test_workspace_id_parity_with_time_travel():
218 + time_travel = pytest.importorskip(
219 + "plugins._time_travel.helpers.time_travel",
220 + reason="requires the full runtime environment",
221 + )
222 + path = "/a0/usr/projects/example"
223 + expected = hashlib.sha256(
224 + time_travel.canonical_workspace_display_path(path).rstrip("/").encode("utf-8")
225 + ).hexdigest()[:32]
226 + assert time_travel.workspace_id_for(path) == expected