Skip to content

[v0.2.1] File watcher infrastructure for agent code (MAH-80)#39

Merged
mahimairaja merged 10 commits into
mainfrom
feat/file-watcher
May 6, 2026
Merged

[v0.2.1] File watcher infrastructure for agent code (MAH-80)#39
mahimairaja merged 10 commits into
mainfrom
feat/file-watcher

Conversation

@mahimairaja
Copy link
Copy Markdown
Collaborator

@mahimairaja mahimairaja commented May 6, 2026

Summary

Phase 3, issue 1 of 8 — the foundation for v0.2 hot reload. Adds an
asyncio-native file watcher that monitors user-edited Python modules
and emits debounced change events. No existing code path changes
behavior
; reload, re-import, and session re-binding ship in MAH-81+.

Public API (locked at design.md §3.5)

from openrtc import FileWatcher, FileChange

watcher = FileWatcher(on_change, debounce_ms=200, paths=None)
await watcher.start()
# user edits agent.py 5 times in 50 ms
# on_change fires once, ~200 ms after the last save
await watcher.stop()

What ships

File Purpose
src/openrtc/execution/file_watcher.py (+400) FileChange, FileWatcher, _discover_user_modules, _collapse_changes
src/openrtc/init.py (+3) Re-export FileWatcher and FileChange
pyproject.toml (+1) watchfiles>=0.21,<2 (was a transitive dep at 1.1.1; now explicit)
tests/execution/test_file_watcher.py (+543) 37 unit tests across 7 test classes
tests/execution/test_file_watcher_smoke.py (+66) end-to-end against a real tempdir

Acceptance criteria (design.md §6)

  • ✓ AC 1 — Library works: watchfiles 1.1.1 has prebuilt wheels for Linux x86_64/aarch64 and macOS x86_64/arm64; CI runs Linux; macOS verified locally.
  • ✓ AC 2 — User-module discovery: synthetic user module under tempdir is included; synthetic site-packages module is excluded; modules without file don't raise.
  • ✓ AC 3 — Debounce: 5 batches over 25 ms produce exactly 1 callback ~200 ms after the last event (±50 ms).
  • ✓ AC 4 — Event shape: list[FileChange] with absolute paths and a Literal["created", "modified", "deleted"] change_type.
  • ✓ AC 5 — Lifecycle: start() and stop() are idempotent; stop() cancels in-flight tasks cleanly; start() after stop() raises (no restart); refresh_paths() updates the auto-discovered set.

Plus the two non-AC bars:

  • ✓ No behavior change: prior 383 tests still pass at 4.04 s (baseline 4.18 s, -3 % drift).
  • ✓ No async leaks: pytest tests/execution/ -W error runs clean under asyncio-mode=strict.

Test plan

  • CI lint + integration jobs pass (Linux)
  • uv run pytest -q shows 421 passed, 2 skipped
  • uv run pytest tests/execution/test_file_watcher_smoke.py -v passes (live filesystem watch)
  • from openrtc import FileWatcher, FileChange works in a fresh interpreter
  • No main touched, no tags created (verify git log main..feat/file-watcher lists 9 commits, git tag --contains feat/file-watcher is empty)

Discoveries logged for follow-ups (out of scope here)

  • watchfiles' default recursive=True works fine for file paths on macOS; if Linux exposes a divergence, log a Discovery and revisit per-file vs per-directory watching.
  • Auto-discovery snapshots sys.modules at watcher start. Modules imported lazily after start are missed until refresh_paths() is called — documented v0.2.1 limitation; v0.2.6 (CLI --watch) calls refresh after each successful reload.
  • Nothing consumes the events yet; MAH-81 wires the re-import + rollback path through the same on_change callback.

Summary by CodeRabbit

  • New Features
    • Introduced file watching functionality to detect changes in user-editable modules with automatic change detection, debouncing, and asynchronous callback support.
    • New FileWatcher and FileChange public APIs now available.

