You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A recurring session wake (created by the harness CronCreate / /loop / ScheduleWakeup) cannot be cancelled with CronDelete once herdctl has captured it into its durable wake set. The harness deletes its own in-memory cron (so the agent's CronList reports no jobs), but herdctl keeps firing the persisted wake on its nextRunAt schedule until the 7-day prune — silently re-injecting the prompt every interval.
The retirement method for this case exists (WakeRegistry.remove(id), documented as the "detected CronDelete" path — "gap 4b") but has zero callers. The detection was scaffolded and never wired.
Environment
Session-drive-mode (persistent openChatSession / reaper keep-alive). This is where wakes are made durable, so it's where the bug bites; batch mode is unaffected.
Observed with @herdctl/core 5.23.0; code paths below are unchanged on main.
Repro
In a session-mode agent, create a recurring wake: CronCreate({ cron: "*/17 * * * *", prompt: "...", recurring: true }). herdctl persists it to the fleet state file's session_wakes map.
Let it fire at least once via a herdctl-resumed turn (so the live process is a resume, not the original).
From that (or any later resumed) turn, call CronDelete(<id>). It returns success and CronList now reports no jobs.
Observed: the wake keeps firing every 17 min anyway — the prompt is re-injected from session_wakes indefinitely, until the 7-day prune drops it. Expected:CronDelete retires the durable wake; firing stops.
On a herdctl-fired resumed turn, the CLI's session-only cron is never re-armed, so the Stop hook reports an empty sessionCrons. reconcileSessionWakes (see the feat(core): reap streaming sessions on idle + re-trigger scheduled wakeups via the existing scheduler #307 "gap 4" doc comment) therefore deliberately keeps a recurring wake that's absent from the report — otherwise every recurring wake would be deleted on its first re-fire.
The comment there states recurring wakes are instead retired by either pruneExpiredWakes (7-day) or "an explicit removeWake (a detected CronDelete)".
WakeRegistry.remove(id) (wraps removeWake) and WakeRegistry.removeAllForSession(sessionId) are both defined — but neither is called anywhere in packages/core/src (only their own definitions match). The sole wake writer is registry.reconcile(...) from the Stop-hook path (session-reaper.ts), which keeps recurring wakes.
So there is no code path that retires a recurring wake in response to a CronDelete. The 7-day prune is the only thing that ever removes it.
A subtlety worth calling out: CronDelete fundamentally cannot be inferred by reconcile on a resumed turn, because the session-only cron isn't armed in that process — deleting it produces no observable present→absent transition. So the fix can't live in reconcile; the delete has to be reported explicitly.
Impact
A recurring CronCreate//loop wake is effectively un-cancellable by the agent for up to 7 days once herdctl owns it. The agent sees CronList: empty and reasonably believes it's cancelled, while the prompt keeps re-firing — wasting turns/tokens and confusing the model (it can't tell why it's being re-invoked).
Also affects /loop teardown and any "cancel my schedule from within the session" flow.
Proposed fix
Have the Stop-hook signal carry the set of deleted cron ids observed during the turn (the harness knows which CronDeletes ran), and route them to WakeRegistry.remove(id). This is the intended "gap 4b" wiring.
And/or call WakeRegistry.removeAllForSession(sessionId) when a session is permanently archived/closed, so a closed session can't leave orphaned recurring wakes.
Add a regression test: recurring wake created → resumed fire → CronDelete on a resumed turn → assert the session_wakes entry is removed and no further fire occurs.
Workarounds today
Wait for the 7-day prune (RECURRING_WAKE_MAX_AGE_MS).
Manually remove the session_wakes.<id> entry from the fleet state file — but the live scheduler load-modify-saves that file, so a raw edit races it; not safe on a running fleet without stopping the scheduler.
Summary
A recurring session wake (created by the harness
CronCreate//loop/ScheduleWakeup) cannot be cancelled withCronDeleteonce herdctl has captured it into its durable wake set. The harness deletes its own in-memory cron (so the agent'sCronListreports no jobs), but herdctl keeps firing the persisted wake on itsnextRunAtschedule until the 7-day prune — silently re-injecting the prompt every interval.The retirement method for this case exists (
WakeRegistry.remove(id), documented as the "detectedCronDelete" path — "gap 4b") but has zero callers. The detection was scaffolded and never wired.Environment
openChatSession/ reaper keep-alive). This is where wakes are made durable, so it's where the bug bites; batch mode is unaffected.@herdctl/core5.23.0; code paths below are unchanged onmain.Repro
CronCreate({ cron: "*/17 * * * *", prompt: "...", recurring: true }). herdctl persists it to the fleet state file'ssession_wakesmap.CronDelete(<id>). It returns success andCronListnow reports no jobs.session_wakesindefinitely, until the 7-day prune drops it.Expected:
CronDeleteretires the durable wake; firing stops.Root cause
Files:
packages/core/src/session/wake-store.ts,wake-registry.ts,session-reaper.ts.sessionCrons.reconcileSessionWakes(see the feat(core): reap streaming sessions on idle + re-trigger scheduled wakeups via the existing scheduler #307 "gap 4" doc comment) therefore deliberately keeps a recurring wake that's absent from the report — otherwise every recurring wake would be deleted on its first re-fire.pruneExpiredWakes(7-day) or "an explicitremoveWake(a detectedCronDelete)".WakeRegistry.remove(id)(wrapsremoveWake) andWakeRegistry.removeAllForSession(sessionId)are both defined — but neither is called anywhere inpackages/core/src(only their own definitions match). The sole wake writer isregistry.reconcile(...)from the Stop-hook path (session-reaper.ts), which keeps recurring wakes.CronDelete. The 7-day prune is the only thing that ever removes it.A subtlety worth calling out:
CronDeletefundamentally cannot be inferred by reconcile on a resumed turn, because the session-only cron isn't armed in that process — deleting it produces no observable present→absent transition. So the fix can't live inreconcile; the delete has to be reported explicitly.Impact
CronCreate//loopwake is effectively un-cancellable by the agent for up to 7 days once herdctl owns it. The agent seesCronList: emptyand reasonably believes it's cancelled, while the prompt keeps re-firing — wasting turns/tokens and confusing the model (it can't tell why it's being re-invoked)./loopteardown and any "cancel my schedule from within the session" flow.Proposed fix
CronDeletes ran), and route them toWakeRegistry.remove(id). This is the intended "gap 4b" wiring.WakeRegistry.removeAllForSession(sessionId)when a session is permanently archived/closed, so a closed session can't leave orphaned recurring wakes.CronDeleteon a resumed turn → assert thesession_wakesentry is removed and no further fire occurs.Workarounds today
RECURRING_WAKE_MAX_AGE_MS).session_wakes.<id>entry from the fleet state file — but the live scheduler load-modify-saves that file, so a raw edit races it; not safe on a running fleet without stopping the scheduler.