@@ -128,11 +128,30 @@ pub struct CronService {
128128 on_notify : Option < NotifyFn > ,
129129 rate_limiter : Mutex < RateLimiter > ,
130130 events_queue : Arc < SystemEventsQueue > ,
131+ /// Minimum ms between exec-triggered heartbeat wakes. Zero disables cooldown.
132+ wake_cooldown_ms : u64 ,
131133}
132134
133135/// Max time a job can be in "running" state before we consider it stuck (2 hours).
134136const STUCK_THRESHOLD_MS : u64 = 2 * 60 * 60 * 1000 ;
135137
138+ /// Minimum cooldown between exec-triggered heartbeat wake calls.
139+ ///
140+ /// Prevents exec-completion callbacks from re-waking the heartbeat
141+ /// in a tight loop when the agent uses `exec` during a heartbeat turn.
142+ /// The wake is skipped if the heartbeat last completed less than this
143+ /// duration ago. This is a safety net — the scheduled interval still
144+ /// applies for normal periodic firing.
145+ ///
146+ /// This cooldown only applies to exec-triggered wakes ([`WAKE_REASON_EXEC_EVENT`]).
147+ /// CronWakeMode::Now wakes ([`WAKE_REASON_CRON_EVENT`]) are never suppressed.
148+ pub const DEFAULT_WAKE_COOLDOWN_MS : u64 = 5 * 60 * 1000 ;
149+
150+ /// Wake reason: exec-completion callback.
151+ pub const WAKE_REASON_EXEC_EVENT : & str = "exec-event" ;
152+ /// Wake reason: cron job with [`CronWakeMode::Now`](crate::types::CronWakeMode::Now) finished.
153+ pub const WAKE_REASON_CRON_EVENT : & str = "cron-event" ;
154+
136155fn now_ms ( ) -> u64 {
137156 SystemTime :: now ( )
138157 . duration_since ( UNIX_EPOCH )
@@ -152,6 +171,7 @@ impl CronService {
152171 on_agent_turn,
153172 None ,
154173 RateLimitConfig :: default ( ) ,
174+ DEFAULT_WAKE_COOLDOWN_MS ,
155175 )
156176 }
157177
@@ -168,6 +188,7 @@ impl CronService {
168188 on_agent_turn,
169189 Some ( on_notify) ,
170190 RateLimitConfig :: default ( ) ,
191+ DEFAULT_WAKE_COOLDOWN_MS ,
171192 )
172193 }
173194
@@ -178,13 +199,15 @@ impl CronService {
178199 on_agent_turn : AgentTurnFn ,
179200 on_notify : Option < NotifyFn > ,
180201 rate_limit_config : RateLimitConfig ,
202+ wake_cooldown_ms : u64 ,
181203 ) -> Arc < Self > {
182204 Self :: with_events_queue (
183205 store,
184206 on_system_event,
185207 on_agent_turn,
186208 on_notify,
187209 rate_limit_config,
210+ wake_cooldown_ms,
188211 SystemEventsQueue :: new ( ) ,
189212 )
190213 }
@@ -199,6 +222,7 @@ impl CronService {
199222 on_agent_turn : AgentTurnFn ,
200223 on_notify : Option < NotifyFn > ,
201224 rate_limit_config : RateLimitConfig ,
225+ wake_cooldown_ms : u64 ,
202226 events_queue : Arc < SystemEventsQueue > ,
203227 ) -> Arc < Self > {
204228 Arc :: new ( Self {
@@ -212,6 +236,7 @@ impl CronService {
212236 on_notify,
213237 rate_limiter : Mutex :: new ( RateLimiter :: new ( rate_limit_config) ) ,
214238 events_queue,
239+ wake_cooldown_ms,
215240 } )
216241 }
217242
@@ -224,13 +249,38 @@ impl CronService {
224249 ///
225250 /// Multiple wake calls coalesce naturally: they all set `next_run_at_ms = now`
226251 /// idempotently, and `running_at_ms` prevents the heartbeat from firing twice.
252+ ///
253+ /// When called with reason [`WAKE_REASON_EXEC_EVENT`], a cooldown guard applies: if the
254+ /// heartbeat last completed less than `wake_cooldown_ms` ago, the wake is skipped.
255+ /// This prevents exec-completion callbacks from creating a re-fire loop.
256+ /// Other reasons (e.g. [`WAKE_REASON_CRON_EVENT`]) are never suppressed.
227257 pub async fn wake ( & self , reason : & str ) {
228258 let now = now_ms ( ) ;
229259 let mut jobs = self . jobs . write ( ) . await ;
230260 if let Some ( job) = jobs. iter_mut ( ) . find ( |j| j. id == "__heartbeat__" )
231261 && job. enabled
232262 && job. state . running_at_ms . is_none ( )
233263 {
264+ // Enforce cooldown for exec-triggered wakes only. This prevents
265+ // exec-completion callbacks from creating a re-fire loop when the
266+ // heartbeat agent uses `exec` during its turn. CronWakeMode::Now wakes
267+ // are never suppressed.
268+ if reason == WAKE_REASON_EXEC_EVENT
269+ && self . wake_cooldown_ms > 0
270+ && let Some ( last_run) = job. state . last_run_at_ms
271+ {
272+ let elapsed = now. saturating_sub ( last_run) ;
273+ if elapsed < self . wake_cooldown_ms {
274+ debug ! (
275+ reason,
276+ elapsed_ms = elapsed,
277+ cooldown_ms = self . wake_cooldown_ms,
278+ "skipping heartbeat wake — within cooldown"
279+ ) ;
280+ return ;
281+ }
282+ }
283+
234284 debug ! ( reason, "waking heartbeat" ) ;
235285 job. state . next_run_at_ms = Some ( now) ;
236286 }
@@ -665,7 +715,7 @@ impl CronService {
665715
666716 // Wake heartbeat immediately if this job requested it.
667717 if job. wake_mode == CronWakeMode :: Now && job. id != "__heartbeat__" {
668- self . wake ( "cron-event" ) . await ;
718+ self . wake ( WAKE_REASON_CRON_EVENT ) . await ;
669719 }
670720
671721 info ! (
0 commit comments