feat(scheduling): declare recurring work with #[Scheduled] - #16
Merged
Merged
Conversation
Work that is not a reply to anything — sweeping rows that have run their course, expiring caches, polling a service with no gateway event — had no home in the framework, so every bot reached for addPeriodicTimer inside a plugin's boot and wrote the same three safeguards, or more often did not. A timer is less forgiving than an event listener: it fires again whether or not the last turn finished or threw, forever. So a task that throws is logged and keeps its place rather than cancelling its own timer, a task still busy from its last turn is skipped rather than started alongside itself, and each turn runs in a fiber so it may await the REST API. The first turn comes after the interval rather than at boot: a task is a repeating chore, and one-off startup work belongs in a plugin where its ordering against everything else is visible.
github-actions Bot
pushed a commit
that referenced
this pull request
Sep 2, 2026
## [0.10.0](v0.9.0...v0.10.0) (2026-09-02) ### Features * **scheduling:** declare recurring work with #[Scheduled] ([#16](#16)) ([7e5e5c4](7e5e5c4))
|
🎉 This PR is included in version 0.10.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
mikield
added a commit
that referenced
this pull request
Sep 2, 2026
Reverts #16, released as `v0.10.0`. I added `#[Scheduled]` to the core without checking what was already planned. [Tempcord/tasks](https://github.com/Tempcord/tasks) already declares `#[Task]` with interval **and** cron scheduling, a console command and per-task statistics. A second attribute in the core is a competing way to say the same thing, and would leave the plugin unable to be installed alongside the framework it extends. Nothing depends on the attribute one release later, so this is the cheapest moment to take it back out. What was worth having moves into the plugin along with its tests: each turn in a fiber so a task may `await` the REST API, a skip when the previous turn has not finished, and containment that logs a throwing task and leaves it in the schedule. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Work that is not a reply to anything — sweeping rows that have run their course, expiring caches, polling a service with no gateway event of its own — had no home in the framework. Every bot reaches for
Loop::get()->addPeriodicTimer()inside a plugin'sboot()and then writes the same three safeguards, or more often does not write them.Discovered like a command or a listener, built by the container, put on the loop before the gateway opens.
Why this is not just sugar over
addPeriodicTimerA timer is less forgiving than an event listener — it fires again whether or not the last turn finished or threw, forever:
awaitthe REST API exactly as a command handler does. This mirrorsEventDispatcher.Deliberate choices worth arguing with
boot()where its ordering against everything else is visible. I left out animmediately:flag rather than guess.start()takes the loop as an argument rather than reaching forLoop::get()itself, so the tests drive aStreamSelectLoopthey own and the overlap and containment behaviour is exercised for real rather than mocked.Adds a reference page and a guide;
composer docsoutput is committed.🤖 Generated with Claude Code