Repository navigation
emrg: a scheduled task can be woken early by a when condition #2592
Description
What is observed
A scheduled task's cadence is fixed by its own record. emrg/server/scheduler.py documents the rule at the top of the module — name, type, enabled, interval are the common base fields, and schedule optionally overrides the cadence inside time-of-day windows. effective_interval is the single producer of "how often, right now", and it answers from the record alone.
So a task runs on its own clock whether or not there is anything to do, and two kinds of waste follow from the same cause:
- A task that mostly has nothing to do still runs. A task polling an inbox every 15 minutes starts a full agent round each time to discover that nothing arrived. The events that actually matter arrive as pulses, and a fixed interval is the wrong instrument for a pulse.
- A task that has something to do waits for its next slot. A task on a four-hour interval can be hours late for an item that appeared one minute after its last run.
Neither end is reachable from inside a task's own prompt: "should I run now" is knowledge the scheduler holds, not the agent.
What is wanted
A new optional field, when, on a task record in tasks.yml:
- name: some-inbox-task
interval: 14400
when:
feishu: 300
- name: some-open-source-task
interval: 14400
when:
rant: 600when answers exactly one question: under what condition may this task be woken earlier than its own cadence? It is a mapping from a condition name to a positive number of seconds meaning "while this condition holds, run it again at most this often".
It does not interfere with interval or schedule. interval governs how often the task runs on its own initiative, schedule overrides that inside time windows, and when only ever wakes a task earlier. It never suppresses a run the task would have taken anyway. A task with no when behaves exactly as it does today.
Conditions are built in — a user writes no command, script or function name
| Condition | What holding means |
|---|---|
rant |
The task's agent has a rant that is not yet completed |
feishu |
Something on Feishu needs the host: a real @-mention of the host, an @-all, or a direct message to the host — any of the three counts |
Defaults
| # | Item |
|---|---|
| 1 | tasks.yml accepts when: a mapping whose keys are condition names and whose values are positive integer seconds. An unknown condition name, a non-positive value, or a value below the task's own minimum are all refused with a readable error — never silently ignored |
| 2 | The built-in condition table has two entries, rant and feishu. Each is a read-only check answering holds / does not hold / cannot tell; cannot tell is treated as does not hold and logged, and never blocks another task |
| 3 | The rant condition reads this machine's rant records and asks whether the task's own agent has an entry whose status is not completed. The rant writer changes not at all — no "on rant write, trigger" path is added |
| 4 | The feishu condition queries through lark-cli as the user, and all three cases count as a hit. It maintains its own watermark of message ids and times under ~/.emrg/wake-state/<task name>.json, because Feishu message times are minute-granular and cannot serve as a cursor — de-duplication must be by message id |
| 5 | Waking uses the existing manual-trigger channel. The task itself is unchanged and does not know it was woken |
| 6 | A wake that arrives while the task is already running does not interrupt it and does not mark the condition read; the condition is evaluated again next time, so a message is never swallowed |
| 7 | The watch is polling — each condition is checked once per its own configured seconds. There is no second, event-push path |
| 8 | The field is documented wherever a task's fields are: both READMEs and the field reference in Agent.md |
| 9 | The GUI's task editor preserves when verbatim when saving. Editing another field must not drop it. This issue requires only that it survives round-tripping, not that the GUI edits it |
| 10 | schedule and when compose: schedule decides the natural rhythm, when only advances it. Neither overrides the other |
Acceptance
| # | Done means |
|---|---|
| 1 | A new test: a task with no when behaves byte-for-byte as the current implementation |
| 2 | A new test: a holding condition wakes the task early, a non-holding one leaves it asleep on its original interval, and two conditions each count on their own seconds without affecting each other |
| 3 | A new test: all four bad inputs (unknown condition, 0, negative, below the minimum) are refused with a readable error |
| 4 | A new test: a condition that holds again while the task is running neither interrupts nor marks it read, and it still holds next round |
| 5 | A new test: the feishu condition de-duplicates by message id (one wake per id) and does not wake on cannot tell |
| 6 | A new test: after the GUI edits another field, when is still on the record |
| 7 | Mutation verification: deleting the trigger, making the condition always true, deleting the minimum check, and deleting the de-duplication each turn the corresponding test red |
| 8 | The repository's full suite passes and GitHub CI is green on all three platforms |
What this must not do
- It must not add a second scheduling path: no event push, no watcher process outside the scheduler's own poll loop.
- It must not change how a rant is written, or add a trigger to the rant writer.
- It must not let a task be woken so often that it burns more than it saves — the configured seconds are a floor on the gap, and that floor is enforced.
Activity
argszero commentedon Oct 26, 2026
Handled by #2593