# Production setup — Laravel scheduler (`schedule:work`)

**Scope:** how the **Laravel scheduler** runs in production. Horizon only drains jobs already on the queue — the scheduler is what **puts time-driven work in motion**. Without it, none of the commands in `app/Console/Kernel.php` ever run.

> The classic footgun: Horizon is up, the site works, WhatsApp sends fine… but the dowayai poll, the broadcast/flow backstops and the Zoom sync silently never fire — because nothing runs `schedule:run`/`schedule:work`. This box had exactly that gap until `petav3-scheduler.service` was added (2026-07-02).

## What runs on the schedule (from `app/Console/Kernel.php`)

| Command | Cadence | Gate |
|---|---|---|
| `calls:poll-dowayai` | every minute | `DOWAYAI_POLL_ENABLED` |
| `whatsapp:run-broadcasts` | every minute | always |
| `whatsapp:reap-flow-runs` | every minute | always |
| `whatsapp:run-funnel-reminders` | every 5 min | always |
| `whatsapp:reconcile-broadcasts` | every 5 min | always |
| `whatsapp:sync-quality` | hourly | always |
| `zoom:sync-recordings --days=3` | daily 03:00 MYT | `ZOOM_SYNC_ENABLED` |

## How it runs — `petav3-scheduler.service`

One systemd unit (same pattern as `horizon.service`) running the long-lived worker:

```ini
[Service]
User=ubuntu
Group=www-data
Restart=always
RestartSec=3
TimeoutStopSec=60
WorkingDirectory=/var/www/html/peta
ExecStart=/usr/bin/php8.4 /var/www/html/peta/artisan schedule:work
```

`schedule:work` ticks every minute and spawns a **fresh `schedule:run` subprocess** per tick, which itself spawns each due command as its own process — so scheduled commands always boot fresh config/code.

**Provisioning is self-healing — two places, one template (byte-identical):**

- **`scripts/server-setup.sh`** installs + enables the unit on a fresh server (alongside `horizon.service`).
- **`scripts/deploy-update.sh`** (run on **every** deploy) *(re)writes the unit when missing or when the template changed*, `daemon-reload`s, enables it, and **restarts it every deploy** — like Horizon, the long-running process must be bounced to pick up new framework/vendor code. A server set up before the unit existed gets it automatically on its next deploy.

`scripts/service-check.sh` (the deploy's post-deploy health check) treats `petav3-scheduler` as **critical**: a dead scheduler fails the check (exit ≠ 0) and probes that the actual `schedule:work` process is alive.

## Required environment (`.env`)

```dotenv
DOWAYAI_POLL_ENABLED=true    # gate for the dowayai poll (keep false until verified)
ZOOM_SYNC_ENABLED=true       # gate for the daily Zoom recordings sync
```

The WhatsApp commands have no gate — as soon as the scheduler runs, they run. That is intended (they are the backstops the WhatsApp modules rely on).

## Local dev (no systemd)

```bash
php artisan schedule:work          # foreground, ticks every minute (WSL)
php artisan calls:poll-dowayai     # or: fire one scheduled command by hand
```

## Troubleshooting

```bash
systemctl status petav3-scheduler                          # unit up?
journalctl -u petav3-scheduler -n 50 --no-pager            # recent ticks / task output
sudo bash scripts/service-check.sh                         # full health report
php8.4 artisan schedule:list                               # what's due when (+ next run)
```

- **A task seems skipped every minute** → its `withoutOverlapping()` mutex may be stuck (a task was hard-killed mid-run; the lock lingers in cache up to 24 h). Clear with `php artisan cache:clear` or delete the `framework/schedule-*` cache keys.
- **Unit runs but a gated command never fires** → check the gate env (`DOWAYAI_POLL_ENABLED` etc.). In production the deploy **caches config** (`config:cache`), so a hand-edited `.env` does NOT take effect until you re-run `php8.4 artisan config:cache` (or just redeploy).
