feat(scene): complete automation runtime #11
@@ -1,6 +1,7 @@
|
||||
package com.flagship.abox.manager.automation.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.withTransaction
|
||||
import com.flagship.abox.manager.domain.AutomationType
|
||||
import com.flagship.abox.manager.domain.ComparisonOperator
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
@@ -27,8 +28,9 @@ import kotlinx.coroutines.flow.map
|
||||
|
||||
class RoomSceneRepository(
|
||||
context: Context,
|
||||
private val dao: AutomationDao,
|
||||
private val database: AutomationDatabase,
|
||||
) : SceneRepository {
|
||||
private val dao = database.automationDao()
|
||||
private val seedPreferences = context.getSharedPreferences("scene_seed_state", Context.MODE_PRIVATE)
|
||||
|
||||
override fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>> = flow {
|
||||
@@ -57,15 +59,17 @@ class RoomSceneRepository(
|
||||
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Scene profile mismatch"))
|
||||
}
|
||||
return runCatching {
|
||||
|
|
||||
val existing = dao.getScene(profileId.value, scene.id.value)
|
||||
if (requireExisting && existing == null) throw IllegalArgumentException("Scene not found")
|
||||
if (!requireExisting && existing != null) throw IllegalArgumentException("Scene already exists")
|
||||
dao.upsertScene(scene.toEntity())
|
||||
|
pr-agent-bot
commented
意外重触发 每次更新场景都会无条件删除全部触发器状态。用户仅修改名称、图标或动作时,如果某个阈值当前仍处于匹配状态,下一轮评估会再次识别为从未匹配到匹配并重复执行自动化。应只清理已删除或定义发生变化的触发器状态,并保留未修改触发器的状态。 **意外重触发**
每次更新场景都会无条件删除全部触发器状态。用户仅修改名称、图标或动作时,如果某个阈值当前仍处于匹配状态,下一轮评估会再次识别为从未匹配到匹配并重复执行自动化。应只清理已删除或定义发生变化的触发器状态,并保留未修改触发器的状态。
<!-- pr-agent-dedup: 084401ee6ca9 -->
<!-- pr-agent-key-issue-location: f6d7241cf954 -->
|
||||
existing?.let(SceneDocumentCodec::decode)?.triggers.orEmpty()
|
||||
.filter { previous -> scene.triggers.none { current -> current.hasSameDefinitionAs(previous) } }
|
||||
.forEach { changed ->
|
||||
dao.deleteTriggerState(profileId.value, scene.id.value, changed.id.value)
|
||||
}
|
||||
database.withTransaction {
|
||||
val existing = dao.getScene(profileId.value, scene.id.value)
|
||||
if (requireExisting && existing == null) throw IllegalArgumentException("Scene not found")
|
||||
if (!requireExisting && existing != null) throw IllegalArgumentException("Scene already exists")
|
||||
dao.upsertScene(scene.toEntity())
|
||||
existing?.let(SceneDocumentCodec::decode)?.triggers.orEmpty()
|
||||
.filter { previous -> scene.triggers.none { current -> current.hasSameDefinitionAs(previous) } }
|
||||
.forEach { changed ->
|
||||
dao.deleteTriggerState(profileId.value, scene.id.value, changed.id.value)
|
||||
}
|
||||
}
|
||||
scene
|
||||
}.fold({ DomainResult.Success(it) }, { DomainResult.Failure(DomainError.InvalidRequest(detail = it.message)) })
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ object AutomationModule {
|
||||
fun provideSceneRepository(
|
||||
@ApplicationContext context: Context,
|
||||
database: AutomationDatabase,
|
||||
): SceneRepository = RoomSceneRepository(context, database.automationDao())
|
||||
): SceneRepository = RoomSceneRepository(context, database)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
||||
@@ -38,18 +38,24 @@ class AutomationBootstrapper(
|
||||
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
|
||||
|
pr-agent-bot
commented
会话校验
**会话校验**
`scheduler.reconcile` 的结果被忽略,随后仅凭仍存在同一 profile 的本地 session 就启动无限评估循环。若会话校验返回无效或失败但 session 流尚未清空,前台仍会执行时间或通知类自动化,并持续尝试需要鉴权的动作;应仅在确认会话有效后进入评估循环。
<!-- pr-agent-dedup: 2b73143ba705 -->
<!-- pr-agent-key-issue-location: 19c0e00c1367 -->
|
||||
previousProfileId = active?.id
|
||||
if (active != null && session?.profileId == active.id) {
|
||||
var scheduleReady = false
|
||||
while (true) {
|
||||
|
pr-agent-bot
commented
漏调度 初始协调成功后, **漏调度**
初始协调成功后,`scheduleReady` 会永久保持为 true,前台循环不再重新协调。若之后创建或启停场景时的单次 `reconcile` 因会话或存储瞬时失败,失败结果又被调用方忽略,后台计划将持续过期;创建首个自动场景时甚至不会产生 WorkManager 任务,直到进程或会话重启。
<!-- pr-agent-dedup: 02d0c47771c5 -->
<!-- pr-agent-key-issue-location: d3d969c486b0 -->
|
||||
isForeground.first { it }
|
||||
when (val validity = sessionCoordinator.validateSession(active.id)) {
|
||||
|
pr-agent-bot
commented
重复调度 有效会话在前台时,每轮 60 秒轮询都会执行完整的调度协调。其下游会更新周期任务、取消全部时间触发任务并逐个重建,即使场景没有任何变化;场景或时间触发器较多时会持续产生 WorkManager 数据库写入和任务取消,并干扰临近执行时间的任务。调度协调应仅由配置、资料或会话变化触发,轮询循环只需执行引擎评估。 **重复调度**
有效会话在前台时,每轮 60 秒轮询都会执行完整的调度协调。其下游会更新周期任务、取消全部时间触发任务并逐个重建,即使场景没有任何变化;场景或时间触发器较多时会持续产生 WorkManager 数据库写入和任务取消,并干扰临近执行时间的任务。调度协调应仅由配置、资料或会话变化触发,轮询循环只需执行引擎评估。
<!-- pr-agent-dedup: 49f38f37f27d -->
<!-- pr-agent-key-issue-location: 228b2c82db3e -->
|
||||
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
|
||||
scheduler.reconcile(active.id)
|
||||
try {
|
||||
engine.evaluate(active.id)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (_: Exception) {}
|
||||
if (!scheduleReady) {
|
||||
scheduleReady = scheduler.reconcile(active.id) is DomainResult.Success
|
||||
}
|
||||
if (scheduleReady) {
|
||||
try {
|
||||
engine.evaluate(active.id)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
} else {
|
||||
scheduler.cancel(active.id)
|
||||
scheduleReady = false
|
||||
}
|
||||
is DomainResult.Failure -> Unit
|
||||
}
|
||||
|
||||
@@ -118,8 +118,18 @@ class DefaultAutomationEngine(
|
||||
}
|
||||
}
|
||||
if (!newlyMatched) continue
|
||||
if (conditionEvaluator.evaluate(scene.conditions, snapshot, now) !is ConditionsEvaluation.Satisfied) {
|
||||
|
pr-agent-bot
commented
触发延迟 时间触发的 occurrence 仅在条件满足后才被认领。如果 08:00 已完成评估但条件不满足,该 occurrence 在宽限期内仍保持未消费;条件于 08:10 变为满足时,同一个 08:00 规则会被延迟执行。非时间触发会先持久化边沿,因此两类触发器的条件语义不一致。应在条件明确不满足时消费或记录该 occurrence,并仅对条件数据不可用的情况保留重试。 **触发延迟**
时间触发的 occurrence 仅在条件满足后才被认领。如果 08:00 已完成评估但条件不满足,该 occurrence 在宽限期内仍保持未消费;条件于 08:10 变为满足时,同一个 08:00 规则会被延迟执行。非时间触发会先持久化边沿,因此两类触发器的条件语义不一致。应在条件明确不满足时消费或记录该 occurrence,并仅对条件数据不可用的情况保留重试。
<!-- pr-agent-dedup: f4cab2f239a0 -->
<!-- pr-agent-key-issue-location: e0ecaad0e2a9 -->
|
||||
continue
|
||||
when (conditionEvaluator.evaluate(scene.conditions, snapshot, now)) {
|
||||
is ConditionsEvaluation.Unavailable -> continue
|
||||
is ConditionsEvaluation.NotSatisfied -> {
|
||||
for ((trigger, occurrenceKey) in matchedTimeTriggers) {
|
||||
when (val claim = runtimeRepository.claimOccurrence(profileId, scene.id, trigger.id, occurrenceKey)) {
|
||||
is DomainResult.Failure -> return claim
|
||||
is DomainResult.Success -> Unit
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
ConditionsEvaluation.Satisfied -> Unit
|
||||
}
|
||||
var executionCount = if (newlyMatchedStateTriggers.isEmpty()) 0 else 1
|
||||
|
pr-agent-bot
commented
触发合并 当同一场景的多个时间触发器在一次评估中同时到期时,所有 occurrence 都会被成功占用,但场景只执行一次。例如后台任务在 10:15 才处理 10:00 和 10:10 的触发器时,两个去重键都会被记录,第二次计划执行将永久丢失。应按成功占用的 occurrence 分别执行,或仅占用本次实际执行对应的 occurrence。 **触发合并**
当同一场景的多个时间触发器在一次评估中同时到期时,所有 occurrence 都会被成功占用,但场景只执行一次。例如后台任务在 10:15 才处理 10:00 和 10:10 的触发器时,两个去重键都会被记录,第二次计划执行将永久丢失。应按成功占用的 occurrence 分别执行,或仅占用本次实际执行对应的 occurrence。
<!-- pr-agent-dedup: cc702268df73 -->
<!-- pr-agent-key-issue-location: c943741e1e43 -->
|
||||
if (matchedTimeTriggers.isNotEmpty()) {
|
||||
|
||||
更新竞态
场景写入和旧触发状态清理是多个独立 DAO 操作,前台轮询或 Worker 可在两者之间读取新场景和旧状态。编辑一个保留相同 ID 的阈值触发器时,引擎可能基于混合状态执行并写入新边沿,随后清理又将其删除,导致下次轮询重复执行或丢失边沿。应将读取、场景更新和触发状态清理放入同一个 Room 事务。