Background Jobs
Long-running agent tasks that don’t block your editor. Submit a job, walk away, come back to a finished result — or watch the live event stream and intervene mid-flight if you want to. Jobs persist across daemon restarts, replay their event history on resume, and surface a heuristic recap the moment they finish.
The Background Jobs panel is VibeCody’s queued-work surface. It’s wired around three principles:
- Durable. Jobs land in
~/.vibecli/jobs.db(encrypted, machine-bound) before they start running. A daemon crash never loses a job. - Replayable. Every agent step (tool call, message, completion) is appended as a
seq-ordered event. Reconnect later and the daemon replays from your last seen sequence. - Recap-aware. Every terminal transition (Complete / Failed / Cancelled) auto-writes a heuristic recap so the Resume flow has something to seed from.
Submitting a job
From the panel
VibeUI → Background Jobs tab → enter a task description → choose provider/model → Submit. The job lands as Queued, transitions to Running when the worker picks it up, and finishes Complete / Failed / Cancelled.
From the CLI
# Submit
curl -X POST http://127.0.0.1:7878/jobs \
-H 'content-type: application/json' \
-H "authorization: bearer $VIBECLI_TOKEN" \
-d '{
"task": "Refactor the SSRF guard in vibe-net",
"provider": "claude",
"model": "claude-sonnet-4-6"
}'
Response: { "session_id": "<sid>" }. That sid is the job’s stable handle.
From scripts (Agent SDK)
import { submitJob } from "@vibecody/agent-sdk";
const { sessionId } = await submitJob({
task: "Run cargo test --workspace and fix any failures",
provider: "claude",
model: "claude-sonnet-4-6",
});
Job lifecycle
┌─────────┐ pickup ┌─────────┐
│ Queued │──────────────▶│ Running │
└─────────┘ └────┬────┘
│
┌──────────────┬───────────────┴────────────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌──────────┐
│Complete│ │ Failed │ │Cancelled │
└────┬───┘ └────┬───┘ └─────┬────┘
│ │ │
└─────────────┴── auto-recap on terminal ───────┘
(J1.2 — heuristic generator)
States are one-way; a Complete job never re-runs (resume creates a fresh job whose parent_job_id links back). Cancellation is graceful: the daemon sends a stop signal, lets the agent commit any pending event, then marks Cancelled.
Streaming events
Jobs emit a seq-ordered event stream you can subscribe to live or replay:
# Subscribe (SSE)
curl -N -H "authorization: bearer $VIBECLI_TOKEN" \
"http://127.0.0.1:7878/jobs/<sid>/events"
# Replay from a checkpoint (after a reconnect)
curl -H "authorization: bearer $VIBECLI_TOKEN" \
"http://127.0.0.1:7878/jobs/<sid>/events?since=42"
Event payloads are typed ({ "t": "step", ... }, { "t": "tool_call", ... }, { "t": "complete", ... }). The panel uses these to render the live timeline; the recap generator uses the full event log to build the headline + bullets.
Cancelling
curl -X POST -H "authorization: bearer $VIBECLI_TOKEN" \
"http://127.0.0.1:7878/jobs/<sid>/cancel" \
-d '{"reason":"user-requested"}'
The daemon transitions to Stopping, waits for the agent to commit, then Cancelled. The cancellation reason is stored on the job and shown in the recap.
Resume
A finished job’s recap shows a Resume from here button. Clicking it:
- Calls
POST /v1/resumewithkind: "job"and the recap id. - Spawns a fresh job whose
parent_job_idandresumed_from_recap_idlink back to the source. - Inherits the parent’s workspace path + approval policy.
You can also resume by subject_id directly:
curl -X POST http://127.0.0.1:7878/v1/resume \
-H 'content-type: application/json' \
-d '{"kind":"job","from_subject_id":"<parent-sid>"}'
See Recap & Resume for the full dispatch logic and the Recap shape.
Per-bucket quotas
Jobs can be created with a quota_bucket so the daemon enforces fair-share limits:
{
"task": "...",
"provider": "claude",
"model": "claude-sonnet-4-6",
"quota_bucket": "team-frontend"
}
The daemon checks the bucket’s Tasks resource before persisting. When the cap is hit you get:
{ "error": "quota_denied", "resource": "Tasks", "used": 50, "hard_limit": 50 }
Configure quotas via JobManager::set_agent_quotas or the [jobs.quotas] block in ~/.vibecli/config.toml.
Webhooks
Set webhook_url on submit and the daemon POSTs the job’s terminal status to that URL:
{
"session_id": "<sid>",
"status": "Complete",
"summary": "Refactored 3 files, all tests pass",
"started_at": 1714521600000,
"finished_at": 1714521660000
}
Delivery is at-least-once with exponential backoff. Permanent failures land in the dead-letter queue (visible at /v1/jobs/dead-letters).
Storage
| Item | Path |
|---|---|
| Job records | ~/.vibecli/jobs.db (encrypted, table jobs) |
| Event log | ~/.vibecli/jobs.db (table events, one row per seq) |
| Job recaps | ~/.vibecli/jobs.db (table recaps) |
| Dead-letter webhooks | ~/.vibecli/jobs.db (table webhook_deliveries, status=dead) |
Single-file SQLite, encrypted with the same machine-bound key as ProfileStore. Backup the file directly — it round-trips cleanly.
Health and metrics
/health.features.background_jobs declares the feature; live counts come from /v1/metrics/jobs:
curl http://127.0.0.1:7878/health | jq '.features.background_jobs'
# { "available": true, "transport": "daemon-http", "routes_prefix": "/jobs",
# "metrics_route": "/v1/metrics/jobs", "store_path": "~/.vibecli/jobs.db" }
curl http://127.0.0.1:7878/v1/metrics/jobs | jq
# {
# "jobs_created": 42,
# "jobs_completed": 38,
# "jobs_failed": 3,
# "jobs_cancelled": 1,
# "queued": 2,
# "running": 1,
# "events_published": 1284,
# "webhooks_delivered": 38,
# "webhooks_dead_lettered": 0
# }
Dashboards should pull from /v1/metrics/jobs (cheap — atomic counters); /health only declares the feature exists.
Configuration
# ~/.vibecli/config.toml
[jobs]
# Max concurrent running jobs. 0 = unlimited.
max_concurrent = 4
# How long a Running job can sit without emitting events before the
# daemon force-marks it Failed. 0 = no timeout.
heartbeat_timeout = "10m"
# Webhook delivery — exponential-backoff retries.
webhook_retry_max = 8
webhook_retry_base = "5s"
[jobs.quotas]
# Per-bucket Tasks-resource caps. Set on JobManager startup.
"team-frontend" = { tasks_per_hour = 30, tasks_total = 200 }
"team-backend" = { tasks_per_hour = 60, tasks_total = 500 }
Troubleshooting
“Daemon not running. Start it with: vibecli –serve –port 7878”
The panel’s offline indicator is correct — the daemon is unreachable. Start it:
vibecli --serve --port 7878
The panel auto-reconnects when /health becomes reachable. No refresh needed.
Jobs stuck in Queued
Either:
- Concurrency cap hit. Check
/v1/metrics/jobs.runningagainst[jobs] max_concurrent. - The pickup loop is wedged. Check the daemon log for
vibecody::jobswarnings.
Event stream goes silent mid-job
The agent may have hung. Check [jobs] heartbeat_timeout — when set, the daemon will force-mark the job Failed after that interval. If it’s 0, the job sits forever; restart the daemon to recover.
Webhooks never arrive
- Check
/v1/metrics/jobs.webhooks_dead_lettered— non-zero means delivery is failing permanently. - Inspect dead letters at
/v1/jobs/dead-letters. Common causes: 4xx from the receiver, TLS handshake failure, DNS. - Re-run from the dead-letter queue:
POST /v1/jobs/<sid>/webhook/redeliver.
“Quota denied”
The bucket is at its hard limit. Either:
- Wait for the per-hour rolling window to drain.
- Reset via
POST /v1/jobs/quota/resetwith the bucket name (admin-only).
Cancellation hangs in Stopping
The agent isn’t responding to the stop signal. After 30 seconds the daemon force-cancels.
Observability
Every job lifecycle transition emits structured tracing events under the vibecody::jobs target:
RUST_LOG=vibecody::jobs=info vibecli serve
Examples:
INFO vibecody::jobs: job.create: queued
sid=ses_a1b2c3 provider=claude priority=0 tag_count=2
INFO vibecody::jobs: job.mark_running sid=ses_a1b2c3
INFO vibecody::jobs: job.mark_terminal
sid=ses_a1b2c3 status=complete has_summary=true has_reason=false
Job content (the task text, agent messages, tool outputs) is never logged at any level — only stable IDs, statuses, and counts.
Cross-client consistency
| Client | Background-jobs surface |
|---|---|
| VibeUI (desktop) | Full panel: submit, list, stream, cancel, recap on completion |
| VibeCLI | vibecli jobs submit / list / cancel (REPL + script) |
| VibeMobile | Full read + cancel (M2 — read jobs, watch live events, tap to cancel; M3 will add submit) |
| VibeWatch | Read-only glances of running + recently-finished jobs |
| IDE plugins | Submit + list via /jobs HTTP routes; per-IDE UX varies |
| Agent SDK | First-class submitJob, streamEvents, cancelJob helpers |
The daemon is the source of truth. Every client reads from /jobs or writes to POST /jobs; nobody bypasses the JobManager.
Related
- Design doc:
docs/design/recap-resume/02-job.md— Job recap shape + resume semantics (J1.x slices). - Recap & Resume:
/recap/— How job recaps fit into the broader recap system. - Source:
vibecli/vibecli-cli/src/job_manager.rs(backend) ·vibeui/src/components/BackgroundJobsPanel.tsx(UI).