Debounce Time Travel snapshots with usr watchdog

Register Time Travel on Agent Zero's existing /a0/usr watchdog and coalesce automatic snapshot triggers into a single pending commit window capped at one commit per workspace every 10 seconds. Exclude top-level /a0/usr plugins and nested Git worktrees from root snapshots, preserve self-root Git workspace tracking, and cover the behavior with Time Travel tests.

Alessandro committed Apr 27, 2026 at 19:06 UTC 59e23500085dde8669ab76e7b2ede697f411b11d
5 files changed +310 -18
plugins/_time_travel/extensions/python/_functions/__main__/init_a0/end/_30_register_watchdog.py new
+9
@@ -0,0 +1,9 @@
1 +from __future__ import annotations
2 +
3 +from helpers.extension import Extension
4 +from plugins._time_travel.helpers.time_travel import register_watchdogs
5 +
6 +
7 +class RegisterTimeTravelWatchdog(Extension):
8 + def execute(self, **kwargs):
9 + register_watchdogs()
plugins/_time_travel/extensions/python/_functions/run_ui/init_a0/end/_30_register_watchdog.py new
+9
@@ -0,0 +1,9 @@
1 +from __future__ import annotations
2 +
3 +from helpers.extension import Extension
4 +from plugins._time_travel.helpers.time_travel import register_watchdogs
5 +
6 +
7 +class RegisterTimeTravelWatchdog(Extension):
8 + def execute(self, **kwargs):
9 + register_watchdogs()
plugins/_time_travel/extensions/python/tool_execute_after/_50_code_execution_snapshot.py
-12
@@ -1,28 +1,16 @@
1 from __future__ import annotations
2
3 -import time
3 from typing import Any
4
5 from helpers.extension import Extension
6 from plugins._time_travel.helpers.time_travel import snapshot_for_agent
7
8
10 -DEBOUNCE_SECONDS = 2.0
11 -_LAST_SNAPSHOT_BY_CONTEXT: dict[str, float] = {}
12 -
13 -
9 class TimeTravelCodeExecutionSnapshot(Extension):
10 async def execute(self, tool_name: str = "", response: Any = None, **kwargs: Any):
11 if tool_name != "code_execution_tool" or not self.agent:
12 return
13
19 - context_id = str(getattr(getattr(self.agent, "context", None), "id", "") or "")
20 - now = time.monotonic()
21 - if context_id and now - _LAST_SNAPSHOT_BY_CONTEXT.get(context_id, 0.0) < DEBOUNCE_SECONDS:
22 - return
23 - if context_id:
24 - _LAST_SNAPSHOT_BY_CONTEXT[context_id] = now
25 -
14 tool = getattr(getattr(self.agent, "loop_data", None), "current_tool", None)
15 args = getattr(tool, "args", {}) if tool else {}
16 runtime = str(args.get("runtime") or "") if isinstance(args, dict) else ""
plugins/_time_travel/helpers/time_travel.py
+231 -6
@@ -8,6 +8,7 @@ import os
8 import posixpath
9 import shutil
10 import subprocess
11 +import threading
12 import time
13 from dataclasses import dataclass
14 from datetime import datetime, timezone
@@ -27,6 +28,13 @@ METADATA_PREFIX = "A0-Time-Travel-Metadata:"
28 EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
29 MAX_RENDERED_PATCH_BYTES = 1_000_000
30 GIT_TIMEOUT_SECONDS = 20
31 +AUTO_SNAPSHOT_DEBOUNCE_SECONDS = 10.0
32 +WATCHDOG_ID = "time_travel_usr"
33 +WATCHDOG_DEBOUNCE_SECONDS = 1.0
34 +
35 +_AUTO_SNAPSHOT_LOCK = threading.RLock()
36 +_AUTO_SNAPSHOT_TIMERS: dict[str, threading.Timer] = {}
37 +_AUTO_SNAPSHOT_PAYLOADS: dict[str, dict[str, Any]] = {}
38
39 STATUS_LABELS = {
40 "A": "added",
@@ -78,6 +86,10 @@ EXCLUDED_FILE_PATTERNS = {
86 "*.class",
87 }
88
89 +USR_ROOT_EXCLUDED_DIR_NAMES = {
90 + "plugins",
91 +}
92 +
93 SAFE_A0PROJ_FILES = {
94 ".a0proj/project.json",
95 ".a0proj/agents.json",
@@ -291,14 +303,29 @@ def clean_summary() -> dict[str, Any]:
303 }
304
305
294 -def snapshot_for_agent(agent: Any, *, trigger: str, metadata: dict[str, Any] | None = None) -> SnapshotResult | None:
306 +def snapshot_for_agent(
307 + agent: Any,
308 + *,
309 + trigger: str,
310 + metadata: dict[str, Any] | None = None,
311 + debounced: bool = True,
312 +) -> SnapshotResult | None:
313 if not agent:
314 return None
315
316 context_id = str(getattr(getattr(agent, "context", None), "id", "") or "")
317 try:
318 workspace = resolve_workspace(context_id, context_loader=lambda _ctxid: agent.context)
301 - return TimeTravelService(workspace).snapshot(trigger=trigger, metadata=_agent_metadata(agent, metadata))
319 + full_metadata = _agent_metadata(agent, metadata)
320 + if debounced:
321 + schedule_debounced_snapshot(
322 + workspace,
323 + trigger=trigger,
324 + metadata=full_metadata,
325 + changed_path_hints=_extract_changed_path_hints(full_metadata),
326 + )
327 + return None
328 + return TimeTravelService(workspace).snapshot(trigger=trigger, metadata=full_metadata)
329 except WorkspaceRejectedError:
330 return None
331 except Exception as exc:
@@ -306,17 +333,167 @@ def snapshot_for_agent(agent: Any, *, trigger: str, metadata: dict[str, Any] | N
333 return None
334
335
309 -def snapshot_for_path_hint(path_hint: str, *, trigger: str, metadata: dict[str, Any] | None = None) -> SnapshotResult | None:
336 +def snapshot_for_path_hint(
337 + path_hint: str,
338 + *,
339 + trigger: str,
340 + metadata: dict[str, Any] | None = None,
341 + debounced: bool = True,
342 +) -> SnapshotResult | None:
343 try:
344 workspace = resolve_workspace_for_path_hint(path_hint)
345 if workspace is None:
346 return None
314 - return TimeTravelService(workspace).snapshot(trigger=trigger, metadata=metadata or {})
347 + full_metadata = metadata or {}
348 + if debounced:
349 + schedule_debounced_snapshot(
350 + workspace,
351 + trigger=trigger,
352 + metadata=full_metadata,
353 + changed_path_hints=_extract_changed_path_hints(full_metadata),
354 + )
355 + return None
356 + return TimeTravelService(workspace).snapshot(trigger=trigger, metadata=full_metadata)
357 except Exception as exc:
358 PrintStyle.error(f"Time Travel file-browser snapshot failed: {exc}")
359 return None
360
361
362 +def register_watchdogs() -> None:
363 + from helpers import watchdog
364 +
365 + root = real_path_for_display(USR_DISPLAY_ROOT)
366 + if not root.exists() or not root.is_dir():
367 + return
368 +
369 + watchdog.add_watchdog(
370 + id=WATCHDOG_ID,
371 + roots=[str(root)],
372 + patterns=["**/*"],
373 + ignore_patterns=[
374 + "**/.git",
375 + "**/.git/**",
376 + "**/.time_travel",
377 + "**/.time_travel/**",
378 + "**/__pycache__",
379 + "**/__pycache__/**",
380 + "**/*.pyc",
381 + "**/.pytest_cache/**",
382 + "**/.mypy_cache/**",
383 + "**/.ruff_cache/**",
384 + "**/.cache/**",
385 + "**/node_modules/**",
386 + "**/.venv/**",
387 + "**/venv/**",
388 + "**/dist/**",
389 + "**/build/**",
390 + ],
391 + events=["create", "modify", "delete", "move"],
392 + debounce=WATCHDOG_DEBOUNCE_SECONDS,
393 + handler=_handle_usr_watchdog_events,
394 + )
395 +
396 +
397 +def schedule_debounced_snapshot(
398 + workspace: WorkspaceInfo,
399 + *,
400 + trigger: str,
401 + metadata: dict[str, Any] | None = None,
402 + changed_path_hints: list[str] | None = None,
403 + delay: float | None = None,
404 +) -> None:
405 + clean_metadata = dict(metadata or {})
406 + metadata_hints = _extract_changed_path_hints(clean_metadata)
407 + clean_metadata.pop("changed_path_hints", None)
408 + hints = _merge_hints(metadata_hints, changed_path_hints or [])
409 + delay_seconds = AUTO_SNAPSHOT_DEBOUNCE_SECONDS if delay is None else max(0.0, float(delay))
410 + with _AUTO_SNAPSHOT_LOCK:
411 + payload = _AUTO_SNAPSHOT_PAYLOADS.get(workspace.id)
412 + if payload is None:
413 + payload = {
414 + "workspace": workspace,
415 + "trigger": trigger,
416 + "metadata": clean_metadata,
417 + "changed_path_hints": hints,
418 + }
419 + _AUTO_SNAPSHOT_PAYLOADS[workspace.id] = payload
420 + timer = threading.Timer(delay_seconds, _flush_debounced_snapshot, args=(workspace.id,))
421 + timer.daemon = True
422 + _AUTO_SNAPSHOT_TIMERS[workspace.id] = timer
423 + timer.start()
424 + return
425 +
426 + payload["trigger"] = trigger
427 + payload["metadata"] = {**payload.get("metadata", {}), **clean_metadata}
428 + payload["changed_path_hints"] = _merge_hints(
429 + payload.get("changed_path_hints", []),
430 + hints,
431 + )
432 +
433 +
434 +def flush_debounced_snapshots() -> None:
435 + with _AUTO_SNAPSHOT_LOCK:
436 + workspace_ids = list(_AUTO_SNAPSHOT_PAYLOADS)
437 + for workspace_id in workspace_ids:
438 + timer = _AUTO_SNAPSHOT_TIMERS.pop(workspace_id, None)
439 + timer and timer.cancel()
440 + for workspace_id in workspace_ids:
441 + _flush_debounced_snapshot(workspace_id)
442 +
443 +
444 +def clear_debounced_snapshots() -> None:
445 + with _AUTO_SNAPSHOT_LOCK:
446 + timers = list(_AUTO_SNAPSHOT_TIMERS.values())
447 + _AUTO_SNAPSHOT_TIMERS.clear()
448 + _AUTO_SNAPSHOT_PAYLOADS.clear()
449 + for timer in timers:
450 + timer.cancel()
451 +
452 +
453 +def _flush_debounced_snapshot(workspace_id: str) -> None:
454 + with _AUTO_SNAPSHOT_LOCK:
455 + _AUTO_SNAPSHOT_TIMERS.pop(workspace_id, None)
456 + payload = _AUTO_SNAPSHOT_PAYLOADS.pop(workspace_id, None)
457 + if not payload:
458 + return
459 +
460 + try:
461 + workspace = payload["workspace"]
462 + TimeTravelService(workspace).snapshot(
463 + trigger=str(payload.get("trigger") or "watchdog"),
464 + metadata=payload.get("metadata") or {},
465 + changed_path_hints=payload.get("changed_path_hints") or None,
466 + )
467 + except WorkspaceRejectedError:
468 + return
469 + except Exception as exc:
470 + PrintStyle.error(f"Time Travel debounced snapshot failed: {exc}")
471 +
472 +
473 +def _handle_usr_watchdog_events(items: list[Any]) -> None:
474 + by_workspace: dict[str, tuple[WorkspaceInfo, list[str]]] = {}
475 + for path, _event in items:
476 + display_path = normalize_display_path(str(path or ""))
477 + if not _is_watchdog_snapshot_candidate(display_path):
478 + continue
479 + workspace = resolve_workspace_for_path_hint(display_path)
480 + if workspace is None:
481 + continue
482 + hints = by_workspace.setdefault(workspace.id, (workspace, []))[1]
483 + hints.append(display_path)
484 +
485 + for workspace, hints in by_workspace.values():
486 + schedule_debounced_snapshot(
487 + workspace,
488 + trigger="watchdog",
489 + metadata={
490 + "source": "watchdog",
491 + "changed_path_hints": _merge_hints(hints),
492 + },
493 + changed_path_hints=hints,
494 + )
495 +
496 +
497 def _agent_metadata(agent: Any, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
498 from helpers import projects
499
@@ -340,6 +517,36 @@ def _agent_metadata(agent: Any, metadata: dict[str, Any] | None = None) -> dict[
517 return {key: value for key, value in result.items() if value not in (None, "")}
518
519
520 +def _extract_changed_path_hints(metadata: dict[str, Any]) -> list[str]:
521 + hints = metadata.get("changed_path_hints")
522 + if not isinstance(hints, list):
523 + return []
524 + return [str(path) for path in hints if path]
525 +
526 +
527 +def _merge_hints(*groups: list[str]) -> list[str]:
528 + merged: list[str] = []
529 + seen: set[str] = set()
530 + for group in groups:
531 + for path in group:
532 + normalized = normalize_display_path(str(path or ""))
533 + if not normalized or normalized in seen:
534 + continue
535 + merged.append(normalized)
536 + seen.add(normalized)
537 + return merged
538 +
539 +
540 +def _is_watchdog_snapshot_candidate(display_path: str) -> bool:
541 + normalized = normalize_display_path(display_path)
542 + if not is_inside_usr_display(normalized):
543 + return False
544 + if normalized == "/a0/usr/plugins" or normalized.startswith("/a0/usr/plugins/"):
545 + return False
546 + parts = [part for part in normalized.split("/") if part]
547 + return ".git" not in parts and ".time_travel" not in parts
548 +
549 +
550 class TimeTravelService:
551 def __init__(self, workspace: WorkspaceInfo):
552 self.workspace = workspace
@@ -642,7 +849,7 @@ class TimeTravelService:
849 def _stage_current_tree(self) -> tuple[str, list[str]]:
850 self.ensure_repo()
851 self._git("read-tree", "--empty")
645 - paths = list(iter_snapshot_paths(self.workspace.real_path))
852 + paths = list(iter_snapshot_paths(self.workspace.real_path, display_path=self.workspace.display_path))
853 if paths:
854 payload = "\0".join(paths).encode("utf-8") + b"\0"
855 self._git_bytes(
@@ -951,8 +1158,12 @@ class TimeTravelService:
1158 return completed
1159
1160
954 -def iter_snapshot_paths(workspace: Path) -> Iterable[str]:
1161 +def iter_snapshot_paths(workspace: Path, *, display_path: str = "") -> Iterable[str]:
1162 workspace = workspace.resolve(strict=False)
1163 + if display_path:
1164 + root_is_usr = normalize_display_path(display_path) == USR_DISPLAY_ROOT
1165 + else:
1166 + root_is_usr = workspace == real_path_for_display(USR_DISPLAY_ROOT)
1167
1168 def walk(folder: Path, rel_prefix: str = "") -> Iterable[str]:
1169 try:
@@ -970,6 +1181,10 @@ def iter_snapshot_paths(workspace: Path) -> Iterable[str]:
1181 except OSError:
1182 continue
1183 if is_dir:
1184 + if root_is_usr and not rel_prefix and entry.name in USR_ROOT_EXCLUDED_DIR_NAMES:
1185 + continue
1186 + if _is_nested_git_worktree_dir(Path(entry.path), workspace):
1187 + continue
1188 if not is_snapshot_candidate(rel, is_dir=True):
1189 continue
1190 yield from walk(Path(entry.path), rel)
@@ -979,6 +1194,16 @@ def iter_snapshot_paths(workspace: Path) -> Iterable[str]:
1194 yield from walk(workspace)
1195
1196
1197 +def _is_nested_git_worktree_dir(folder: Path, workspace: Path) -> bool:
1198 + try:
1199 + if folder.resolve(strict=False) == workspace.resolve(strict=False):
1200 + return False
1201 + except OSError:
1202 + return False
1203 + dot_git = folder / ".git"
1204 + return dot_git.exists() or dot_git.is_symlink()
1205 +
1206 +
1207 def is_snapshot_candidate(rel_path: str, *, is_dir: bool) -> bool:
1208 rel = rel_path.replace("\\", "/").strip("/")
1209 if not rel:
tests/test_time_travel.py
+61
@@ -131,6 +131,29 @@ def test_kernel_boundary_real_git_repo_and_git_dir_exclusion(workspace):
131 assert run_git(root, "rev-parse", "HEAD") == real_head
132 assert run_git(root, "status", "--short") == real_status_before
133 assert all(not path.startswith(".git/") and path != ".git" for path in tracked_paths(service))
134 + assert "tracked.txt" in tracked_paths(service, snapshot.hash)
135 + assert "untracked.txt" in tracked_paths(service, snapshot.hash)
136 +
137 +
138 +def test_usr_root_snapshot_skips_plugins_and_nested_git_projects(tmp_path: Path):
139 + root = tmp_path / "usr"
140 + root.mkdir()
141 + (root / "workdir").mkdir()
142 + (root / "workdir" / "note.txt").write_text("note\n", encoding="utf-8")
143 + (root / "plugins" / "demo").mkdir(parents=True)
144 + (root / "plugins" / "demo" / "plugin.yaml").write_text("name: demo\n", encoding="utf-8")
145 + (root / "projects" / "git-project").mkdir(parents=True)
146 + (root / "projects" / "git-project" / ".git").mkdir()
147 + (root / "projects" / "git-project" / "app.py").write_text("print('tracked elsewhere')\n", encoding="utf-8")
148 + (root / "projects" / "plain-project").mkdir(parents=True)
149 + (root / "projects" / "plain-project" / "app.py").write_text("print('plain')\n", encoding="utf-8")
150 +
151 + paths = set(tt.iter_snapshot_paths(root, display_path="/a0/usr"))
152 +
153 + assert "workdir/note.txt" in paths
154 + assert "projects/plain-project/app.py" in paths
155 + assert "plugins/demo/plugin.yaml" not in paths
156 + assert "projects/git-project/app.py" not in paths
157
158
159 def test_metadata_policy_tracks_safe_project_files_and_preserves_exclusions(workspace):
@@ -238,6 +261,44 @@ def test_pagination_large_diff_and_invalid_inputs(workspace, monkeypatch: pytest
261 service.history_diff(commit_hash=hashes[-1], path="../file.txt", mode="commit")
262
263
264 +def test_debounced_snapshots_coalesce_to_one_commit(workspace):
265 + root, service = workspace
266 + tt.clear_debounced_snapshots()
267 + try:
268 + (root / "file.txt").write_text("one\n", encoding="utf-8")
269 + tt.schedule_debounced_snapshot(
270 + service.workspace,
271 + trigger="watchdog",
272 + metadata={"source": "watchdog", "changed_path_hints": ["/a0/usr/file.txt"]},
273 + delay=60,
274 + )
275 + assert service.current_hash() == ""
276 +
277 + (root / "file.txt").write_text("two\n", encoding="utf-8")
278 + tt.schedule_debounced_snapshot(
279 + service.workspace,
280 + trigger="text_editor_write",
281 + metadata={"source": "text_editor", "changed_path_hints": ["/a0/usr/other.txt"]},
282 + delay=60,
283 + )
284 + tt.flush_debounced_snapshots()
285 +
286 + current = service.current_hash()
287 + assert current
288 + commits = service.history_list(limit=10)["commits"]
289 + assert len(commits) == 1
290 + assert commits[0]["hash"] == current
291 + assert commits[0]["metadata"]["trigger"] == "text_editor_write"
292 + assert commits[0]["metadata"]["source"] == "text_editor"
293 + assert commits[0]["metadata"]["changed_path_hints"] == [
294 + "/a0/usr/file.txt",
295 + "/a0/usr/other.txt",
296 + ]
297 + assert "two" in service.history_diff(commit_hash=current, path="file.txt", mode="commit")["patch"]
298 + finally:
299 + tt.clear_debounced_snapshots()
300 +
301 +
302 def test_workspace_resolution_prefers_project_and_rejects_external_paths(monkeypatch: pytest.MonkeyPatch, workspace):
303 root, _service = workspace
304 projects_mod = ModuleType("helpers.projects")