Skip to content

feat(monitor): instrumentation + inspector + :background-monitor module#28

Merged
happycodelucky merged 4 commits into
mainfrom
claude/confident-stonebraker-6783d4
May 17, 2026
Merged

feat(monitor): instrumentation + inspector + :background-monitor module#28
happycodelucky merged 4 commits into
mainfrom
claude/confident-stonebraker-6783d4

Conversation

@happycodelucky

@happycodelucky happycodelucky commented May 16, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the full instrumentation + inspector + optional monitor module from the plan in /Users/paulbates/.claude/plans/in-backgrounders-i-d-like-humble-eclipse.md. Four commits, each a self-contained wave.

Existing BackgrounderEventListener consumers see no behaviour change — the four v1-shape callbacks (onScheduled / onStarted / onCompleted / onCancelled) still fire identically. Everything new layers alongside without breaking anything.

What's in each commit

Wave 1 — MonitorEvent model + emitter foundation (a03e1d4)

  • MonitorEvent — sealed interface with 10 cases: Scheduled, ScheduleReplaced, Cancelled, WorkStarted, WorkCompleted, AttemptDeferred, Skipped, AttemptFailed, RetryScheduled, LibraryError.
  • Four helper sealed types (CancelSource, DeferralReason, SkipReason, AttemptFailureReason) — top-level, not nested inside MonitorEvent, because SKIE bridges nested-inside-sealed unreliably (LESSONS D-004).
  • MonitorEventEmitter — internal single-point fan-out. Synchronous dispatch to the v1 listener for the four v1-shape events only; tryEmit to a SharedFlow(replay=0, extraBufferCapacity=64, DROP_OLDEST) for all 10. Non-suspending so a slow collector cannot pin scheduler dispatch (CLAUDE.md §3).
  • Backgrounder.events(): SharedFlow<MonitorEvent> — public, SKIE-bridged to AsyncSequence<MonitorEvent> for Swift.
  • MonitorEventEmitterTest (4 cases) — green on macOS / iOS sim / Android host.

Wave 2 — platform emit-site swaps + 6 richer events (f2f5983)

Every platform scheduler now routes through MonitorEventEmitter. Builders thread one emitter instance through scheduler + bridge + dispatcher.

  • iOS (BGTaskBackedScheduler / IOSCoroutineBridge / IOSPeriodicDispatcher):
    • Scheduled / ScheduleReplaced (Replace policy displacing an active task) / Cancelled(User) from BGTaskBackedScheduler.
    • LibraryError on BGTaskScheduler.submit failure (previously Kermit-only).
    • WorkStarted with expectedAt read from IOSStateStore.nextRunEpochMs.
    • Skipped(NotRegistered) on no-factory; AttemptFailed(FactoryThrew) / AttemptFailed(WorkerThrew) / AttemptFailed(ExpiredByOS) on the matching paths.
    • AttemptDeferred(ReachabilityTimeout) from both the bridge and the periodic dispatcher when the network gate times out.
    • RetryScheduled on one-shot retry resubmits and periodic backoff overrides; emitted after WorkCompleted so observers see "attempt finished → retry queued" in natural order.
  • macOS (NSBackgroundActivityBackedScheduler): mirror of iOS, including RetryScheduled from handleOneShotRetry. Cancel emit calls hoisted outside the synchronized blocks — emit-outside-the-lock is the convention going forward.
  • Android (WorkManagerScheduler / RegistryDispatchWorker): Scheduled / ScheduleReplaced / Cancelled(User) from the scheduler; ephemeral-not-ready bail → Skipped(EphemeralExpired); input deser failure → LibraryError; no-factory → Skipped(NotRegistered); factory/worker throw → AttemptFailed(FactoryThrew|WorkerThrew); RetryScheduled with delay = ZERO / nextRunHint = null (WorkManager owns the backoff curve internally).

Only test fixture needing an update was IOSPeriodicDispatcherTest, which now wraps its RecordingListener in MonitorEventEmitter(events) before passing it to the dispatcher.

Wave 3 — inspector additions (6a031d9)