Promote watchfiles from a transitive dependency (via livekit-agents)
to an explicit one. Used by the file watcher landing in
src/openrtc/execution/file_watcher.py for MAH-80. Pin >=0.21,<2 to
accept the originally-targeted 0.21 line and the current 1.x
(1.1.1 ships with livekit-agents 1.5).
Introduce src/openrtc/execution/file_watcher.py with the FileChange
frozen dataclass (path + change_type). Frozen=True gives equality and
hash for free, so FileChange instances can be deduplicated through a
set during the debounce coalesce step (MAH-80 Step 7).

This is the public event-payload contract for the watcher; subsequent
commits add the discovery helper and the FileWatcher class itself.
Add the private _discover_user_modules() helper. Walks sys.modules,
keeps modules with a real __file__, drops anything under
site.getsitepackages() / site.getusersitepackages() / sys.prefix /
sys.base_prefix, returns distinct absolute paths.

Tests cover: synthetic user module under tempdir is included; synthetic
site-packages module is excluded; modules with __file__ = None or
missing don't raise; aliased modules pointing at the same file collapse
to one entry.
Add the FileWatcher class with the locked public API (design.md §3.5):
construction with on_change + debounce_ms + paths, async start()/stop(),
sync refresh_paths(), and the new -> running -> stopped state machine.

The skeleton lands without watchfiles wiring or debounce logic — those
are MAH-80 Steps 6 and 7. start() and stop() are idempotent; start()
after stop() raises (no restart). refresh_paths() rebuilds the path
list only for auto-discover watchers; it is a no-op when explicit
paths were supplied.

Eleven new tests cover construction, debounce-validation, path-copy
isolation, lifecycle idempotency, the no-restart guard, and refresh
behavior in both auto and explicit modes.
start() now spawns a background asyncio task that consumes
watchfiles.awatch(*paths, stop_event=...) and translates each
(Change, str) into a FileChange. The watchfiles Change enum is
mapped explicitly: added -> created, modified -> modified,
deleted -> deleted; unrecognized variants are dropped.

Events land in self._pending. The trailing-edge debounce that flushes
the buffer through on_change ships in the next commit (Step 7).

stop() sets the stop event and cancels the watch task, awaiting it
under contextlib.suppress(CancelledError) so shutdown is clean.
Three new tests cover the live wiring (write -> buffered FileChange),
clean cancellation, and the empty-path-list edge case.
…Watcher

Add the trailing-edge debounce on top of the watchfiles event loop.
The pipeline is now: watch loop -> _handle_change_batch (extends
_pending, reschedules _flush_task) -> _flush_after sleeps debounce_ms
-> _collapse_changes -> on_change.

_collapse_changes follows design.md §3.4: deleted dominates,
created beats modified, modified is the fall-through. First-seen
path order is preserved.

User callback exceptions are caught and logged; the watcher keeps
running. stop() cancels both the watch task and any in-flight flush
task, drops _pending, and shuts down without leaking tasks or firing
the cancelled flush.

Ten new tests cover the design-doc collapse rules and the four AC-3
scenarios: 1 save -> 1 callback at ~200ms, 5 rapid saves -> 1 callback
~200ms after the last, raising callback isolated, stop during pending
flush cancels cleanly with no leaked tasks.
Add the locked public API surface (design.md §3.5) to the openrtc
package: from openrtc import FileWatcher, FileChange works in a
fresh interpreter. Both names land in __all__ alongside AgentPool,
AgentConfig, etc., so star-imports and IDEs see them.

Three tests guard the contract: a fresh subprocess imports both names
without going through the runtime, the names appear in __all__, and
the package-level re-exports are object-identity with the originals.
End-to-end smoke covering watchfiles -> _handle_change_batch ->
trailing debounce -> on_change against a real file. Writes initial
contents, starts the watcher, edits the file, waits up to 3s for the
debounced callback, then asserts exactly one callback arrived with the
expected path and a non-deleted change_type. Finally checks the clean
shutdown contract: state -> stopped, _watch_task and _flush_task both
cleared.
Expand the module-level docstring with a Contract summary covering
discovery, debounce, callback isolation, and stop() semantics. Each
public method now documents Args / Side effects / Raises explicitly:

