From 79c2ea13414d5b9e11a147f6f899cc3cde22ddd6 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:53:36 +0300 Subject: [PATCH] feat(cron): surface last_fire_error on Cron Job records (#925) (#933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend stamps last_fire_error ({at, detail}) on a job when a scheduled fire can't reach the gateway loopback (gateway down / api_server not bound) — the "job runs manually but never auto-fires" case. Previously invisible in mobile. - CronJob.kt: model as CronJobFireError(at, detail) — the backend sends a nested object, NOT a flat string (issue's String? proposal would fail to deserialize), plus the last_fire_error field. - CronJobsScreen: "Fire failed" ERROR badge in the list + "Dispatch Error" row (detail + timestamp) in the Run Details dialog. - Strings en/zh/ko. Test: job with last_fire_error loads + parses. Backward-compatible: field has a default, existing jobs without it parse fine. Co-authored-by: M57 --- .../m57/hermescontrol/data/model/CronJob.kt | 15 +++++++++++ .../hermescontrol/ui/cron/CronJobsScreen.kt | 22 ++++++++++++++++ app/src/main/res/values-ko/strings.xml | 2 ++ app/src/main/res/values-zh/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ .../ui/cron/CronJobsViewModelTest.kt | 26 +++++++++++++++++++ 6 files changed, 69 insertions(+) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt index 58d24cee..2ce8a3e9 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt @@ -10,6 +10,18 @@ data class CronJobRepeat( val completed: Int = 0, ) +/** + * Missed scheduled-fire record stamped on the job when the scheduler cannot + * forward a fire to the gateway loopback (gateway down / api_server not bound). + * `{at: iso, detail: str}` — see hermes-agent cron/jobs.py `_stamp_fire_error`. + * A successful run clears it. + */ +@Serializable +data class CronJobFireError( + val at: String? = null, + val detail: String? = null, +) + @Serializable data class CronJob( val id: String, @@ -24,6 +36,9 @@ data class CronJob( val last_run_at: String? = null, val last_error: String? = null, val last_delivery_error: String? = null, + // Missed scheduled-fire stamp (gateway unreachable / api_server not bound). + // Nested {at, detail} object — see CronJobFireError. + val last_fire_error: CronJobFireError? = null, // Full editor fields — all optional with defaults for backward compat val enabled: Boolean? = null, val prompt: String? = null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index 140a453c..b463b804 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -194,6 +194,14 @@ fun CronJobsScreen( status = StatusBadgeType.INFO, ) } + // Dispatch error badge: a scheduled fire was missed + // (gateway unreachable / api_server not bound). + if (job.last_fire_error != null) { + StatusBadge( + text = stringResource(R.string.cron_badge_dispatch_error), + status = StatusBadgeType.ERROR, + ) + } } Spacer(modifier = Modifier.height(spacing.xs)) Text( @@ -305,6 +313,20 @@ fun CronJobsScreen( if (job.last_error != null && job.last_error.isNotBlank()) { RunDetailRow("Error", job.last_error) } + job.last_fire_error?.let { fireErr -> + if (fireErr.detail != null || fireErr.at != null) { + RunDetailRow( + stringResource(R.string.cron_detail_dispatch_error), + buildString { + fireErr.detail?.let { append(it) } + fireErr.at?.let { at -> + if (fireErr.detail != null) append(" ") + append("($at)") + } + }, + ) + } + } job.script?.let { if (it.isNotBlank()) RunDetailRow("Script", it) } job.monitorSource?.let { if (it.isNotBlank()) RunDetailRow("Monitor", it) } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6350da29..0fc2c695 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -584,6 +584,8 @@ 실행 연속성 이 작업의 이전 출력을 매 실행에 주입 연속 + 발송 오류 + 실행 실패 예: 0 9 * * * 또는 every 2h 예: origin, local, all, 또는 telegram:chat_id 한 줄에 하나씩 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index aadf8c2f..67626a45 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -732,6 +732,8 @@ 运行连续性 将本任务上一次的输出注入到每次运行中 连续 + 调度错误 + 触发失败 监控模式 关 脚本 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b71df96..968d28ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -829,6 +829,8 @@ Run continuity Inject this job\'s previous output into each run Continuity + Dispatch Error + Fire failed Monitor mode Off Script diff --git a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt index 69ce7443..defba80b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt @@ -5,6 +5,7 @@ import com.m57.hermescontrol.data.model.CronBlueprint import com.m57.hermescontrol.data.model.CronBlueprintField import com.m57.hermescontrol.data.model.CronBlueprintListResponse import com.m57.hermescontrol.data.model.CronJob +import com.m57.hermescontrol.data.model.CronJobFireError import com.m57.hermescontrol.data.model.DeliveryTarget import com.m57.hermescontrol.data.model.DeliveryTargetsResponse import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest @@ -30,6 +31,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -528,4 +530,28 @@ class CronJobsViewModelTest { ) assertFalse(vm.uiState.value.editorState.isOpen) } + + @Test + fun `loadCronJobs surfaces last_fire_error on a job`() { + val vm = createViewModel() + coEvery { mockApi.getCronJobs() } returns + Response.success( + listOf( + CronJob( + id = "j11", + name = "Flaky job", + schedule = JsonPrimitive("every 1h"), + last_fire_error = CronJobFireError(at = "2026-08-18T09:00:00", detail = "gateway unreachable"), + ), + ), + ) + + vm.loadCronJobs() + settle() + + val job = vm.uiState.value.jobs.first() + assertNotNull(job.last_fire_error) + assertEquals("gateway unreachable", job.last_fire_error?.detail) + assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at) + } }