Skip to content

fix(ui-server): commit the Activity notifier and push bookkeeping on the write worker - #2994

Merged
schlessera merged 5 commits into
mainfrom
fix/2850-notifier-offloop-044
Oct 26, 2026
Merged

schlessera merged 5 commits into
mainfrom
fix/2850-notifier-offloop-044

Conversation

@schlessera

Copy link
Copy Markdown
Owner

Refs #2850

Sixth slice of #2850: the Activity notifier and push delivery bookkeeping (group 4, "notifier and push ticks").

What changed

The hosted server no longer commits the Activity notifier on its app connection. These now commit on the ui.db write worker at synchronous=FULL (packages/ui-server/src/activity/notify-writes.ts):

  • the notifier's 20-second tick: intent detection under its cursor, in one immediate transaction, and the stuck-run watchdog;
  • intent delivery status, acknowledgement (one or all) and retention;
  • push delivery's subscription bookkeeping: a delivered device's last_used_at, and dropping a gone device.

The tick runs the same createActivityNotifier on the write connection. Who is watching the run or its session is taken from the Activity stream when the tick is submitted (ActivityStream.watched()), as isWatched answered before. The tick's log records come back to the app's log. A tick still committing is not submitted again; the next interval runs after it. The inbox and the pending queue are still read on the app connection. Without a write worker (tests, the cron runner) the runtime keeps the synchronous notifier.

What callers wait for:

  • Delivery pass: settles only once its subscription bookkeeping and the intent's delivery status have committed. The next intent re-reads its devices without a gone one, and the next pass never sends an already-delivered intent again.
  • Acknowledgement routes: answer once the acknowledgement has committed.

Action and storage notices (their own delivery state machines), push subscribe/unsubscribe and the lazy VAPID key insert stay on the app connection for later slices.

Why this shape

The tick's detection reads what it writes: the cursor, tag dedupe and the hourly rate cap. So the whole transaction runs where it commits, rather than splitting reads from writes. isWatched is the one input that lives only in the app process; passing it as data keeps the worker's notifier identical.

Proof