- __init__: each parameter described, ValueError raise contract
- start: side effect (task + event creation, state transition),
  RuntimeError on restart, idempotency note
- stop: side effects (state, stop_event, task cancellation, dropped
  buffer), idempotency, no-restart invariant
- refresh_paths: synchronous side effect on auto-discover watchers,
  no-op for explicit-path watchers

No behavior change. README + user guide land separately in MAH-87.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bf701976-6254-4377-a10c-2eb18ce9eedf

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: adding file watcher infrastructure for agent code in version 0.2.1, with a reference to the issue tracker (MAH-80).
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/file-watcher

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/openrtc/execution/file_watcher.py`:
- Around line 64-66: The FileChange.path doc says paths are absolute but the
code constructs FileChange using Path(raw_path) which may be relative; update
all places that create FileChange (e.g., in the FileChange dataclass/factory and
the event-emission sites around the code referenced and also lines ~314-317) to
convert raw_path to an absolute Path by using
Path(raw_path).resolve(strict=False) (or Path(raw_path).absolute equivalent)
before passing into FileChange so emitted events always contain absolute paths.
- Around line 215-218: refresh_paths() currently mutates self._paths but does
not reconfigure the running watch iterator created by _run_watch_loop() because
watchfiles.awatch(*self._paths, ...) captures paths at creation; update
refresh_paths() to signal the running watcher to restart so the awatch iterator
is recreated with the new paths (e.g., add an asyncio.Event or asyncio.Condition
like _restart_event set by refresh_paths(), have _run_watch_loop() check the
event inside the async for loop, break out and recreate the awatch iterator when
the event is set, then clear the event), referencing refresh_paths,
_run_watch_loop, self._paths, awatch, and _restart_event.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ccd330bf-d612-4c02-97d3-705ebd062a11

📥 Commits

Reviewing files that changed from the base of the PR and between 06c9265 and a0172db.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • pyproject.toml
  • src/openrtc/__init__.py
  • src/openrtc/execution/file_watcher.py
  • tests/execution/__init__.py
  • tests/execution/test_file_watcher.py
  • tests/execution/test_file_watcher_smoke.py

Comment thread src/openrtc/execution/file_watcher.py Outdated
Comment on lines +64 to +66
Frozen so instances are hashable and can be deduplicated in sets.
Paths are absolute. ``change_type`` is one of ``"created"``,
``"modified"``, or ``"deleted"`` (mapped from watchfiles' ``Change``
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

FileChange.path absolute-path contract is not consistently enforced.

The docs say emitted paths are absolute, but events currently use Path(raw_path) directly. If explicit paths are relative (or backend emits relative), downstream receives relative paths.

Localized fix
-                    batch.append(
-                        FileChange(
-                            path=Path(raw_path),
-                            change_type=change_type,
-                        )
-                    )
+                    batch.append(
+                        FileChange(
+                            path=Path(raw_path).resolve(),
+                            change_type=change_type,
+                        )
+                    )

Also applies to: 314-317

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openrtc/execution/file_watcher.py` around lines 64 - 66, The
FileChange.path doc says paths are absolute but the code constructs FileChange
using Path(raw_path) which may be relative; update all places that create
FileChange (e.g., in the FileChange dataclass/factory and the event-emission
sites around the code referenced and also lines ~314-317) to convert raw_path to
an absolute Path by using Path(raw_path).resolve(strict=False) (or
Path(raw_path).absolute equivalent) before passing into FileChange so emitted
events always contain absolute paths.

Comment thread src/openrtc/execution/file_watcher.py Outdated
Comment on lines +215 to +218
Synchronous because rebuilding the path set is a fast
in-process snapshot. The live watch loop picks up the
change on its next iteration boundary; this method does
not restart the watcher.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the FileWatcher implementation
find . -name "file_watcher.py" -type f | head -5

Repository: mahimailabs/openrtc-runtime

Length of output: 111


🏁 Script executed:

# Read the file to understand the structure
wc -l src/openrtc/execution/file_watcher.py

