From 8bed5bd20b64868dce03f0531a9e92a9a131cae5 Mon Sep 17 00:00:00 2001 From: sugarforever Date: Sun, 25 Oct 2026 01:38:50 +0000 Subject: [PATCH] fix: remove Android background execution time limit --- README.md | 10 +- docs/android-execution-duration.md | 15 +++ docs/background-recovery.md | 8 +- .../android/src/main/AndroidManifest.xml | 2 +- .../background/ExecutionWakeLock.java | 55 +++++++++ .../background/MooseExecutionService.kt | 30 +++-- tests/background-wake-lock.test.ts | 48 ++++++++ tests/support/ExecutionWakeLockHarness.java | 107 ++++++++++++++++++ 8 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 docs/android-execution-duration.md create mode 100644 modules/moose-background/android/src/main/java/dev/verysmallwoods/background/ExecutionWakeLock.java create mode 100644 tests/background-wake-lock.test.ts create mode 100644 tests/support/ExecutionWakeLockHarness.java diff --git a/README.md b/README.md index 9bc50b3..16d6de4 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Moose v0.8.0 is an open source AI agent for iOS and Android powered by Pi Durabl Requires Node.js 22.19 or later. The Moose app itself does not run Node.js. Node is used for development, builds, and tests, and by the optional companion service inside Termux. +Tests also require JDK 17 or later (`java` and `javac` on PATH) to compile and exercise the Android wake-lock lifecycle without an emulator. + ```sh npm ci npm run ios @@ -82,7 +84,7 @@ Automated tests use controlled responses, without signing into real accounts or Recurring tasks run immediately for the first iteration, with an interval of at least one minute and a total of 2–100 iterations. Requests receive a stable ID before submission. Unconfirmed submissions can retry the same ID to avoid duplicate queue entries. Stopping a recurring task stops future scheduling; an already submitted response must be stopped separately in the conversation. -Android protects user-initiated finite runs with a bounded foreground service; see [Android foreground execution](#android-foreground-execution). iOS may pause when suspended. Exact recurring execution times are not guaranteed. Reopening recovers committed state, and an overdue recurring task runs once before calculating its next interval. This version has no remote execution host or cross-device synchronization. +Android protects user-initiated finite runs with a foreground service; see [Android foreground execution](#android-foreground-execution). iOS may pause when suspended. Exact recurring execution times are not guaranteed. Reopening recovers committed state, and an overdue recurring task runs once before calculating its next interval. This version has no remote execution host or cross-device synchronization. Restoring a checkpoint creates a new branch from the corresponding historical message and preserves the original conversation. @@ -241,7 +243,7 @@ npx expo-doctor Current validation coverage: - iOS: Native debug build, installation, and operation on an iPhone 17 Pro simulator. Checks covered chat layout, message menus, keyboard avoidance, skill downloads and persistence, and local authentication callback listening and cancellation cleanup. -- Android: Debug APK compilation, installation on an Android 35 emulator, and controlled foreground-service SSE / local file-tool / next-round execution with SQLite commits, Activity destruction/recreation, notification stopping, and force-stop persistence passed. Physical-device Doze, vendor power policies, the full 30-minute timeout, and real Termux operations remain unverified. Release assets exported successfully. [GitHub Actions successfully compiled a test APK](https://github.com/sugarforever/moose/actions/runs/39503789329) containing the native Termux setup module. Phone installation, permission grants, and real Termux operations have not yet been validated. +- Android: Debug APK compilation, installation on an Android 35 emulator, and controlled foreground-service SSE / local file-tool / next-round execution with SQLite commits, Activity destruction/recreation, notification stopping, and force-stop persistence passed. Physical-device Doze, vendor power policies, long-duration physical-device execution and real Termux operations remain unverified. Release assets exported successfully. [GitHub Actions successfully compiled a test APK](https://github.com/sugarforever/moose/actions/runs/39503789329) containing the native Termux setup module. Phone installation, permission grants, and real Termux operations have not yet been validated. - Expo Doctor: 19 of 20 checks passed. The remaining check concerns new-architecture metadata for the TCP library and missing public-directory metadata for the local Termux and iSH modules; this is not a fully passing result. - File environment: The local package completed real file reads, writes, edits, SQLite reopening, and workspace isolation checks on the iOS simulator using controlled OpenAI SSE responses, without real credentials or billable requests. @@ -274,11 +276,11 @@ See [AGENTS.md](AGENTS.md) for development constraints and [chat layout notes](d On Android, a user-initiated answer or finite task establishes a shared foreground service and notification before model work begins. The existing Hermes runtime and Pi Harness continue the same SSE, local tools and SQLite commits when the Activity is hidden. Returning to the app does not resubmit, reopen the Harness or resume it again. Notification permission is required; a denied permission or failed service handshake prevents model execution. -Automatic compaction runs as part of the active answer rather than as speculative background work. All active conversations share the service. The last run releases it; each continuous service generation has a 30-minute execution budget and a bounded partial wake lock. The notification's Stop action cancels all answers and recurring tasks, including future iterations. If JavaScript cannot acknowledge cancellation within 10 seconds, the native service terminates this process; a persistent cancellation marker is processed before recovery on the next manual launch. A missing startup handshake triggers cancellation after 15 seconds. +Automatic compaction runs as part of the active answer rather than as speculative background work. All active conversations share the service. The last run releases it. Moose imposes no total execution-time limit; a timed partial wake lock is renewed every five minutes while the service is active, with each acquisition expiring after ten minutes. Completion, Stop and service destruction cancel renewal and release the lock. The notification's Stop action cancels all answers and recurring tasks, including future iterations. If JavaScript cannot acknowledge cancellation within 10 seconds, the native service terminates this process; a persistent cancellation marker is processed before recovery on the next manual launch. A missing startup handshake triggers cancellation after 15 seconds. Recurring tasks do not hold the service while waiting. An iteration due while the app is hidden waits until Moose is visible unless another active run already holds the service. There is no scheduled background wakeup, boot receiver or automatic service restart. A tool requiring new permission stores the pending permission scope and prompts the user to open Moose; it never grants permission automatically. Termux is a separate app and is not kept alive by Moose's service. Switching execution environments is blocked while any conversation, repeat, pending request or foreground admission is active. -Chat shows the current task separately from historical reply attempts: running, retrying, waiting for network or authorization, completed, failed or stopped. Tap the task status or an attempt's Details for bounded, credential-redacted errors, stage, time and retry count. Native notification stop, budget expiry, startup timeout, permission loss, runtime destruction and service destruction keep distinct cancellation reasons. These diagnostics are persisted with the conversation (latest 200 events); they are separate from session-only Termux transport diagnostics. +Chat shows the current task separately from historical reply attempts: running, retrying, waiting for network or authorization, completed, failed or stopped. Tap the task status or an attempt's Details for bounded, credential-redacted errors, stage, time and retry count. Native notification stop, wake-lock renewal failure, startup timeout, permission loss, runtime destruction and service destruction keep distinct cancellation reasons. Historical budget-expiry reasons remain readable, but new runs are not cancelled by a total-duration timer. These diagnostics are persisted with the conversation (latest 200 events); they are separate from session-only Termux transport diagnostics. 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). diff --git a/docs/android-execution-duration.md b/docs/android-execution-duration.md new file mode 100644 index 0000000..7630811 --- /dev/null +++ b/docs/android-execution-duration.md @@ -0,0 +1,15 @@ +# Android execution duration + +## Decision + +User-started Android agent runs have no Moose-imposed total duration limit. Remove the service's 30-minute cancellation timer and its notification/manifest wording. Keep the existing shared foreground service, notification permission requirement, 15-second startup handshake, explicit Stop action, 10-second cancellation acknowledgement deadline, and persistent stop reasons. + +The partial wake lock remains time-bounded: acquire it for 10 minutes and renew every 5 minutes while this service generation is active. Service shutdown cancels renewal and releases the lock. A renewal failure stops renewal and enters the existing durable cancellation path with `wake_lock_failed`; it does not silently run without CPU protection. Renewal uses Android's main Handler and uptime clock, independently of React view updates. No new agent scheduler or model retry policy is introduced. + +Waiting recurring tasks still release the service. There is no scheduled wakeup or automatic process restart. Android may suspend, restrict networking, or terminate the application; removing a local timer does not override those rules. Historical `budget_expired` diagnostics remain readable, but new service generations no longer produce that reason. + +## Verification + +Compile and execute the production wake-lock lifecycle helper on the JVM with a controlled clock, covering operation beyond 30 minutes, renewal before lock expiry, explicit stop, late callbacks and acquisition failure. These tests require JDK 17 or newer and run within `npm test`. Keep the existing execution-owner and durable cancellation regression tests. + +Run the complete project verification. Android native compilation and device operation are separate evidence. On a physical phone, use a controlled task lasting over 35 minutes, verify completion before reopening, then check Stop and final resource release. No paid inference is required for controlled verification. diff --git a/docs/background-recovery.md b/docs/background-recovery.md index 2ea2ba9..5ee6bbe 100644 --- a/docs/background-recovery.md +++ b/docs/background-recovery.md @@ -6,7 +6,7 @@ Locking the screen must not cancel a run. Keep the existing process-lifetime Har - Keep a bounded conversation-scoped `moose.execution` document (200 events) for response failures, generation restarts and explicit cancellation. Record task ID, phase, timestamp, attempt/retry count, stop reason and sanitized error. The transcript remains the source of final results and keeps older errors beyond the diagnostic window. Never store headers, tokens, request bodies or tool arguments in this document. - Derive current status from committed `pi.live`, transcript and execution diagnostics. Approval waiting overrides running. A failed attempt followed by successful generation in the same user turn is historical recovery, not a failed current task. Details remain accessible beside the attempt. -- Persist cancellation intent before aborting. Android preserves the first native cancellation reason and time before emitting the bridge event; next launch consumes pending cancellation before scheduling. Distinguish notification stop, budget expiry, handshake timeout, notification permission loss, runtime destruction and unexpected service destruction. Abrupt process death cannot reliably identify an OS reason; report runtime restart without inventing one. +- Persist cancellation intent before aborting. Android preserves the first native cancellation reason and time before emitting the bridge event; next launch consumes pending cancellation before scheduling. Distinguish notification stop, wake-lock renewal failure, handshake timeout, notification permission loss, runtime destruction and unexpected service destruction. Historical budget-expiry reasons remain readable; new runs have no total-duration cancellation timer. Abrupt process death cannot reliably identify an OS reason; report runtime restart without inventing one. - Use Pi's generation retry checkpoint, explicitly bounded to three retries with 2/4/8 second backoff (30 second delay ceiling). Authentication, quota and cancellation are terminal. Recovery reads committed history/tool results; it does not resume an SSE byte offset. Retain Pi's unsafe tool replay protection, and prevent a model from bypassing it with another mutating call after an unknown outcome. The conversation retains an independent unknown-effect latch across compaction and reopening. Only the explicit “Allow further changes” action clears it; a new user message does not clear it, and the action itself never reruns a tool. - Committed views drive UI, including after reopening. Diagnostic hooks commit before the provider outcome is classified. No UI callback owns execution or persistence. Terminal outcome remains authoritative even when UI detaches. @@ -72,3 +72,9 @@ The standalone cold-route regression initially opened saved chat 367 instead of To repeat the cold regression on a release APK with the controlled BackgroundSmoke database selected, run `ADB=/path/to/adb node scripts/notification-route-smoke.mjs 367 379`, substituting two known controlled probe IDs. The script requires the source fixture on screen and no active execution service before ending the process, retains the recent task, and verifies that the requested saved probe appears after cold launch. After rebasing onto the independently merged iOS iSH integration (`78eaeea`), the complete verification passed again: 245 tests passed, 37 skipped (including the environment-gated embedded iSH suite), 3 bootstrap tests passed, portable smoke and both Hermes exports passed, and Expo Doctor passed 20/20. No iSH native or iOS background-execution validation is claimed by this notification change. + +## Execution duration change, 2026-10-25 + +The foreground service no longer cancels active tasks at 30 minutes. Its partial wake lock is acquired with a ten-minute expiry and renewed every five minutes until the last execution lease releases the service. A failed renewal enters durable cancellation as `wake_lock_failed`. Startup handshake, notification permission and explicit cancellation behavior remain unchanged. Waiting recurring tasks still do not hold the service, and automatic process restart is not introduced. + +See [duration design and acceptance](android-execution-duration.md). The earlier native-budget validation notes above describe previous versions. Device execution beyond 30 minutes must be validated separately; JVM controlled-clock tests do not establish Android screen-off behavior. diff --git a/modules/moose-background/android/src/main/AndroidManifest.xml b/modules/moose-background/android/src/main/AndroidManifest.xml index 905d28b..98402fc 100644 --- a/modules/moose-background/android/src/main/AndroidManifest.xml +++ b/modules/moose-background/android/src/main/AndroidManifest.xml @@ -7,7 +7,7 @@ + android:value="Continue user-initiated AI conversations: consume streaming responses, execute local tools and commit SQLite while the Activity is hidden. One shared service with a visible stop action, released when active runs finish; no scheduled wakeup or automatic restart." /> diff --git a/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/ExecutionWakeLock.java b/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/ExecutionWakeLock.java new file mode 100644 index 0000000..6da1820 --- /dev/null +++ b/modules/moose-background/android/src/main/java/dev/verysmallwoods/background/ExecutionWakeLock.java @@ -0,0 +1,55 @@ +package dev.verysmallwoods.background; + +/** Execution lock lifecycle, with Android operations supplied by the service. */ +final class ExecutionWakeLock { + private static final long LOCK_TIMEOUT_MS = 10L * 60 * 1000; + private static final long RENEW_AFTER_MS = 5L * 60 * 1000; + interface Scheduler { + void postDelayed(Runnable callback, long delay); + void removeCallbacks(Runnable callback); + } + interface Lock { + void acquire(long timeout); + void release(); + } + private final Scheduler scheduler; + private final Lock lock; + private final Runnable onFailure; + private boolean active; + private long generation; + private Runnable renewal; + + ExecutionWakeLock(Scheduler scheduler, Lock lock, Runnable onFailure) { + this.scheduler = scheduler; + this.lock = lock; + this.onFailure = onFailure; + } + void start() { + if (active) return; + active = true; + long current = ++generation; + renewal = () -> { + if (!active || generation != current) return; + try { + lock.acquire(LOCK_TIMEOUT_MS); + scheduler.postDelayed(renewal, RENEW_AFTER_MS); + } catch (RuntimeException error) { + stop(); + onFailure.run(); + } + }; + try { + lock.acquire(LOCK_TIMEOUT_MS); + scheduler.postDelayed(renewal, RENEW_AFTER_MS); + } catch (RuntimeException error) { + stop(); + throw error; + } + } + void stop() { + if (!active) return; + active = false; + scheduler.removeCallbacks(renewal); + lock.release(); + } +} 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 d323e1c..454a990 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 @@ -22,12 +22,11 @@ class MooseExecutionService : Service() { private var token: String? = null private var task: Int? = null private var context: ReactContext? = null - private var lock: PowerManager.WakeLock? = null + private var executionLock: ExecutionWakeLock? = 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 { // Persisted cancellation is handled before recovery on next explicit launch. @@ -66,7 +65,7 @@ class MooseExecutionService : Service() { val notification = NotificationCompat.Builder(this, CHANNEL) .setSmallIcon(android.R.drawable.stat_notify_sync) .setContentTitle("Moose is running tasks") - .setContentText("Up to 30 minutes. Stop cancels all tasks.") + .setContentText("Running. Stop cancels all tasks.") .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setOngoing(true).setOnlyAlertOnce(true) .addAction(android.R.drawable.ic_media_pause, "Stop", stop) @@ -74,10 +73,22 @@ class MooseExecutionService : Service() { this.notification = notification ServiceCompat.startForeground(this, 7301, notification.build(), if (Build.VERSION.SDK_INT >= 34) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0) - lock = (getSystemService(POWER_SERVICE) as PowerManager) - .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Moose:finite-execution") - .apply { setReferenceCounted(false); acquire(BUDGET + 15000) } - handler.postDelayed(budget, BUDGET) + val lock = (getSystemService(POWER_SERVICE) as PowerManager) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Moose:execution") + .apply { setReferenceCounted(false) } + executionLock = ExecutionWakeLock( + object : ExecutionWakeLock.Scheduler { + override fun postDelayed(callback: Runnable, delay: Long) { + check(handler.postDelayed(callback, delay)) { "Execution handler stopped" } + } + override fun removeCallbacks(callback: Runnable) { handler.removeCallbacks(callback) } + }, + object : ExecutionWakeLock.Lock { + override fun acquire(timeout: Long) { lock.acquire(timeout) } + override fun release() { if (lock.isHeld) lock.release() } + }, + Runnable { cancel("wake_lock_failed") }, + ).also { it.start() } handler.postDelayed(handshake, 15000) val data = Arguments.createMap().apply { putString("token", requested) } task = HeadlessJsTaskContext.getInstance(pending.context) @@ -157,8 +168,8 @@ class MooseExecutionService : Service() { token = null context = null notification = null - if (lock?.isHeld == true) lock?.release() - lock = null + executionLock?.stop() + executionLock = null stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() if (instance === this) instance = null @@ -202,7 +213,6 @@ class MooseExecutionService : Service() { } private const val PREFS = "moose-execution-control" private const val ACTION_STOP = "dev.verysmallwoods.moose.STOP_EXECUTION" - private const val BUDGET = 30L * 60 * 1000 private data class Admission(val token: String, val context: ReactContext, val promise: Promise) private var admission: Admission? = null var instance: MooseExecutionService? = null diff --git a/tests/background-wake-lock.test.ts b/tests/background-wake-lock.test.ts new file mode 100644 index 0000000..34df583 --- /dev/null +++ b/tests/background-wake-lock.test.ts @@ -0,0 +1,48 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +let classes: string; +beforeAll(() => { + classes = mkdtempSync(join(tmpdir(), "moose-wake-lock-")); + execFileSync( + "javac", + [ + "--release", + "17", + "-d", + classes, + resolve( + "modules/moose-background/android/src/main/java/dev/verysmallwoods/background/ExecutionWakeLock.java", + ), + resolve("tests/support/ExecutionWakeLockHarness.java"), + ], + { stdio: "pipe" }, + ); +}); +afterAll(() => { + if (classes) rmSync(classes, { recursive: true, force: true }); +}); + +it.each([ + "long-run", + "stop", + "late-callback", + "restart", + "renewal-failure", + "startup-failure", +])("runs the native wake-lock lifecycle: %s", (scenario) => { + const result = execFileSync( + "java", + [ + "-cp", + classes, + "dev.verysmallwoods.background.ExecutionWakeLockHarness", + scenario, + ], + { encoding: "utf8" }, + ); + expect(result.trim()).toBe("PASS"); +}); diff --git a/tests/support/ExecutionWakeLockHarness.java b/tests/support/ExecutionWakeLockHarness.java new file mode 100644 index 0000000..e4571b3 --- /dev/null +++ b/tests/support/ExecutionWakeLockHarness.java @@ -0,0 +1,107 @@ +package dev.verysmallwoods.background; + +import java.util.ArrayList; +import java.util.List; + +/** Android boundary doubles; the lifecycle under test is production Java. */ +public final class ExecutionWakeLockHarness { + private static void check(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } + + private static final class Clock implements ExecutionWakeLock.Scheduler { + record Event(Runnable callback, long at) {} + final List events = new ArrayList<>(); + long now; + public void postDelayed(Runnable callback, long delay) { + events.add(new Event(callback, now + delay)); + } + public void removeCallbacks(Runnable callback) { + events.removeIf(event -> event.callback() == callback); + } + void advance(long until) { + while (true) { + Event next = events.stream().filter(event -> event.at() <= until) + .min(java.util.Comparator.comparingLong(Event::at)).orElse(null); + if (next == null) break; + events.remove(next); + now = next.at(); + next.callback().run(); + } + now = until; + } + } + + private static final class Lock implements ExecutionWakeLock.Lock { + final Clock clock; + int acquisitions; + int releases; + long expires; + boolean fail; + Lock(Clock clock) { this.clock = clock; } + public void acquire(long timeout) { + if (fail) throw new IllegalStateException("acquire failed"); + check(timeout > 0 && timeout < 7_200_000, "every acquisition must expire before the long task ends"); + if (acquisitions > 0) check(clock.now < expires, "lock expired before renewal"); + acquisitions++; + expires = clock.now + timeout; + } + public void release() { releases++; } + } + + public static void main(String[] args) { + Clock clock = new Clock(); + Lock lock = new Lock(clock); + int[] failures = {0}; + ExecutionWakeLock lease = new ExecutionWakeLock(clock, lock, () -> failures[0]++); + if (args[0].equals("startup-failure")) { + lock.fail = true; + try { + lease.start(); + throw new AssertionError("startup must fail"); + } catch (IllegalStateException expected) { + check(clock.events.isEmpty(), "failed startup left renewal queued"); + } + } else { + lease.start(); + lease.start(); + check(lock.acquisitions == 1, "duplicate start acquired twice"); + Runnable late = clock.events.get(0).callback(); + switch (args[0]) { + case "long-run" -> { + clock.advance(7_200_000); + check(failures[0] == 0 && lock.releases == 0, "long task was stopped"); + check(lock.acquisitions > 1 && lock.expires > clock.now, "long task lost lock"); + check(clock.events.size() == 1, "renewal leaked timers"); + lease.stop(); + } + case "stop", "late-callback" -> { + clock.advance(30_000); + lease.stop(); + lease.stop(); + if (args[0].equals("late-callback")) late.run(); + clock.advance(7_200_000); + check(lock.acquisitions == 1, "stopped lease reacquired lock"); + check(lock.releases == 1 && clock.events.isEmpty(), "stop leaked resources"); + } + case "restart" -> { + lease.stop(); + lease.start(); + late.run(); + check(lock.acquisitions == 2 && clock.events.size() == 1, "old callback affected new lease"); + lease.stop(); + } + case "renewal-failure" -> { + lock.fail = true; + clock.advance(600_000); + check(failures[0] == 1 && lock.releases == 1, "failure did not stop lease"); + check(clock.events.isEmpty(), "failed renewal kept retrying"); + lease.stop(); + check(lock.releases == 1, "failure released twice"); + } + default -> throw new AssertionError("unknown scenario"); + } + } + System.out.println("PASS"); + } +}