Skip to content

Tasks & schedules ​

The task queue is Botholomew's execution substrate. Humans and agents both write to it; workers are the readers. Each task and each schedule is a markdown file you can vim, grep, git diff, and edit by hand.


Tasks ​

A task is a unit of work with a lifecycle:

 pending ──► in_progress ──► complete
     │            │          │ failed
     │            │          │ waiting
     │            └── (reset by timeout)
     ▼
 blocked (via blocked_by)

Tasks live as tasks/<id>.md files (id is uuidv7). Status, priority, dependencies, and output are stored in YAML frontmatter; the body is the human/LLM-readable description:

markdown
---
id: 0193abcd-7c10-7d8a-...
name: Summarize PR #42
priority: medium
model: null              # named `models` entry to run on; null = `default_model`
status: pending          # pending | in_progress | complete | failed | waiting
blocked_by: []           # task ids that must reach status: complete first
context_paths: []        # files under context/ this task should reference
output: null             # filled on completion (summary string from complete_task)
waiting_reason: null
created_at: 2026-05-02T10:00:00Z
updated_at: 2026-05-02T10:00:00Z
---

# Description

The free-form body the LLM sees. Markdown all the way down.

Frontmatter is strictly validated by Zod (src/tasks/schema.ts). Files that fail validation are quarantined — workers skip them and the DAG checker ignores them. botholomew tasks doctor lists malformed files so you can fix them in place.

Pinning a task to a model ​

model names an entry in the models registry from configuration, letting expensive work run on a frontier model while routine work runs somewhere cheap:

bash
botholomew task add "audit the Q3 numbers" --model default
botholomew task add "tidy up my notes"     --model fast
botholomew task update <id> --model ""     # clear the pin

Precedence when a worker picks up the task:

worker's --model flag  >  the task's `model:`  >  default_model

The flag wins so a worker run --model local debugging session can't let tasks quietly escape to a paid model; when it displaces a task's pin the worker logs the override. An unknown name fails that one task (with the valid names listed) rather than crashing the worker — and a typo'd --model is caught before the worker starts at all.


The claim loop ​

Tasks are claimed by lockfile, not by a DB row update. claimNextTask(projectDir, workerId) in src/tasks/claim.ts:

  1. Walk tasks/ and parse the frontmatter of each .md file.
  2. Filter to status pending where every blocked_by id has status complete on disk.
  3. Order by priority, then created_at.
  4. For each candidate, try open(O_CREAT|O_EXCL|O_WRONLY) on tasks/.locks/<id>.lock. The lockfile body holds the worker id and claimed_at. The first worker wins; the rest get EEXIST and try the next candidate.
  5. On claim, atomic-write the canonical <id>.md with status: in_progress (mtime check; abort and retry if the file changed underneath).

Multiple workers can race on the same queue safely because the kernel serializes O_EXCL. Two cleanup paths release stuck claims:

  • Timeout: resetStaleTasks() (called at the top of every tick) walks the lockfiles, reads each one's claimed_at, and unlinks any whose age exceeds max_tick_duration_seconds * 3 — the matching task file is rewritten to status: pending.
  • Dead worker: reapDeadWorkers() walks tasks/.locks/ and schedules/.locks/, looks up each lockfile's worker id in workers/, and unlinks the lock if the owner is dead or missing. See architecture.md.

A single worker can also target a specific task via botholomew worker run --task-id <id> and the chat spawn_worker tool.


DAG validation ​

blocked_by defines a dependency DAG. Cycles would deadlock the claim loop, so validateBlockedBy() rejects them at insert time:

  • DFS from each blocker, looking for a path back to the task being created.
  • If any path exists, createTask() throws.

This is cheap because the graph is almost always shallow — the common pattern is "produce N subtasks from a schedule" which is a flat one-level fan-out.


Predecessor outputs ​

When the agent works a task that was blocked by others, it doesn't start from zero. runAgentLoop() (src/worker/llm.ts) fetches each blocker's output (the summary passed to complete_task) and injects it into the user message:

Task:
Name: Produce weekly summary
Description: ...
Priority: medium

Predecessor Task Outputs:
### Read email (01JE...)
- 3 urgent threads from customers about Q4 rollover...

### Check calendar (01JE...)
- 5 meetings this week, 2 with external stakeholders...

