Skip to content

Tasks

The Tasks view in hive-app lets you schedule shell commands to run inside a Hive session. A task is a (name, command, working directory, schedule) tuple persisted by the daemon. When the schedule fires, the daemon spawns a PTY session, runs the command through the platform shell, and records the exit code on the task.

Tasks are executor-agnostic - claude -p "...", ./backup.sh, and pnpm test are all just shell command lines. For interactive Claude back-and-forth use a Session; Tasks are for unattended runs that exit 0 (success) or non-zero (failure).

The Tasks view

Route: /tasks, sidebar icon: clock.

Table columns

ColumnNotes
Name / CommandDisplay name on top, command line on a secondary line (monospaced, truncated)
ScheduleOne-shot, Cron: <expr>, or Every Nm/Nh/Ns
Statuspending, running, completed, failed, cancelled
Last RunRelative time since the last invocation
Next RunRelative time until the next scheduled run (only meaningful for cron/interval)
NodeNode that owns and executes the task (filterable from the header)
ProjectOptional project association (filterable from the header)
ActionsView session • History • Edit • Run now / Cancel • Delete

Actions

  • Run now (▶) - trigger the task immediately, regardless of schedule. The current next_run_at is preserved.
  • Cancel (✕, only while running) - kills the underlying session; task status becomes cancelled.
  • View session (↗, when a session exists) - jumps to the session detail view so you can scrub through the captured output.
  • History (clock-with-arrow) - expands an inline panel of the task's past runs (status, duration, attempt, exit code, and a per-run output snapshot).
  • Edit - modify name, command, working directory, project, schedule, env vars, timeout, or retries. Selecting a project auto-fills the working directory from the project (you can still override it), the same way the new-session dialog does.
  • Move - edit a non-running task and choose a different node. Hive previews the destination project and path, then moves the task and its retained run history after explicit confirmation.
  • Delete - removes the task. A running session is not affected; it keeps running until it exits on its own.

Keyboard

  • n - open the New Task dialog (when the daemon is connected).

Defining a task

A task has four required pieces:

  1. Command - a single shell command line. Pipes, redirects, env-var expansion, && / || chaining all work because the daemon runs it through sh -c on Unix or cmd /C on Windows.
  2. Working directory - absolute path on the daemon's host. The literal . resolves to the daemon's configured default working dir.
  3. Schedule - one of:
    • One-shot - runs immediately, exactly once.
    • Cron - a 6-field cron expression evaluated in UTC: second minute hour day-of-month month day-of-week (seconds are mandatory - a bare 5-field expression is rejected). An optional 7th year field is allowed. * and ? mean "any"; steps (*/15), ranges (9-17), and lists (1,15) work; day-of-week is 1=Sun through 7=Sat or names (Mon-Fri); months accept 1-12 or names. Day-of-month and day-of-week are combined with AND. The shorthands @hourly, @daily, @weekly, @monthly, and @yearly are also accepted. Examples: 0 0 * * * * (every hour), 0 */15 * * * * (every 15 minutes), 0 0 9 * * Mon-Fri (09:00 UTC on weekdays). The create/edit dialog shows a live preview of the next run times so you can confirm the expression before saving.
    • Interval - fires every N seconds, starting N seconds from creation.
  4. Name (optional) - used in the list view and notifications. Falls back to the task's UUID prefix if omitted.

