@@ -535,12 +535,9 @@ open class AppContainer private constructor(
535535 // represent a finished run worth notifying about.
536536 if (isLiveRunActivity(event)) {
537537 // The add-under-lock is required both for thread-safety and for the
538− // LRU access-order refresh (keeps a continuously-streaming session
539− // from being evicted), so it runs per event.
538+ // access-order refresh (keeps a continuously-streaming session from
539+ // being evicted), so it runs per event.
540540 synchronized(activeRuns) {
541− if (activeRuns.size >= activeRunsLimit) {
542− activeRuns.remove(activeRuns.iterator().next())
543− }
544541 activeRuns.add(sid)
545542 // Derive the flag from the set state *inside* the same lock that
546543 // guards the set, so a concurrent reconnect sweep (which clears
@@ -577,9 +574,11 @@ open class AppContainer private constructor(
577574 /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
578575 * an access-order [java.util.LinkedHashMap] so re-adding an already-present id on each
579576 * streaming event refreshes its recency (a plain LinkedHashSet.add is a no-op that does
580− * NOT reorder). This keeps a long, continuously-streaming session from being evicted as
581− * the eldest once [activeRunsLimit] other sessions appear — which would drop its
582− * completion notification when its SessionIdle finally arrives. */
577+ * NOT reorder). Entries are drained by [SessionIdle] (and cleared wholesale on reconnect
578+ * reconcile); we intentionally do NOT cap the set, because evicting the eldest would drop
579+ * its completion notification when its SessionIdle eventually arrives — a busy server
580+ * with many concurrent runs is still bounded by the server's own concurrency, so growth
581+ * is not unbounded in practice. */
583582 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(
584583 java.util.Collections.newSetFromMap(java.util.LinkedHashMap<String, Boolean>(16, 0.75f, true)),
585584 )
@@ -589,9 +588,6 @@ open class AppContainer private constructor(
589588 * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
590589 private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
591590
592− /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
593− private val activeRunsLimit = NetworkConfig.activeRunsLimit
594−
595591 /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
596592 private val connectionMutex = Mutex()
597593
@@ -650,14 +646,22 @@ open class AppContainer private constructor(
650646 onDone: (Boolean) -> Unit,
651647 ) {
652648 appScope.launch {
653− val success = runCatchingCancellable {
654− val api = activeConnection.value?.api ?: return@runCatchingCancellable false
655− api.respondPermission(sessionId, permissionId, response)
656− true
657− }.onFailure {
658− Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
659− }.getOrDefault(false)
660− onDone(success)
649+ // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
650+ // scope is cancelled mid-call (process shutdown cancels appScope first). Without
651+ // this, a cancelled scope leaves pending.finish() never called → a receiver ANR
652+ // and the permission response silently lost.
653+ var success = false
654+ try {
655+ success = runCatchingCancellable {
656+ val api = activeConnection.value?.api ?: return@runCatchingCancellable false
657+ api.respondPermission(sessionId, permissionId, response)
658+ true
659+ }.onFailure {
660+ Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
661+ }.getOrDefault(false)
662+ } finally {
663+ onDone(success)
664+ }
661665 }
662666 }
663667
@@ -671,30 +675,36 @@ open class AppContainer private constructor(
671675 * notification on success. */
672676 open fun sendPromptFromNotification(sessionId: String, text: String, onDone: (Boolean) -> Unit) {
673677 appScope.launch {
674− // Attribute to the active (or most-recent) profile so a reconnect to a different
675− // server doesn't misfire this against a session id that only exists elsewhere.
676− val profileId = activeConnection.value?.profile?.id
677− ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
678− val enqueued = if (profileId == null) {
679− false
680− } else {
681− runCatchingCancellable {
682− outboxStore.enqueue(
683− OutboxMessage(
684− id = java.util.UUID.randomUUID().toString(),
685− profileId = profileId,
686− sessionId = sessionId,
687− text = text,
688− model = null,
689− createdAt = System.currentTimeMillis(),
690− ),
691− )
692− }.onFailure {
693− Log.w("AppContainer", "notif reply enqueue failed: ${safeExceptionSummary(it)}")
694− }.isSuccess
678+ // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
679+ // scope is cancelled mid-call — see respondToPermissionFromNotification above.
680+ var enqueued = false
681+ try {
682+ // Attribute to the active (or most-recent) profile so a reconnect to a different
683+ // server doesn't misfire this against a session id that only exists elsewhere.
684+ val profileId = activeConnection.value?.profile?.id
685+ ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
686+ enqueued = if (profileId == null) {
687+ false
688+ } else {
689+ runCatchingCancellable {
690+ outboxStore.enqueue(
691+ OutboxMessage(
692+ id = java.util.UUID.randomUUID().toString(),
693+ profileId = profileId,
694+ sessionId = sessionId,
695+ text = text,
696+ model = null,
697+ createdAt = System.currentTimeMillis(),
698+ ),
699+ )
700+ }.onFailure {
701+ Log.w("AppContainer", "notif reply enqueue failed: ${safeExceptionSummary(it)}")
702+ }.isSuccess
703+ }
704+ if (enqueued) flushOutbox()
705+ } finally {
706+ onDone(enqueued)
695707 }
696− if (enqueued) flushOutbox()
697− onDone(enqueued)
698708 }
699709 }
700710
@@ -750,7 +760,12 @@ open class AppContainer private constructor(
750760 _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
751761 val callback = object : ConnectivityManager.NetworkCallback() {
752762 override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
753− override fun onLost(network: Network) { _isOnline.value = cm.activeNetwork != null }
763+ // Don't flip offline on onLost: during a Wi-Fi→cellular handoff, onLost(WiFi) fires
764+ // before onAvailable(cellular), and cm.activeNetwork is transiently null in that
765+ // window — eagerly marking offline would skip outbox flushing and flash an offline
766+ // indicator. onUnavailable (no networks left at all) is the reliable offline signal.
767+ override fun onLost(network: Network) { /* handled by onUnavailable */ }
768+ override fun onUnavailable() { _isOnline.value = false }
754769 }
755770 // Only match networks that actually provide internet — a capability-less request
756771 // fires onAvailable for transports that can't reach the server (and would trigger
@@ -854,7 +869,8 @@ open class AppContainer private constructor(
854869 .onSuccess { onDeleted() }
855870 .onFailure { onError(it) }
856871 }
857− pendingProfileDeletes.put(id, job)?.cancel()
872+ pendingProfileDeletes[id]?.cancel()
873+ pendingProfileDeletes[id] = job
858874 }
859875
860876 /** Cancel a pending deferred profile delete (the Undo action). Returns true if it was
@@ -917,7 +933,8 @@ open class AppContainer private constructor(
917933 .onFailure { onError(it) }
918934 }
919935 }
920− pendingSessionDeletes.put(id, job)?.cancel()
936+ pendingSessionDeletes[id]?.cancel()
937+ pendingSessionDeletes[id] = job
921938 }
922939
923940 /** Cancel a pending deferred session delete (the Undo action). Returns true if it was