feat(scene): complete automation runtime #11
@@ -6,6 +6,8 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
import com.flagship.abox.manager.domain.SessionCoordinator
|
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||||
|
import com.flagship.abox.manager.domain.SessionValidity
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -36,14 +38,21 @@ class AutomationBootstrapper(
|
|||||||
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
|
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
|
||||||
previousProfileId = active?.id
|
previousProfileId = active?.id
|
||||||
if (active != null && session?.profileId == active.id) {
|
if (active != null && session?.profileId == active.id) {
|
||||||
|
|
|||||||
scheduler.reconcile(active.id)
|
|
||||||
while (true) {
|
while (true) {
|
||||||
isForeground.first { it }
|
isForeground.first { it }
|
||||||
|
pr-agent-bot
commented
漏调度 初始协调成功后, **漏调度**
初始协调成功后,`scheduleReady` 会永久保持为 true,前台循环不再重新协调。若之后创建或启停场景时的单次 `reconcile` 因会话或存储瞬时失败,失败结果又被调用方忽略,后台计划将持续过期;创建首个自动场景时甚至不会产生 WorkManager 任务,直到进程或会话重启。
<!-- pr-agent-dedup: 02d0c47771c5 -->
<!-- pr-agent-key-issue-location: d3d969c486b0 -->
|
|||||||
try {
|
when (val validity = sessionCoordinator.validateSession(active.id)) {
|
||||||
engine.evaluate(active.id)
|
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
|
||||||
|
pr-agent-bot
commented
重复调度 有效会话在前台时,每轮 60 秒轮询都会执行完整的调度协调。其下游会更新周期任务、取消全部时间触发任务并逐个重建,即使场景没有任何变化;场景或时间触发器较多时会持续产生 WorkManager 数据库写入和任务取消,并干扰临近执行时间的任务。调度协调应仅由配置、资料或会话变化触发,轮询循环只需执行引擎评估。 **重复调度**
有效会话在前台时,每轮 60 秒轮询都会执行完整的调度协调。其下游会更新周期任务、取消全部时间触发任务并逐个重建,即使场景没有任何变化;场景或时间触发器较多时会持续产生 WorkManager 数据库写入和任务取消,并干扰临近执行时间的任务。调度协调应仅由配置、资料或会话变化触发,轮询循环只需执行引擎评估。
<!-- pr-agent-dedup: 49f38f37f27d -->
<!-- pr-agent-key-issue-location: 228b2c82db3e -->
|
|||||||
} catch (cancelled: CancellationException) {
|
scheduler.reconcile(active.id)
|
||||||
throw cancelled
|
try {
|
||||||
} catch (_: Exception) {}
|
engine.evaluate(active.id)
|
||||||
|
} catch (cancelled: CancellationException) {
|
||||||
|
throw cancelled
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
} else {
|
||||||
|
scheduler.cancel(active.id)
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> Unit
|
||||||
|
}
|
||||||
if (isForeground.value) delay(60_000L)
|
if (isForeground.value) delay(60_000L)
|
||||||
}
|
}
|
||||||
} else if (active != null) {
|
} else if (active != null) {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ data class ScheduledAutomationOccurrence(
|
|||||||
val sceneId: String,
|
val sceneId: String,
|
||||||
val triggerId: String,
|
val triggerId: String,
|
||||||
val occurrenceKey: String,
|
val occurrenceKey: String,
|
||||||
|
val scheduledAtEpochMillis: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
class DefaultAutomationEngine(
|
class DefaultAutomationEngine(
|
||||||
@@ -88,7 +89,8 @@ class DefaultAutomationEngine(
|
|||||||
val evaluation = if (
|
val evaluation = if (
|
||||||
|
pr-agent-bot
commented
过期触发
**过期触发**
`scheduledOccurrence` 只根据场景和触发器 ID 强制判定命中,没有校验计划时间与当前时间。WorkManager 因关机、Doze 或会话重试而延迟数小时甚至数天后,过期的定时规则仍会执行;应携带计划时间戳并应用宽限窗口,过期时仅重排下一次任务。
<!-- pr-agent-dedup: 5194bf61fc8d -->
<!-- pr-agent-key-issue-location: beb64bf775b3 -->
|
|||||||
trigger is SceneTrigger.Time &&
|
trigger is SceneTrigger.Time &&
|
||||||
|
pr-agent-bot
commented
定时遗漏
**定时遗漏**
`scheduledOccurrence` 仅在计划时间后的 20 分钟内有效,但 WorkManager 在 Doze、省电模式或系统繁忙时可能延迟更久。此时本次 occurrence 会被判定为未匹配,后续协调又会直接安排下一次,导致当前定时自动化永久漏执行。应依赖 occurrence 去重接受迟到任务,或改用能满足该时效要求的调度机制。
<!-- pr-agent-dedup: b30e71db5b2b -->
<!-- pr-agent-key-issue-location: bf8dfa560fb0 -->
|
|||||||
scheduledOccurrence?.sceneId == scene.id.value &&
|
scheduledOccurrence?.sceneId == scene.id.value &&
|
||||||
scheduledOccurrence.triggerId == trigger.id.value
|
scheduledOccurrence.triggerId == trigger.id.value &&
|
||||||
|
snapshot.observedAtEpochMillis in scheduledOccurrence.scheduledAtEpochMillis..scheduledOccurrence.expiresAtEpochMillis
|
||||||
) {
|
) {
|
||||||
TriggerEvaluation.Matched(scheduledOccurrence.occurrenceKey)
|
TriggerEvaluation.Matched(scheduledOccurrence.occurrenceKey)
|
||||||
} else {
|
} else {
|
||||||
@@ -167,4 +169,11 @@ class DefaultAutomationEngine(
|
|||||||
socketStatuses = socketDevices.associateWith { socketGateway.getStatus(profileId, it) },
|
socketStatuses = socketDevices.associateWith { socketGateway.getStatus(profileId, it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val ScheduledAutomationOccurrence.expiresAtEpochMillis: Long
|
||||||
|
get() = scheduledAtEpochMillis + SCHEDULED_OCCURRENCE_GRACE_MILLIS
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SCHEDULED_OCCURRENCE_GRACE_MILLIS = 20L * 60L * 1000L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,16 @@ class AutomationPollWorker @AssistedInject constructor(
|
|||||||
return when (val validity = sessionCoordinator.validateSession(profileId)) {
|
return when (val validity = sessionCoordinator.validateSession(profileId)) {
|
||||||
|
pr-agent-bot
commented
无效重试 明确的 **无效重试**
明确的 `SessionValidity.INVALID` 被当作可恢复错误返回 `Result.retry()`,会让周期任务和一次性定时任务无限退避重试。例如凭据过期且应用长期未打开时,Worker 会持续唤醒并验证会话;应取消该 profile 的自动化并返回成功,仅对真正的暂时性失败重试。
<!-- pr-agent-dedup: 11bf0ce54e11 -->
<!-- pr-agent-key-issue-location: 5f0026961b14 -->
|
|||||||
is DomainResult.Failure -> Result.retry()
|
is DomainResult.Failure -> Result.retry()
|
||||||
is DomainResult.Success -> if (validity.value != SessionValidity.VALID) {
|
is DomainResult.Success -> if (validity.value != SessionValidity.VALID) {
|
||||||
Result.retry()
|
automationScheduler.cancel(profileId)
|
||||||
|
Result.success()
|
||||||
} else {
|
} else {
|
||||||
val scheduledOccurrence = inputData.getString(WorkManagerAutomationScheduleStore.KEY_OCCURRENCE_KEY)?.let { key ->
|
val scheduledOccurrence = inputData.getString(WorkManagerAutomationScheduleStore.KEY_OCCURRENCE_KEY)?.let { key ->
|
||||||
val sceneId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_SCENE_ID)
|
val sceneId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_SCENE_ID)
|
||||||
val triggerId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_ID)
|
val triggerId = inputData.getString(WorkManagerAutomationScheduleStore.KEY_TRIGGER_ID)
|
||||||
if (sceneId != null && triggerId != null) ScheduledAutomationOccurrence(sceneId, triggerId, key) else null
|
val scheduledAt = inputData.getLong(WorkManagerAutomationScheduleStore.KEY_SCHEDULED_AT_EPOCH_MILLIS, -1L)
|
||||||
|
if (sceneId != null && triggerId != null && scheduledAt >= 0L) {
|
||||||
|
ScheduledAutomationOccurrence(sceneId, triggerId, key, scheduledAt)
|
||||||
|
} else null
|
||||||
}
|
}
|
||||||
when (automationEngine.evaluate(profileId, scheduledOccurrence)) {
|
when (automationEngine.evaluate(profileId, scheduledOccurrence)) {
|
||||||
is DomainResult.Success -> {
|
is DomainResult.Success -> {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
|
|||||||
.putString(KEY_SCENE_ID, next.scene.id.value)
|
.putString(KEY_SCENE_ID, next.scene.id.value)
|
||||||
.putString(KEY_TRIGGER_ID, next.trigger.id.value)
|
.putString(KEY_TRIGGER_ID, next.trigger.id.value)
|
||||||
.putString(KEY_OCCURRENCE_KEY, "${next.scheduledAt.toLocalDate()}:${next.trigger.timeOfDay}:${next.trigger.id.value}")
|
.putString(KEY_OCCURRENCE_KEY, "${next.scheduledAt.toLocalDate()}:${next.trigger.timeOfDay}:${next.trigger.id.value}")
|
||||||
|
.putLong(KEY_SCHEDULED_AT_EPOCH_MILLIS, next.scheduledAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
@@ -84,5 +85,6 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS
|
|||||||
const val KEY_SCENE_ID = "scene_id"
|
const val KEY_SCENE_ID = "scene_id"
|
||||||
const val KEY_TRIGGER_ID = "trigger_id"
|
const val KEY_TRIGGER_ID = "trigger_id"
|
||||||
const val KEY_OCCURRENCE_KEY = "occurrence_key"
|
const val KEY_OCCURRENCE_KEY = "occurrence_key"
|
||||||
|
const val KEY_SCHEDULED_AT_EPOCH_MILLIS = "scheduled_at_epoch_millis"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
会话校验
scheduler.reconcile的结果被忽略,随后仅凭仍存在同一 profile 的本地 session 就启动无限评估循环。若会话校验返回无效或失败但 session 流尚未清空,前台仍会执行时间或通知类自动化,并持续尝试需要鉴权的动作;应仅在确认会话有效后进入评估循环。