Skip to content

Recurring session wakes (CronCreate/loop) can't be cancelled via CronDelete — WakeRegistry.remove has no callers (gap 4b unwired) #409

Description

@edspencer

Summary

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

  1. 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.
  2. Let it fire at least once via a herdctl-resumed turn (so the live process is a resume, not the original).
  3. From that (or any later resumed) turn, call CronDelete(<id>). It returns success and CronList now reports no jobs.
  4. 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.

Root cause

Files: packages/core/src/session/wake-store.ts, wake-registry.ts, session-reaper.ts.

  • 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

  1. 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.
  2. And/or call WakeRegistry.removeAllForSession(sessionId) when a session is permanently archived/closed, so a closed session can't leave orphaned recurring wakes.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions