scheduling
The cross-repo scheduling system (sch_* tables, matrx-scheduler). Use when touching features/scheduling/**, /schedules or /administration/scheduling, migrations/sch_*.sql, a scheduled agent, cron, tri
Files1
View on GitHubSKILL.md.claude/skills/scheduling/SKILL.md
GitHubScheduling
Cross-repo scheduling spine. Three live systems share four Supabase tables
(sch_task, sch_agent_task, sch_trigger, sch_run):
- matrx-frontend — control plane (this repo). Users create/observe.
- aidream + matrx-scheduler — server-side executor.
- matrx-extend (Chrome extension) — browser-context executor for context-match triggers.
Spec: docs/SCHEDULING.md. FEATURE doc:
features/scheduling/FEATURE.md.
30-second mental model
sch_task kind='agent' (today). Spine: title, surfaces[], next_due_at.
↳ sch_agent_task 1:1 extension: agent_id, prompt, variables, conversation_id.
sch_trigger when it fires (one-shot, interval, cron, heartbeat, context-match, …). v1 = one trigger per task.
sch_run each execution attempt. Lease via claim_token + claim_expires_at.
RLS: owner-or-is_super_admin() on all four tables. Service_role is
NEVER used for user-initiated writes; admin pages use the regular browser
client and rely on RLS for cross-user reads.
Atomicity: a partial unique index sch_run_unique_active_per_task
forces at most one active run per task. Concurrent claimers lose the race
to the index, not the application.
DB invariants: sch_task.next_due_at = MIN(sch_trigger.next_due_at) is
maintained by trigger sch_trigger_cascade_next_due_at. Don't UPDATE
sch_task.next_due_at directly — write to sch_trigger and let the
cascade flow.
When to use this skill
- Adding or modifying a
sch_*migration. - Changing the trigger taxonomy or adding a new trigger type.
- Building / fixing FE pages under
/schedulesor/administration/scheduling. - Editing the Python
matrx-schedulerpackage or its router on aidream. - Wiring a new executor surface (e.g. a desktop app scanner).
- Debugging "ghost tasks" (queued runs not getting picked up), missed fires, double-runs, stuck claims.
Where each thing lives
| Layer | Files |
|---|---|
| DB migrations | migrations/sch_*.sql |
| FE types | features/scheduling/types.ts |
| FE Supabase façade (reads) | features/scheduling/service/queries.ts — joined task reads + taskDetailToAgendaTask reshape + residual updateAgentTaskFields write (the one sch_agent_task field-patch HTTP doesn't cover yet). ONLY place that .from('sch_*'). |
| FE HTTP client (writes + compute) | features/scheduling/service/schedulerClient.ts — typed wrapper over aidream's /scheduler/* (16 endpoints): tasks/triggers/runs CRUD, cron validate, preview-fires, compute-next-due-at, scanner status. Primary path for all user-facing writes. |
| FE TS twin of next_due | lib/scheduler-client/next-due.ts — used by CronForm and cron-tester for live-typing previews; also exported to non-HTTP clients (Chrome extension, edge functions). |
| FE Redux slices | features/scheduling/redux/{tasks,runs}/ |
| FE hooks | features/scheduling/hooks/ — useScheduledTasks (+ list realtime), useTaskDetail, useTaskRuns, useRunStream |
| FE list / detail / form | features/scheduling/components/{list,detail,form}/ |
| User routes | app/(authenticated)/schedules/ |
| Admin routes | app/(authenticated)/(admin-auth)/administration/scheduling/ |
| Admin service | lib/services/scheduling-admin-service.ts (browser client + is_platform_admin() RLS) |
| Python package | aidream/packages/matrx-scheduler/matrx_scheduler/ |
| Python router | aidream/aidream/api/routers/scheduling.py |
| Per-request supabase client | aidream/aidream/services/scheduling/per_request_client.py |
| matrx-ai bridge | aidream/aidream/services/scheduling/agent_runner_adapter.py |
| Capture emitter | aidream/aidream/services/scheduling/capture_emitter.py |
Non-negotiable rules
- NEVER call
.from('sch_*')outsidefeatures/scheduling/service/queries.tsorlib/services/scheduling-admin-service.ts. The select shape needs to stay consistent; row→AgendaTask flattening lives in one place. New user-facing writes go throughservice/schedulerClient.ts— don't add parallel Supabase writes toqueries.ts. - NEVER use the aidream service_role supabase singleton in scheduling routes. Use
make_user_supabase_client(jwt)so RLS binds to the caller. - NEVER insert into
sch_rundirectly from the FE. Use thesch_enqueue_manual_run(p_task_id)RPC — it stampsuser_idfrom the task row, setsstatus='queued', and refuses to spoof other fields. - NEVER UPDATE
sch_task.next_due_atfrom application code. Write tosch_trigger.next_due_at; the DB trigger cascades. - NEVER bypass the RLS gate by tightening
is_super_admin()tois_platform_admin()(or vice-versa). The two tiers exist deliberately — scheduling usesis_super_admin()to match the route layout'srequireSuperAdmingate.
Common tasks
Add a new trigger type (e.g. event)
- Add to the
TriggerTypediscriminated union infeatures/scheduling/types.tsand the Pythonmodels.py. - Add the case to
computeNextDueAtinlib/scheduler-client/next-due.ts(TS twin used by previews) ANDcompute_next_due_atin the Pythonnext_duemodule (authoritative for writes). - Add a subform component in
features/scheduling/components/form/triggers/and wire it inScheduleForm.tsx. - Add validation in
features/scheduling/utils/validation.ts. - Add a Jest + pytest case for the new type.
- Update the DB CHECK on
sch_trigger.typeif you're adding a brand-new value — the existing 8 values already cover everything in the spec.
Add a new executor surface (e.g. desktop)
- Add the value to:
- the FE
SURFACE_VALUESconstant - the
sch_task_surfaces_chkCHECK constraint (migration) - the
SURFACE_METAmap for the picker tooltip
- the FE
- In the new surface's code (e.g. Tauri desktop app), call
matrx_scheduler.configure(surface="desktop", ...)andstart_scanner(). Each surface has its own scanner; the partial unique index ensures no double-runs across surfaces.
Debug a "ghost task" (queued run that never executes)
- Confirm the row's
surfacevalue — manual fires set it toNULLso any surface can claim; if it's hardcoded to a value no executor is running on, it'll sit forever. - Check
sch_task.surfaces— does it contain'any'or a value that matches an online scanner? - Check the scanner status: GET
/scheduler/status(admin only), or the FE admin page at/administration/scheduling/scanner-health. - Confirm
AIDREAM_SCHEDULER=1is set on the host process you expect to execute the run.
Debug a "stuck claimed" run
- Visit
/administration/scheduling/orphan-leases. - The scanner sweeps any
status IN (claimed,running) AND claim_expires_at < now()tofailedon every tick — if a row sits in claimed for >60s after expiry, something's broken upstream. - Manual remediation: use the page's "Mark failed" button (RPC-less, direct UPDATE — gated by RLS owner-or-super-admin).
Verification
After changes, always:
- Python:
uv run pytest packages/matrx-scheduler/tests(25 tests, must all pass). - TypeScript:
pnpm exec jest features/scheduling/utils/__tests__/(triggerHumanize + validation). - Type check:
pnpm tsc --noEmitclean across the feature. - Smoke: create a schedule via the form, confirm it lands in all 3
tables (use Supabase MCP
execute_sqlto inspect). - For Python execution changes: enable
AIDREAM_SCHEDULER=1, create an interval task onsurfaces=['server'], watch logs for "scheduler.run requested" and the resultingsch_runrow transition.
Related skills
protected-resources— scheduling is NOT a protected resource (no audit log, no SECURITY DEFINER mutation gate), but if scheduling ever grows admin-level operations that warrant audit, follow that pattern.connect-matrx-extend— relevant when addingcontext-matchUX or cross-repo run-pickup support.window-panels— for future "Quick Schedule" overlay (v1.5).