It can also carry optional environment variables, a timeout, and retry settings - see Timeout, retries, and env vars. The create/edit dialog also exposes Notify on success and Notify on failure toggles (notify_on_success / notify_on_failure, both default off): they decide whether a completed run raises a user-facing notification. This is also what the Android Background Notifications poller reports when the app was closed at the time the task finished. A task can also be associated with a project. Beyond filtering and grouping, the project determines the task's execution context the same way it does for an interactive session: when a project is selected the working directory is pre-filled from the project, and at run time the run-as OS user is resolved from the project's run_as_users mapping for this node (falling back to the node's default_session_user). A blank working directory also falls back to the project's directory.

Choose the execution node

Tasks stay node-local, but the app can manage tasks on every online cluster node through the current connection. The Node field in the create dialog chooses which daemon owns and runs a new task. Selecting a project pins the task to that project's node and the node selector reflects that choice. An offline target is shown but cannot be selected for creation.

The table includes a Node column and node filter. Row actions are routed to the task's owning node, so viewing, running, cancelling, editing, and deleting a peer-owned task do not require reconnecting the app.

Move a task between nodes

Edit the task and change Node to choose an online destination. The app then requires a destination project owned by that node and an absolute working directory that exists there, followed by an explicit move confirmation. The CLI also supports project-free moves when you provide the destination working directory directly. Moving preserves the task UUID, schedule, configuration, timestamps, and full retained run history. A running task cannot move; cancel it or wait for the run to finish first.

The move is durable and staged. Hive first pauses the source schedule, stages an inactive copy on the destination, removes the source, and then activates the destination. A failure before source removal automatically aborts the staged copy and restores the source. A failure after source removal is recoverable: retrying the same move operation ID resumes its recorded phase instead of creating a duplicate. The app remembers recoverable operations on the device, and the CLI prints the operation ID and an exact retry command.

Scheduling engine

Tasks are scheduled entirely inside hived - there is no integration with the OS scheduler (cron/crond, systemd timers, or Windows Task Scheduler). The daemon owns a single in-process scheduler (crates/clusto-daemon/src/scheduler.rs):

  • One Tokio loop ticks every 100 seconds and runs any task whose next_run_at is <= now.
  • One-shot tasks run immediately on creation rather than waiting for the next tick, so a one-shot you just created starts right away.
  • Cron expressions are parsed and advanced by the Rust cron crate, always in UTC. Interval schedules just add N seconds (the first run lands one interval after creation).
  • Because scheduling lives in the daemon process, tasks are local to each node and not replicated across cluster peers. A task runs on its current owning daemon, which is the creation target until an explicit move completes.

This means hived must be running for a task to fire. A task whose scheduled time passes while the daemon is down does not backfill - it runs at the first tick after the daemon is back up (one catch-up run, not one per missed slot).

Execution model

When a task fires:

  1. The scheduler picks the (program, argv) pair for the executor. For shell that's ("/bin/sh", ["-c", command_line]) on Unix and ("cmd", ["/C", command_line]) on Windows.
  2. It creates a new PTY session in the task's working dir and links the session ID back to the task (task.session_id).
  3. The session runs to completion. Its output is captured in the normal session machinery, so the View session link works while it's running and after it finishes.
  4. When the session exits, the scheduler:
    • records a TaskRun with the outcome and a captured output snapshot
    • sets task.last_exit_code to the captured code
    • sets status to completed (exit 0 or signal), failed (non-zero), cancelled (if you hit the cancel button), or treats it as timed_out when the wall-clock timeout fired
    • if the run failed and retries remain, re-fires it after the retry delay (see Timeout, retries, and env vars)
    • for cron/interval schedules, resets the task to pending for the next run (next_run_at was already advanced at spawn)

While a run is in flight the task shows running regardless of schedule type, and reverts to pending (cron/interval) or a terminal status (one-shot) when the run ends. This also acts as an overlap guard: the tick only fires pending tasks, so a run that outlives its interval never spawns a concurrent twin.

The scheduler tick interval is 100 seconds. One-shot tasks run immediately on creation, but a recurring (cron/interval) task whose next_run_at falls between two ticks runs at the next tick, not exactly on time. Use Run now if you need precise immediate execution.

Tasks survive daemon restarts. Any task that was running when the daemon exited comes back as pending so the next tick re-evaluates it. Runs that were still in flight when the daemon stopped are marked interrupted at startup: they never reached a real outcome, so history shows them as interrupted rather than failed.

Runs history & output

The parent Task still carries a summary of its latest run (last_run_at, last_exit_code, status, and the current session_id). On top of that, the daemon records a bounded per-run history so you can see past runs, not just the most recent state.

Each run is a TaskRun row with its own outcome and a captured output snapshot:

FieldMeaning
id / task_idRun identity and its parent task
attempt1-based attempt within a retry cycle (>1 means a retry)
statusrunning while in flight, then completed / failed / timed_out / cancelled; interrupted if the daemon stopped mid-run
started_at / finished_atRun timing (drives the displayed duration)
exit_codeProcess exit code (null if it never produced one)
outputCollapsed, capped output snapshot (tail kept; 64 KB max)

How it behaves:

  • History is retained per task, most recent first. The daemon keeps the newest 50 runs per task (TASK_RUNS_KEEP); older runs are pruned on each new insert. In the Tasks view, the history icon on a row expands an inline panel listing those runs with status, time-ago, duration, attempt, and exit code. Each run with captured output has an Output toggle, and a running run shows a Watch button that attaches to its live session - useful for headless commands like claude -p whose output only appears when they finish.
  • Output is captured at completion. When a run's session exits (or is killed by the timeout), the daemon collapses the PTY output through the same headless VT used for history replay (so spinners/status redraws don't stack) and stores the tail. This survives the underlying session being torn down - unlike View session, which only works while that session is still live.
  • Live updates. Runs start as running and finish to a terminal status; both transitions are broadcast (TaskRunUpdated) so an open history panel updates without a manual refresh. Listing a task's runs is ListTaskRunsTaskRunList.
  • Notifications still fire per terminal run. TaskCompleted fires on each completion that is not a pending retry, carrying the task name, exit code, and a failed flag. Whether that completion raises a user-facing alert is controlled per task by the Notify on success / Notify on failure flags (below). These same alerts are what the Android Background Notifications poller surfaces when the app was closed at completion time.