Closes the inspector half of the plan — operators can ask the Backgrounder who owns each task id, what's blocking each scheduled task from running, and is the environment configured correctly.

  • Backgrounder.registeredTaskIds(): Set<TaskId> — delegates to WorkerRegistry.registeredIds().
  • Backgrounder.registeredFactories(): List<FactoryDescriptor> — new WorkerRegistry.factoryDescriptors(). FactoryDescriptor is a sealed interface with PerId (one closure per id) and Bulk (a BackgroundWorkerFactory object owning many ids) cases. Bulk descriptors carry the new optional BackgroundWorkerFactory.factoryId for module attribution; default null, no existing implementer needs updating.
  • Backgrounder.diagnostics(): PlatformDiagnostics — new PlatformDiagnostic sealed interface with MissingInfoPlistEntry(taskId) (iOS), BackgroundRefreshDisabled (iOS, reserved — see below), WorkManagerNotInitialized (Android), and the common RegistryNotSealed. Backed by expect/actual platformDiagnostics(...).
    • iOS actual reads BGTaskSchedulerPermittedIdentifiers from NSBundle.mainBundle and emits MissingInfoPlistEntry per registered id absent from the array. BackgroundRefreshDisabled is defined but not emitted yet — UIApplication.backgroundRefreshStatus requires the main thread, and diagnostics() is a sync call. A future suspend fun variant can hop to main to read it.
    • Android actual reads WorkManager.isInitialized() via AndroidBackgrounderInternals (the side-table now keeps an Application ref keyed by WorkerRegistry).
    • macOS actual returns only the RegistryNotSealed finding when applicable — NSBackgroundActivityScheduler is in-process, no OS-level config to check.
  • ScheduledTask.pendingPredicates: List<PendingPredicate> (default empty) — NetworkRequired, RequiresCharging, WaitingForBackoff(until), WaitingForEarliestBeginDate(at). Per-platform population:
    • iOS (IOSScheduledTaskQuery): reads networkRequired and nextRunEpochMs from IOSStateStore. RequiresCharging reserved (not currently persisted; v2 follow-up gated on a schema bump).
    • Android (AndroidScheduledTaskMapper): reads WorkInfo.constraints (network type → NetworkRequired, requiresCharging()RequiresCharging) plus the existing nextScheduleTimeMillis → backoff/earliestBegin distinction. WorkInfoView gains an optional constraintsView projection.
    • macOS: WaitingForBackoff(null) when attempt > 0. Constraints aren't retained after the NSBackgroundActivityScheduler is built.
  • InspectorApiTest (6 cases) covering descriptor ordering, factoryId surface, mixed-registration ordering, defaults, and PlatformDiagnostics.isHealthy semantics.

Wave 4 — :background-monitor module + docs + LESSONS (b09776d)

New optional sibling Gradle module published as com.happycodelucky.backgrounder:background-monitor. KMP + Android targets matching the core, ARM-only per CLAUDE.md §1. SKIE intentionally omitted — pure-Swift consumers continue to use the core's existing Backgrounder.xcframework. api(project(":backgrounder")) so consumers transitively see MonitorEvent, ScheduledTask, etc.

Three public types in commonMain:

  • Monitor — single-method suspend fun onEvent(event) interface; SKIE bridges as a Swift protocol with an async requirement.
  • AttachedMonitor — handle returned by attachMonitor(scope, monitor), with idempotent detach() and isActive.
  • SnapshotPoller(backgrounder, interval) — runs scheduled() + diagnostics() on a poll loop and exposes the latest results as StateFlow<List<ScheduledTask>?> + StateFlow<PlatformDiagnostics>. Errors per iteration are swallowed; the next tick retries.

attachMonitor(scope, monitor) is an extension function on Backgrounder defined in :background-monitor so the core stays free of any Monitor reference — a circular module dependency would otherwise arise (mirrors how Ktor wires plugins). Multiple monitors can attach to one Backgrounder; each runs its own collector coroutine.

MonitorAttachTest (5 cases) exercises delivery, detach, idempotency, multi-subscriber fan-out, and isActive semantics. Subscriber-count await is the load-bearing detail — without it, tryEmit on a replay=0 flow drops events emitted before the launched collector registers.

Docs:

  • docs/recipes/inspect.md — "v2: Reactive observe()" section replaced with a real "Reactive event stream" example pointing at events(), plus paragraphs introducing registeredFactories() and diagnostics().
  • docs/recipes/monitor.md — new code-first walk-through of the 10 MonitorEvent cases, the optional :background-monitor attach API, SnapshotPoller usage, and a "What can go wrong" section matching the docs voice.
  • mkdocs.yml nav updated; docs/check.py invariants hold.

LESSONS.md D-019 documents the tryEmit-only contract on MonitorEventEmitter — the load-bearing reason a slow events() collector cannot pin scheduler dispatch (CLAUDE.md §3, the per-task Mutex on iOS).

