# Task Scheduler

Cross-platform scheduling abstraction for Dave. Lets the PA manage recurring tasks (distillation sweeps, health checks, sync jobs) using OS-native facilities without knowing the backend details.


## How it works

1. **detect.mjs** identifies the OS and available scheduling backends
2. You write a schedule definition as JSON (see format below)
3. **install.mjs** translates cron syntax to the native format and installs it
4. **verify.mjs** spawns the schedule's command once under the installed env, as a smoke test
5. **list.mjs** shows all Dave-managed schedules for the active backend
6. **remove.mjs** cleans up a schedule from the backend
7. **index.mjs** re-exports all functions for programmatic use by other tools

All tools are Node.js ESM modules (`.mjs`). Each works as both a CLI tool (`node detect.mjs`) and as an importable module (`import { detect } from './detect.mjs'`). Zero external dependencies -- only `node:` built-in modules.

All schedule definitions use **cron syntax** as the universal format. The install script handles translation to launchd plists, systemd timers, or Windows schtasks.


## Supported backends

| Backend | OS | Prefix | Notes |
|---|---|---|---|
| launchd | macOS | `com.dave.{id}` | Recommended on macOS. Plist in `~/Library/LaunchAgents/` |
| cron | macOS, Linux | `dave-{id}` | Widely available fallback |
| systemd | Linux | `dave-{id}` | Recommended on Linux. User-level timer + service units |
| schtasks | Windows | `dave-{id}` | Windows Task Scheduler via `schtasks.exe` (native cmd/PowerShell) |
| docker | Any |; | Detected but not directly managed (use for container-based setups) |


## Schedule definition format

Schedule files live in `schedules/` at the repo root. Each is a JSON file:

```json
{
  "id": "distillation-sweep",
  "name": "Daily Distillation Sweep",
  "schedule": "0 3 * * *",
  "command": "bash core/tools/distill/run.sh",
  "workingDirectory": "${DAVE_ROOT}",
  "enabled": true,
  "backend": null,
  "description": "Run the librarian distillation sweep daily at 3am"
}
```

| Field | Required | Description |
|---|---|---|
| `id` | Yes | Unique identifier. Used in backend naming (`com.dave.{id}`, `dave-{id}`) |
| `name` | No | Human-readable name |
| `schedule` | Yes | Cron expression: `minute hour day-of-month month day-of-week` |
| `command` | Yes | Shell command to execute |
| `workingDirectory` | No | Working directory. Supports `${DAVE_ROOT}` variable. Defaults to repo root |
| `enabled` | No | Set to `false` to skip installation. Defaults to `true` |
| `backend` | No | Override backend for this schedule. `null` uses the deployment default |
| `host` | No | Device id or hostname this schedule is pinned to run on. Enforced at `install` and `launch` time; see "Host enforcement" below. Omitting it is a warning-only pass, never a refusal. |
| `description` | No | Human-readable description |
| `environment` | No | Map of env vars to set for the scheduled process. **Read the warning below before using.** |

The JSON schema is at `packages/core/schema/schedule.json`.


## Host enforcement

A schedule's `host` field is matched against the current machine's
hostname; exact match, then short-name-before-first-dot (so
`smarthome01` matches `smarthome01.local`). Two enforcement points:

- **`scheduler install`** refuses (exit 2) to install a schedule whose
  `host` doesn't match the installing machine. `--force-host` overrides
  the refusal (still prints a warning, and records it in the result's
  `warnings` array); for deliberate cross-provisioning.
- **`launch`** refuses (exit 2) to RUN a task whose schedule declares a
  foreign host. Since `launch` is invoked non-interactively (by cron/
  launchd/systemd, not by an operator typing a flag), the override is an
  env var instead: `DAVE_FORCE_HOST=1`. `--dry-run` is exempt from the
  hard refusal (it never executes anything) but still prints a warning on
  a mismatch.

A schedule with no `host` field at all is a warning-only pass in both
places; host data backfill onto existing schedule files is estate-side,
not something either tool does automatically.

## Name-collision check

`scheduler install` refuses (exit 2) when another `.json` file in the
same schedules directory declares the same `id` as the one being
installed. Two different files sharing an id would otherwise silently
clobber each other's installed backend entry (same cron tag, same
launchd label); whichever installs last wins, and the loser's
association vanishes with no error anywhere. Malformed sibling files are
skipped (not this check's job to validate every other file in the
directory), not treated as a collision or a fatal error.

## Common pitfalls

### Do not set `environment.PATH`, `environment.HOME`, or `environment.DAVE_ROOT` unless you know what you're doing

The launchd installer auto-injects these three variables (`withLaunchdDefaults`) precisely because launchd's default agent env is minimal (`/usr/bin:/bin:/usr/sbin:/sbin`, no `HOME`) and that minimal env silently breaks anything installed via Homebrew. If you set an explicit `environment.PATH` in your schedule, **your value wins**; and on Apple Silicon Macs that almost always means `/opt/homebrew/bin` is missing, and tools like `sops`, `gh`, `jq` become invisible to the scheduled job.

