@@ -535,12 +535,9 @@ open class AppContainer private constructor(
535 // represent a finished run worth notifying about.
535 // represent a finished run worth notifying about.
536 if (isLiveRunActivity(event)) {
536 if (isLiveRunActivity(event)) {
537 // The add-under-lock is required both for thread-safety and for the
537 // The add-under-lock is required both for thread-safety and for the
538− // LRU access-order refresh (keeps a continuously-streaming session
538+ // access-order refresh (keeps a continuously-streaming session from
539− // from being evicted), so it runs per event.
539+ // being evicted), so it runs per event.
540 synchronized(activeRuns) {
540 synchronized(activeRuns) {
541− if (activeRuns.size >= activeRunsLimit) {
542− activeRuns.remove(activeRuns.iterator().next())
544 activeRuns.add(sid)
541 activeRuns.add(sid)
545 // Derive the flag from the set state *inside* the same lock that
542 // Derive the flag from the set state *inside* the same lock that
546 // guards the set, so a concurrent reconnect sweep (which clears
543 // guards the set, so a concurrent reconnect sweep (which clears
@@ -577,9 +574,11 @@ open class AppContainer private constructor(
577 /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
574 /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
578 * an access-order [java.util.LinkedHashMap] so re-adding an already-present id on each
575 * an access-order [java.util.LinkedHashMap] so re-adding an already-present id on each
579 * streaming event refreshes its recency (a plain LinkedHashSet.add is a no-op that does
576 * 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
577+ * NOT reorder). Entries are drained by [SessionIdle] (and cleared wholesale on reconnect
581− * the eldest once [activeRunsLimit] other sessions appear — which would drop its
578+ * reconcile); we intentionally do NOT cap the set, because evicting the eldest would drop
582− * completion notification when its SessionIdle finally arrives. */
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. */
583 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(
582 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(
584 java.util.Collections.newSetFromMap(java.util.LinkedHashMap<String, Boolean>(16, 0.75f, true)),
583 java.util.Collections.newSetFromMap(java.util.LinkedHashMap<String, Boolean>(16, 0.75f, true)),
@@ -589,9 +588,6 @@ open class AppContainer private constructor(
589 * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
588 * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
590 private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
589 private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
592− /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
593− private val activeRunsLimit = NetworkConfig.activeRunsLimit
595 /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
591 /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
596 private val connectionMutex = Mutex()
592 private val connectionMutex = Mutex()
@@ -650,14 +646,22 @@ open class AppContainer private constructor(
650 onDone: (Boolean) -> Unit,
646 onDone: (Boolean) -> Unit,
652 appScope.launch {
648 appScope.launch {
653− val success = runCatchingCancellable {
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.
655+ success = runCatchingCancellable {
654− val api = activeConnection.value?.api ?: return@runCatchingCancellable false
656+ val api = activeConnection.value?.api ?: return@runCatchingCancellable false
655− api.respondPermission(sessionId, permissionId, response)
657+ api.respondPermission(sessionId, permissionId, response)
657− }.onFailure {
659+ }.onFailure {
658− Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
660+ Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
659− }.getOrDefault(false)
661+ }.getOrDefault(false)
660− onDone(success)
663+ onDone(success)
@@ -671,30 +675,36 @@ open class AppContainer private constructor(
671 * notification on success. */
675 * notification on success. */
672 open fun sendPromptFromNotification(sessionId: String, text: String, onDone: (Boolean) -> Unit) {
676 open fun sendPromptFromNotification(sessionId: String, text: String, onDone: (Boolean) -> Unit) {
673 appScope.launch {
677 appScope.launch {
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
674− // Attribute to the active (or most-recent) profile so a reconnect to a different
682+ // 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.
683+ // server doesn't misfire this against a session id that only exists elsewhere.
676− val profileId = activeConnection.value?.profile?.id
684+ val profileId = activeConnection.value?.profile?.id
677− ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
685+ ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
678− val enqueued = if (profileId == null) {
686+ enqueued = if (profileId == null) {
680− } else {
688+ } else {
681− runCatchingCancellable {
689+ runCatchingCancellable {
682− outboxStore.enqueue(
690+ outboxStore.enqueue(
683− OutboxMessage(
691+ OutboxMessage(
684− id = java.util.UUID.randomUUID().toString(),
692+ id = java.util.UUID.randomUUID().toString(),
685− profileId = profileId,
693+ profileId = profileId,
686− sessionId = sessionId,
694+ sessionId = sessionId,
687− text = text,
695+ text = text,
688− model = null,
696+ model = null,
689− createdAt = System.currentTimeMillis(),
697+ createdAt = System.currentTimeMillis(),
692− }.onFailure {
700+ }.onFailure {
693− Log.w("AppContainer", "notif reply enqueue failed: ${safeExceptionSummary(it)}")
701+ Log.w("AppContainer", "notif reply enqueue failed: ${safeExceptionSummary(it)}")
694− }.isSuccess
702+ }.isSuccess
704+ if (enqueued) flushOutbox()
696− if (enqueued) flushOutbox()
@@ -750,7 +760,12 @@ open class AppContainer private constructor(
750 _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
760 _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
751 val callback = object : ConnectivityManager.NetworkCallback() {
761 val callback = object : ConnectivityManager.NetworkCallback() {
752 override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
762 override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
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.
753− override fun onLost(network: Network) { _isOnline.value = cm.activeNetwork != null }
767+ override fun onLost(network: Network) { /* handled by onUnavailable */ }
768+ override fun onUnavailable() { _isOnline.value = false }
755 // Only match networks that actually provide internet — a capability-less request
770 // Only match networks that actually provide internet — a capability-less request
756 // fires onAvailable for transports that can't reach the server (and would trigger
771 // fires onAvailable for transports that can't reach the server (and would trigger
@@ -854,7 +869,8 @@ open class AppContainer private constructor(
854 .onSuccess { onDeleted() }
869 .onSuccess { onDeleted() }
855 .onFailure { onError(it) }
870 .onFailure { onError(it) }
857− pendingProfileDeletes.put(id, job)?.cancel()
872+ pendingProfileDeletes[id]?.cancel()
873+ pendingProfileDeletes[id] = job
860 /** Cancel a pending deferred profile delete (the Undo action). Returns true if it was
876 /** Cancel a pending deferred profile delete (the Undo action). Returns true if it was
@@ -917,7 +933,8 @@ open class AppContainer private constructor(
917 .onFailure { onError(it) }
933 .onFailure { onError(it) }
920− pendingSessionDeletes.put(id, job)?.cancel()
936+ pendingSessionDeletes[id]?.cancel()
937+ pendingSessionDeletes[id] = job
923 /** Cancel a pending deferred session delete (the Undo action). Returns true if it was
940 /** Cancel a pending deferred session delete (the Undo action). Returns true if it was