Test plan

  • :backgrounder:macosArm64Test — full existing suite + MonitorEventEmitterTest (4) + InspectorApiTest (6) green.
  • :backgrounder:iosSimulatorArm64Test — full existing suite green; IOSPeriodicDispatcherTest updated.
  • :backgrounder:testAndroidHostTest — full existing suite green.
  • :background-monitor:macosArm64Test / iosSimulatorArm64Test / testAndroidHostTestMonitorAttachTest (5) green on all three.
  • Reviewer to confirm the wider event model (10 cases) covers the use cases the user enumerated, and the inspector surface (registeredFactories / diagnostics / pendingPredicates) is the right shape for the dashboards they want to build downstream.

🤖 Generated with Claude Code

happycodelucky and others added 4 commits May 16, 2026 04:57
…ntation)

Adds the foundation for the planned instrumentation layer (plan
`in-backgrounders-i-d-like-humble-eclipse.md`) — sealed `MonitorEvent`
model, `MonitorEventEmitter` fan-out, and a public `Backgrounder.events()`
`SharedFlow` — without touching platform scheduler code paths. Existing
`BackgrounderEventListener` consumers see no behaviour change.

- `MonitorEvent` is a sealed interface with 10 cases (scheduled,
  schedule-replaced, cancelled, started, completed, attempt-deferred,
  skipped, attempt-failed, retry-scheduled, library-error). The four
  helper sealed types (`CancelSource`, `DeferralReason`, `SkipReason`,
  `AttemptFailureReason`) are deliberately top-level — nested-inside-
  sealed bridges unreliably through SKIE (LESSONS D-004).
- `MonitorEventEmitter` is the single fan-out seam: synchronous dispatch
  to the v1 listener (for the four v1-shape events only — richer events
  flow through the flow exclusively, preserving the v1 listener
  contract), then `tryEmit` to a `SharedFlow(replay=0,
  extraBufferCapacity=64, DROP_OLDEST)`. Emit is non-suspending so a slow
  collector cannot pin scheduler dispatch (CLAUDE.md §3). A runCatching
  guards the listener fan-out against contract-violating implementations.
- Engine + all three platform builders construct the emitter from the
  user-supplied listener. Platform scheduler code still calls the
  listener directly; the emit-site swap to `emitter.emit(...)` is wave 2.
- `MonitorEventEmitterTest`: 4 tests covering v1-event listener delivery,
  richer-event listener silence, full flow ordering, and crash-resilient
  emit. Green on macOS + iOS sim + Android host.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Every platform scheduler now routes through MonitorEventEmitter instead of
calling BackgrounderEventListener directly, and emits the six richer
MonitorEvent cases the wave-1 model defined but no producer drove.
Existing v1 listener consumers see no behaviour change — the four
v1-shape callbacks still fire identically, fanned out from the emitter.

iOS (BGTaskBackedScheduler / IOSCoroutineBridge / IOSPeriodicDispatcher):
- BGTaskBackedScheduler: schedule → Scheduled; Replace policy displacing
  an active task → ScheduleReplaced (before Scheduled); submit failure →
  LibraryError; cancel / cancelAll → Cancelled(User); one-shot Retry path
  → RetryScheduled with delay + next-run hint.
- IOSCoroutineBridge: WorkStarted with expectedAt from state-store
  nextRunEpochMs; no-factory → Skipped(NotRegistered); factory throwing →
  AttemptFailed(FactoryThrew); reachability gate timeout →
  AttemptDeferred(ReachabilityTimeout); worker throwing →
  AttemptFailed(WorkerThrew); OS expiration → AttemptFailed(ExpiredByOS);
  WorkCompleted with measured runtime.
- IOSPeriodicDispatcher: same lifecycle inside runOne, plus RetryScheduled
  emitted after WorkCompleted when the result is Retry and the backoff
  cap hasn't been exhausted. NoMatchingTick is intentionally not emitted
  for the "tick woke but nothing due" branch — every MonitorEvent carries
  a taskId, and that branch has none to attribute.

macOS (NSBackgroundActivityBackedScheduler):
- Mirror of iOS: schedule / ScheduleReplaced (Replace + existing) /
  Scheduled / Cancelled(User) on cancel paths. Cancel paths now emit
  outside the synchronized block (previously inline — emitting tryEmit
  inside a critical section is harmless but the wider pattern across the
  codebase keeps emit calls outside locks). WorkStarted / Skipped /
  AttemptFailed / AttemptDeferred / WorkCompleted in launchActivity.
  One-shot handleOneShotRetry → RetryScheduled before the resubmit.