Head b86abb765: the change 3882428ed, a merge of origin/main 4c97b68dc, d5423301b (tags ActivityStream.watched() @internal, so the public API report is unchanged), and a merge of origin/main 71503546f (#2992; one doc conflict in sqlite-write-worker.md, resolved by keeping both bullets). Every heavy run went through the host slot wrapper, on this head.

Gate Result
bun run check:pr --base origin/main passed (local preflight)
bun run test ./tests 1966 pass, 0 fail
bun run test packages/ui-server 4079 pass, 1 fail. The failure is maintenance-cancellation-cleanup's control case: its child run's git init was killed by the case's 250 ms timeout under host load. Rerun alone on this head: 2/2 passes, twice.
focused: notify-offloop, the notifier, push and Activity files, principal-writes-offloop, http-websocket-admission, api-surface, autonomous-runtime 312/312
tsc --noEmit, bun run lint, decision-citations clean; 44/44
native hosted proof (prove-native-host.ts, all four cells, under flock on the fixture lock, dd … conv=fsync writers outside the lock) no load: passed; 3 fsync writers: passed (both with the deliberate retained-index-namespace failure verified)

An earlier ./tests run on d5423301b failed one timing case, autonomous-runtime's revocation abort: 3,235 ms against a 3,000 ms bound, while fsync writers ran. It passes alone and in the run above.

Focused tests (packages/ui-server/tests/notify-offloop.test.ts, new). The app connection refuses writes to notification_intents, settings and push_subscriptions; the worker refuses any below FULL. The cases:

  • A tick creates a failure intent for an unwatched run and a suppressed one for a watched session, on the worker. Its log records reach the app's log.
  • Delivery status, acknowledge, acknowledge-all and retention commit on the worker.
  • A tick still committing behind a slow commit is not submitted again.
  • A delivery pass held behind a slow commit settles only once a gone device is dropped and the intent is marked sent; nothing is left pending.
  • The Activity runtime ticks its notifier on the worker when it has one.

Mutations (each applied, its named test run, restored)

# Mutation Fails
N1 notifier writes on the app connection NOTIFY_TICK_COMMITS_ON_WORKER_AT_FULL (APP_CONNECTION_WRITE notification_intents)
N2 a tick commits below FULL same, SQLITE_WRITE_BELOW_FULL
N3 the tick ignores who was watching NOTIFY_TICK_COMMITS_ON_WORKER_WITH_WATCHED_SNAPSHOT
N4 a pending tick submitted again NOTIFY_PENDING_TICK_NOT_RESUBMITTED
N5 a delivery pass settles before its delivery status commits PUSH_PASS_SETTLES_AFTER_DELIVERY_COMMITS
N6 tick log records dropped NOTIFY_TICK_LOGS_REACH_THE_APP
N7 the runtime ticks its notifier on the app connection NOTIFY_RUNTIME_TICKS_ON_WORKER

N1 and N2 first failed only on the intents assertion, because the notifier swallows its own error into a "tick failed" log. That log's error is now asserted first, so the cause is named.

Lag attribution (#2416 method with the turn wait joined, as on #2884–#2992; diagnostic not committed). Native pi UID 1000 cell, 3 fsync writers, 3 runs each.

  • Before (origin/main 2577504d2, the runs recorded on fix(ui-server): commit authentication's per-request principal writes on the write worker #2992): the notifier tick ran on the app connection 3 times, up to 819 ms (activity/notify.ts:205 ← activity/runtime.ts:144), its own commit. Windows ≥ 250 ms after ready from it: 376, 810 and 350 ms.
  • After (this change, on 06604d3a8): no app-connection turn from the notifier or push bookkeeping at all. Windows ≥ 250 ms after ready:
    • 353 ms: a governed turn's exposure write (provider-exposure.ts:54) waiting for a worker upsertSession. That's the governed-admission group, the next PR.
    • 374 ms: a governed turn's own admission record (governed-turn.ts:301), same group.
    • 448 ms, 18 ms after ready: the proof fixture's own setup write (hosted-app-child.ts:295), not the server.

Checklist

  • Focused local preflight/behavioral receipts and complete selected hosted proof passed; actual head/base/job checkout evidence is in Proof.
  • Acceptance criteria from the issue are all met, or the gap is named below. (Met for the notifier and push bookkeeping; the remaining groups are listed below.)
  • The PR and completed/advanced issues have verified release milestones under docs/process/github.md.
  • A changeset is included, or this changes nothing a consumer can see. (.changeset/notifier-off-event-loop.md)
  • No personal data anywhere in the diff — the leakage gate covers the whole tree.
  • The integration contract index or relevant component is updated in this same commit, or nothing in the machine surface moved. (Nothing moved: ActivityNotifier, ActivityStream and createPushSender are internal.)
  • Docs under docs/ reflect the change, or nothing documented moved. (docs/decisions/sqlite-write-worker.md; shifted and re-pointed citations.)
  • Provider history stays append-only (docs/decisions/provider-history.md): the provider-history guard passes, or nothing on a turn's path changed.

Anything left open

Authentication's request-path writes landed in #2992. Next, as their own PRs: local exchanges, classified blocks and saved worker preferences; storage history and notices; a governed turn's admission records. Still to move after those (the inventory in the #2850 comment of 2026-10-26): retained retries, drafts, policy proposals, voice transcription claims and settings; reference sources and managed worktrees; action notices; inbox lifecycle; account and security administration (revocation, passkeys, push subscribe/unsubscribe, VAPID keys); schedules.

🤖 Generated with Claude Code

schlessera and others added 5 commits October 26, 2026 02:35
…the write worker

The hosted server no longer commits the Activity notifier on its app
connection (#2850). The notifier's tick (intent detection under its
cursor and the stuck-run watchdog), intent delivery status,
acknowledgement and retention, and push delivery's subscription
bookkeeping (last_used_at, a gone subscription) commit on the ui.db
write worker at synchronous=FULL (activity/notify-writes.ts).

The tick runs the same notifier on the write connection, with who is
watching taken from the Activity stream where the tick was submitted,
and its log records come back to the app's log; a tick still committing
is not submitted again. A push delivery pass settles only once its
bookkeeping and delivery status have committed, so the next pass never
resends an intent or retries a gone device. The inbox and the pending
queue are still read on the app connection.

Refs #2850

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…face

ActivityStream.watched() exists for the runtime's off-loop notifier;
tag it @internal so the public API report is unchanged.

Refs #2850

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…loop-044

# Conflicts:
#	docs/decisions/sqlite-write-worker.md
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