The failure mode is silent: launchd records exit code 78 (`EX_CONFIG`), the StandardErrorPath log is 0 bytes, and the job repeats the same failure every cycle without surfacing.

**Rule of thumb:** the `environment` block is for *operator-meaningful* variables (`API_KEY`, `LOG_LEVEL`, `MY_FEATURE_FLAG`). Leave `PATH`, `HOME`, and `DAVE_ROOT` to the installer. If you genuinely need a custom `PATH`, *extend* the installer's default rather than replacing it; start your value with `${PATH}:/your/extra/bin`.

### cron's real environment is minimal; nvm/pnpm/Homebrew are invisible unless the schedule sources them

Proven live (w-2026-07-15-003, verified with `env -i`): cron's crond process provides a small fixed environment (`SHELL`, a narrow system `PATH`; `/usr/bin:/bin`; `HOME`, `LOGNAME`/`USER`), NOT a login shell's env. A schedule whose `command` relies on `pnpm`/`node` being on PATH via nvm silently fails to spawn once actually installed, even if it "worked" when tested from an interactive terminal.

The generated cron line handles this for you automatically: `scheduler install` detects `$HOME/.nvm/nvm.sh` on the target AT INSTALL TIME and, if present, sources it inside the rendered chain. The generated cron line is `bash -c '{ date -u; . $HOME/.nvm/nvm.sh && cd <workDir> && <command>; } >> /tmp/<agent>-<id>.log 2>&1'`; the `{ ...; }` braces are load-bearing: without them the `>> log 2>&1` binds only to `<command>` and any earlier link failing (nvm-source, cd) sends its stderr to cron mail, which hosts with no MTA silently discard (w-2026-09-13-dave-042; 0.1.39 fix, smarthome01 crons fired but wrote no log 2026-09-09 -> 13). The leading `date -u` prints an ISO timestamp so a run that dies mid-chain still leaves dated evidence. If no nvm install is found, the preamble is simply omitted; nothing to do for schedules that only need system binaries. `environment.*` values are emitted as `export VAR='val';` statements ahead of the command (not a bare `VAR='val' cd ...` prefix, which in POSIX shell only scopes to the `cd` builtin and never actually reaches the command after `&&`; a real, silent bug the old generator had).

`scheduler verify` now runs this EXACT generated line (not a bare `sched.command`), under cron's actual minimal environment; never the verifying shell's `process.env`. This is deliberate: the old verify path inherited the operator's full login-shell env, which reliably masked this whole bug class. If `verify` reports `ok` today, that's an honest signal the schedule will actually fire under real cron. The cron verify PATH is cron's base `/usr/bin:/bin`, then `/usr/local/bin`, then the PATH entries of `/etc/environment` when that file exists, because Debian cron reads `/etc/environment` through pam_env (w-2026-10-17-dave-007). A `PATH` in the schedule's own `environment` still replaces it. The result JSON carries the PATH used as `path`.

**Quoting and `%` (w-2026-10-17-dave-006, 0.1.62).** cron hands the command field to `/bin/sh -c`, so the `bash -c` wrapper is single-quoted, with any single quote inside rendered as `'\''` (the same rule the systemd backend uses for `ExecStart`). sh therefore passes the inner script to bash untouched and every `$(...)`, `$1` and `${VAR}` is expanded by bash after the `cd`, not by sh in the cron user's HOME. The old double-quoted wrapper let sh expand them first (smarthome01 workspace-refresh failed 162 of 162 runs with `fatal: ambiguous argument 'origin/'`). cron also turns an unescaped `%` into a newline and feeds the rest of the line to stdin, so every `%` in the rendered line is written as `\%`; cron strips the backslash before sh sees it. `scheduler verify` on the cron backend runs the rendered command the way cron does: it takes the full `bash -c '...'` string, undoes the `\%` escaping, and executes it through `/bin/sh -c` from the working directory, so a quoting or `%` defect shows up in verify. Write schedule `command` strings exactly as you would type them into bash in the working directory: no `\$` or `\%` workarounds. Lines installed by 0.1.61 or earlier keep the double-quoted form until the schedule is re-installed; `list`, `remove` and `install` match both forms by the trailing `# <agent>-<id>` tag. The tag must end the line (w-2026-10-17-dave-007), so an id that is a prefix of another (`dave-foo` against `dave-foo-bar`) matches only its own line. When a schedule's `agent` field changes, `install` also removes the line with the same id under the old agent prefix and prints one line naming both tags. The systemd backend escapes `%` as `%%` and `$` as `$$` in `ExecStart` (w-2026-10-17-dave-007), because systemd resolves `%` specifiers and expands `$VAR` and `${VAR}` itself, inside single quotes too, before bash sees the command; bash then receives the command exactly as written.

### Verify your schedule before walking away

After installing a schedule, run `verify.mjs` to confirm the command actually launches under the installed env. The verify deliberately uses the same env-derivation logic as the installer, so it catches the env-mismatch class of bugs (narrow PATH, missing HOME, etc.) before the job's first scheduled fire; instead of finding out two weeks later from a stale audit log.

