@@ -2171,6 +2171,20 @@ export function startHeartbeatRunner(opts: {
21712171 agent . nextDueMs = seekActiveSlotForAgent ( agent , rawDueMs ) ;
21722172 } ;
21732173
2174+ const advanceStaleScheduleAfterDeferral = (
2175+ agent : HeartbeatAgentState ,
2176+ now : number ,
2177+ reason ?: string ,
2178+ decision ?: DeferDecision ,
2179+ ) => {
2180+ if ( ! decision ?. defer || decision . reason === "not-due" || agent . nextDueMs > now ) {
2181+ return ;
2182+ }
2183+ // Deferrals that do not have wake-layer retry ownership still need to move
2184+ // the due slot forward; otherwise scheduleNext() will keep rearming at 0ms.
2185+ advanceAgentSchedule ( agent , now , reason ) ;
2186+ } ;
2187+
21742188 // Centralized cooldown gate. Both targeted and broadcast dispatch branches
21752189 // call this before invoking `runOnce`. Manual wakes are never deferred.
21762190 // Everything else respects `nextDueMs`, the min-spacing floor, and the flood
@@ -2366,6 +2380,7 @@ export function startHeartbeatRunner(opts: {
23662380 }
23672381 const deferral = evaluateWakeDeferral ( targetAgent , now , reason , intent ) ;
23682382 if ( deferral . defer ) {
2383+ advanceStaleScheduleAfterDeferral ( targetAgent , now , reason , deferral ) ;
23692384 return { status : "skipped" , reason : deferral . reason } ;
23702385 }
23712386 try {
@@ -2395,11 +2410,10 @@ export function startHeartbeatRunner(opts: {
23952410 return res ;
23962411 }
23972412 // Non-retryable outcome (ran, disabled, failed-but-not-busy). Record
2398- // bookkeeping so subsequent wakes within the cooldown window defer.
2413+ // bookkeeping and move the due slot so scheduleNext() cannot hot-loop
2414+ // on a stale past-due agent.
23992415 recordRunBookkeeping ( targetAgent , now ) ;
2400- if ( res . status !== "skipped" || res . reason !== "disabled" ) {
2401- advanceAgentSchedule ( targetAgent , now , reason ) ;
2402- }
2416+ advanceAgentSchedule ( targetAgent , now , reason ) ;
24032417 return res . status === "ran" ? { status : "ran" , durationMs : Date . now ( ) - startedAt } : res ;
24042418 } catch ( err ) {
24052419 const errMsg = formatErrorMessage ( err ) ;
@@ -2428,6 +2442,7 @@ export function startHeartbeatRunner(opts: {
24282442 const runOneAgent = async ( agent : HeartbeatAgentState ) : Promise < AgentWakeOutcome > => {
24292443 const deferral = evaluateWakeDeferral ( agent , now , reason , intent ) ;
24302444 if ( deferral . defer ) {
2445+ advanceStaleScheduleAfterDeferral ( agent , now , reason , deferral ) ;
24312446 return { ran : false } ;
24322447 }
24332448
@@ -2462,9 +2477,7 @@ export function startHeartbeatRunner(opts: {
24622477 }
24632478 // Non-retryable outcome — record bookkeeping for cooldown gates.
24642479 recordRunBookkeeping ( agent , now ) ;
2465- if ( res . status !== "skipped" || res . reason !== "disabled" ) {
2466- advanceAgentSchedule ( agent , now , reason ) ;
2467- }
2480+ advanceAgentSchedule ( agent , now , reason ) ;
24682481 let agentRan = res . status === "ran" ;
24692482
24702483 const defaultSessionKey = resolveHeartbeatSession (
0 commit comments