This is how multi-step workflows chain without a dedicated orchestrator.


Schedules ​

A schedule is a recurring task template described in natural language:

bash
botholomew schedule add "Morning review" \
  --frequency "every weekday at 7am" \
  --description "Read my email, check my calendar, draft a morning summary"

Schedules live as schedules/<id>.md files with the same frontmatter

  • body shape as tasks:
yaml
---
id: ...
name: Morning review
description: Read my email, check my calendar, draft a morning summary
frequency: every weekday at 7am   # human-friendly; LLM evaluator decides if due
last_run_at: 2026-05-02T07:03:00Z
enabled: true
model: null                       # inherited by every task this schedule spawns
created_at: ...
updated_at: ...
---

LLM-evaluated "is it due?" ​

A schedule's own model: is inherited by the tasks it spawns, so "run this recurring work on the cheap model" is a one-time setting. The schedule's due/not-due evaluation itself always uses fast_model — it's a cheap structured decision, not the work.

Instead of parsing cron expressions, processSchedules(projectDir, config, workerId) (src/worker/schedules.ts) walks schedules/<id>.md, filters to enabled: true and a schedule_min_interval_seconds window past last_run_at, and tries O_EXCL on schedules/.locks/<id>.lock for each. Only the worker that wins the claim evaluates that schedule — so two concurrent workers evaluating the same schedule never produce duplicate task batches.

Once a worker holds the claim, it asks the model:

Given the frequency "every weekday at 7am", last_run_at = 2025-04-16T07:03:12Z, and now = 2025-04-17T07:41:05Z — is this schedule due? If yes, what task(s) should be created?

The LLM returns structured output: { isDue: boolean, tasksToCreate: Array<{ name, description, priority }> }. If isDue is true, the worker writes new tasks/<id>.md files for each entry, then atomic-writes the schedule's own <id>.md back with an updated last_run_at (mtime check; if you edited the schedule in vim mid-evaluation the worker aborts and retries next tick). Finally it unlinks the lockfile.

If the schedule describes a multi-step workflow ("read email and summarize"), the model can return multiple tasks with blocked_by linking them — so a schedule naturally expands into a chained DAG.

Trade-offs:

  • Flexibility. "Every weekday at 7am, except US holidays, unless I'm on vacation (check calendar)" is specifiable in English and evaluable by the model.
  • Cost. One (cheap) model call per enabled schedule per tick. For dozens of schedules this is negligible; for thousands, you'd want a parser.
  • Drift. The model's idea of "morning" might not match yours. Tighten the frequency text if you see misfires.

botholomew schedule trigger <id> runs the same evaluation loop on demand and creates the task(s) immediately — handy for verifying that a new schedule produces the tasks you expect without waiting for the next tick.


Running the queue by hand ​

bash
# Add work
botholomew task add "Draft Q4 retro" --priority high

# Inspect (newest first; supports --status, --priority, --limit, --offset)
botholomew task list --status pending
botholomew task list --limit 20 --offset 20
botholomew task view <id>

# Run a worker now (foreground, one-shot by default)
botholomew worker run
botholomew worker run --persist       # long-running tick loop
botholomew worker run --task-id <id>  # target a specific task

# Unstick a task
botholomew task reset <id>
botholomew task delete <id>

# Manually fire a schedule
botholomew schedule trigger <id>

All of the same operations are available to the chat agent (create_task, list_tasks, view_task, update_task, task_edit, delete_task, create_schedule, schedule_edit, list_schedules) so you can drive the queue conversationally too. delete_task refuses tasks in in_progress — the worker has no mid-execution interrupt, so wait for it to finish or run botholomew task reset <id> from the CLI first.

Editing tasks and schedules ​

Two complementary tools exist for tasks: update_task is the typed field updater (name, description, priority, blocked_by) with a DAG-cycle check, and task_edit applies line-range patches to the whole task file (frontmatter + body). task_edit refuses non-pending tasks and re-validates blocked_by so a body edit can't introduce a cycle. schedule_edit is the only edit path for schedules and works the same way.

Both tools use the shared git-hunk patch format, re-parse the patched output against their Zod schemas, refuse to write on validation failure, and bump updated_at on success. Concurrent edits surface as error_type: "mtime_conflict".

Released under the MIT License.