feat(scene): complete automation runtime #11

Merged
KremeCN merged 9 commits from feat/scene-automation-runtime into main 2026-08-28 06:39:31 +08:00
6 changed files with 86 additions and 20 deletions
Showing only changes of commit fe285322ef - Show all commits
@@ -11,6 +11,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@@ -30,20 +31,20 @@ class AutomationBootstrapper(
combine(
Outdated
Review

后台轮询

分钟级轮询启动后只依赖应用级协程作用域,没有与 Activity 或进程前台生命周期绑定。用户将应用切到后台,甚至仅由 WorkManager 拉起应用进程后,该循环仍会继续读取设备并评估场景,与 WorkManager 后台任务重复运行并产生持续的网络和电量开销;应在进程进入后台时停止该循环。

**后台轮询** 分钟级轮询启动后只依赖应用级协程作用域,没有与 Activity 或进程前台生命周期绑定。用户将应用切到后台,甚至仅由 WorkManager 拉起应用进程后,该循环仍会继续读取设备并评估场景,与 WorkManager 后台任务重复运行并产生持续的网络和电量开销;应在进程进入后台时停止该循环。 <!-- pr-agent-dedup: aa2efc5bb6a5 --> <!-- pr-agent-key-issue-location: ef1c3147c48c -->
profileStore.observeActiveProfile(),
sessionCoordinator.observeActiveSession(),
isForeground,
) { active, session, foreground -> Triple(active, session, foreground) }
.collectLatest { (active, session, foreground) ->
Outdated
Review

执行中断

collectLatest 将前台状态监听和场景评估放在同一个可取消块中。应用在场景执行期间进入后台时,isForeground 的变化会取消正在运行的 engine.evaluate();此时触发状态或定时 occurrence 已经持久化,包含延迟或慢设备动作的场景可能只执行一部分,后续 worker 也不会重试该次触发。应只停止后续轮询,并允许当前评估完成。

**执行中断** `collectLatest` 将前台状态监听和场景评估放在同一个可取消块中。应用在场景执行期间进入后台时,`isForeground` 的变化会取消正在运行的 `engine.evaluate()`;此时触发状态或定时 occurrence 已经持久化,包含延迟或慢设备动作的场景可能只执行一部分,后续 worker 也不会重试该次触发。应只停止后续轮询,并允许当前评估完成。 <!-- pr-agent-dedup: bca7d5c309ee --> <!-- pr-agent-key-issue-location: 3417d4aebe9e -->
) { active, session -> active to session }
.collectLatest { (active, session) ->
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
previousProfileId = active?.id
if (active != null && session?.profileId == active.id) {
Outdated
Review

会话校验

scheduler.reconcile 的结果被忽略,随后仅凭仍存在同一 profile 的本地 session 就启动无限评估循环。若会话校验返回无效或失败但 session 流尚未清空,前台仍会执行时间或通知类自动化,并持续尝试需要鉴权的动作;应仅在确认会话有效后进入评估循环。

**会话校验** `scheduler.reconcile` 的结果被忽略,随后仅凭仍存在同一 profile 的本地 session 就启动无限评估循环。若会话校验返回无效或失败但 session 流尚未清空,前台仍会执行时间或通知类自动化,并持续尝试需要鉴权的动作;应仅在确认会话有效后进入评估循环。 <!-- pr-agent-dedup: 2b73143ba705 --> <!-- pr-agent-key-issue-location: 19c0e00c1367 -->
scheduler.reconcile(active.id)
while (foreground) {
while (true) {
isForeground.first { it }
try {
Review

漏调度

初始协调成功后,scheduleReady 会永久保持为 true,前台循环不再重新协调。若之后创建或启停场景时的单次 reconcile 因会话或存储瞬时失败,失败结果又被调用方忽略,后台计划将持续过期;创建首个自动场景时甚至不会产生 WorkManager 任务,直到进程或会话重启。

**漏调度** 初始协调成功后,`scheduleReady` 会永久保持为 true,前台循环不再重新协调。若之后创建或启停场景时的单次 `reconcile` 因会话或存储瞬时失败,失败结果又被调用方忽略,后台计划将持续过期;创建首个自动场景时甚至不会产生 WorkManager 任务,直到进程或会话重启。 <!-- pr-agent-dedup: 02d0c47771c5 --> <!-- pr-agent-key-issue-location: d3d969c486b0 -->
engine.evaluate(active.id)
} catch (cancelled: CancellationException) {
Review

重复调度

有效会话在前台时,每轮 60 秒轮询都会执行完整的调度协调。其下游会更新周期任务、取消全部时间触发任务并逐个重建,即使场景没有任何变化;场景或时间触发器较多时会持续产生 WorkManager 数据库写入和任务取消,并干扰临近执行时间的任务。调度协调应仅由配置、资料或会话变化触发,轮询循环只需执行引擎评估。

**重复调度** 有效会话在前台时,每轮 60 秒轮询都会执行完整的调度协调。其下游会更新周期任务、取消全部时间触发任务并逐个重建,即使场景没有任何变化;场景或时间触发器较多时会持续产生 WorkManager 数据库写入和任务取消,并干扰临近执行时间的任务。调度协调应仅由配置、资料或会话变化触发,轮询循环只需执行引擎评估。 <!-- pr-agent-dedup: 49f38f37f27d --> <!-- pr-agent-key-issue-location: 228b2c82db3e -->
throw cancelled
} catch (_: Exception) {}
delay(60_000L)
if (isForeground.value) delay(60_000L)
}
} else if (active != null) {
scheduler.cancel(active.id)
@@ -28,9 +28,18 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
interface AutomationEngine {
suspend fun evaluate(profileId: ProviderProfileId): DomainResult<Int>
suspend fun evaluate(
profileId: ProviderProfileId,
scheduledOccurrence: ScheduledAutomationOccurrence? = null,
): DomainResult<Int>
}
data class ScheduledAutomationOccurrence(
val sceneId: String,
val triggerId: String,
val occurrenceKey: String,
)
class DefaultAutomationEngine(
private val sceneRepository: SceneRepository,
private val runtimeRepository: AutomationRuntimeRepository,
@@ -43,11 +52,17 @@ class DefaultAutomationEngine(
) : AutomationEngine {
private val evaluationMutex = Mutex()
override suspend fun evaluate(profileId: ProviderProfileId): DomainResult<Int> = evaluationMutex.withLock {
evaluateLocked(profileId)
override suspend fun evaluate(
profileId: ProviderProfileId,
scheduledOccurrence: ScheduledAutomationOccurrence?,
): DomainResult<Int> = evaluationMutex.withLock {
evaluateLocked(profileId, scheduledOccurrence)
}
private suspend fun evaluateLocked(profileId: ProviderProfileId): DomainResult<Int> {
private suspend fun evaluateLocked(
profileId: ProviderProfileId,
scheduledOccurrence: ScheduledAutomationOccurrence?,
): DomainResult<Int> {
val scenes = when (val result = sceneRepository.observeScenes(profileId).first()) {
is DomainResult.Failure -> return result
is DomainResult.Success -> result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC }
@@ -70,7 +85,15 @@ class DefaultAutomationEngine(
is DomainResult.Success -> state.value
}
}
val evaluation = triggerEvaluator.evaluate(trigger, snapshot, now, wasMatched)
val evaluation = if (
trigger is SceneTrigger.Time &&
scheduledOccurrence?.sceneId == scene.id.value &&
scheduledOccurrence.triggerId == trigger.id.value
) {
TriggerEvaluation.Matched(scheduledOccurrence.occurrenceKey)
} else {
triggerEvaluator.evaluate(trigger, snapshot, now, wasMatched)
}
val isMatched = evaluation is TriggerEvaluation.Matched
if (trigger is SceneTrigger.Time && evaluation is TriggerEvaluation.Matched) {
newlyMatched = true
@@ -28,12 +28,17 @@ class AutomationPollWorker @AssistedInject constructor(
if (profileStore.activeProfile()?.id != profileId) return Result.success()
sessionCoordinator.bootstrap()
return when (val validity = sessionCoordinator.validateActiveSession()) {
Review

无效重试

明确的 SessionValidity.INVALID 被当作可恢复错误返回 Result.retry(),会让周期任务和一次性定时任务无限退避重试。例如凭据过期且应用长期未打开时,Worker 会持续唤醒并验证会话;应取消该 profile 的自动化并返回成功,仅对真正的暂时性失败重试。

**无效重试** 明确的 `SessionValidity.INVALID` 被当作可恢复错误返回 `Result.retry()`,会让周期任务和一次性定时任务无限退避重试。例如凭据过期且应用长期未打开时,Worker 会持续唤醒并验证会话;应取消该 profile 的自动化并返回成功,仅对真正的暂时性失败重试。 <!-- pr-agent-dedup: 11bf0ce54e11 --> <!-- pr-agent-key-issue-location: 5f0026961b14 -->
return when (val validity = sessionCoordinator.validateSession(profileId)) {
is DomainResult.Failure -> Result.retry()
is DomainResult.Success -> if (validity.value != SessionValidity.VALID) {
Result.retry()
} else {
when (automationEngine.evaluate(profileId)) {
val scheduledOccurrence = inputData.getString(WorkManagerAutomationScheduleStore.KEY_OCCURRENCE_KEY)?.let { key ->
val sceneId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_SCENE_ID)
val triggerId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_ID)
if (sceneId != null && triggerId != null) ScheduledAutomationOccurrence(sceneId, triggerId, key) else null
}
when (automationEngine.evaluate(profileId, scheduledOccurrence)) {
is DomainResult.Success -> {
automationScheduler.reconcile(profileId)
Result.success()
@@ -36,7 +36,7 @@ class ProfileAutomationScheduler(
override suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit> {
val active = profileStore.activeProfile()
if (active?.id != profileId) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active"))
return when (val validity = sessionCoordinator.validateActiveSession()) {
Outdated
Review

会话错配

validateActiveSession() 只验证当前活动会话是否有效,没有确认其 profileId 与待调度的 profileId 一致。切换配置后旧会话仍有效的窗口内,新配置可能被错误调度;worker 也采用相同验证方式,可能在旧会话上下文中评估新配置。应恢复按配置验证,或显式核对活动会话归属。

**会话错配** `validateActiveSession()` 只验证当前活动会话是否有效,没有确认其 `profileId` 与待调度的 `profileId` 一致。切换配置后旧会话仍有效的窗口内,新配置可能被错误调度;worker 也采用相同验证方式,可能在旧会话上下文中评估新配置。应恢复按配置验证,或显式核对活动会话归属。 <!-- pr-agent-dedup: ebc3f0b1da28 --> <!-- pr-agent-key-issue-location: d5dbad8484e3 -->
return when (val validity = sessionCoordinator.validateSession(profileId)) {
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
when (val result = sceneRepository.observeScenes(profileId).first()) {
is DomainResult.Failure -> result
@@ -39,16 +39,25 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
private fun scheduleNextTimeTrigger(profileId: ProviderProfileId, scenes: List<Scene>) {
val now = LocalDateTime.now()
val candidates = scenes.flatMap { scene -> scene.triggers.filterIsInstance<SceneTrigger.Time>() }
Outdated
Review

计划遗漏

整个配置档只保存最早的一个时间 occurrence。若 Doze 或电量限制使任务延迟超过二十分钟,期间其他已到期的时间触发器既没有独立的计划任务,也已超出普通评估的宽限期;重新协调时又只会查找未来时间,因此这些执行会被永久跳过。应为每个 occurrence 建立唯一任务,或持久化并依次处理全部待执行 occurrence。

**计划遗漏** 整个配置档只保存最早的一个时间 occurrence。若 Doze 或电量限制使任务延迟超过二十分钟,期间其他已到期的时间触发器既没有独立的计划任务,也已超出普通评估的宽限期;重新协调时又只会查找未来时间,因此这些执行会被永久跳过。应为每个 occurrence 建立唯一任务,或持久化并依次处理全部待执行 occurrence。 <!-- pr-agent-dedup: 44f30beb57cd --> <!-- pr-agent-key-issue-location: bdcee4cf7c5b -->
.mapNotNull { trigger -> nextOccurrence(trigger, now) }
val next = candidates.minOrNull() ?: run {
val next = scenes.flatMap { scene ->
scene.triggers.filterIsInstance<SceneTrigger.Time>().mapNotNull { trigger ->
nextOccurrence(trigger, now)?.let { occurrence -> ScheduledWorkOccurrence(scene, trigger, occurrence) }
}
}.minByOrNull { it.scheduledAt } ?: run {
workManager.cancelUniqueWork(timeWorkName(profileId))
return
}
val delay = Duration.between(now.atZone(ZoneId.systemDefault()).toInstant(), next.atZone(ZoneId.systemDefault()).toInstant())
val delay = Duration.between(now.atZone(ZoneId.systemDefault()).toInstant(), next.scheduledAt.atZone(ZoneId.systemDefault()).toInstant())
val request = OneTimeWorkRequestBuilder<AutomationPollWorker>()
.setInitialDelay(delay.toMillis().coerceAtLeast(0), TimeUnit.MILLISECONDS)
.setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build())
Review

延迟执行

时间触发任务的有效期被放宽到最多 24 小时或下一次发生前。设备关机、处于 Doze 或 WorkManager 严重延迟时,早晨的设备控制场景可能在当晚甚至次日才执行。应使用与前台评估一致的短宽限期,并让过期任务只安排下一次触发而不执行动作。

**延迟执行** 时间触发任务的有效期被放宽到最多 24 小时或下一次发生前。设备关机、处于 Doze 或 WorkManager 严重延迟时,早晨的设备控制场景可能在当晚甚至次日才执行。应使用与前台评估一致的短宽限期,并让过期任务只安排下一次触发而不执行动作。 <!-- pr-agent-dedup: 9c4e97f865b9 --> <!-- pr-agent-key-issue-location: 33287255f21a -->
.setInputData(
androidx.work.Data.Builder()
.putString(KEY_PROFILE_ID, profileId.value)
.putString(KEY_SCENE_ID, next.scene.id.value)
.putString(KEY_TRIGGER_ID, next.trigger.id.value)
.putString(KEY_OCCURRENCE_KEY, "${next.scheduledAt.toLocalDate()}:${next.trigger.timeOfDay}:${next.trigger.id.value}")
.build(),
)
.build()
Review

过期执行

定时触发的有效期最长被设置为 24 小时,而引擎会在整个有效期内接受该触发。设备休眠或 WorkManager 延迟数小时后,“定时开关设备”等动作仍可能在接近下一次计划时间时执行,与前台评估采用的 20 分钟宽限期也不一致。应将有效期限制为明确且较短的迟到窗口。

**过期执行** 定时触发的有效期最长被设置为 24 小时,而引擎会在整个有效期内接受该触发。设备休眠或 WorkManager 延迟数小时后,“定时开关设备”等动作仍可能在接近下一次计划时间时执行,与前台评估采用的 20 分钟宽限期也不一致。应将有效期限制为明确且较短的迟到窗口。 <!-- pr-agent-dedup: a69636e81e58 --> <!-- pr-agent-key-issue-location: b2e10030dd23 -->
workManager.enqueueUniqueWork(timeWorkName(profileId), androidx.work.ExistingWorkPolicy.REPLACE, request)
}
@@ -64,5 +73,16 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
return null
}
companion object { const val KEY_PROFILE_ID = "profile_id" }
private data class ScheduledWorkOccurrence(
val scene: Scene,
val trigger: SceneTrigger.Time,
val scheduledAt: LocalDateTime,
)
companion object {
const val KEY_PROFILE_ID = "profile_id"
const val KEY_SCENE_ID = "scene_id"
const val KEY_TRIGGER_ID = "trigger_id"
const val KEY_OCCURRENCE_KEY = "occurrence_key"
}
}
@@ -3,6 +3,7 @@ package com.flagship.abox.manager.domain
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@@ -173,7 +174,23 @@ class SessionCoordinator(
suspend fun validateActiveSession(): DomainResult<SessionValidity> = storeOperation {
val profile = profileStore.activeProfile()
?: return@storeOperation DomainResult.Success(SessionValidity.INVALID)
profile.gateway().validateSession(profile.id)
validateSession(profile.id)
}
suspend fun validateSession(profileId: ProviderProfileId): DomainResult<SessionValidity> = storeOperation {
val profile = profileStore.activeProfile()
if (profile?.id != profileId) return@storeOperation DomainResult.Success(SessionValidity.INVALID)
val session = profile.gateway().observeSession(profileId).first()
if (session?.profileId != profileId) return@storeOperation DomainResult.Success(SessionValidity.INVALID)
when (val validity = profile.gateway().validateSession(profileId)) {
is DomainResult.Failure -> validity
is DomainResult.Success -> {
val stillActive = profileStore.activeProfile()?.id == profileId
val currentSession = profile.gateway().observeSession(profileId).first()
if (stillActive && currentSession?.profileId == profileId) validity
else DomainResult.Success(SessionValidity.INVALID)
}
}
}
suspend fun logout(): DomainResult<Unit> = storeOperation {