feat(monitor): instrumentation + inspector + :background-monitor module#28
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
BackgrounderEventListenerconsumers 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 —
MonitorEventmodel + emitter foundation (a03e1d4)MonitorEvent— sealed interface with 10 cases:Scheduled,ScheduleReplaced,Cancelled,WorkStarted,WorkCompleted,AttemptDeferred,Skipped,AttemptFailed,RetryScheduled,LibraryError.CancelSource,DeferralReason,SkipReason,AttemptFailureReason) — top-level, not nested insideMonitorEvent, 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;tryEmitto aSharedFlow(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 toAsyncSequence<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.BGTaskBackedScheduler/IOSCoroutineBridge/IOSPeriodicDispatcher):Scheduled/ScheduleReplaced(Replace policy displacing an active task) /Cancelled(User)fromBGTaskBackedScheduler.LibraryErroronBGTaskScheduler.submitfailure (previously Kermit-only).WorkStartedwithexpectedAtread fromIOSStateStore.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.RetryScheduledon one-shot retry resubmits and periodic backoff overrides; emitted afterWorkCompletedso observers see "attempt finished → retry queued" in natural order.NSBackgroundActivityBackedScheduler): mirror of iOS, includingRetryScheduledfromhandleOneShotRetry. Cancel emit calls hoisted outside the synchronized blocks — emit-outside-the-lock is the convention going forward.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);RetryScheduledwithdelay = ZERO/nextRunHint = null(WorkManager owns the backoff curve internally).Only test fixture needing an update was
IOSPeriodicDispatcherTest, which now wraps itsRecordingListenerinMonitorEventEmitter(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 toWorkerRegistry.registeredIds().Backgrounder.registeredFactories(): List<FactoryDescriptor>— newWorkerRegistry.factoryDescriptors().FactoryDescriptoris a sealed interface withPerId(one closure per id) andBulk(aBackgroundWorkerFactoryobject owning many ids) cases. Bulk descriptors carry the new optionalBackgroundWorkerFactory.factoryIdfor module attribution; defaultnull, no existing implementer needs updating.Backgrounder.diagnostics(): PlatformDiagnostics— newPlatformDiagnosticsealed interface withMissingInfoPlistEntry(taskId)(iOS),BackgroundRefreshDisabled(iOS, reserved — see below),WorkManagerNotInitialized(Android), and the commonRegistryNotSealed. Backed byexpect/actual platformDiagnostics(...).BGTaskSchedulerPermittedIdentifiersfromNSBundle.mainBundleand emitsMissingInfoPlistEntryper registered id absent from the array.BackgroundRefreshDisabledis defined but not emitted yet —UIApplication.backgroundRefreshStatusrequires the main thread, anddiagnostics()is a sync call. A futuresuspend funvariant can hop to main to read it.WorkManager.isInitialized()viaAndroidBackgrounderInternals(the side-table now keeps anApplicationref keyed byWorkerRegistry).RegistryNotSealedfinding when applicable —NSBackgroundActivityScheduleris in-process, no OS-level config to check.ScheduledTask.pendingPredicates: List<PendingPredicate>(default empty) —NetworkRequired,RequiresCharging,WaitingForBackoff(until),WaitingForEarliestBeginDate(at). Per-platform population:IOSScheduledTaskQuery): readsnetworkRequiredandnextRunEpochMsfromIOSStateStore.RequiresChargingreserved (not currently persisted; v2 follow-up gated on a schema bump).AndroidScheduledTaskMapper): readsWorkInfo.constraints(network type →NetworkRequired,requiresCharging()→RequiresCharging) plus the existingnextScheduleTimeMillis→ backoff/earliestBegin distinction.WorkInfoViewgains an optionalconstraintsViewprojection.WaitingForBackoff(null)whenattempt > 0. Constraints aren't retained after theNSBackgroundActivityScheduleris built.InspectorApiTest(6 cases) covering descriptor ordering, factoryId surface, mixed-registration ordering, defaults, andPlatformDiagnostics.isHealthysemantics.Wave 4 —
:background-monitormodule + 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 existingBackgrounder.xcframework.api(project(":backgrounder"))so consumers transitively seeMonitorEvent,ScheduledTask, etc.Three public types in
commonMain:Monitor— single-methodsuspend fun onEvent(event)interface; SKIE bridges as a Swift protocol with anasyncrequirement.AttachedMonitor— handle returned byattachMonitor(scope, monitor), with idempotentdetach()andisActive.SnapshotPoller(backgrounder, interval)— runsscheduled()+diagnostics()on a poll loop and exposes the latest results asStateFlow<List<ScheduledTask>?>+StateFlow<PlatformDiagnostics>. Errors per iteration are swallowed; the next tick retries.attachMonitor(scope, monitor)is an extension function onBackgrounderdefined in:background-monitorso the core stays free of anyMonitorreference — 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, andisActivesemantics. Subscriber-count await is the load-bearing detail — without it,tryEmiton areplay=0flow 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 atevents(), plus paragraphs introducingregisteredFactories()anddiagnostics().docs/recipes/monitor.md— new code-first walk-through of the 10MonitorEventcases, the optional:background-monitorattach API,SnapshotPollerusage, and a "What can go wrong" section matching the docs voice.mkdocs.ymlnav updated;docs/check.pyinvariants hold.LESSONS.md D-019 documents the
tryEmit-only contract onMonitorEventEmitter— the load-bearing reason a slowevents()collector cannot pin scheduler dispatch (CLAUDE.md §3, the per-taskMutexon iOS).Test plan
:backgrounder:macosArm64Test— full existing suite +MonitorEventEmitterTest(4) +InspectorApiTest(6) green.:backgrounder:iosSimulatorArm64Test— full existing suite green;IOSPeriodicDispatcherTestupdated.:backgrounder:testAndroidHostTest— full existing suite green.:background-monitor:macosArm64Test/iosSimulatorArm64Test/testAndroidHostTest—MonitorAttachTest(5) green on all three.registeredFactories/diagnostics/pendingPredicates) is the right shape for the dashboards they want to build downstream.🤖 Generated with Claude Code