Android (WorkManagerScheduler / RegistryDispatchWorker):
- WorkManagerScheduler: ScheduleReplaced on Replace + tracked id;
  Scheduled; Cancelled(User) per id in cancel + cancelAll.
- RegistryDispatchWorker: ephemeral-not-ready bail → Skipped(EphemeralExpired)
  followed by WorkCompleted(Failure(REASON_NOT_READY)); input deser
  failure → LibraryError; no-factory → Skipped(NotRegistered); factory
  throwing → AttemptFailed(FactoryThrew); worker throwing →
  AttemptFailed(WorkerThrew); WorkStarted / WorkCompleted; Retry within
  the cap → RetryScheduled (delay/nextRunHint nulled — WorkManager owns
  the backoff curve internally).

Builders thread a single MonitorEventEmitter instance through every
emit-site collaborator, replacing the wave-1 pattern of constructing one
inside BackgrounderEngine's argument list. The emitter is still
constructed from the user-supplied BackgrounderEventListener; the
listener parameter at the Builder.build() boundary is unchanged.

Tests: full Apple + Android host suites green. The only test fixture
needing an update was IOSPeriodicDispatcherTest, which now wraps its
RecordingListener in MonitorEventEmitter(events) before passing it as
the dispatcher's emitter argument.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…(wave 3)

Closes the inspector half of the plan: callers can now ask the Backgrounder
*who owns each task id*, *what's blocking each scheduled task from
running*, and *is the environment configured correctly* — without
reaching into the registry or platform-specific state stores.

Three additions, all available from Backgrounder:

1. `registeredTaskIds(): Set<TaskId>` — delegates to existing
   `WorkerRegistry.registeredIds()`.
2. `registeredFactories(): List<FactoryDescriptor>` — new
   `WorkerRegistry.factoryDescriptors()`. `FactoryDescriptor` is a sealed
   interface with `PerId` (one closure per id) and `Bulk` (a
   `BackgroundWorkerFactory` object owning many ids) cases. Bulk descriptors
   carry the new optional `BackgroundWorkerFactory.factoryId` for module
   attribution in inspector UIs (default `null`, no existing implementer
   needs updating).
3. `diagnostics(): PlatformDiagnostics` — new `PlatformDiagnostic` sealed
   interface with `MissingInfoPlistEntry(taskId)` (iOS),
   `BackgroundRefreshDisabled` (iOS, reserved — see below),
   `WorkManagerNotInitialized` (Android), and the common
   `RegistryNotSealed`. Backed by `expect/actual platformDiagnostics(...)`.
   iOS reads `BGTaskSchedulerPermittedIdentifiers` from
   `NSBundle.mainBundle` and emits `MissingInfoPlistEntry` per registered
   id absent from the plist array. Android reads
   `WorkManager.isInitialized()` via `AndroidBackgrounderInternals` (the
   side-table now keeps an `Application` ref keyed by `WorkerRegistry`).
   macOS has no equivalent OS-level config to check; returns only the
   `RegistryNotSealed` finding when applicable. `BackgroundRefreshDisabled`
   is defined but not emitted yet: `UIApplication.backgroundRefreshStatus`
   requires the main thread, and `diagnostics()` is a sync call. A future
   `suspend fun` variant can hop to main to read it.

`ScheduledTask` gains a `pendingPredicates: List<PendingPredicate>` field
(default empty) so inspector views can answer "why isn't this dispatching
yet?". `PendingPredicate` cases: `NetworkRequired(NetworkRequirement)`,
`RequiresCharging`, `WaitingForBackoff(until)`,
`WaitingForEarliestBeginDate(at)`. Population by platform:
- iOS (`IOSScheduledTaskQuery`): reads `networkRequired` and
  `nextRunEpochMs` from `IOSStateStore`; chooses backoff-vs-earliestBegin
  based on the existing `State.Backoff`/`State.Pending` mapping.
  `RequiresCharging` is reserved — not currently persisted in
  `IOSStateStore` (v2 follow-up gated on a schema bump).
- Android (`AndroidScheduledTaskMapper`): reads `WorkInfo.constraints`
  (network type → `NetworkRequired`, `requiresCharging()` →
  `RequiresCharging`) plus the existing `nextScheduleTimeMillis` →
  backoff/earliestBegin distinction. `WorkInfoView` gains a
  `constraintsView` projection so the pure-function mapper stays
  Robolectric-free; existing test call sites that built `WorkInfoView`
  manually still compile (the field defaults to `null`).
