From 5e1299e36fa6fa7b1bafd8f746dbec3c29ea5821 Mon Sep 17 00:00:00 2001 From: sugarforever Date: Fri, 23 Oct 2026 01:38:09 +0100 Subject: [PATCH 1/5] feat: show committed Android task notifications --- App.tsx | 6 + README.md | 4 +- app.json | 4 +- docs/background-recovery.md | 10 ++ .../2026-10-23-background-notifications.md | 21 +++ .../background/MooseBackgroundModule.kt | 6 +- .../background/MooseExecutionService.kt | 72 ++++++++-- package-lock.json | 4 +- package.json | 2 +- src/dev/BackgroundSmoke.tsx | 29 +++- src/platform/background.ts | 67 ++++++++- src/platform/open.ts | 4 +- src/platform/runtime-owner.ts | 2 +- src/runtime/engine.ts | 10 +- src/runtime/execution-notifications.ts | 125 ++++++++++++++++ src/runtime/execution-owner.ts | 5 +- src/termux/recovery.ts | 4 +- src/ui/conversation-link.ts | 6 + src/ui/useMoose.ts | 48 ++++++- tests/conversation-link.test.ts | 15 ++ tests/execution-notifications.test.ts | 134 ++++++++++++++++++ tests/execution-recovery.test.ts | 70 +++++++++ 22 files changed, 613 insertions(+), 35 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-23-background-notifications.md create mode 100644 src/runtime/execution-notifications.ts create mode 100644 src/ui/conversation-link.ts create mode 100644 tests/conversation-link.test.ts create mode 100644 tests/execution-notifications.test.ts diff --git a/App.tsx b/App.tsx index db26af5..de27c3d 100644 --- a/App.tsx +++ b/App.tsx @@ -73,6 +73,12 @@ function MooseApp() { const [history, setHistory] = useState(false); const [artifactsVisible, setArtifactsVisible] = useState(false); const [keyboardVisible, setKeyboardVisible] = useState(false); + useEffect(() => { + if (!moose.notificationNavigation) return; + setTab("chat"); + setHistory(false); + setArtifactsVisible(false); + }, [moose.notificationNavigation]); const followBottom = useRef(true); const list = useRef>(null); const activityMessages = groupActivities(moose.messages, moose.activity); diff --git a/README.md b/README.md index 3a57a6e..4ec962e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Moose -Moose v0.7.0 is an open source AI agent for iOS and Android powered by Pi Durable. Expo / React Native provides the interface, Pi Durable runs on the phone's Hermes runtime, and SQLite stores conversations and tasks. Model requests go directly to the provider; no Moose backend is required. +Moose v0.7.1 is an open source AI agent for iOS and Android powered by Pi Durable. Expo / React Native provides the interface, Pi Durable runs on the phone's Hermes runtime, and SQLite stores conversations and tasks. Model requests go directly to the provider; no Moose backend is required. ## Features @@ -272,6 +272,8 @@ Chat shows the current task separately from historical reply attempts: running, Transient model failures use Pi's durable retry checkpoint, with at most three generation retries and exponential 2/4/8-second backoff. Reopening projects committed messages and tool results and reconciles already-settled outbox items; it does not resubmit a task on foreground return. A failed attempt that later recovered is labelled as history. Network waiting is inferred from a transport error with a scheduled retry, rather than a claim that connectivity has been measured. Authentication and quota errors are not retried automatically. An operation whose effects are unknown blocks further mutations across restart and context compaction; inspect its effects before choosing **Review interrupted operation → Allow further changes**. That action does not rerun the command. See [background recovery design and validation](docs/background-recovery.md). +Android’s ongoing notification shows committed execution progress. Background completion or failure posts one result alert per conversation; foreground results are silent. Tap a single-task or result notification to open its saved conversation without resubmitting it. Multiple active conversations share a count notification and the existing Stop-all action. Failure alerts use brief fixed categories without provider bodies or credentials. Task results can be muted separately in Android notification settings. + System reclamation, Doze, network loss and vendor power policies can still interrupt execution. Process death cannot preserve the original TCP connection or Promise, and recovery cannot guarantee a model is billed only once. Force-stop is respected. iOS retains its existing suspension behavior. The Android `specialUse` declaration describes finite AI work; this does not imply Google Play approval. For a controlled native probe, rebuild the Android development client and run `EXPO_PUBLIC_MOOSE_BACKGROUND_SMOKE=1 npx expo start --dev-client`. Tap Run background probe, confirm the service starts, then press Home. After 15 seconds the probe should finish its file tool and second model round, commit SQLite and release its notification. The probe uses a separate database and controlled SSE, never real credentials or paid requests. Notification stopping can be checked during the delay. The same probe has a five/ten-minute lock test and a durable-results reader. For actual native network transport, start `node scripts/background-probe-server.mjs` on the development machine, then use **Run network probe** in the Android emulator; it connects only to the controlled local endpoint through `10.0.2.2:8106`. No request is forwarded to a model service. diff --git a/app.json b/app.json index 2a0b224..81d9151 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Moose", "slug": "moose-mobile", - "version": "0.7.0", + "version": "0.7.1", "scheme": "moose", "orientation": "default", "userInterfaceStyle": "light", @@ -15,7 +15,7 @@ }, "android": { "package": "dev.verysmallwoods.moose", - "versionCode": 11 + "versionCode": 20 }, "plugins": [ "expo-sqlite", diff --git a/docs/background-recovery.md b/docs/background-recovery.md index 9e83a31..17cf4c6 100644 --- a/docs/background-recovery.md +++ b/docs/background-recovery.md @@ -45,3 +45,13 @@ To repeat: start the opt-in probe from README with a freshly loaded development Native UI inspection used the opt-in preview with the actual Markdown component: recovered attempts used neutral text, terminal stopped attempts used red text, details expanded to show error/time/phase/retries, and task details opened a native alert. This was a controlled component preview, not a real-account chat. Delivery checks: `npm run verify` passed with 226 tests and 8 existing skipped watcher cases, 3 Termux bootstrap tests, type checking, portable runtime smoke and Android/iOS Hermes exports. Android `assembleDebug` succeeded and that client was used for the native cases above. Independent reviews found and verified fixes for compacted-context replay protection, lifecycle-event association, cancelled retry counts and acknowledgement races. No native iOS build was performed. + +## Task notifications + +Android keeps one ongoing notification for the shared execution service. During admitted work it observes committed conversation views to show running, retrying, network retry waiting, or authorization waiting. A single conversation links directly to its chat; multiple active conversations show a count and open Moose without assigning the shared service to the wrong chat. Stop still cancels all tasks, and native stop causes and acknowledgement are unchanged. + +After settlement, Moose rereads the final committed view before releasing the service. A changed completed or failed outcome posts one result notification per conversation on the **Task results** channel; overlapping leases are deduplicated. A final authorization error counts as a failed result, while an in-progress permission prompt remains waiting. Cancelled tasks do not alert. Unchanged historical outcomes never alert when reopening or retrying an already-settled request. Each repeat iteration is an admitted execution and may produce a result; sleeping intervals do not hold a service. + +Result notifications are suppressed when the native Activity is resumed. Failure text uses fixed categories (credentials, network, usage limit, or generic failure) without copying provider bodies or user content. Tapping a result selects the existing persisted conversation through `moose://conversation/`, including a cold launch; the route never submits a request. Invalid or deleted conversation targets are ignored. + +Notifications remain required for Android admission. Denying permission prevents work from starting with an explicit error; losing permission retains native cancellation semantics. Muting the separate Task results channel silences results without changing task persistence. No notification permission or new background guarantee is added on iOS. This feature adds no executor, scheduler, or background wakeup. diff --git a/docs/superpowers/plans/2026-10-23-background-notifications.md b/docs/superpowers/plans/2026-10-23-background-notifications.md new file mode 100644 index 0000000..a15ea53 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-background-notifications.md @@ -0,0 +1,21 @@ +# Background task notifications implementation plan + +**Goal:** Show committed Android task progress and one result alert, with conversation routing. +**Architecture:** Extend the existing execution guard with conversation IDs. A small observer watches committed views during admitted work and reads the final view before releasing the existing service. Native code keeps the shared ongoing notification and posts per-conversation results on a separate alert channel. No executor or scheduler is added. +**Tech stack:** Pi Durable, Expo module, Android NotificationCompat, React Native Linking. + +## Constraints and acceptance + +- Preserve service admission, notification permission, Stop-all, cancellation acknowledgement and 30-minute budget. +- Observe only admitted work; an unchanged historical outcome cannot alert. Deduplicate overlapping leases for one conversation. +- Progress uses persisted state. Results are read after settlement before service release. Never include provider bodies or user text in notifications; failures use fixed categories. +- Multiple active conversations show a count without a misleading conversation target. Each completed conversation gets its own result target. +- Suppress result alerts while the Activity is resumed. Denied permission retains existing admission failure and loss cancellation semantics. +- Warm and cold URL handling validates an existing conversation and only selects it; no submission occurs. iOS gains no background guarantee. + +## Work + +- [ ] Write failing observer tests covering persisted final reads, old failures, retries, overlapping leases, cancellation and bounded errors; implement the observer and guard ID propagation. +- [ ] Extend native progress/result methods and notification intents, preserving stop causes. Add validated warm/cold conversation selection. +- [ ] Run complete verify, native compile and controlled emulator probes; record evidence and limits. +- [ ] Review diff, create PR, fix findings/checks, merge; increment actual latest patch version, tag and inspect release APK/manifest/signature/companion. diff --git a/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseBackgroundModule.kt b/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseBackgroundModule.kt index 7a32dea..0f3a774 100644 --- a/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseBackgroundModule.kt +++ b/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseBackgroundModule.kt @@ -28,7 +28,11 @@ class MooseBackgroundModule : Module() { Function("ready") { token: String -> MooseExecutionService.instance?.ready(token) } AsyncFunction("stop") { token: String -> MooseExecutionService.releaseRequest(token) } .runOnQueue(expo.modules.kotlin.functions.Queues.MAIN) - Function("waiting") { value: Boolean -> MooseExecutionService.instance?.waiting(value) } + Function("waiting") { id: Double, value: Boolean -> MooseExecutionService.instance?.waiting(id.toLong(), value) } + Function("progress") { items: List> -> MooseExecutionService.instance?.progress(items) } + AsyncFunction("result") { id: Double, failed: Boolean, detail: String -> + MooseExecutionService.result(requireNotNull(appContext.reactContext), id.toLong(), failed, detail) + }.runOnQueue(expo.modules.kotlin.functions.Queues.MAIN) Function("pendingStop") { MooseExecutionService.pendingStop(requireNotNull(appContext.reactContext)) } Function("stopDetail") { MooseExecutionService.stopDetail(requireNotNull(appContext.reactContext)) } Function("acknowledgeStop") { diff --git a/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseExecutionService.kt b/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseExecutionService.kt index 5feeb1a..810b6ab 100644 --- a/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseExecutionService.kt +++ b/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/MooseExecutionService.kt @@ -7,6 +7,8 @@ import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.os.* +import android.net.Uri +import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat @@ -25,6 +27,8 @@ class MooseExecutionService : Service() { private var lock: PowerManager.WakeLock? = null private var stopping = false private var notification: NotificationCompat.Builder? = null + private var progressItems: List> = emptyList() + private val approvals = mutableSetOf() private val budget = Runnable { cancel("budget_expired") } private val handshake = Runnable { cancel("handshake_timeout") } private val unresponsive = Runnable { @@ -60,7 +64,7 @@ class MooseExecutionService : Service() { val stop = PendingIntent.getService(this, 1, Intent(this, MooseExecutionService::class.java).setAction(ACTION_STOP).putExtra("token", requested), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val launch = packageManager.getLaunchIntentForPackage(packageName) + val launch = conversationIntent(this, null) val notification = NotificationCompat.Builder(this, CHANNEL) .setSmallIcon(android.R.drawable.stat_notify_sync) .setContentTitle("Moose is running tasks") @@ -68,7 +72,7 @@ class MooseExecutionService : Service() { .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setOngoing(true).setOnlyAlertOnce(true) .addAction(android.R.drawable.ic_media_pause, "Stop", stop) - if (launch != null) notification.setContentIntent(PendingIntent.getActivity(this, 0, launch, PendingIntent.FLAG_IMMUTABLE)) + notification.setContentIntent(launch) this.notification = notification ServiceCompat.startForeground(this, 7301, notification.build(), if (Build.VERSION.SDK_INT >= 34) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0) @@ -87,16 +91,39 @@ class MooseExecutionService : Service() { } return START_NOT_STICKY } - fun waiting(value: Boolean) { + fun waiting(id: Long, value: Boolean) { handler.post { - val builder = notification ?: return@post - if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - cancel("notification_permission_lost") - return@post - } - if (!stopping) getSystemService(NotificationManager::class.java).notify(7301, - builder.setContentText(if (value) "Approval needed. Open Moose to continue." else "Up to 30 minutes. Stop cancels all tasks.").build()) + if (value) approvals.add(id) else approvals.remove(id) + renderProgress() + } + } + fun progress(items: List>) { + handler.post { + progressItems = items + renderProgress() + } + } + private fun renderProgress() { + val builder = notification ?: return + if (!NotificationManagerCompat.from(this).areNotificationsEnabled() || + (Build.VERSION.SDK_INT >= 26 && getSystemService(NotificationManager::class.java).getNotificationChannel(CHANNEL)?.importance == NotificationManager.IMPORTANCE_NONE)) { + cancel("notification_permission_lost") + return } + if (stopping) return + val single = progressItems.singleOrNull() + val id = (single?.get("id") as? Number)?.toLong() + val state = if (id != null && approvals.contains(id)) "waiting_authorization" else single?.get("state") as? String + val text = when { + progressItems.size > 1 -> "${progressItems.size} tasks active. Stop cancels all tasks." + state == "waiting_network" -> "Waiting for network. Retry scheduled." + state == "retrying" -> "Retrying task." + state == "waiting_authorization" -> "Approval needed. Open Moose to continue." + else -> "Running. Stop cancels all tasks." + } + getSystemService(NotificationManager::class.java).notify(7301, + builder.setContentTitle(if (progressItems.size > 1) "Moose is running tasks" else "Moose task") + .setContentText(text).setContentIntent(conversationIntent(this, id)).build()) } fun ready(value: String) { handler.post { @@ -145,6 +172,31 @@ class MooseExecutionService : Service() { } companion object { private const val CHANNEL = "moose-execution" + private const val RESULTS = "moose-task-results" + private fun conversationIntent(context: Context, id: Long?): PendingIntent? { + val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null + launch.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + if (id != null) launch.action = Intent.ACTION_VIEW + if (id != null) launch.data = Uri.parse("moose://conversation/$id") + return PendingIntent.getActivity(context, if (id == null) 0 else (id % Int.MAX_VALUE).toInt(), launch, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + fun result(context: ReactContext, id: Long, failed: Boolean, detail: String) { + // Lifecycle is checked at delivery on the main queue, including warm Activity returns. + if (context.lifecycleState == com.facebook.react.common.LifecycleState.RESUMED || pendingStop(context)) return + if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return + val manager = context.getSystemService(NotificationManager::class.java) + if (Build.VERSION.SDK_INT >= 26) manager.createNotificationChannel( + NotificationChannel(RESULTS, "Task results", NotificationManager.IMPORTANCE_DEFAULT)) + val builder = NotificationCompat.Builder(context, RESULTS) + .setSmallIcon(if (failed) android.R.drawable.stat_notify_error else android.R.drawable.stat_notify_sync_noanim) + .setContentTitle(if (failed) "Moose task failed" else "Moose task completed") + .setContentText(if (failed) detail else "Open Moose to read the result.") + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setAutoCancel(true).setOnlyAlertOnce(false) + .setContentIntent(conversationIntent(context, id)) + manager.notify("conversation:$id", 7302, builder.build()) + } private const val PREFS = "moose-execution-control" private const val ACTION_STOP = "dev.verysmallwoods.moose.STOP_EXECUTION" private const val BUDGET = 30L * 60 * 1000 diff --git a/package-lock.json b/package-lock.json index ecc57e9..1e7e2da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "moose-mobile", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "moose-mobile", - "version": "0.7.0", + "version": "0.7.1", "dependencies": { "@earendil-works/chord": "1.3.0", "@earendil-works/pi-ai": "1.3.0", diff --git a/package.json b/package.json index 11245c7..c83e1b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moose-mobile", - "version": "0.7.0", + "version": "0.7.1", "private": true, "main": "index.ts", "scripts": { diff --git a/src/dev/BackgroundSmoke.tsx b/src/dev/BackgroundSmoke.tsx index 3ea89aa..ce11953 100644 --- a/src/dev/BackgroundSmoke.tsx +++ b/src/dev/BackgroundSmoke.tsx @@ -29,6 +29,7 @@ import { responseEvents, sseResponse } from "./controlled-sse"; let runtime: Promise | undefined; let requests = 0; +const attempts = new Map(); function getRuntime() { return (runtime ??= (async () => { const db = await openDatabaseAsync("background-smoke.sqlite"); @@ -36,6 +37,20 @@ function getRuntime() { async () => "controlled-fixture-only", (async (_url, init) => { requests++; + const bodyText = String(init?.body); + const probe = /probe=(\d+)/.exec(bodyText)?.[1] ?? "unknown"; + const attempt = (attempts.get(probe) ?? 0) + 1; + attempts.set(probe, attempt); + if ( + bodyText.includes("mode=failure") || + (bodyText.includes("mode=retry") && attempt === 1) + ) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + const retry = bodyText.includes("mode=retry"); + return sseResponse( + `data: ${JSON.stringify({ type: "response.failed", response: { id: "controlled", error: { code: retry ? "server_error" : "invalid_request_error", message: retry ? "Network error" : "Invalid request access_token=fixture-secret" } } })}\n\n`, + ); + } if (String(init?.body).includes("network=1")) { console.log( "MooseBackgroundSmoke network request", @@ -102,7 +117,7 @@ function getRuntime() { {}, acquireBackground, ); - configureBackground((cause) => engine.stopAll(cause)); + configureBackground((cause) => engine.stopAll(cause), engine); await cancelInterruptedBackground((cause) => engine.stopAll(cause)); await engine.resume(); return engine; @@ -131,7 +146,7 @@ export function BackgroundSmokeApp() { }) .catch((e) => setStatus(String(e))); }, []); - async function start(delay = 15000, network = false) { + async function start(delay = 15000, network = false, mode = "success") { try { setStatus("Starting"); const engine = await getRuntime(); @@ -143,7 +158,7 @@ export function BackgroundSmokeApp() { }); const submission = await engine.submit( chat.id, - `Controlled file tool delay=${delay} network=${network ? 1 : 0} probe=${chat.id}`, + `Controlled file tool delay=${delay} network=${network ? 1 : 0} mode=${mode} probe=${chat.id}`, `native-${Date.now()}`, ); setStatus("Running: press Home now"); @@ -179,6 +194,14 @@ export function BackgroundSmokeApp() { {status}