chadtmiller.com/knotless
git that lives in your Atmosphere account
Workflows run on a schedule, at the default branch's tip
MergedThis adds a schedule trigger. The runner learns each workflow's cron expressions from a push to the default branch and keeps them in its state. Each minute it asks for the workflows due, as a run asked for by name, with KNOTLESS_SCHEDULE set, and rings the repository. The cron parser, its tests, the docs and the change file are all there. I found nothing that breaks it. KNOTLESS_SCHEDULE passes RerunVar::valid_name, so the steps do see it. The runs_when_asked exception applies only when the subject is the default branch.
Two small things I'd fix:
docs/reference/runner-internals.mdhas the state-file field table, and AGENTS.md lists it among the knotless-run docs to update. The newschedulesandfiredfields are indocs/design/runner.mdbut not there.- A run asked for by name isn't held to
--run-timeout, and its steptimeoutmay go past--step-timeout(docs/design/runner.mdanddocs/reference/workflows.mdsay so). A scheduled run inherits that and recurs on its own, so a hung scheduled step ties up a job on the runner until the next run cancels it. Say so in the Schedules docs, or hold scheduled runs to the timeouts.
Two minor points, not blocking. fired entries for removed schedules are never pruned. on_schedule rewrites the state file every minute even when nothing was due.
This round resolves what I asked for earlier. I read only parts of the diff and the files below, not all of it, and I didn't see the check results.
docs/reference/runner-internals.mdnow listsschedulesandfiredin the state table.- Scheduled runs are now held to
--step-timeoutand--run-timeout(code aroundlib.rs:2890and2949). learnprunesfiredentries for workflows whose schedule is gone (lib.rs:1803).on_schedulewrites the state file only when something was rung (lib.rs:1846).
I found nothing that breaks it. The skip-while-running check builds its key as id\nref\nname and compares it to the keys in going. I did not confirm that going uses the same key format, though the new test a_schedule_is_read_at_start_and_waits_for_its_last_run exercises that path.
Since my last round the only change is a small refactor. learn now gets the last-read commit through a new known_schedule helper instead of locking the state inline, and the behavior is the same. My earlier requests were already done and I found nothing new. The checks for this commit are all still queued, so I didn't see any results. check and test should pass before this merges, since the author didn't run the Rust tests locally.
The only change since my last round is in the test a_schedule_runs_its_workflow. It now reads the run's log by fetching the check's log blob from the runner's PDS with the existing blob helper. Before, it ran knotless checks --log through the CLI. The blob helper exists in tests/run.rs, and the assertion on ==> on its schedule KNOTLESS_SCHEDULE=… is unchanged. I found nothing wrong, and my earlier requests were already done.
The test check was still running and check showed as cancelled, so I didn't see a result for this commit. Both should pass before this merges.
I found nothing new this round, and what I asked for earlier was done. This pull adds the schedule trigger: the runner keeps each workflow's cron expressions, asks for the ones due each minute as runs asked for by name, and holds them to the timeouts. The checks for this commit are all still queued, so I saw no results. check and test should pass before this merges, because the author didn't run the Rust tests locally.
9 passed, 1 skipped
What changes
A
scheduletrigger.on: {schedule: [{cron: '30 3 * * *'}]}runs a workflow on its own at the tip of the default branch, at the minutes its cron expressions name, in UTC. An expression is five fields (*, numbers, ranges, steps, lists) or@hourly,@daily,@weekly,@monthlyor@yearly. A schedule runs at most every five minutes. One that would run more often fails as the workflow loads, so its check says why on the next push.How the runner keeps them. Nothing rings for a schedule, so the runner reads them itself. Each pass over a repository, it reads the workflows at the default branch's tip if that tip isn't the one it last read, and stores each workflow's cron expressions in its state. So a runner learns schedules as soon as it starts watching a repository, with no push needed. As each minute begins it asks for the workflows due, as a run asked for by name is asked for, and rings the repository. The pass that follows runs them through the existing asked-for path:
KNOTLESS_SCHEDULE, the expression that ran it;==> on its schedule KNOTLESS_SCHEDULE=…;Long runs. When a workflow's time comes while its last run is still going, that time is skipped, so the run finishes rather than being cancelled. Unlike a run the owner asks for, a scheduled run is held to
--step-timeoutand--run-timeout, so a hung one frees its job and the schedule runs again.Listed.
knotless workflows, the MCPworkflowstool anddev.knotless.listWorkflowsshow each workflow's schedules at the default branch (a new optionalschedule: [{cron}]), and count a scheduled workflow there as one the owner may ask for.Each workflow is asked for at most once a minute, restarts included. A minute the runner was down for is not made up.
Why
This is the third easy item on Basic forge features missing, from archive downloads to webhooks: nightly builds, dependency checks and the like had no trigger.
Running scheduled workflows as runs asked for by name reuses everything that path already does: turns, secrets, minutes, the
runnersfilter, a stop asking for them again on start. The schedule loop only adds an entry towantedand rings the repository.I wrote the cron parser by hand rather than adding a crate. It's small, takes numbers only, and enforces the five-minute minimum.
How it was checked
Unit tests in
cron.rscover:@names;workflow.rstests: a schedule parses, a push doesn't run a schedule-only workflow, and a bad cron names its file.a_schedule_runs_its_workflowpushes a schedule due at the next real minute, an@yearlyone and a push one, then checks:knotless workflowslists both schedules.The real-minute wait adds up to 90 seconds.
a_schedule_is_read_at_start_and_waits_for_its_last_runpushes an@dailyworkflow before the runner starts. It checks that the runner reads it with no push. It then callson_schedulefor two midnights while the first run's step waits on a file, and checks that the second time was passed over and the first run passed.just docs. I didn't build or run the Rust tests locally; that's left tocheckandtest.🤖 Generated with Claude Code