Wire protocol

Tasks are exchanged over the same WebSocket as everything else (see protocol.md for the framing). The task-specific messages are:

Task

jsonc
{
  "id": "uuid",
  "node_id": "owning node uuid | null",
  "project_id": "uuid | null",
  "name": "string | null",
  "executor": { "kind": "shell", "command_line": "pnpm test" },
  "working_dir": "/abs/path",
  "schedule": { "type": "one_shot" }
                | { "type": "cron", "expression": "0 0 3 * * *" }
                | { "type": "interval", "seconds": 3600 },
  "status": "pending | running | completed | failed | cancelled",
  "created_at": "RFC3339",
  "last_run_at": "RFC3339 | null",
  "next_run_at": "RFC3339 | null",
  "session_id": "uuid | null",
  "last_exit_code": "i32 | null",
  "env": { "KEY": "value" },        // extra env vars injected into the run
  "timeout_seconds": "u64 | null",  // wall-clock kill; null = no limit
  "max_retries": 0,                 // re-fires after a failed run
  "retry_delay_seconds": 0,         // wait between a failure and its retry
  "notify_on_success": false,       // alert on a successful completion
  "notify_on_failure": false        // alert on a failed / timed-out completion
}

A TaskRun (per-run history record):

jsonc
{
  "id": "uuid",
  "task_id": "uuid",
  "session_id": "uuid | null",
  "attempt": 1,
  "status": "running | completed | failed | timed_out | cancelled | interrupted",
  "started_at": "RFC3339",
  "finished_at": "RFC3339 | null",
  "exit_code": "i32 | null",
  "output": "string | null"         // collapsed, capped (64 KB tail)
}

The executor field is discriminated by kind. Today there is one variant:

  • {"kind": "shell", "command_line": "<line>"} - runs through the platform shell. Future variants (e.g. a dedicated webhook executor) can be added without breaking existing tasks because the wire format and the SQL schema both treat the executor as a single tagged JSON blob.

Client → Daemon

MessagePayload
CreateTask{ name?, executor, working_dir, schedule, project_id?, env, timeout_seconds?, max_retries, retry_delay_seconds, notify_on_success, notify_on_failure, node_id? }
ListTasks- returns the receiving node's tasks merged with reachable peers
GetTask{ task_id, node_id? }
ListTaskRuns{ task_id, node_id? } - fetch a task's run history from its owner
UpdateTask{ task_id, name?, executor?, working_dir?, schedule?, project_id??, env?, timeout_seconds?, max_retries?, retry_delay_seconds?, notify_on_success?, notify_on_failure?, node_id? } (only provided fields are changed; timeout_seconds and project_id are nested options so they can be cleared - inner null detaches the project)
DeleteTask{ task_id, node_id? }
CancelTask{ task_id, node_id? }
TriggerTask{ task_id, node_id? } (run now)
MoveTask{ operation_id, task_id, source_node_id, target_node_id, destination_project_id?, destination_working_dir } - durably migrate a non-running task and its retained history
AbortTaskMove{ operation_id, source_node_id, target_node_id } - abort a prepared move before source removal commits