- macOS (`NSBackgroundActivityBackedScheduler`): `WaitingForBackoff(null)`
  when `attempt > 0`. Constraints aren't retained after the
  `NSBackgroundActivityScheduler` is built; surfacing them requires
  carrying the original `WorkConstraints` on the activity map — a
  follow-up if anyone wants the parity.

Tests:
- New `InspectorApiTest` (6 cases): PerId descriptor ordering,
  factoryId surface, mixed-registration ordering, defaults, and
  `PlatformDiagnostics.isHealthy` semantics. Green on macOS / iOS sim /
  Android host.
- Existing test suites unchanged and unchanged-passing — the
  `pendingPredicates` field has a default value, so test call sites that
  construct `ScheduledTask` directly keep compiling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wraps the instrumentation feature: an optional sibling Gradle module
gives consumers a callback-shaped attach API and a snapshot poller on
top of the core's `events()` flow + inspector surface, and the docs and
lessons catalogue catch up to the new vocabulary.

`:background-monitor` (new module):
- KMP + Android targets matching the core, ARM-only per CLAUDE.md §1.
- Published as `com.happycodelucky.backgrounder:background-monitor` via
  the same vanniktech Sonatype Central Portal pipeline as the core. SKIE
  intentionally omitted — the module's public types reference
  :backgrounder's already-SKIE-bridged surface, and pure-Swift consumers
  continue to use the core's existing `Backgrounder.xcframework`.
- `api(project(":backgrounder"))` so consumers depending on the monitor
  module automatically see MonitorEvent, ScheduledTask,
  PlatformDiagnostics, etc. without a second `implementation` line.

Three public types in `commonMain`:
- `Monitor` — single-method `suspend fun onEvent(event)` interface;
  SKIE bridges as a Swift protocol with an `async` requirement.
- `AttachedMonitor` — handle returned by `attachMonitor(scope, monitor)`,
  with idempotent `detach()` and `isActive`.
- `SnapshotPoller(backgrounder, interval)` — runs `scheduled()` +
  `diagnostics()` on a poll loop and exposes the latest results as
  `StateFlow<List<ScheduledTask>?>` + `StateFlow<PlatformDiagnostics>`.
  Errors per iteration are swallowed so the loop survives transient
  failures; the next tick retries.

`attachMonitor(scope, monitor)` is an extension function on the core's
`Backgrounder`, *defined in :background-monitor* so the core stays free
of any `Monitor` reference — a circular module dependency would
otherwise arise. Multiple monitors can attach to one Backgrounder;
each runs its own collector coroutine. `MonitorAttachTest` (5 cases)
exercises delivery, detach, idempotency, multi-subscriber fan-out, and
`isActive` semantics. Subscriber-count await is the load-bearing detail
— without it, `tryEmit` on a `replay=0` flow drops events emitted
before the launched collector registers.

Docs:
- `docs/recipes/inspect.md` rewritten — the "v2: Reactive observe()"
  section is replaced with a real "Reactive event stream" example
  pointing at `events()`, plus paragraphs introducing
  `registeredFactories()` and `diagnostics()` (the wave-3 inspector
  surface).
- `docs/recipes/monitor.md` is new — code-first walk-through of the
  10 MonitorEvent cases, the optional `:background-monitor` attach
  API, `SnapshotPoller` usage, and a "What can go wrong" section
  matching the docs voice across the rest of the recipes.
- `mkdocs.yml` nav references the new page so `mkdocs build --strict`
  and `docs/check.py` stay green.

LESSONS.md D-019 documents the `tryEmit`-only contract on
`MonitorEventEmitter` — the load-bearing reason a slow `events()`
collector cannot pin scheduler dispatch (CLAUDE.md §3, the per-task
Mutex on iOS).

Verification: full test suites green across `:backgrounder` and
`:background-monitor` on macosArm64, iosSimulatorArm64, and
testAndroidHostTest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@happycodelucky happycodelucky changed the title feat(monitor): MonitorEvent stream + emitter seam (wave 1) feat(monitor): instrumentation + inspector + :background-monitor module May 17, 2026
@happycodelucky happycodelucky merged commit dfbf853 into main May 17, 2026
2 of 3 checks passed
@happycodelucky happycodelucky deleted the claude/confident-stonebraker-6783d4 branch May 17, 2026 21:20
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