fix(watcher): a held note is reported once; future-dated and both stale markers handled (review of #210, pass 2)
CI / Lint / Test / Vet (pull_request) Successful in 1m4s
CI / Mirror to GitHub (pull_request) Skipped

- a marker that blocks a note is warned about once, not silently forever
- a marker dated more than 10 minutes ahead (clock skew, a peer) is not
  believed and is treated as stale
- every stale marker is removed, not only the last; removal errors are logged
Each has a test that fails without it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:17:10 +02:00
co-authored by Claude Sonnet 5.5
parent 1025d77795
commit 6717221989
2 changed files with 98 additions and 16 deletions
+37 -16
View File
@@ -134,11 +134,16 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
// Any entry at the marker path counts (Lstat, not Stat): a dangling
// marker symlink made os.Stat report nothing and the note was
// reprocessed every interval (hyperguild#158).
if skip, why := markerBlocks(path); skip {
if skip, stale := markerBlocks(path); skip {
warnMarkerOnce(path)
return nil
} else if why != "" {
slog.Warn("watcher: ignoring a stale marker older than its note", "marker", why)
_ = root.Remove(filepath.ToSlash(relOf(rawDir, path)) + staleSuffix(why))
} else {
for _, m := range stale {
slog.Warn("watcher: ignoring a stale marker older than its note", "marker", m)
if rmErr := root.Remove(relOf(rawDir, m)); rmErr != nil {
slog.Error("watcher: failed to remove a stale marker", "marker", m, "error", rmErr)
}
}
}
// A note whose archive copy or marker was refused is not retried
// until it changes: a refusal has no marker to stop the next round,
@@ -488,27 +493,47 @@ func cutFence(s string) (block, rest string, found bool) {
// markerBlocks reports whether a marker next to path means "already handled".
// Any non-regular entry at the marker path counts (a dangling symlink must not
// be written through, hyperguild#158). A regular marker older than the note
// describes an EARLIER note of the same name, so it does not block this one:
// stale is the marker's path and the caller warns and removes it.
func markerBlocks(path string) (skip bool, stale string) {
// be written through, hyperguild#158). A regular marker describes an EARLIER
// note of the same name, so it does not block this one, and is returned in
// stale, when it is older than the note or dated implausibly far in the future
// (clock skew, a peer): those are removed by the caller. Heuristic by nature:
// a marker at least as new as the note still means "done".
func markerBlocks(path string) (skip bool, stale []string) {
note, err := os.Lstat(path)
if err != nil {
return false, ""
return false, nil
}
for _, suffix := range []string{".processed", ".failed"} {
m, err := os.Lstat(path + suffix)
if err != nil {
continue
}
if !m.Mode().IsRegular() || !m.ModTime().Before(note.ModTime()) {
return true, ""
if !m.Mode().IsRegular() {
return true, nil
}
stale = path + suffix
if m.ModTime().Before(note.ModTime()) || m.ModTime().After(time.Now().Add(markerSkew)) {
stale = append(stale, path+suffix)
continue
}
return true, nil
}
return false, stale
}
// markerSkew is how far ahead of now a marker may be dated before it is not
// believed.
const markerSkew = 10 * time.Minute
// markerWarned remembers notes already reported as held by a marker, so the
// 30-second poll says it once instead of forever.
var markerWarned sync.Map
func warnMarkerOnce(path string) {
if _, seen := markerWarned.LoadOrStore(path, struct{}{}); !seen {
slog.Warn("watcher: note is held by a marker newer than itself and will not be processed; delete the marker or edit the note", "file", path)
}
}
func relOf(rawDir, path string) string {
rel, err := filepath.Rel(rawDir, path)
if err != nil {
@@ -516,7 +541,3 @@ func relOf(rawDir, path string) string {
}
return rel
}
func staleSuffix(markerPath string) string {
return filepath.Ext(markerPath)
}