```bash
node core/tools/scheduler/verify.mjs schedules/my-task.json launchd
```


## Usage

### Detect available backends

```bash
node core/tools/scheduler/detect.mjs
```

Output:
```json
{
  "os": "darwin",
  "available": [
    {"backend": "launchd", "recommended": true, "notes": "Native macOS, survives reboots"},
    {"backend": "cron", "recommended": false, "notes": "Available but launchd preferred on macOS"}
  ]
}
```

### Install a schedule

```bash
node core/tools/scheduler/install.mjs schedules/distillation-sweep.json launchd
```

### Verify a schedule

After installing, run the schedule's command once under the same environment the installer would use. This catches silent-failure bugs caused by env mismatches (e.g. a narrow `environment.PATH` that masks Homebrew binaries on Apple Silicon).

```bash
node core/tools/scheduler/verify.mjs schedules/my-task.json launchd
```

If the backend is omitted, verify auto-detects (the same way `detect.mjs` does). Default timeout is 120 seconds; override with `--timeout`, which is in seconds on both `pa scheduler verify` and `verify.mjs` (w-2026-10-17-dave-007; the `pa` CLI used to read it as milliseconds):

```bash
node core/tools/scheduler/verify.mjs schedules/long-job.json launchd --timeout 60
```

Exit codes: `0` ok, `1` usage error, `2` command exited non-zero, `3` spawn failure (binary not found), `4` timeout.

### List installed schedules

```bash
node core/tools/scheduler/list.mjs launchd
```

Pass no argument to auto-detect the backend:

```bash
node core/tools/scheduler/list.mjs
```

#### Listing schedules across hosts

`list.mjs <backend>` only sees what the named backend has installed **on the current host**. It reads the running user's crontab, the local `~/Library/LaunchAgents/` directory, the local systemd user units, or the local `schtasks.exe` namespace; there is no remote query. Running `list.mjs cron` on a launchd-recommended macOS workstation returns `(none)` even when a cron job is actively firing on a Linux host elsewhere; that's expected, not a bug. To inspect schedules installed on another machine, run `list.mjs` there directly (`ssh <host> 'cd <repo> && node core/tools/scheduler/list.mjs <backend>'` is a fine pattern). The `/schedule` slash command and the schedules workspace page surface the *intent* (the JSON definitions in `schedules/`); for the *actual installed state*, you must query each host.

### Remove a schedule

```bash
node core/tools/scheduler/remove.mjs distillation-sweep launchd
```

Add `--delete-file` to also remove the JSON definition:

```bash
node core/tools/scheduler/remove.mjs distillation-sweep launchd --delete-file
```

### Programmatic usage

```js
import { detect, install, list, remove, verify } from './core/tools/scheduler/index.mjs';

const backends = detect();
console.log(backends);

install('schedules/my-task.json', 'launchd');
const v = await verify('schedules/my-task.json', { backend: 'launchd', timeoutMs: 30000 });
if (v.result !== 'ok') console.error('schedule does not start cleanly:', v);

console.log(list('launchd'));
remove('my-task', 'launchd', { deleteFile: true });
```


## Integration with /init

During environment discovery, the PA should run `detect.mjs` and store the result in `.dave/config.json` under the `scheduler` key:

```json
{
  "scheduler": {
    "backend": "launchd",
    "detectedAt": "2026-04-13T12:00:00Z",
    "available": ["launchd", "cron"]
  }
}
```

The `/schedule` slash command reads this config and uses the stored backend as the default.


## Security notes

- All tools are intentionally simple and auditable -- users should read them before granting scheduler access
- Scheduled tasks run as the current user with that user's permissions
- No elevated/root permissions are requested
- Log files are written to `/tmp/` by default (configurable per schedule)
- The tools never auto-install without explicit invocation


## Tests

```bash
node --test packages/core/test/scheduler/*.test.mjs
node --test packages/cli/test/scheduler/*.test.mjs packages/cli/test/launch/*.test.mjs
```

The install tests cover the pure plist/cron/systemd/schtasks builders, `withLaunchdDefaults`, the env-complete cron generator (`buildCronCommand`/`buildCronLine`; nvm preamble, log redirect, `export`-based env propagation), `validateWorkDir` (unresolved-variable / node_modules-trap / nonexistent-dir refusals), `evaluateHostGate`, and `checkNameCollision`. The verify tests cover the env-derivation logic (including cron's new minimal-env honesty), the spawn outcomes (success / non-zero / spawn-failure / timeout), the generated-cron-line execution path, and a regression case for issue #68 (an explicit narrow PATH masking a binary that's available at the parent shell's PATH). `host.test.mjs` covers hostname normalisation/matching in isolation. The CLI-level suites (`packages/cli/test/scheduler/cli-bin.test.mjs`, `packages/cli/test/launch/cli.test.mjs`) exercise host refusal, `--force-host`/`DAVE_FORCE_HOST=1` overrides, and name-collision refusal through the real bins.
