feat(cron): surface last_fire_error on Cron Job records (#925) (#933)

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 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-18 12:53:36 +03:00
1 parent 1039d7db1d
commit 79c2ea1341
6 files changed
+69

No files matched your search

@@ -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,
@@ -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) }
}