Skip to content

Start a watch at its own start, not at HEY's cursor - #502

Merged
robzolkos merged 17 commits into
mainfrom
watch-no-stale-first-change
Sep 27, 2026
Merged

robzolkos merged 17 commits into
mainfrom
watch-no-stale-first-change

Conversation

@robzolkos

@robzolkos robzolkos commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

hey watch --exit-on-first --timeout 20s exited at once on every run, printing an old change — for example {"change":"added","at":"2026-09-18T23:45:39.083Z","box":{"kind":"imbox",...},"new":false,...} eight days after the fact. A plain hey watch reported that backlog on startup as if it had just happened, and --exit-on-first ("wait for one change of any kind") never waited. --events new was unaffected only because the stale line was new: false.

Why

The first read of each box started from the since in the box's posting_changes_url, on the understanding that HEY bakes its clock into it. It does not:

  • HEY builds that since from Box#last_posting_activity_at: the latest updated_at among the box's unbundled postings, or the box's own updated_at when it has none. The changes feed also answers deletions and bundled postings, so they can come later than it. On the real account an empty Reply Later has a since from 2020 and answers a deletion from 2026-08-19 on every start.
  • /boxes.json reaches the watch through the SDK's ETag cache. HEY's ETag for it is fresh_when etag: @boxes.to_a, meaning the box rows, and posting activity never touches a box. So a 304 serves the since as it stood when the list was first cached. On the real account the cached Imbox since was 18:13:37Z while the live one was 18:33:09Z, and the watch reported the posting in between (new: false, created before the watch began). This is the reported Imbox symptom.
  • noLaterThan, which moved a later since back to the watch's start, never fired against HEY. Its cursors carry microseconds (iso8601(6)), and the millisecond layout refused to parse them.

What changes

  • Every box's first read starts at the watch's start. That start is on HEY's clock (serverNow), and only the version is kept from HEY's URL (watchStartSince replaces noLaterThan). The catch-up reports only what happened after the start, so --exit-on-first waits. Mail that lands between reading HEY's clock and reading the box list is still read, and still counts as new.
  • The start can be up to a second early, and the docs say so. HEY's Date header is whole seconds, so a change from up to a second (plus the request's time) before the watch began can still be reported, and its at shows when it happened. Nothing HEY serves gives a finer server time: Action Cable pings are Time.now.to_i, no JSON answer carries a server "now", and next_incremental_sync_url is whole seconds too. Rounding the other way would skip changes that came after the start. TestWatchReadsTheWholeSecondHEYsClockWasReadIn pins this.
  • A skip-ahead (409 → resync) now moves to HEY's clock at the skip, keeping the feed version from the box's URL. It used to take the listed since, and after a drop long enough for 2,000 changes the ETag-cached box list could hand back the very cursor that had fallen behind, so every read answered 409 again. The resync line's at and the box's new-mail floor are the skip point: HEY's clock when it answered (serverNowAnswered), not reduced by the request's time. A resync has no gap to catch, and a point pushed back by a slow or retried request could leave a busy feed still behind. An interrupt or --timeout during that clock read ends quietly. A calendar's 409 skips the same way.
  • A skip reads its list past the SDK cache (newUncachedSDKClient). The list's ETag doesn't change when the feed version does, and HEY answers 409 for a version it no longer serves, so a cached list could loop. A list or clock read that fails is retried on the backoff, and an interrupt during one ends quietly. A feed still too busy after a skip is one recovery: the first skip is read from immediately, later skips wait on the doubling retry backoff instead of every doorbell, and the one resync goes out with the clean read that ends it, with at set to the last skip.
  • Unchanged: --since still reads back first.
  • Calendars get the same treatment. The recording feeds and the calendar list now start at the watch's start (calendarCursor; calendarCursorNoLaterThan is gone). The list's since is the latest calendar updated_at, so the first poll of every watch used to report a calendar deleted after the rest last changed.
  • A watch that cannot read HEY's clock no longer starts. serverNow used to fall back to the workstation's clock. Both the feed cursors and the new-mail cutoff are measured against that start, so a fast local clock would skip changes and call new mail old, and a slow one would report history. It now returns an error when the request fails or the answer has no Date header. A request that fails there would fail at the box list next anyway.
  • Separate commit: a skip-ahead's new-mail floor (newMail.skippedTo) was parsed with the same millisecond layout, so no floor was ever set against HEY. It now reads RFC 3339 with any fraction.

