023 · Workers
Workers (scheduled, unattended workflows) (spec_workers_023)
| Status | Implemented, first version. Reverse-engineered from 53f8c53 (2026-09-24) and kept with the code since; its paths and names checked 2026-09-27 (//tools/specs). |
| Source | pkg/engine/workers/{worker,schedule,permission,policy,state,runs}.go; pkg/engine/workers.go; apps/service/internal/server/{scheduler,worker}.go; apps/cli/workers.go |
| Tests | pkg/engine/workers/*_test.go, pkg/engine/workers_test.go, apps/service/internal/server/worker_test.go, apps/cli/workers_test.go |
| Depends on | spec_approvals_005 (unattended approvals), spec_workspace_018, spec_service_021 |
A workspace defines workers in .agents/workers/<name>/WORKER.md (or workers/<name>/WORKER.md). The service runs enabled workers on their schedules, unattended, with exactly the permissions they declare (capped by host policy). Nothing runs until a person reviews and enables the exact content; editing disables it again.
---
description: Report outdated Go modules
schedule: Weekdays at 9:30 # cron "30 9 * * 1-5", "@every 2h", or a phrase
timezone: Europe/Paris # optional; default the machine's
permissions: ["shell:go list -m -u all", "write:reports/"]
limits: { max_turns: 30, max_cost_usd: 0.50, timeout: 20m }
catch_up: once # none (default) | once
overlap: skip # only "skip"
agent: qa # optional: runs as this agent
model: anthropic/claude-haiku-4-5 # optional: runs on this model
---
Check for outdated Go modules and write reports/deps.md.
- WK-01 Worker roots:
[workers] paths(default.agents/workers, thenworkers) relative to the workspace; a later root doesn’t override an earlier worker of the same name. Directories withoutWORKER.mdare ignored. - WK-01a
CreateWorker(spec)writes a new worker from a form’s fields (api.WorkerSpec: name, description, schedule, time zone, agent, model, permissions, limits, catch-up, workflow) in the first root:workers.Renderwrites the frontmatter with only the fields set (YAML, so text is quoted as needed) and the workflow. It is loaded from a temporary copy first, as the scheduler would read it, and an agent that isn’t defined is a problem too: with problems nothing is written and they’re returned. A name any root already has isErrWorkerExists(WORKER_EXISTS). The new worker isnew(disabled) until reviewed and enabled (WK-10). - WK-01b 2026-10-01. Editing.
GetWorkerSpec(name)returns a worker’sWORKER.mdas written (workers.ReadSpec: the schedule, time zone, permissions and limits as given, not as understood or capped by the policy) with the hash of its files. It reads what it can of a file that isn’t valid, so a broken worker can be fixed from the form: unknown keys are ignored, and a file whose frontmatter can’t be read comes back whole as the workflow.UpdateWorker(spec, hash)rewritesWORKER.mdin the worker’s directory only if its files still have that hash (ErrHashMismatch,HASH_MISMATCH: changed since they were read), and only if the result passes WK-01a’s checks (otherwise its problems, nothing written). The name, which is the directory and keys the run log and the enabled state, can’t change. The file is replaced atomically (a temporary dot file beside the worker’s directory, never in its hash). The new content has a new hash, so an enabled worker becomeschangedand stops running until it’s enabled again, as after any edit (WK-10); the service rescans at once. Writing the file drops YAML comments, keys Blitz doesn’t know andoverlap(onlyskip, the default). - WK-01c 2026-10-02. Deleting.
DeleteWorker(name)(WorkerService.DeleteWorker) removes the worker’s directory (itsWORKER.mdand anything beside it,os.RemoveAll) and forgets it in the store (Store.Forget: it’snewif it comes back; a workspace with none left isn’t watched). Its past runs stay in the run log. Refused while a run of it goes (RUN_IN_PROGRESS); an unknown name isUNKNOWN_WORKER. The scheduler rescans after. The desktop deletes from the Workers menu and the worker’s view (DSK-65a), confirmed. - WK-02 The directory name is the worker’s name:
^[a-z0-9][a-z0-9_-]{0,63}$; a frontmatternamemust match it. Only valid names are looked up (they become file names). - WK-03 Frontmatter is decoded strictly (unknown keys are errors — a misspelling would silently change behaviour). A leading BOM and blank lines are tolerated; missing frontmatter delimiters are errors.
- WK-04 Invalid if: bad name, empty workflow body, unparseable schedule or time zone, bad permission, bad or non-positive
limits.timeout, negative limits, unsupportedoverlap/catch_up, hashing failure. An invalid worker still loads with its problems listed. - WK-05 Content hash: SHA-256 over every regular file under the worker directory (relative path, size, content; symlinks skipped; ≤ 64 MB),
sha256:<hex>.
- WK-10 Accepted: five-field cron, descriptors (
@hourly,@daily,@weekly,@monthly,@every 90m), or fixed plain-text phrases translated by rules, never guessed:hourly|daily|weekly|monthly,every [N|word] minute(s)|hour(s)|day(s)(non-divisors become@every),daily|weekdays|weekends|every <weekday> at <time>where time is6,6 am,6:30 PM,18:30,noon,midnight. Anything else is an error listing the accepted forms. - WK-11
Next(t)evaluates in the worker’s time zone.
- WK-20 A permission is
<kind>:<pattern>:shell:<command or glob>,write:<path glob>,delete:<path glob>,web:<host or search provider>,mcp:<server>:<tool glob>. Write/delete paths are workspace-relative and may not be absolute or use... Globs usepath.Match(*doesn’t cross/); a pattern ending in/covers everything below. - WK-20a Since v0.6.3. A
writeordeletepattern written with a leading./(write:./daily-news/*) is read without it (daily-news/*), as the tools give paths; before, it matched nothing, and the worker’s writes were refused. - WK-21 A request is allowed only if it has targets and every target is covered by a permission of its kind. File targets must be clean workspace-relative paths. A shell command containing any of
; & | \$ ( ) < > \ \n \ris covered **only** by a permission that is exactly that command (sogo list *can't covergo list x; rm -rf ~`). Reading never needs permission. - WK-22 Host policy
[workers.policy]:allow(permission kinds; default all five),default_max_turns50,default_max_cost_usd1,default_timeout30m,max_turns200,max_cost_usd10,max_timeout2h,max_concurrent2. Disallowed permissions are dropped and caps applied, each with a note shown to the user.
- WK-30 State per (workspace, name) in
~/.blitz/workers.json(the service is its only writer; atomic writes):new,enabled(at the current hash),disabled,changed(enabled at an older hash — doesn’t run),invalid. - WK-31
blitz workers enable <name>shows the path, description, schedule (as written, as cron, time zone), effective permissions, limits, problems and content hash, asks[y/N](or-y), then enables the hash shown; a change in between fails withHASH_MISMATCH. Invalid workers can’t be enabled. When no login item keeps the service running, it saysWorkers run in the Blitz service: keep it running with 'blitz service install'.(Corrected 2026-10-14: it saidblitz serve.) - WK-32
disable,list(name, state, schedule, cron, next run, description, problems),runs <name> [-n 10],run <name>(runs now, prints events and the record; non-success exits 1).undo <run-id> [-f]puts back the files a run changed. Request errors (unknown, not enabled, running, disabled, hash mismatch) exit 2. These always go through the service, starting it when needed (CLI-14). (Corrected 2026-10-14: an in-process path no longer exists.)
- WK-40 A run requires the worker enabled at its current hash. The same worker never runs twice at once: a second request is
RUN_IN_PROGRESS, and a skipped scheduled run is recorded (skipped). - WK-41 Each run gets its own session titled
⏰ <name> <YYYY-MM-DD HH:MM>(openable with/resume) and recorded as the run’s agent, and a prompt prefixed with an unattended preamble (nobody can answer; only permitted actions work; end with a summary of what was and wasn’t done). The transcript records the workflow text. - WK-42 Unattended approvals (spec_approvals_005 APR-10): permitted requests pass; everything else is refused and recorded as a refusal
{tool, kind, detail, time}.ask_user_questionis refused. - WK-43 Limits:
max_turns(model calls),max_cost_usd(checked after each event; cancels the run),timeout— applied through the turn’s own limits (WS-29), so the run’s error is the limit’s message. Status:succeeded,failed,limited(turns, cost or time, with the cause as error),skipped;runningwhile live. - WK-45 Agent and model: a run uses the worker’s
agent(else the workspace’s active agent) andmodel(else the workspace’s), for that run only (spec_engine_016 ENG-16); the workspace’s active agent and model are unchanged. An undefined agent is listed as a problem and fails the run; a model that can’t be built fails the run with the (secret-masked) reason.workers enableshows both so they are part of what is approved. - WK-44 Run log: one JSONL file per worker,
~/.blitz/worker-runs/<hex(sha256(workspace))[:16]>-<name>.jsonl(0600):id(YYYYMMDDTHHMMSS-<16 hex>), workspace, worker, hash, status, manual, started, duration, cost, calls, session, refusals, error. Torn last lines are skipped. Listed newest first.
-
WK-50 Runs only in the service (
blitzd) with[workers] enabled. It watches every workspace that has at least one enabled worker, even when no client has it open, rescanning every minute to pick up new, edited and removed workers; a changed hash, cron or time zone re-registers the entry. -
WK-51 Concurrency:
max_concurrentslots across workspaces. A manual run fails at once withTOO_MANY_RUNSwhen all are busy; a scheduled run waits, but a worker already waiting isn’t queued again (a worker that falls behind runs once, not once per missed tick). -
WK-52
catch_up: once: when a worker is first registered (the service starts, or it’s enabled) and a scheduled time passed since its last run, it runs once as soon as possible. A worker that has never run has nothing to catch up. Ticks missed while the service runs (a machine asleep) collapse into at most one run when its timer fires; nothing more is promised. -
WK-53 Live runs keep their events so
WatchWorkerRunreplays them, then streams live ones until the run finishes; events aren’t kept afterwards. Stopping the scheduler cancels all runs and waits for them. -
WK-53a Since v0.6.3 (the owner’s request). A run going now can be stopped. Each run has its own context under the scheduler’s (
liveRun.stop);StopWorkerRun(run_id)cancels it withapi.ErrRunStoppedas the cause and returns the record as it was (RUN_NOT_RUNNINGwhen no such run is going). The turn winds down, and the run is recorded with statusstopped(RUN_STATUS_STOPPED, error “stopped by the user”,RUN_STOPPEDon its finished event); its changed files are kept and, like any run’s, undone withblitz workers undo.ListRunningWorkerRunslists the runs going now in every workspace, oldest first.blitz workers stop <name|run-id>stops the workspace’s run of that worker. Stopping the scheduler still cancels every run (recorded as failed, as before). -
WK-53b Since v0.6.3 (the owner’s request).
RewriteWorker(workspace, worker)has the suggestions model (else the reviewer’s, else the main one) rewrite a worker’s instructions, as the form has them, for an unattended run (pkg/engine/workerrewrite.go): self-contained (no looking up its own definition or listing folders), named sources fetched once, bounded work, an exact output path and format, what to do when a step fails, a short finish, nothing invented. The model also lists the permissions they need, as narrowly as they allow, and up to five lines on what changed. It replies in JSON; permissions that don’t parse (ParsePermission) are dropped, a reply without instructions is a problem, and a model that fails givesproblem(redacted) instead of an error. The instructions sent are redacted for secrets. Nothing is saved: the form takes the suggestion only when the user says so.NO_INSTRUCTIONSwithout instructions. -
WK-54 A run’s file changes are its own checkpoint (BL-WK-01): its turn is a worker turn (
Checkpoints.BeginWorkerTurn, the run’s ID on it), which/checkpointsand/undoin interactive sessions leave out, and changes go to their own session’s turn even while an interactive turn runs (FS-60a). The run record lists the files it changed (files;blitz workers runsshows them with the run’s ID). -
WK-55
blitz workers undo <run-id> [--force](WorkerService.UndoWorkerRun,Workspace.UndoWorkerRun) restores those files as they were before the run, with/undo’s conflict rules (FS-63): a file changed since blocks it unless forced;NOTHING_TO_UNDOwhen nothing is left (undone already, or trimmed from the store). It’s audited (BL-WK-02). -
WK-56
[workers] notify(BL-WK-10): a shell command run after each scheduled run (and each scheduled run skipped because the last still ran) whose status is innotify_on(defaultfailed,limited), never after manual runs. It gets the run record as a line of JSON on stdin,BLITZ_WORKERandBLITZ_RUN_STATUSin its environment, runs in the workspace outside the sandbox with a 30 s limit, and a failure is a warning. Only the user’s settings may set it (never a project’s). -
WK-57 Since v0.7.1 (the owner’s request). The tray’s Workers submenu (
tray.WorkerEntries), so workers, which run while the window is closed, can be seen and handled from the tray: first each run going on, Stop(running) (StopWorkerRun); then each worker of every registered workspace (named<folder> › <worker>when there’s more than one workspace), with Run now (RunWorker), Enabled (a check item:DisableWorker, orEnableWorkerat its hash for one disabled at the content it was enabled with; a new, changed or invalid worker says Review it in Blitz to enable it, greyed, and doesn’t run from here) and Show in Blitz, which opensblitz://workers?dir=<workspace>(EL-22): the app opens the workspace on its workers. Asked every 2 s with the tray’s status; redrawn only when it changes. The home workspace’s workers are namedGlobal › <worker>(WK-60); their Show in Blitz opens Settings › Workers. -
WK-60 Since v0.7.1 (the owner’s request). Global workers: work that belongs to no project. They’re defined in
~/.blitz/workers/<name>/WORKER.md(config.UserWorkersDir) and run in the home workspace,~/.blitz/home(config.HomeWorkspace, made when missing): a workspace of its own, so they have the same states, review, policy, schedule, run log, sessions and RPCs as any workspace’s, withworkspaceset to the home workspace. The engine gives the home workspace~/.blitz/workersas its only worker folder (workerRoots), soCreateWorkerthere writes to it. -
WK-61 Since v0.7.1 (the owner’s request).
WorkerService.ListAllWorkerslists the home workspace first, then every workspace with a worker on record in~/.blitz/workers.json, enabled or disabled (Store.Known), by folder, each with its workers. A workspace whose folder is gone ismissing, with the workers on record (name, enabled or disabled) and nothing opened; one that can’t be opened has its (redacted)problem. This is what keeps a worker from running unseen: the service runs a workspace’s enabled workers whether or not the app has the workspace open (WK-50). -
WK-62 Since v0.7.1 (the owner’s request). A workspace whose folder is gone (moved, deleted, an unplugged drive) is skipped by the rescan and logged once, not every minute; its workers run again if it comes back.
WorkerService.ForgetWorkspaceWorkers(workspace)drops every worker of the workspace from the store (Store.ForgetWorkspace): none is enabled any more, and the service stops looking for the folder. Files aren’t touched (a folder that comes back has its workers as new). Refused while one of them runs (RUN_IN_PROGRESS); a relative path isINVALID_WORKSPACE. -
WK-63 Since v0.7.1 (the owner’s request). The global limits on every worker are a form at the top of Settings › Workers (
WorkerLimits): the default and maximum model calls, cost and time, how many runs go at once, and the permission kinds a worker may be given (chips; all off isnone, read only). Each field is saved as it’s left withSetConfigValue(workers.policy.*, typed: counts, US dollars, lengths of time like30m, kinds; empty puts back the default) in the global settings, andListAllWorkersreturns them as they’re set (WorkerPolicySettings). Open workspaces reload the policy (ReloadWorkerPolicy) and apply it from their next listing or run; a run going keeps the limits it started with.max_concurrentapplies when the service restarts: until then the form says how many the service runs at once (running_max_concurrent).
service installis needed for workers to keep schedules across logins; API keys must be in~/.blitz/.env.toml.