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)
+ }
}