Skip to content

Add one-time Task scheduler for agent-driven follow-ups#185

Merged
LIU9293 merged 5 commits intomainfrom
feat/one-time-tasks-1776488908
Apr 18, 2026
Merged

Add one-time Task scheduler for agent-driven follow-ups#185
LIU9293 merged 5 commits intomainfrom
feat/one-time-tasks-1776488908

Conversation

@LIU9293
Copy link
Copy Markdown
Contributor

@LIU9293 LIU9293 commented Apr 18, 2026

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

  • Schema (new tasks table 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.
  • State machine: pending → running → success | failed | cancelled. Users can cancel pending tasks; running/terminal tasks reject cancel.
  • Cross-process idempotency: markTaskTriggered issues an atomic UPDATE ... SET status='running' WHERE id=? AND status='pending' and only the winner proceeds, mirroring the minute-cursor pattern used by cron jobs.
  • Scheduler (packages/core/tasks/scheduler.ts) polls every 10s. When threadId is set, it reuses the thread's existing session so the agent wakes up with full context; otherwise it uses a synthetic task:{id} thread and posts as a new channel message.
  • Slack replies inside the anchored thread via sendMessage(channelId, threadId, text). Discord/Lark fall back to sendChannelMessage (no thread-aware helper yet).

Interfaces

  • CLI: ode task create --time <ISO8601> --channel <id> --message "..." [--thread <id>] [--title] [--agent] [--run-now] plus list, show, cancel, delete, run. Commands talk to the local daemon HTTP API (same pattern as cron jobs) rather than opening the DB directly.
  • HTTP under /api/tasks*: GET list / GET by id / POST create / PUT update / DELETE / POST cancel / POST run.
  • Web UI at Settings → Tasks: datetime-local picker, channel/thread/agent fields, Run Now / Cancel / Delete, paired with a new sidebar entry next to Cron Jobs.
  • Agent awareness: buildSystemPrompt gains a ONE-TIME SCHEDULED TASKS section, so all providers (opencode, claudecode, codex, kimi, kiro, kilo, qwen, goose, gemini) learn when and how to schedule follow-ups.

Tests

  • New packages/config/local/tasks.test.ts covers atomic claim, status transitions, cancel semantics, update-only-pending, list ordering, seconds→ms normalization, and unknown-channel rejection (11 tests, all pass).
  • Full suite: 243 pass / 0 fail / 1 skip (baseline was 232 before this PR).
  • bun run typecheck clean outside pre-existing web-ui/svelte-vendor errors unrelated to this change.

Documentation

  • AGENTS.md gains a ## One-time Tasks (ode task) section describing CLI, persistence, and when agents should prefer tasks over blocking.

Kai Liu 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).
@LIU9293 LIU9293 merged commit 80ad45a into main Apr 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant