# Work on a schedule, for anything that ships

*12 August 2026*

## The gap

Pulse rolls raw page views into daily totals and then deletes them. If that
never runs, the module keeps every raw event for ever — the opposite of what it
promises — and the dashboard stays empty because nothing has been aggregated.

It registers `pulse.roll` as a queue handler, which is right. Nothing can ever
push it.

**An extension has no way to run scheduled work.** There is no
`queue:push` command, and `Convoro::console()` registers a hardcoded list of
command classes that an extension cannot add to. Core modules get around this
by shipping a command — `tracker:sprint-day` pushes `tracker.sprint_day` — and
that door is closed to everything installed as a package.

So this is not a Pulse problem. Backup cannot take a nightly backup. Guardian
cannot re-check a queue. Anything an extension wants to do on a timer is
currently impossible, and the only visible symptom is a feature that quietly
never happens.

## The shape

A module declares what it wants run and how often:

```php
$this->schedule()->daily('pulse.roll');
$this->schedule()->hourly('backup.verify');
```

and one command runs everything registered at a cadence:

```
php tools/convoro schedule:run daily
```

which the cron already in `tools/deploy/convoro-cron` calls beside the jobs it
already runs.

## 🚨 Pushed onto the queue, never run inline

The scheduler does not execute anything. It pushes each registered key onto the
queue and returns, exactly as `tracker:sprint-day` already does.

That matters for three reasons and each is a real failure otherwise:

- **Retries.** A rollup that failed because the database blinked should be
  tried again, and the queue is the only thing here that knows how.
- **One path.** Work that can run either through the queue or directly has two
  behaviours, and the one that gets tested is not the one that runs at 3am.
- **A slow job cannot block the next cadence.** `schedule:run daily` returns in
  milliseconds whatever it queued. A scheduler that ran a twenty-minute reindex
  inline would still be running when the minutely timer next fired.

## 🚨 Idempotent, or it does not belong here

A cron can run twice — an operator runs it by hand, a timer overlaps, a machine
is restored from a snapshot. Anything registered must be safe to run again, and
that is a requirement on the handler rather than something the scheduler can
enforce. Said in the method's docblock, because it cannot be checked.

Pulse's rollup already satisfies it: rolling a day recomputes it from the
events that exist rather than adding to what was there.

## What this deliberately is not

**Not cron syntax.** Three cadences, matching the three timers already
installed. `*/7 * * * *` in a manifest would mean shipping a cron parser, a
timezone question and a "why did this not run" support burden, to schedule work
that is a nightly rollup in every case anyone has needed so far.

**Not a per-site schedule editor.** An administrator turning off a module's
rollup would be switching off part of the module without being told that is
what they were doing. Switching the module off is the control that exists.

**Not a guarantee it ran.** The queue records failures and `queue:work` reports
them; the scheduler's job ends at "queued". A module that needs to know whether
its work happened should look at its own data, which Pulse does — an unrolled
day is visible as raw events that are still there.
