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")