Help text, docs/cli.md, docs/omarchy.md, the skill and AGENTS.md now describe this behaviour.

Tests

The new tests run against a hermetic fake HEY whose changes feed answers only what is strictly later than its cursor, as HEY's does. The fake guards its state with a mutex, so the tests are race-free. On main these fail:

  • TestWatchDoesNotReportHistoryAsItStarts: with --exit-on-first, a stale Imbox since and an empty Reply Later with a later deletion produce only ready, and the watch keeps waiting. A reply that lands after the start is then reported, new, and ends the watch.
  • TestWatchReadsMailThatLandedBeforeItReadTheBoxes: mail that lands after the clock read but before the box list is reported and new, and none of the history is.
  • TestWatchPollDoesNotReportHistoryAsItStarts: the first calendar-list poll doesn't report a calendar deleted before the start, but does report one deleted after it.
  • TestWatchedBoxesStartAtTheWatchsStart and TestCalendarCursor: every cursor starts at the start and keeps HEY's version, whatever since HEY served.
  • TestNewMailAfterASkipAheadIsSinceTheSkip: sets a floor from a microsecond cursor.
  • TestServerNowRefusesWithoutHEYsClock (replaces the local-clock fallback test): an unreachable server and an answer with no Date header are both errors.
  • TestWatchSkipAheadGetsPastACachedBoxList: a box list served from the SDK's cache (304) after a 409 leads to a single resync and then the next change. Before the fix it produced a second resync. TestWatchSkipsAheadToHEYsClock, TestWatchReportsAResyncAfterSkippingAhead and TestWatchCalendarSkipsAheadOnAFullSync pin the skip point, the resync's at, the floor and the feed version. TestWatchSkipAheadEndsQuietlyWhenInterrupted covers an interrupt during the skip's clock read, for both a box and a calendar.

TestWatchSinceReadsTheHistoryFirst passes both before and after: --since still reports the backlog, marked not new, and then ready. The reconnect and ready tests are unchanged.

Checked read-only against a real account. The released build printed a posting created before the watch began and exited. This branch printed {"change":"ready"} and waited out the 20s timeout.


Summary by cubic

Fixes hey watch reporting stale history as news on startup, so --exit-on-first now waits for a change instead of exiting on an old one.

Each box's first read now starts at the watch's own start, read off HEY's clock, keeping only the version from HEY's posting_changes_url. HEY's since is a box's last posting activity, not its clock, and can arrive stale through the SDK's ETag cache; neither is reported as new anymore. Mail that lands between the clock read and the box list is still reported and new. HEY's clock is read to the whole second and taken back by the time the request took, so a change from up to about a second before the watch began — plus that request's time — can also be reported, be new, and end --exit-on-first; its at says when it happened. Calendars get the same treatment for the calendar list and recording feeds. --since still reads back history first.

A skip-ahead (409 → resync) now moves the cursor to HEY's clock at the skip rather than to the box's listed cursor: the box list is ETag-cached on its box rows, so after a long drop a 304 handed back the since the watch had already fallen behind from, and every subsequent read answered 409 again. The box or calendar list is still read for the feed's version and to confirm the feed exists — read past the SDK's cache, since a new feed version need not change that ETag, and HEY refuses a version it no longer speaks. The resync line's at and the new-mail floor are the skip point, read with RFC 3339 (any fraction) so HEY's microsecond cursors set a floor. The skip point is HEY's clock when it answered, not taken back by the request's time: a resync has no gap to catch, and a point taken back by a slow or retried request could leave a busy feed still too far behind.

A 409 straight after a skip is the same catch-up, one resync for the whole episode: the next skip waits on the retry backoff while the feed stays too busy, and the resync goes out with the clean read that ends the episode, at the last skip, so a reader re-reading on it missed nothing a later skip passed. A list or clock read that fails is warned about and held for that backoff — doorbells leave a held feed alone so they don't keep repeating the failing reads — and an interrupt or --timeout during one ends the skip quietly, leaving the cursor where it was. Calendars skip the same way.

