Add one-time Task scheduler for agent-driven follow-ups#185
Merged
Conversation
added 5 commits
April 18, 2026 13:31
Tasks are one-shot scheduled prompts: the scheduler fires each one
exactly once at an absolute time, then posts the agent result back
to the channel (or thread, if anchored). This lets agents schedule
follow-ups and return instead of blocking on long waits (deploys,
nightly builds, approvals).
- Storage: new 'tasks' table in shared SQLite (~/.config/ode/inbox.db)
with pending → running → success|failed|cancelled state machine.
Cross-process idempotency via atomic UPDATE WHERE status='pending'.
- Scheduler: polls every 10s, reuses an existing thread's session when
--thread is set (preserving context) or posts as a new channel
message under a synthetic task:{id} thread otherwise.
- HTTP API at /api/tasks* mirrors the cron-jobs route surface.
- CLI: ode task create/list/show/cancel/delete/run, talking to the
local daemon API rather than the DB directly.
- Agent awareness: system prompt gains a ONE-TIME SCHEDULED TASKS
section so all providers learn when and how to schedule follow-ups.
- Web UI: /tasks settings page + sidebar entry next to Cron Jobs.
- tasks.ts: normalizeAgent now lower-cases and rejects values that
are not in AGENT_PROVIDERS, mirroring how channel-level agent
providers are resolved. The scheduler is still channel-default;
this makes the column a usable override surface rather than free
text, and fails fast on typos ("claude-code-beta", "gpt4",
etc.) from either the CLI or HTTP API.
- Web UI Tasks form: swap the free-text agent input for a Select
populated from the enabled agent providers (same source the
workspace channel picker uses). Includes a sticky extra option
for an already-persisted-but-now-disabled agent so edits don't
silently lose data. Task list badges now show the human label.
- CLI help: clarify --agent accepts a provider id (opencode,
codex, claudecode, ...), not an arbitrary agent name.
- Tests: +4 cases covering unsupported agent rejection on create
and update, case-insensitive normalization, and blank strings
collapsing to channel default. 15 pass total in tasks.test.ts
(was 11); full suite 247 pass / 0 fail / 1 skip.
The task scheduler previously ignored `task.agent` and always ran on the channel's configured provider, so the `agent` column was recorded but had no effect. This wires the field through the adapter so per-task overrides actually take effect. - createAgentAdapter accepts an optional `providerOverride`. When set, `getOrCreateSession` picks that provider instead of the channel's default. All other adapter calls remain keyed by sessionId (session -> provider map), so downstream behaviour is unchanged. - scheduler.resolveTaskAgentProvider implements the documented fallback chain: task.agent -> channel agent -> getChannelAgentProvider default (opencode). Unknown/legacy values on `task.agent` fall through to the channel default instead of crashing the tick. Creation/update already rejects bad values at the source, so this is purely defense-in-depth. - runTask + prepareTaskSession both construct the adapter with the resolved override so session creation and message dispatch agree. Tests: - New scheduler.test.ts: 4 cases covering the full fallback chain (override wins, channel fallback, legacy-agent fallback, global opencode fallback). - Full suite: 251 pass / 0 fail / 1 skip (was 247).
When a one-time task is anchored to an existing thread, resume on
that thread's provider instead of honouring task.agent. Sessions
are provider-scoped in storage — getThreadSessionId keys on
(channelId, threadId, providerId) — so switching providers mid-
thread would silently spin up a fresh session under the override
and drop the conversation history the task was trying to continue.
resolveTaskAgentProvider now follows this order:
1. anchored thread's persisted session provider (when present),
2. task.agent (per-task override),
3. channel agent (channelDetails.agentProvider),
4. global default from getChannelAgentProvider ("opencode").
If the task is anchored but no session was ever persisted (brand
new thread), step 1 is skipped and the resolver falls through to
the normal override/channel/default chain.
Tests: +2 cases covering anchored-thread wins and anchored-but-no-
session-yet falls through. 6 pass in scheduler.test.ts (was 4);
full suite 253 pass / 0 fail / 1 skip (was 251).
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.
Summary
Adds a one-shot scheduled Task primitive so agents can schedule a follow-up and return instead of blocking on long waits (deploys, overnight builds, approvals). Tasks fire exactly once at an absolute time, then post the agent's result back to the channel (or thread, if anchored).
Design
taskstable in~/.config/ode/inbox.db):id, title, scheduled_at, platform, workspace_id/name, channel_id/name, thread_id, message_text, agent, status, last_error, triggered_at, completed_at, created_at, updated_at.pending → running → success | failed | cancelled. Users can cancel pending tasks; running/terminal tasks reject cancel.markTaskTriggeredissues an atomicUPDATE ... SET status='running' WHERE id=? AND status='pending'and only the winner proceeds, mirroring the minute-cursor pattern used by cron jobs.packages/core/tasks/scheduler.ts) polls every 10s. WhenthreadIdis set, it reuses the thread's existing session so the agent wakes up with full context; otherwise it uses a synthetictask:{id}thread and posts as a new channel message.sendMessage(channelId, threadId, text). Discord/Lark fall back tosendChannelMessage(no thread-aware helper yet).Interfaces
ode task create --time <ISO8601> --channel <id> --message "..." [--thread <id>] [--title] [--agent] [--run-now]pluslist,show,cancel,delete,run. Commands talk to the local daemon HTTP API (same pattern as cron jobs) rather than opening the DB directly./api/tasks*: GET list / GET by id / POST create / PUT update / DELETE / POST cancel / POST run.buildSystemPromptgains aONE-TIME SCHEDULED TASKSsection, so all providers (opencode, claudecode, codex, kimi, kiro, kilo, qwen, goose, gemini) learn when and how to schedule follow-ups.Tests
packages/config/local/tasks.test.tscovers atomic claim, status transitions, cancel semantics, update-only-pending, list ordering, seconds→ms normalization, and unknown-channel rejection (11 tests, all pass).bun run typecheckclean outside pre-existing web-ui/svelte-vendor errors unrelated to this change.Documentation
AGENTS.mdgains a## One-time Tasks (ode task)section describing CLI, persistence, and when agents should prefer tasks over blocking.