feat(scene): complete automation runtime #11
+6
-5
@@ -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(
|
||||
profileStore.observeActiveProfile(),
|
||||
sessionCoordinator.observeActiveSession(),
|
||||
isForeground,
|
||||
) { active, session, foreground -> Triple(active, session, foreground) }
|
||||
.collectLatest { (active, session, foreground) ->
|
||||
) { 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) {
|
||||
scheduler.reconcile(active.id)
|
||||
while (foreground) {
|
||||
while (true) {
|
||||
isForeground.first { it }
|
||||
try {
|
||||
|
|
||||
engine.evaluate(active.id)
|
||||
} catch (cancelled: CancellationException) {
|
||||
|
pr-agent-bot
commented
重复调度 有效会话在前台时,每轮 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
-5
@@ -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
|
||||
|
||||
+7
-2
@@ -28,12 +28,17 @@ class AutomationPollWorker @AssistedInject constructor(
|
||||
if (profileStore.activeProfile()?.id != profileId) return Result.success()
|
||||
|
||||
sessionCoordinator.bootstrap()
|
||||
return when (val validity = sessionCoordinator.validateActiveSession()) {
|
||||
|
pr-agent-bot
commented
无效重试 明确的 **无效重试**
明确的 `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()
|
||||
|
||||
+1
-1
@@ -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()) {
|
||||
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
|
||||
|
||||
+26
-6
@@ -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>() }
|
||||
.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())
|
||||
|
pr-agent-bot
commented
延迟执行 时间触发任务的有效期被放宽到最多 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()
|
||||
|
pr-agent-bot
commented
过期执行 定时触发的有效期最长被设置为 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 {
|
||||
|
||||
Reference in New Issue
Block a user
漏调度
初始协调成功后,
scheduleReady会永久保持为 true,前台循环不再重新协调。若之后创建或启停场景时的单次reconcile因会话或存储瞬时失败,失败结果又被调用方忽略,后台计划将持续过期;创建首个自动场景时甚至不会产生 WorkManager 任务,直到进程或会话重启。