The watch refuses to start when HEY's clock cannot be read (request failure or missing Date header), instead of falling back to the workstation's clock, which can be out of sync with HEY.

Written for commit f07e7ff. Summary will update on new commits.

Review in cubic

Copilot AI balanced review requested due to automatic review settings September 26, 2026 18:37
@robzolkos
robzolkos requested a review from a team as a code owner September 26, 2026 18:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Local-clock fallback can place cursors in HEY’s future or reintroduce stale startup events.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Updates watch startup cursors to suppress stale mail/calendar history while preserving explicit --since behavior.

Changes:

  • Starts mail and calendar feeds at the watch start.
  • Supports microsecond-precision skip-ahead cursors.
  • Adds regression tests and updates documentation.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
internal/​cmd/​watch.go Changes mail startup cursor behavior.
internal/​cmd/​watch_test.go Adds startup-history regression tests.
internal/​cmd/​watch_new.go Parses microsecond skip-ahead cursors.
internal/​cmd/​watch_new_test.go Tests cursor precision and cutoff behavior.
internal/​cmd/​watch_calendar.go Aligns calendar cursors with watch start.
internal/​cmd/​watch_calendar_test.go Tests calendar history suppression.
docs/​cli.md Documents watch startup behavior.
docs/​omarchy.md Updates notification semantics.
skills/​hey/​SKILL.md Updates agent-facing watch guidance.
AGENTS.md Records implementation details.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/cmd/watch.go Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approved. Cursor Security Agent completed successfully with no findings that need human review, and Cursor Bugbot was not running on this PR. No reviewers were assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Approver

Copilot AI previously approved these changes Sep 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approved

The cursor changes are consistent across mail and calendars and are covered by focused regression tests.

Review effort: Balanced
Findings: 1 High severity

Open (1)

@robzolkos
robzolkos requested a balanced review from Copilot September 26, 2026 18:52
Copilot AI dismissed their stale review, a newer Copilot review was requested September 26, 2026 18:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Local-clock fallback can still misclassify new mail, and several documentation guarantees omit the intentional history-replay fallback.

Review effort: Balanced
Findings: 1 High severity · 4 Low severity

Open (5)
Resolved since last review (1)

Comment thread internal/cmd/watch.go Outdated
Comment thread docs/cli.md Outdated
Comment thread docs/omarchy.md Outdated
Comment thread internal/cmd/watch.go Outdated
Comment thread skills/hey/SKILL.md Outdated
Copilot AI previously approved these changes Sep 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approved

The cursor changes are consistent across mail and calendars and have focused regression coverage.

Review effort: Balanced
Findings: None

Resolved since last review (5)

@robzolkos
robzolkos requested a balanced review from Copilot September 26, 2026 19:28
Copilot AI dismissed their stale review, a newer Copilot review was requested September 26, 2026 19:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The authored description materially contradicts the implemented 409 resync behavior.

Review effort: Balanced
Findings: 1 Low severity

Open (1)

Comment thread internal/cmd/watch.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Cancellation handling emits misleading warnings, and public documentation understates the possible replay window.

Review effort: Balanced
Findings: 6 Low severity

Open (6)
Resolved since last review (1)

Comment thread docs/cli.md Outdated
Comment thread docs/omarchy.md
Comment thread internal/cmd/watch.go Outdated
Comment thread internal/cmd/watch.go Outdated
Comment thread internal/cmd/watch_calendar.go Outdated
Comment thread skills/hey/SKILL.md Outdated
Copilot AI previously approved these changes Sep 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approved

The behavior is coherent and comprehensively tested; only a minor stale code comment remains.

Review effort: Balanced
Findings: None

Resolved since last review (6)
Previously missed (1)

In code that hasn't changed since last review

Low severity Correct skip-ahead cursor behavior documentation

internal/​cmd/​watch.go:318

This comment says a skip-ahead resumes from HEY's cursor, but skipAhead now keeps only the listed feed version and replaces Since with serverNow. That contradicts both the implementation and the skip-ahead documentation below; describe the two caller-specific replacements instead.