Repository: mahimailabs/openrtc-runtime

Length of output: 113


🏁 Script executed:

# Read the relevant sections of the file
cat -n src/openrtc/execution/file_watcher.py | head -250 | tail -100

Repository: mahimailabs/openrtc-runtime

Length of output: 4704


🏁 Script executed:

# Get more context around the specific lines mentioned
cat -n src/openrtc/execution/file_watcher.py | sed -n '200,330p'

Repository: mahimailabs/openrtc-runtime

Length of output: 6157


🏁 Script executed:

# Check if watchfiles is available and examine its behavior
python3 << 'EOF'
import inspect
try:
    import watchfiles
    # Check the signature and docstring of awatch
    print("=== watchfiles.awatch signature ===")
    print(inspect.signature(watchfiles.awatch))
    print("\n=== watchfiles.awatch docstring ===")
    print(watchfiles.awatch.__doc__)
except ImportError:
    print("watchfiles not installed, checking via web search needed")
EOF

Repository: mahimailabs/openrtc-runtime

Length of output: 128


🌐 Web query:

watchfiles awatch path arguments dynamic monitoring behavior

💡 Result:

watchfiles.awatch accepts one or more paths as positional arguments; it yields (as an async generator) a set of (Change, path) tuples whenever watched paths change. It can watch files or directories, and directories are watched recursively by default. The “dynamic monitoring” question (changing the set of watched paths while the async for loop is already running) is not supported by the API design; adding/removing watches would require starting a new awatch instance (there is an open feature request for this behavior).

Citations:


refresh_paths() does not actually reconfigure a running watcher.

_run_watch_loop() creates the watchfiles.awatch(*self._paths, ...) iterator once at startup, so the paths list is captured at that moment. Mutations to self._paths afterward have no effect—watchfiles does not support dynamic path changes. This contradicts the docstring claim that "the live watch loop picks up the change on its next iteration boundary," and causes the method to silently fail to watch newly discovered modules.

To fix this, restart the awatch iterator when refresh_paths() is called during a running state (e.g., via an asyncio.Event signal that breaks and restarts the async for loop).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openrtc/execution/file_watcher.py` around lines 215 - 218,
refresh_paths() currently mutates self._paths but does not reconfigure the
running watch iterator created by _run_watch_loop() because
watchfiles.awatch(*self._paths, ...) captures paths at creation; update
refresh_paths() to signal the running watcher to restart so the awatch iterator
is recreated with the new paths (e.g., add an asyncio.Event or asyncio.Condition
like _restart_event set by refresh_paths(), have _run_watch_loop() check the
event inside the async for loop, break out and recreate the awatch iterator when
the event is set, then clear the event), referencing refresh_paths,
_run_watch_loop, self._paths, awatch, and _restart_event.

…e running

Two issues from review:

1. FileChange paths emitted by the live watcher are now resolved via
   Path.resolve(strict=False) at the boundary, matching the dataclass
   docstring's "Paths emitted by FileWatcher are absolute" claim.
   The dataclass itself does not normalize in __post_init__: doing so
   would break literal '/tmp/agent.py' assertions on macOS where /tmp
   resolves to /private/tmp. Manual constructions are documented as the
   caller's responsibility.

2. refresh_paths() previously mutated self._paths but watchfiles.awatch
   captures paths at construction, so a running watcher never picked up
   the new set. Add _restart_event: refresh_paths() sets it,
   _run_watch_loop wraps awatch in an outer loop that tears down the
   current iterator (via a per-iteration mirror task that signals
   awatch's stop_event on either stop or restart) and recreates awatch
   with the latest self._paths. stop() also sets _restart_event so the
   mirror task wakes promptly.

Two regression tests:

- test_emitted_paths_are_absolute: every FileChange landing in the
  pending buffer has an absolute path.
- test_refresh_paths_during_run_swaps_watched_set: with two files in
  separate dirs, edits to file_b are silent before refresh_paths(),
  and fire the callback after.
@mahimairaja mahimairaja merged commit bb929e9 into main May 6, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant