Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ android {
applicationId = "com.hermes.client"
minSdk = 26
targetSdk = 37
versionCode = 55
versionName = "0.1.51"
versionCode = 56
versionName = "0.1.52"
testInstrumentationRunner = "com.hermes.client.HiltTestRunner"
// App name; the beta build type overrides this so both can be installed at once.
manifestPlaceholders["appLabel"] = "Hermes"
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/java/com/hermes/client/data/network/ServerEvent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,24 @@ internal fun ServerEvent.bool(key: String): Boolean? =

internal fun ServerEvent.strList(key: String): List<String> =
(payload[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList()

/**
* Counts todo items from a `tool.complete` payload's `todos` array (gateway sends the full list
* as `{id, content, status}` objects). `done` counts `completed`; `total` counts every item that
* is NOT `cancelled` — a cancelled task never completes, so including it would stall the progress
* bar below 100% forever. Defensive like [str]: a malformed or absent payload yields 0 to 0
* rather than throwing, because a throw here would escape the event collector.
*/
internal fun ServerEvent.todoCounts(): Pair<Int, Int> {
val arr = payload["todos"] as? JsonArray ?: return 0 to 0
var done = 0
var total = 0
for (el in arr) {
val obj = el as? JsonObject ?: continue
val status = (obj["status"] as? JsonPrimitive)?.content?.lowercase()
if (status == "cancelled") continue
total++
if (status == "completed") done++
}
Comment thread
adebnar marked this conversation as resolved.
return done to total
}
69 changes: 69 additions & 0 deletions app/src/main/java/com/hermes/client/data/progress/RunProgress.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.hermes.client.data.progress

import com.hermes.client.data.network.ServerEvent
import com.hermes.client.data.network.bool
import com.hermes.client.data.network.str
import com.hermes.client.data.network.todoCounts

/**
* State of the agent run currently in flight, derived purely from gateway WebSocket events.
*
* Deliberately NOT part of ChatUiState: that is scoped to an open chat screen and dies when the
* app is backgrounded, which is exactly when this state must survive to drive a notification.
*/
data class RunProgress(
val running: Boolean = false,
val sessionId: String? = null,
val profile: String? = null,
val tool: String? = null,
val done: Int = 0,
val total: Int = 0,
) {
/** A determinate bar is only possible once the `todo` tool has reported a non-empty list. */
val determinate: Boolean get() = total > 0
}

/**
* Folds one gateway event into run state. Pure — no Android, no IO.
*
* `activeProfile` is latched into the run at the moment it starts (or restarts) so the
* tenant travels with the run itself rather than being re-read from a mutable source at
* notification-post time — a profile switch mid-run must never re-tag an in-flight run's
* notification with a different tenant while its route still points at the original session.
*
* `session.info.running` is the authoritative backstop: `message.complete` alone misses
* interrupted and compacted turns, which would otherwise strand a permanent "running" state.
* Tool events are ignored while idle so a late/stray `tool.*` cannot resurrect a finished run.
*/
fun RunProgress.reduce(event: ServerEvent, activeProfile: String?): RunProgress = when (event.type) {
"message.start" -> RunProgress(running = true, sessionId = event.sessionId, profile = activeProfile)

"tool.start" -> if (!running) this else copy(tool = event.str("name")?.ifBlank { null })

"tool.complete" -> when {
!running -> this
event.str("name") == "todo" -> {
val (d, t) = event.todoCounts()
copy(tool = null, done = d, total = t)
}
else -> copy(tool = null)
}

"message.complete", "error" -> RunProgress()

// Authoritative busy/idle signal. A missing `running` field leaves state untouched.
"session.info" -> when (event.bool("running")) {
false -> RunProgress()
true -> when {
!running -> RunProgress(running = true, sessionId = event.sessionId, profile = activeProfile)
// A differing (non-null) sessionId means a different run started — start fresh
// rather than silently keeping the stale run's counts/sessionId.
event.sessionId != null && event.sessionId != sessionId ->
RunProgress(running = true, sessionId = event.sessionId, profile = activeProfile)
else -> this
}
null -> this
}

else -> this
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,27 @@ import kotlinx.coroutines.flow.map

private val Context.notificationDataStore by preferencesDataStore(name = "notifications")

/** Device-local notification preferences (master toggle + approvals + run-finished). Off by default. */
/**
* Device-local notification preferences (master toggle + approvals + run-finished +
* run-progress). Off by default.
*/
class NotificationSettings(private val context: Context) {
private val kEnabled = booleanPreferencesKey("enabled")
private val kApprovals = booleanPreferencesKey("approvals")
private val kRunFinished = booleanPreferencesKey("runFinished")
private val kRunProgress = booleanPreferencesKey("runProgress")

val prefs: Flow<NotificationPrefs> = context.notificationDataStore.data.map { p ->
NotificationPrefs(
enabled = p[kEnabled] ?: false,
approvals = p[kApprovals] ?: true,
runFinished = p[kRunFinished] ?: true,
runProgress = p[kRunProgress] ?: true,
)
}

suspend fun setEnabled(v: Boolean) = context.notificationDataStore.edit { it[kEnabled] = v }
suspend fun setApprovals(v: Boolean) = context.notificationDataStore.edit { it[kApprovals] = v }
suspend fun setRunFinished(v: Boolean) = context.notificationDataStore.edit { it[kRunFinished] = v }
suspend fun setRunProgress(v: Boolean) = context.notificationDataStore.edit { it[kRunProgress] = v }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent
import android.os.IBinder
import com.hermes.client.data.network.HermesGatewayClient
import com.hermes.client.data.progress.reduce
import com.hermes.client.data.repository.NotificationSettings
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
Expand All @@ -22,8 +23,12 @@ class GatewayConnectionService : Service() {
@Inject lateinit var client: HermesGatewayClient
@Inject lateinit var settings: NotificationSettings
@Inject lateinit var notifier: HermesNotifier
@Inject lateinit var profiles: com.hermes.client.data.repository.ProfileManager

private val scope = CoroutineScope(SupervisorJob())
// Held separately (not just scope.coroutineContext[Job]) so onDestroy can register an
// invokeOnCompletion callback on it directly — see onDestroy for why.
private val job = SupervisorJob()
private val scope = CoroutineScope(job)

// Latest notification prefs, kept current by a collector so the hot event loop never blocks on
// DataStore. @Volatile for cross-thread visibility (scope has no single-thread dispatcher).
Expand All @@ -35,6 +40,15 @@ class GatewayConnectionService : Service() {
// dispatcher).
@Volatile private var appInForeground = false

// Live run state, folded from the same event stream. @Volatile for the same reason as the
// fields above: the collector scope has no single-thread dispatcher.
@Volatile private var runProgress = com.hermes.client.data.progress.RunProgress()

// Last spec actually posted. message.delta fires many times per second and does not change
// the spec, so re-posting on every event would burn cycles and visibly flicker the
// notification. Only act when the derived spec actually changes.
@Volatile private var lastRunSpec: RunProgressSpec? = null

// ProcessLifecycleOwner is a process-lifetime singleton; hold the observer so onDestroy can
// remove it — otherwise each stop/start of this service (e.g. toggling notifications) would
// leak the retired Service instance (and its injected WS client) forever.
Expand All @@ -56,6 +70,16 @@ class GatewayConnectionService : Service() {
}
lifecycleObserver = obs
androidx.lifecycle.ProcessLifecycleOwner.get().lifecycle.addObserver(obs)
// START_STICKY means Android can recreate this service headlessly (no UI ever launched),
// in which case ProfileManager._active is still the initial null — nothing but UI code
// ever calls refresh(). Seed it here so a bare-restart run's progress notification still
// gets the tenant name instead of falling back to a generic title. Non-blocking (launched,
// not awaited) and runCatching-wrapped: a gateway unreachable at boot must not crash the
// service. Skipped when a profile is already known so we don't redundantly refresh on
// every ordinary (UI-driven) start.
if (profiles.active.value == null) {
scope.launch { runCatching { profiles.refresh() } }
}
// Track the latest prefs reactively so the event loop reads a cached value instead of
// collecting DataStore per event. Started first so its replayed value is in place before
// events arrive; also picks up mid-run toggles (e.g. approvals turned off).
Expand All @@ -66,6 +90,7 @@ class GatewayConnectionService : Service() {
// ChatViewModel's reduce() uses around event handling.
runCatching {
toNotificationSpec(event, latestPrefs, appInForeground)?.let { notifier.post(it) }
updateRunProgress(event)
}
Comment thread
adebnar marked this conversation as resolved.
}
}
Expand All @@ -74,6 +99,22 @@ class GatewayConnectionService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
override fun onBind(intent: Intent?): IBinder? = null

/** Folds the event into run state and posts/cancels the progress notification on change. */
private fun updateRunProgress(event: com.hermes.client.data.network.ServerEvent) {
// Single read: profiles.active.value feeds the reducer, which latches it into the run
// itself (RunProgress.profile). The spec and the post call below both derive their tenant
// from that latched value rather than re-reading profiles.active.value, so a profile
// switch landing mid-run can never split a notification's title/route from its accent
// colour across two different tenants — see RunProgress.reduce's kdoc.
val activeProfile = profiles.active.value
runProgress = runProgress.reduce(event, activeProfile)
val spec = runProgress.toSpec(latestPrefs)
if (spec == lastRunSpec) return
lastRunSpec = spec
if (spec != null) notifier.postRunProgress(spec, runProgress.profile)
else notifier.cancelRunProgress()
}

// Android 15+ (API 35) caps a dataSync foreground service at ~6h and calls this instead of
// just killing the process; Android 16 (API 36) added a fgsType-aware overload. Implement
// both so whichever the OS invokes stops the service cleanly rather than crashing/ANR-ing.
Expand All @@ -87,6 +128,15 @@ class GatewayConnectionService : Service() {
}

override fun onDestroy() {
// A stopped service must never strand an ongoing "running" notification. scope.cancel()
// is cooperative: it does not preempt a collector iteration already executing
// synchronously, so if the collector is mid-lambda when we get here it can still call
// notifier.postRunProgress(...) after this function returns, with no ordering guarantee
// against a cancelRunProgress() called from here directly. Hanging the final cancel off
// the Job's actual completion — rather than off the cancel() call itself — guarantees it
// runs strictly after every child coroutine (including any such in-flight iteration) has
// finished, so no post can ever win the race.
job.invokeOnCompletion { notifier.cancelRunProgress() }
scope.cancel()
// onDestroy() runs on the main thread — remove the observer synchronously so this Service
// instance isn't retained (and no event can hit a defunct instance in a deferred window).
Expand Down
Loading
Loading