Skip to content

Claudinite scheduler #53

Claudinite scheduler

Claudinite scheduler #53

# Claudinite per-repo task scheduler — the repo's ONLY cron
# (per-project-scheduling DESIGN §3). A thin shim: all logic lives in the
# vendored engine, so this file changes rarely (schema and behavior changes ride
# the vendor refresh, not edits here). It runs hourly on a repo-hashed minute
# constrained to :10–:50 — spreading the fleet, dodging GitHub's :00 stampede,
# and staying clear of the hour boundary the slot math anchors on.
#
# The minute below is a placeholder; bootstrap/vendoring rewrites it to this
# repo's stable hashed minute in :10–:50, computed by
# `node .claudinite/shared/engine/scheduler/hash-minute.mjs <owner/repo>`
# (scheduler-workflow-shape enforces the range; baselining re-derives the value to
# catch drift). `workflow_dispatch` allows a manual run; the concurrency group
# serializes runs so a slow run and its successor never overlap.
#
# A manual run may carry `overrides` — ONE free-form `KEY=value` string, because
# GitHub cannot declare arbitrary named inputs. It reaches the engine as
# `CLAUDINITE_OVERRIDES`, and the engine understands exactly one key:
# `FORCE_TASKS=<comma-separated task ids>`.
# It runs those tasks even though their slot has already passed, and
# WITHOUT consulting their preconditions — forcing is the operator's decision, so
# nothing asks the task whether it agrees. The engine learns "run these ids",
# never what any of them do, and no task declaration mentions forcing at all.
name: Claudinite scheduler
on:
schedule:
- cron: '24 * * * *'
workflow_dispatch:
inputs:
overrides:
description: 'Manual-run overrides: comma-separated KEY=value. FORCE_TASKS=<task ids> runs those tasks unconditionally, even when their slot has passed, e.g. FORCE_TASKS=baselining.'
required: false
default: ''
concurrency:
group: claudinite-scheduler
cancel-in-progress: false
permissions:
contents: write # read the repo + vendored engine; baselining's deliver() pushes the maintenance branch (agent-preprocessing §7/E4)
issues: write # file / label / close dispatch issues
pull-requests: write # baselining's deliver() opens the per-cycle maintenance PR and arms auto-merge
actions: write # read the run ledger (last successful run) for due-slot math, AND
# dispatch a workflow a task drives (store-release fires the release
# orchestrator's daily leg) — creating a workflow_dispatch event needs
# write, not read, and `read` silently 403s the POST
jobs:
schedule:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
- name: Evaluate schedule and dispatch due tasks
env:
GITHUB_TOKEN: ${{ github.token }}
SCRAPER_API_KEY: ${{ secrets.SCRAPER_API_KEY }}
# Empty on every scheduled run — `inputs` is only populated by
# workflow_dispatch. The engine reads only FORCE_TASKS out of it.
CLAUDINITE_OVERRIDES: ${{ inputs.overrides }}
run: node .claudinite/shared/engine/scheduler/run.mjs
# A scheduled run reaches no one when it goes red, so escalate a whole-run
# failure to a human-visible `workflow-failure` issue (DESIGN §3.6,
# gha/scheduled-failure-escalation). Converges to ONE open issue by a fixed
# title (create-or-comment), and ensures the label first since a first-run
# failure may precede the scheduler's own label-ensure.
report-failure:
needs: schedule
if: failure()
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const title = 'Claudinite scheduler run failed';
await github.rest.issues.createLabel({ owner, repo, name: 'workflow-failure', color: 'b60205', description: 'Claudinite scheduler: a scheduler run or task failed' }).catch(() => {});
const run = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const body = `The Claudinite scheduler run failed: ${run}`;
const found = await github.rest.search.issuesAndPullRequests({ q: `repo:${owner}/${repo} is:issue is:open in:title "${title}"` });
if (found.data.total_count > 0) await github.rest.issues.createComment({ owner, repo, issue_number: found.data.items[0].number, body });
else await github.rest.issues.create({ owner, repo, title, labels: ['workflow-failure'], body });