@robzolkos
robzolkos requested a balanced review from Copilot September 26, 2026 19:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Repeated skip-aheads can silently discard later changes without emitting another resync signal.

Review effort: Balanced
Findings: None

Resolved since last review (2)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Later recovery skips discard changes without notifying consumers

internal/​cmd/​watch.go:724

A later skip in the same recovery can discard a new range of changes, but this branch suppresses the only signal telling consumers to re-read. For example, a consumer may react to the first resync immediately, then the feed can return another 409 and skip from that first cutoff to a later one; those intervening changes are never emitted, and no second resync or final recovery event tells the consumer its refreshed view is stale. Emit a signal for every successful skip, or defer the single signal until the recovery has reached a clean read.

Medium severity Suppressing later resync signals leaves consumers with stale calendars

internal/​cmd/​watch_calendar.go:363

Suppressing calendar_resync after the first skip can leave consumers stale. If a consumer re-reads the calendar when the first signal arrives and a later retry skips another overloaded interval, recordings in that later gap are neither emitted nor followed by another re-read signal. Emit on each successful skip, or wait until a clean feed read before emitting the one recovery signal.

@robzolkos

Copy link
Copy Markdown
Collaborator Author

On Copilot's "previously missed" notes in the review of f2c271c (watch.go:724, watch_calendar.go:363, where a later skip in the same recovery could pass over changes after the one resync had already gone out): fixed in b6adbff. The recovery's single resync/calendar_resync is now sent with the clean read that ends it, after every skip, and its at is the last skip. The first skip is read from immediately, so a skip that works first time isn't left waiting for a doorbell. A 409 on that read or any later one still waits on the retry backoff. TestWatchRecoversFromARepeated409Once asserts that nothing is written while the feed keeps answering 409, that exactly one resync appears at the clean read, with the last skip as its at, and that a second recovery gets its own. On f2c271c it fails with an early resync.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Removed feeds can leave the shared retry backoff elevated, delaying subsequent recovery attempts.

Review effort: Balanced
Findings: 1 Low severity

Open (1)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Reset shared backoff when removing a disappearing box

internal/​cmd/​watch.go:856

If this box disappears while a skip is being retried, stopWatching removes it from unread but never calls settleBackoff. A recovery that reached the two-minute cap therefore leaves the shared backoff at two minutes, so the next unrelated transient box/calendar failure waits two minutes instead of restarting at two seconds. Settle the backoff after removing the box.

Medium severity Reset shared backoff when removing a disappearing calendar

internal/​cmd/​watch_calendar.go:446

When a calendar disappears during a retried recovery, removing it from calendar.unread does not reset the shared backoff. After repeated 409s, a later unrelated transient failure can consequently inherit the two-minute delay instead of starting at two seconds. Settle the backoff after dropping the calendar.

Comment thread internal/cmd/watch_test.go
@robzolkos

Copy link
Copy Markdown
Collaborator Author

On the "previously missed" backoff notes in the review of b6adbff (stopWatching / stopWatchingCalendar not calling settleBackoff): this needs a box or calendar to disappear from the account in the middle of a skip-ahead retry. Even then, the only effect is that the next unrelated transient failure waits on the elevated backoff (at most two minutes) before retrying, instead of two seconds. Nothing is lost, since the cursor and retry still work. This is rare and self-correcting, and I haven't changed it in this PR.

hey watch --exit-on-first --timeout 20s exited at once on every run,
printing a posting from eight days before; a plain watch reported that
backlog on startup as if it had just happened.

The first read of each box started from the since in its
posting_changes_url, on the understanding that HEY bakes its clock in.
It does not. HEY builds that since from Box#last_posting_activity_at —
the latest updated_at among the box's unbundled postings, or the box's
own updated_at when it has none — while the feed also answers deletions
and bundled postings, so an empty Reply Later answers a deletion from
last month on every start. And /boxes.json reaches the watch through
the SDK's ETag cache: HEY's ETag for it is the box rows, which posting
activity never touches, so a 304 serves the since as it stood when the
list was first cached. Against the real account the cached Imbox since
lagged the live one by twenty minutes, and the watch reported the
posting in between. noLaterThan, which moved a later since back to the
start, never fired against HEY either: its cursors carry microseconds,
which the millisecond layout refused to parse.