Daemon → Client

MessagePayload
TaskList{ tasks: Task[] } - response to ListTasks and GetTask
TaskCreated{ task }
TaskUpdated{ task } - broadcast to every connected client on create, status change (run start, session exit, cancel), and manual edit
TaskDeleted{ task_id } - broadcast to every connected client when a task is deleted
TaskRunList{ task_id, runs: TaskRun[] } - response to ListTaskRuns, most recent first
TaskRunUpdated{ run } - fired when a run starts or finishes
TaskMoved{ operation_id, task } - migration completed and the task is active on the destination
TaskMoveFailed{ operation_id, task_id, message, recoverable } - migration stopped; retry the same operation ID when recoverable
TaskMoveAborted{ operation_id, task_id } - a prepared migration was rolled back

Persistence

Tasks live in the SQLite tasks table (extended through schema v26), with per-run history in task_runs and the durable cross-node move journal added in schema v44:

sql
CREATE TABLE tasks (
    id            TEXT PRIMARY KEY,
    project_id    TEXT REFERENCES projects(id) ON DELETE SET NULL,
    name          TEXT,
    executor      TEXT NOT NULL,    -- JSON of TaskExecutor (tagged by `kind`)
    working_dir   TEXT NOT NULL,
    schedule      TEXT NOT NULL,    -- JSON of TaskSchedule
    status        TEXT NOT NULL,
    created_at    TEXT NOT NULL,
    last_run_at   TEXT,
    next_run_at   TEXT,
    session_id    TEXT,
    last_exit_code INTEGER,
    env                 TEXT,       -- JSON object of env vars (v23)
    timeout_seconds     INTEGER,    -- v23
    max_retries         INTEGER NOT NULL DEFAULT 0,   -- v23
    retry_delay_seconds INTEGER NOT NULL DEFAULT 0,   -- v23
    notify_on_success   INTEGER NOT NULL DEFAULT 0,   -- v26
    notify_on_failure   INTEGER NOT NULL DEFAULT 0    -- v26
);

CREATE TABLE task_runs (           -- v23
    id           TEXT PRIMARY KEY,
    task_id      TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
    session_id   TEXT,
    attempt      INTEGER NOT NULL DEFAULT 1,
    status       TEXT NOT NULL,
    started_at   TEXT NOT NULL,
    finished_at  TEXT,
    exit_code    INTEGER,
    output       TEXT
);

CREATE TABLE task_moves (          -- v44
    operation_id TEXT PRIMARY KEY,
    task_id      TEXT NOT NULL,
    role         TEXT NOT NULL,     -- source or target
    phase        TEXT NOT NULL,
    bundle_json  TEXT NOT NULL,
    last_error   TEXT,
    updated_at   TEXT NOT NULL
);

Run history is pruned to the newest 50 rows per task on each insert. Deleting a task cascades to its runs. Move journal rows make a repeated operation ID idempotent across retries and daemon restarts.

Tasks are local to each daemon. They are not replicated across cluster peers - unlike sessions and projects, every daemon runs its own task list. Use the app's node selector or hive task new --node <NODE_ID> to schedule one on a specific online node. The CLI accepts --node <NODE_ID> on task list, get, update, delete, cancel, and trigger commands. Omitting the flag keeps the connected node as the default target.

Timeout, retries, and env vars

  • Timeout (timeout_seconds) - a wall-clock limit per run. A run that exceeds it is killed and recorded as timed_out (and counts as a failure for retry purposes). null/0 means no limit.
  • Retries (max_retries, retry_delay_seconds) - after a run fails (or times out), the task re-fires up to max_retries more times, waiting retry_delay_seconds between attempts. Each retry increments the run's attempt. Retry bookkeeping is in-memory and resets on success, on exhaustion, or on daemon restart. A cancelled run never retries.
  • Env vars (env) - a key/value map injected into the run's environment on top of the daemon's own (task values win on conflict). In the UI, enter one KEY=value per line.

Caveats

  • Shell injection is on you. The command line goes verbatim into sh -c / cmd /C. Don't paste untrusted input.

Hive - remote AI coding agents over WebSocket.