Fix Time Travel snapshot resilience

Force-add curated snapshot paths so workspace .gitignore rules cannot break Time Travel snapshots, while preserving Time Travel's own exclusions for secrets and generated files. Repair invalid shadow Git repositories by restoring HEAD when possible or quarantining and reinitializing unusable repos, and canonicalize workspace paths to avoid duplicate shadow histories for aliases. Add regression coverage for ignored paths, corrupt shadow HEAD recovery, and canonical workspace identity.

Alessandro committed May 2, 2026 at 20:27 UTC d8c0d6b9fe6cbfdb9af126d513f420f648f73dcd
2 files changed +167 -21
plugins/_time_travel/helpers/time_travel.py
+115 -21
@@ -31,6 +31,7 @@ GIT_TIMEOUT_SECONDS = 20
31 AUTO_SNAPSHOT_DEBOUNCE_SECONDS = 10.0
32 WATCHDOG_ID = "time_travel_usr"
33 WATCHDOG_DEBOUNCE_SECONDS = 1.0
34 +SHADOW_REPO_BACKUP_PREFIX = "repo.git.invalid"
35
36 _AUTO_SNAPSHOT_LOCK = threading.RLock()
37 _AUTO_SNAPSHOT_TIMERS: dict[str, threading.Timer] = {}
@@ -189,7 +190,7 @@ def is_inside_usr_display(display_path: str) -> bool:
190
191
192 def workspace_id_for(display_path: str) -> str:
192 - normalized = normalize_display_path(display_path).rstrip("/")
193 + normalized = canonical_workspace_display_path(display_path).rstrip("/")
194 return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:32]
195
196
@@ -202,6 +203,13 @@ def real_path_for_display(display_path: str) -> Path:
203 return Path(normalized).expanduser().resolve(strict=False)
204
205
206 +def canonical_workspace_display_path(display_path: str) -> str:
207 + normalized = normalize_display_path(display_path)
208 + real_path = real_path_for_display(normalized)
209 + canonical = normalize_display_path(str(real_path))
210 + return (canonical if canonical.startswith("/a0") else normalized).rstrip("/") or canonical
211 +
212 +
213 def resolve_workspace(context_id: str = "", *, context_loader=None) -> WorkspaceInfo:
214 from helpers import projects, settings
215
@@ -220,7 +228,7 @@ def resolve_workspace(context_id: str = "", *, context_loader=None) -> Workspace
228 configured = str(settings.get_settings().get("workdir_path") or "")
229 display_path = configured or files.normalize_a0_path(files.get_abs_path("usr/workdir"))
230
223 - normalized = normalize_display_path(display_path)
231 + normalized = canonical_workspace_display_path(display_path)
232 if not is_inside_usr_display(normalized):
233 raise WorkspaceRejectedError("Time Travel is only available for workspaces inside /a0/usr.")
234
@@ -241,7 +249,7 @@ def resolve_workspace(context_id: str = "", *, context_loader=None) -> Workspace
249 def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
250 from helpers import settings
251
244 - normalized = normalize_display_path(path_hint)
252 + normalized = canonical_workspace_display_path(path_hint)
253 if not is_inside_usr_display(normalized):
254 return None
255
@@ -251,7 +259,7 @@ def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
259 return _workspace_from_display(project_display, project_name=parts[3])
260
261 configured = str(settings.get_settings().get("workdir_path") or "")
254 - workdir_display = normalize_display_path(configured or files.normalize_a0_path(files.get_abs_path("usr/workdir")))
262 + workdir_display = canonical_workspace_display_path(configured or files.normalize_a0_path(files.get_abs_path("usr/workdir")))
263 if normalized == workdir_display or normalized.startswith(workdir_display.rstrip("/") + "/"):
264 return _workspace_from_display(workdir_display)
265
@@ -259,7 +267,7 @@ def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
267
268
269 def _workspace_from_display(display_path: str, *, project_name: str = "", context_id: str = "") -> WorkspaceInfo:
262 - normalized = normalize_display_path(display_path)
270 + normalized = canonical_workspace_display_path(display_path)
271 if not is_inside_usr_display(normalized):
272 raise WorkspaceRejectedError("Time Travel is only available for workspaces inside /a0/usr.")
273 workspace_id = workspace_id_for(normalized)
@@ -553,22 +561,11 @@ class TimeTravelService:
561
562 def ensure_repo(self) -> None:
563 self.workspace.shadow_path.mkdir(parents=True, exist_ok=True)
556 - if not self.workspace.repo_git_path.exists():
557 - completed = subprocess.run(
558 - ["git", "init", "--bare", str(self.workspace.repo_git_path)],
559 - capture_output=True,
560 - text=True,
561 - encoding="utf-8",
562 - errors="replace",
563 - timeout=GIT_TIMEOUT_SECONDS,
564 - )
565 - if completed.returncode != 0:
566 - raise GitCommandError(
567 - (completed.stderr or completed.stdout or "Could not initialize shadow Git repository.").strip(),
568 - stdout=completed.stdout,
569 - stderr=completed.stderr,
570 - )
571 - self._git("symbolic-ref", "HEAD", CURRENT_REF)
564 + if not self._shadow_repo_valid():
565 + self._repair_shadow_repo_head()
566 + if not self._shadow_repo_valid():
567 + self._initialize_shadow_repo(quarantine_existing=True)
568 + self._ensure_current_head_ref()
569
570 self._git("config", "user.name", "Agent Zero Time Travel")
571 self._git("config", "user.email", "time-travel@agent-zero.local")
@@ -854,6 +851,7 @@ class TimeTravelService:
851 payload = "\0".join(paths).encode("utf-8") + b"\0"
852 self._git_bytes(
853 "add",
854 + "-f",
855 "-A",
856 "--pathspec-from-file=-",
857 "--pathspec-file-nul",
@@ -1106,6 +1104,102 @@ class TimeTravelService:
1104 env["GIT_OPTIONAL_LOCKS"] = "0"
1105 return env
1106
1107 + def _run_git_dir(self, *args: str, check: bool = False) -> subprocess.CompletedProcess[str]:
1108 + return subprocess.run(
1109 + ["git", f"--git-dir={self.workspace.repo_git_path}", *args],
1110 + capture_output=True,
1111 + text=True,
1112 + encoding="utf-8",
1113 + errors="replace",
1114 + env=self._git_env(),
1115 + timeout=GIT_TIMEOUT_SECONDS,
1116 + check=check,
1117 + )
1118 +
1119 + def _shadow_repo_valid(self) -> bool:
1120 + if not self.workspace.repo_git_path.is_dir():
1121 + return False
1122 + completed = self._run_git_dir("rev-parse", "--git-dir")
1123 + return completed.returncode == 0
1124 +
1125 + def _repair_shadow_repo_head(self) -> None:
1126 + if not self.workspace.repo_git_path.is_dir():
1127 + return
1128 + if not (self.workspace.repo_git_path / "objects").is_dir() or not (self.workspace.repo_git_path / "refs").is_dir():
1129 + return
1130 + target_ref = CURRENT_REF if self._loose_ref_exists(CURRENT_REF) else self._first_loose_head_ref()
1131 + try:
1132 + (self.workspace.repo_git_path / "HEAD").write_text(f"ref: {target_ref}\n", encoding="utf-8")
1133 + except OSError:
1134 + return
1135 +
1136 + def _initialize_shadow_repo(self, *, quarantine_existing: bool = False) -> None:
1137 + if quarantine_existing and self.workspace.repo_git_path.exists():
1138 + backup_path = self._next_invalid_repo_backup_path()
1139 + shutil.move(str(self.workspace.repo_git_path), str(backup_path))
1140 + completed = subprocess.run(
1141 + ["git", "init", "--bare", str(self.workspace.repo_git_path)],
1142 + capture_output=True,
1143 + text=True,
1144 + encoding="utf-8",
1145 + errors="replace",
1146 + env=self._git_env(),
1147 + timeout=GIT_TIMEOUT_SECONDS,
1148 + )
1149 + if completed.returncode != 0:
1150 + raise GitCommandError(
1151 + (completed.stderr or completed.stdout or "Could not initialize shadow Git repository.").strip(),
1152 + stdout=completed.stdout,
1153 + stderr=completed.stderr,
1154 + )
1155 + updated = self._run_git_dir("symbolic-ref", "HEAD", CURRENT_REF)
1156 + if updated.returncode != 0:
1157 + raise GitCommandError(
1158 + (updated.stderr or updated.stdout or "Could not initialize shadow Git HEAD.").strip(),
1159 + stdout=updated.stdout,
1160 + stderr=updated.stderr,
1161 + )
1162 +
1163 + def _loose_ref_exists(self, ref: str) -> bool:
1164 + return self.workspace.repo_git_path.joinpath(*ref.split("/")).is_file()
1165 +
1166 + def _first_loose_head_ref(self) -> str:
1167 + heads_dir = self.workspace.repo_git_path / "refs" / "heads"
1168 + try:
1169 + refs = sorted(path for path in heads_dir.rglob("*") if path.is_file())
1170 + except OSError:
1171 + refs = []
1172 + if not refs:
1173 + return CURRENT_REF
1174 + return "refs/heads/" + refs[0].relative_to(heads_dir).as_posix()
1175 +
1176 + def _next_invalid_repo_backup_path(self) -> Path:
1177 + stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
1178 + base_path = self.workspace.shadow_path / f"{SHADOW_REPO_BACKUP_PREFIX}-{stamp}"
1179 + backup_path = base_path
1180 + counter = 2
1181 + while backup_path.exists():
1182 + backup_path = self.workspace.shadow_path / f"{base_path.name}-{counter}"
1183 + counter += 1
1184 + return backup_path
1185 +
1186 + def _ensure_current_head_ref(self) -> None:
1187 + current_ref = self._run_git_dir("symbolic-ref", "-q", "HEAD")
1188 + if current_ref.returncode == 0 and current_ref.stdout.strip() == CURRENT_REF:
1189 + return
1190 +
1191 + current_commit = self._run_git_dir("rev-parse", "--verify", "HEAD^{commit}")
1192 + if current_commit.returncode == 0:
1193 + self._run_git_dir("update-ref", CURRENT_REF, current_commit.stdout.strip())
1194 +
1195 + updated = self._run_git_dir("symbolic-ref", "HEAD", CURRENT_REF)
1196 + if updated.returncode != 0:
1197 + raise GitCommandError(
1198 + (updated.stderr or updated.stdout or "Could not repair shadow Git HEAD.").strip(),
1199 + stdout=updated.stdout,
1200 + stderr=updated.stderr,
1201 + )
1202 +
1203 def _git(self, *args: str, input: str | None = None, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
1204 self.workspace.shadow_path.mkdir(parents=True, exist_ok=True)
1205 completed = subprocess.run(
tests/test_time_travel.py
+52
@@ -135,6 +135,58 @@ def test_kernel_boundary_real_git_repo_and_git_dir_exclusion(workspace):
135 assert "untracked.txt" in tracked_paths(service, snapshot.hash)
136
137
138 +def test_snapshot_force_adds_curated_paths_ignored_by_workspace_gitignore(workspace):
139 + root, service = workspace
140 + (root / ".gitignore").write_text("ignored.txt\nignored-dir/\n.env\n", encoding="utf-8")
141 + (root / "ignored.txt").write_text("still important\n", encoding="utf-8")
142 + (root / "ignored-dir").mkdir()
143 + (root / "ignored-dir" / "note.txt").write_text("nested\n", encoding="utf-8")
144 + (root / ".env").write_text("SECRET=still excluded\n", encoding="utf-8")
145 +
146 + snapshot = service.snapshot(trigger="manual")
147 + paths = tracked_paths(service, snapshot.hash)
148 +
149 + assert ".gitignore" in paths
150 + assert "ignored.txt" in paths
151 + assert "ignored-dir/note.txt" in paths
152 + assert ".env" not in paths
153 +
154 +
155 +def test_shadow_repo_empty_head_is_repaired_without_losing_history(workspace):
156 + root, service = workspace
157 + (root / "a.txt").write_text("one\n", encoding="utf-8")
158 + first = service.snapshot(trigger="manual")
159 + (service.workspace.repo_git_path / "HEAD").write_text("", encoding="utf-8")
160 +
161 + (root / "a.txt").write_text("one\ntwo\n", encoding="utf-8")
162 + second = service.snapshot(trigger="manual")
163 +
164 + assert second.created is True
165 + assert service.current_hash() == second.hash
166 + assert [commit["hash"] for commit in service.history_list(limit=10)["commits"][:2]] == [
167 + second.hash,
168 + first.hash,
169 + ]
170 +
171 +
172 +def test_workspace_identity_canonicalizes_symlink_aliases():
173 + name = f"tt-{uuid.uuid4().hex}"
174 + root = PROJECT_ROOT / "usr" / "time-travel-tests" / name
175 + target = root / "target"
176 + alias = root / "alias"
177 + target.mkdir(parents=True)
178 + os.symlink(target, alias)
179 +
180 + target_workspace = _workspace_from_display(f"/a0/usr/time-travel-tests/{name}/target")
181 + alias_workspace = _workspace_from_display(f"/a0/usr/time-travel-tests/{name}/alias")
182 + try:
183 + assert alias_workspace.id == target_workspace.id
184 + assert alias_workspace.display_path == target_workspace.display_path
185 + finally:
186 + shutil.rmtree(root, ignore_errors=True)
187 + shutil.rmtree(target_workspace.shadow_path, ignore_errors=True)
188 +
189 +
190 def test_usr_root_snapshot_skips_plugins_and_nested_git_projects(tmp_path: Path):
191 root = tmp_path / "usr"
192 root.mkdir()