Summary
On macOS, FSEvents delivers event paths as realpaths. resync::Worker::carriers is
keyed by the carrier path exactly as the declaration produced it. When any ancestor of the
catalog is a symlink, no delivered event ever matches a carrier key, so mark_mutated
matches nothing and the resync watcher silently observes nothing for every agent on that
host.
There is no error, no diagnostic and no degraded-mode signal. The watcher is installed, the
subscriptions are live, st2 reports coverage as immediate — and no resync event is ever
emitted for a real carrier change. It fails exactly the way this module is built not to fail.
This is distinct from #368 (a purge that drops queued events when the registration set
changes, fixed in #383). #368 loses events that were in flight; this loses every event,
permanently, for the whole host.
Where
Worker::mark_mutated (src/resync.rs) matches on *carrier == path and
carrier.starts_with(&path).
Worker::watched / refresh_watches register the parent directory as given.
notify's FSEvents backend keys its own recursive_info by path.canonicalize(), and the
paths it hands to the callback come from FSEvents itself, which resolves symlinks.
So notify matches internally and emits, but it emits /private/var/... where st2 stored
/var/..., and the two never meet.
Measurement
The easiest demonstration is the default macOS TMPDIR (/var/folders/..., where /var is
a symlink to private/var). Running
run::tests::compile_invalid_seat_does_not_block_existing_live_resync_watch on
aarch64-darwin with that TMPDIR:
Point TMPDIR at a path with no symlink component (e.g. /private/tmp/...) and the first
assertion passes.
Why CI never caught it
The darwin job is nix build .#st2, and a nix build's TMPDIR on darwin is
/nix/var/nix/builds/nix-<pid>-<n> — measured, and identical to its own realpath. No symlink
component, so the test gets past the first assertion in CI. That is also why #368 presents in
CI as a failure at the second assertion rather than the first: the two defects are
distinguishable by exactly which assertion fails.
Production catalogs under a plain $HOME path are usually unaffected for the same reason,
but nothing guarantees it — a catalog reached through any symlinked ancestor (a symlinked
home, an external volume via /Volumes, a bind-style symlink into a repo checkout) silently
loses resync on macOS.
Note for whoever picks this up
Do not fix this by following symlinks. src/resync.rs is deliberately built the other
way: notify::Config::default().with_follow_symlinks(false), O_NOFOLLOW in read_regular,
and per-component O_NOFOLLOW in read_confined against a containment root. The carrier a
symlink points at is intentionally not the carrier st2 reads.
The fix is about the event-matching key, not the read policy — the delivered path needs to
be reconciled with the stored key without changing which bytes get digested, and whatever
canonical form is chosen has to stay consistent with containment_root confinement and with
the dir_identity (dev, ino) staleness check. Worth settling deliberately rather than
patching mark_mutated in isolation.
Related: #368, #383.
Posted on behalf of @schickling
| field |
value |
agent_identity |
dev3.direct.claude.paqjmjfq |
session |
dev3.paqjmjfq |
agent_persona |
generalist |
agent_supervisor |
unavailable |
agent_tool |
Claude Code |
agent_tool_version |
2.1.250 |
agent_runtime |
Claude Code 2.1.250 |
tooling_profile |
dotfiles@a1a5f89 |
Summary
On macOS, FSEvents delivers event paths as realpaths.
resync::Worker::carriersiskeyed by the carrier path exactly as the declaration produced it. When any ancestor of the
catalog is a symlink, no delivered event ever matches a carrier key, so
mark_mutatedmatches nothing and the resync watcher silently observes nothing for every agent on that
host.
There is no error, no diagnostic and no degraded-mode signal. The watcher is installed, the
subscriptions are live,
st2reports coverage asimmediate— and no resync event is everemitted for a real carrier change. It fails exactly the way this module is built not to fail.
This is distinct from #368 (a purge that drops queued events when the registration set
changes, fixed in #383). #368 loses events that were in flight; this loses every event,
permanently, for the whole host.
Where
Worker::mark_mutated(src/resync.rs) matches on*carrier == pathandcarrier.starts_with(&path).Worker::watched/refresh_watchesregister the parent directory as given.notify's FSEvents backend keys its ownrecursive_infobypath.canonicalize(), and thepaths it hands to the callback come from FSEvents itself, which resolves symlinks.
So notify matches internally and emits, but it emits
/private/var/...where st2 stored/var/..., and the two never meet.Measurement
The easiest demonstration is the default macOS
TMPDIR(/var/folders/..., where/varisa symlink to
private/var). Runningrun::tests::compile_invalid_seat_does_not_block_existing_live_resync_watchonaarch64-darwin with that
TMPDIR:watched across the compile error") on
main— the very first resync event never arrives.Point
TMPDIRat a path with no symlink component (e.g./private/tmp/...) and the firstassertion passes.
Why CI never caught it
The darwin job is
nix build .#st2, and a nix build'sTMPDIRon darwin is/nix/var/nix/builds/nix-<pid>-<n>— measured, and identical to its own realpath. No symlinkcomponent, so the test gets past the first assertion in CI. That is also why #368 presents in
CI as a failure at the second assertion rather than the first: the two defects are
distinguishable by exactly which assertion fails.
Production catalogs under a plain
$HOMEpath are usually unaffected for the same reason,but nothing guarantees it — a catalog reached through any symlinked ancestor (a symlinked
home, an external volume via
/Volumes, a bind-style symlink into a repo checkout) silentlyloses resync on macOS.
Note for whoever picks this up
Do not fix this by following symlinks.
src/resync.rsis deliberately built the otherway:
notify::Config::default().with_follow_symlinks(false),O_NOFOLLOWinread_regular,and per-component
O_NOFOLLOWinread_confinedagainst a containment root. The carrier asymlink points at is intentionally not the carrier st2 reads.
The fix is about the event-matching key, not the read policy — the delivered path needs to
be reconciled with the stored key without changing which bytes get digested, and whatever
canonical form is chosen has to stay consistent with
containment_rootconfinement and withthe
dir_identity(dev, ino) staleness check. Worth settling deliberately rather thanpatching
mark_mutatedin isolation.Related: #368, #383.
Posted on behalf of @schickling
agent_identitysessionagent_personaagent_supervisoragent_toolagent_tool_versionagent_runtimetooling_profile