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
4 changed files with 39 additions and 19 deletions
Showing only changes of commit b6cc083bc7 - Show all commits
@@ -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 {
Review

更新竞态

场景写入和旧触发状态清理是多个独立 DAO 操作,前台轮询或 Worker 可在两者之间读取新场景和旧状态。编辑一个保留相同 ID 的阈值触发器时,引擎可能基于混合状态执行并写入新边沿,随后清理又将其删除,导致下次轮询重复执行或丢失边沿。应将读取、场景更新和触发状态清理放入同一个 Room 事务。

**更新竞态** 场景写入和旧触发状态清理是多个独立 DAO 操作,前台轮询或 Worker 可在两者之间读取新场景和旧状态。编辑一个保留相同 ID 的阈值触发器时,引擎可能基于混合状态执行并写入新边沿,随后清理又将其删除,导致下次轮询重复执行或丢失边沿。应将读取、场景更新和触发状态清理放入同一个 Room 事务。 <!-- pr-agent-dedup: a4b099355e82 --> <!-- pr-agent-key-issue-location: c376684e873a -->
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())
Outdated
Review

意外重触发

每次更新场景都会无条件删除全部触发器状态。用户仅修改名称、图标或动作时,如果某个阈值当前仍处于匹配状态,下一轮评估会再次识别为从未匹配到匹配并重复执行自动化。应只清理已删除或定义发生变化的触发器状态,并保留未修改触发器的状态。

**意外重触发** 每次更新场景都会无条件删除全部触发器状态。用户仅修改名称、图标或动作时,如果某个阈值当前仍处于匹配状态,下一轮评估会再次识别为从未匹配到匹配并重复执行自动化。应只清理已删除或定义发生变化的触发器状态,并保留未修改触发器的状态。 <!-- 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) }
Outdated
Review

会话校验

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

**会话校验** `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) {
Review

漏调度

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

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

重复调度

有效会话在前台时,每轮 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) {
Review

触发延迟

时间触发的 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
Outdated
Review

触发合并

当同一场景的多个时间触发器在一次评估中同时到期时,所有 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()) {