Claudinite scheduler #53
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 }); |