Every box now starts at the watch's start, keeping only the version from
HEY's URL. The catch-up reports what happened after the start and
nothing before, so --exit-on-first waits for a change; mail that lands
between reading HEY's clock and reading the box list is still read, and
is new. --since still reads back first, and a skip-ahead still resumes
from HEY's since. The calendars' recording feeds and the calendar list
start the same way: the list's since is the latest calendar updated_at,
so a calendar deleted after the rest last changed was reported by the
first poll of every watch.
A box that answers 409 is skipped ahead to the since in its
posting_changes_url, and that since becomes the box's floor: activity at
or before it is never new there, because the watch never read the gap.
The floor was read in the watch's millisecond layout, and HEY writes the
since to the microsecond, so the parse failed and no floor was ever set
against the real server — a thread moved or labelled while unseen in the
gap could read as new mail. The since is now read as RFC 3339 with any
fraction.
serverNow falls back to the workstation's clock when HEY's cannot be
read, and a start on that clock is only as good as the clock: a fast one
puts it in HEY's future, where every change until then would go unread.
The start now says which clock it came from. On HEY's, a feed starts at
the start as before; on the workstation's alone, HEY's own since is kept
when it is the earlier of the two, or cannot be read. That may report
the history this branch set out to suppress, but only when HEY's clock
could not be read, and missing mail is worse than repeating it.
The watch notes named startingAt, which is now watchStart.since, and did not say what happens when HEY's clock cannot be read.
The previous commit kept HEY's cursor when only the workstation's clock
said when a watch began, but new mail is measured against that same
start: a fast clock would still call mail that arrived after startup
old, and a slow one would call replayed history new, and the docs
promised a start that the fallback did not keep.

serverNow now returns an error when HEY's clock cannot be read — the
request failed, or the answer carried no Date header — and the watch
exits with it rather than guess. A request that fails there would fail
at the box list next anyway. Every feed starts at the start again
(watchStartSince), with no second rule to describe.
HEY's Date header is whole seconds, so the start read off it can be up to
a second — plus the request's own time — before the watch really began,
and a change from that window is read, reported, can be new, and can end
--exit-on-first. Nothing HEY serves says the time any finer: Action
Cable's pings carry Time.now.to_i, no JSON answer carries a server "now",
and the one sync URL built from the clock (a box's
next_incremental_sync_url) is whole seconds too. Rounding the other way
would skip up to a second of changes that came after the start, which is
worse than repeating one that did not.

The help text, docs/cli.md, docs/omarchy.md, the skill and AGENTS.md now
say so, and a test pins the boundary: a change in the Date header's own
second is reported, with its at, and one from before it is not.
A read that answers 409 skipped ahead to the since in the box's
posting_changes_url, read from a fresh box list. The list comes through
the SDK's ETag cache, and HEY's ETag for /boxes.json is the box rows,
which posting activity never touches: after a drop long enough for 2,000
changes, the 304 handed back the since the watch had fallen behind from,
the next read answered 409 again, and every doorbell after that was
another resync that did not advance. Even a fresh since is the box's last
posting activity, which a deletion or a bundled posting can come later
than.

A skip-ahead now moves the cursor to HEY's clock at the skip, read the
way the watch's start is, keeping the feed's version from the box's URL.
The box list is still read for that version and to learn whether the box
is gone. The resync line's at is the skip point, and so is the box's
new-mail floor. A clock that cannot be read leaves the cursor where it
was, to be tried again on the retry backoff, as any failed read is.

A calendar's 409 skips the same way. Its list is not stale the same way,
since a recording touches its calendar and so the list's ETag, but its
since is still the calendar's updated_at rather than HEY's clock, and
one rule is easier to reason about than two.
The start is the Date header taken back by the whole of the clock request, retries included, so a change can be reported from a second before the watch began plus however long that request took, not about a second. The help text, docs/cli.md, docs/omarchy.md, the skill and AGENTS.md now say so.
An interrupt or --timeout while a skip-ahead read HEY's clock fell through to the retry path: it warned that the box or calendar could not be skipped ahead and armed a retry the ending watch would never run. Both skip-aheads now check the context first, as the feed reads beside them do, and return quietly with the cursor where it was.
watchCursor's comment still said a skip-ahead resumes from HEY's since. Neither caller keeps it without --since: a first read starts at the watch's start and a skip-ahead at HEY's clock, both keeping the feed version.
A skip-ahead took its point from serverNow, the watch start's reading: the Date header taken back by the whole request, retries included. The start needs that, to catch what lands while it is asked. A skip has already given up the gap, which the resync line says, so it has nothing to catch there, and a point taken back by a slow or retried request could leave a busy feed still too far behind to follow. Skip-aheads now read serverNowAnswered: the millisecond before the Date header.
A skip-ahead keeps the feed version from the box or calendar list and
replaces only the since. The list comes through the SDK's ETag cache, and
HEY's ETag for /boxes.json is the box rows — for /calendars.json the
calendars and the selection — which a new feed version need not change.
If HEY moved a feed to a new version, the 304 handed back the old one,
HEY answers 409 for a version it no longer speaks
(Changes::BaseController#version_mismatch?), and every read after every
skip was another 409 and another resync.

The SDK has no per-request way past its cache, so newUncachedSDKClient
builds a sibling of the CLI's client from the same configuration and
options, minus the cache, scoped to the same account. Skip-aheads read
their list through it; a 409 is rare enough to build one each time.
…rrupt

The clock read in a skip-ahead already told an interrupt from a failure,
but the box and calendar list reads before it returned any error
straight out of the watch: Ctrl-C or --timeout during the read exited
with an error, and a passing 500 ended a watch that would have recovered
on the next try. All three reads — the uncached client, the list and the
clock — now go through skipFailed: an interrupt ends quietly, a
permanent failure (credentials, a malformed request) still ends the
watch, and anything else warns and retries on the backoff with the
cursor where it was.

The "too much changed ... skipping ahead" notice was printed before the
skip was tried, so a skip that failed still claimed one. It now follows
the skip, beside the resync line.
A skip-ahead lands on HEY's clock at the whole second before its Date
header. A feed busy enough to carry more than an increment's worth of
changes after that answers 409 again on the next read, and every
doorbell after it — as many as the feed was changing — read, skipped and
reported another resync with no delay between them.

A feed's recovery from a 409 is now one episode (feedRecovery). The
first skip is announced with its notice and its resync line and counts
as the feed read. A 409 straight after it skips again without a word and
leaves the box or calendar on the retry backoff, which doubles while the
feed stays that busy; doorbells for it wait for that retry rather than
skipping it again. A clean read ends the episode, and the next 409 after
that is a recovery of its own.

The help text, docs/cli.md, the skill and AGENTS.md say one resync
covers the whole catch-up.
The docs said nothing HEY serves gives a finer time than the Date header. A posting doorbell's at (Posting::Broadcasting, iso8601_with_ms) and the feeds' cursors carry microseconds, but only once something has changed, never as a now before the watch starts. AGENTS.md and serverNow now say that. docs/omarchy.md also said a box's first read carries only what arrived while the watch was starting; it now includes the whole-second window described just before it.
A skip-ahead whose list or clock read failed put the box or calendar on the retry backoff, but doorbells kept reading it: each one met the same 409 and made the same failing requests, and the backoff waited for nothing. The failed skip now marks the feed holding, as a repeated 409 does, so doorbells leave it to the retry. A skip that lands clears the hold, and the feed follows its doorbells again.
A recovery announced its resync at its first skip and stayed quiet for
the skips after it. A reader that re-read the box on that resync could
then be left stale: a later skip in the same recovery passed over
changes that were neither reported nor followed by another cue to
re-read.

The resync now goes out when the recovery ends, with the first clean
read, after every skip it took; its at is the last skip. So that a
skip that works first time is not left waiting for the next doorbell,
the first skip of a recovery is read from straight away. A 409 on that
read, or on any later one, still waits on the retry backoff. Boxes and
calendars alike.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Stateful retry and resynchronization changes across mail and calendar feeds warrant final human review despite comprehensive tests.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@robzolkos
robzolkos merged commit e08436e into main Sep 27, 2026
26 checks passed
@robzolkos
robzolkos deleted the watch-no-stale-first-change branch September 27, 2026 03:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants