Scheduled jobs (src/jobs/, src/cli/jobCommand.ts, 0.7)
This page is generated from
docs/lld/jobs.mdin the repository.
Purpose
Section titled “Purpose”Build a finished plan later, with nobody at the keyboard: overnight, for example. The user approves once, when the job is made; the run then asks nothing. The job works on its own branch in its own worktree, so the user’s checkout never changes, and a report says what happened.
sequenceDiagram participant U as User (chat) participant RT as Runtime participant J as .garuda/jobs/#lt;id#gt;.json participant R as garuda run #lt;id#gt; participant W as worktree (garuda/job-#lt;id#gt;) U->>RT: plan-mode turn ends (done): lastPlan U->>RT: /schedule [HH:MM] RT->>RT: base commit, the rules of the permissions block, links RT->>U: one question (the rules, the base, the branch) RT->>J: job (status scheduled) U->>R: garuda run #lt;id#gt; [--at 01:00] R->>R: wait until HH:MM (optional) R->>W: git worktree add -b garuda/job-#lt;id#gt; #lt;base#gt;, link node_modules … R->>R: Runtime in the worktree: job rules, unattended engine, approver "deny" R->>R: one turn (the job prompt) R->>W: commit (no hooks, no links, no Garuda files) R->>J: result, status, #lt;id#gt;.md report, notification
| File | Role |
|---|---|
jobs/job.ts |
The job format (Zod), newJobId, saveJob (temp file + rename, 0600), loadJob (checks the file and every rule again), listJobs, planPermissions, planTitle. |
jobs/create.ts |
createJob: the checks, the question, the job file. Runtime.scheduleJob calls it. |
jobs/worktree.ts |
jobBase, linkCandidates, worktreeDir, prepareWorktree, commitJob, jobChanges. |
jobs/git.ts |
git(executor, cwd, args) and hostCommand (launchctl, osascript): through the Executor (N8), outside the sandbox; git with the user’s config but core.hooksPath=/dev/null. |
jobs/launchd.ts |
The launchd agent (macOS): agentPlist, installAgent, removeAgent, nextTime, defaultAgentEnv. |
jobs/proof.ts |
Proof of work (0.11): detectTestCommand, riskFlags, stackOf, reviewerSystem, reviewPrompt, parseReview, jobVerdict. |
jobs/night.ts |
The night shift (0.11): nightQueue, processRunner, runQueue, nightDigest, writeDigest. |
jobs/text.ts |
jobPrompt (the task for the unattended run) and jobReport (Markdown). |
cli/jobCommand.ts |
prepareJob (load, --at wait, worktree, settings, status running) and finishJob (commit, result, report, notification), msUntil. |
The job file
Section titled “The job file”<root>/.garuda/jobs/<id>.json (id YYYYMMDD-HHMM-xxxx). The user may edit it before the run; loadJob
checks it again (schema, id, root, rules).
| Field | Meaning |
|---|---|
title, prompt, planSession |
The request’s first line; the task (request + plan + the rules of an unattended run); the plan’s session. |
root, base, branch, worktree |
The checkout; the start commit; garuda/job-<id>; ~/.garuda/worktrees/<project>-<hash>/<id>. |
model |
The chat’s model at scheduling time; garuda run -m overrides it. |
allow |
Permission rules for this job only, in the settings format. |
writeScope |
(0.17, review T6) The paths in the worktree that the job may change: src/ is a folder, README.md one file. /schedule proposes it (proposeWriteScope: the static prefix of each edit_file/write_file rule, and the build folders of the marker files) and the question shows it. Entries are relative, not the root, no .., not .git/.garuda, no link (scopeEntryProblem, prepareWriteScope). Absent: the whole worktree (a job from before 0.17, or a rule with no folder); the run’s first lines say so. |
onUnapproved |
deny-and-continue (the only mode in 0.7). |
at, maxSteps, links |
The time given to /schedule; the step limit (100); linked ignored folders. |
queue |
In the project’s night queue (0.11); /schedule sets it. |
network |
The network allowlist (0.13) in effect in the chat at /schedule (the question shows it). The run uses it, not the project’s current list, with no consent question; other hosts are denied and appear in the report’s denied calls. Absent: no network. |
test, review |
Proof of work (0.11): the test command run before and after (absent: none); the review (default true). |
status, error, startedAt, endedAt, result |
scheduled → running → done, stopped (Ctrl-C) or failed. A job never stays running with nothing running (0.14.1, review): when garuda run stops between prepareJob and finishJob, a process exit handler marks it failed (failJobSync, with error); after each night job process, settleJob does the same for a killed one. links are single folder names in the root; the result: stop reason, steps, tokens, cost, time, session, commit, files with line counts, denied calls, the agent’s last answer. |
Making a job (/schedule [HH:MM])
Section titled “Making a job (/schedule [HH:MM])”- Needs a finished plan:
Runtime.runTurnkeeps the last assistant text of a plan-mode turn that endeddone, with its prompt and session (lastPlan). PLAN_NOTEasks each plan to end with a```permissionsblock: one rule per line for what the build needs beyond the sandbox (edit_file(path),write_file(path), globs;bash(command)for the network;web_fetch(host)).planPermissionskeeps the lines that parse; the others are shown as “Not rules, left out”.- Refused without an OS sandbox (every command would ask) and without a git repository with a commit.
- The base (
jobBase): HEAD; with uncommitted changes of tracked files, one plain commit on HEAD that holds them (git stash create, thencommit-treeon its tree: the branch history stays linear, and the user’s stash list and checkout do not change). Untracked files are counted (not Garuda’s own) and named in the question. - Links:
node_modules,.venv,venvwhen they exist in the checkout. - One question shows the title, model, base, branch, links and the rules; “Yes” writes the file and prints
garuda run <id> [--at HH:MM].
Running a job (garuda run <id> [--at HH:MM] [-m model])
Section titled “Running a job (garuda run <id> [--at HH:MM] [-m model])”prepareJob: load; refuserunning(a crash leaves it; the message says how to reset) anddone.--atwaits until the next HH:MM in local time (Ctrl-C cancels; the job stays scheduled).- The worktree:
git worktree add -b <branch> <worktree> <base>(or the existing branch after an earlier run); symlinks for the links. - Settings: the worktree’s
.garuda/settings.json(its parts that loosen safety only when the user pinned them for the main checkout:gateProjectSettings, no question), plus the job’s rules inallow,maxSteps, the links’ real paths as sandbox write paths (tool caches), and the write scope (settings.writeScope).prepareWriteScoperefuses a scope entry through a link or outside the worktree, and creates missing folder entries (bubblewrap binds only existing paths). - The CLI builds the runtime as for
-p, with root = the worktree, the session store of the checkout (the session file stays out of the branch), the approverAutoApprover("deny")(consents for project MCP servers, hooks, skills, agents and Claude’s search all get “no”), no undo snapshots (the branch is the safety net), andunattendedfor the engine. Without an OS sandbox the run stops (statusfailed). - One turn with the job prompt. The engine allows reads, commands in the sandbox and the job’s rules; a
call that would ask is denied with
JOB_DENIAL(“not in the approved list … continue without it … list it”) and recorded. With a write scope, the OS sandbox makes the worktree read-only except the scope (ExecPolicy.writeScope), and the engine denies an edit outside it even when a rule allows it (also recorded); the path as named and the place it reaches through links (for a new file: its nearest existing parent) must both be in the scope (scopeDenial), and the write tools check it again just before they write (writeScopeDenial). A job never runs a command outside the sandbox: the engine deniesoutside_sandboxbefore it reads any rule. The job’s test command has the same scope;execPolicycreates missing scope folders again before each command (a clean after the tests before the job removes empty ones). finishJob: with a write scope,outsideScope(git status --porcelain -z --untracked-files=all, without Garuda’s files and the links) first; a change outside the scope fails the job with no commit. Thengit add -Awithout.garuda/sessions|index|evals|jobsand the links, a commit with no hooks (Garuda job <id>: <title>, plus the stop reason when it did not enddone), the changed files (diff --name-statusand--numstatfrom the base), the result in the job file, the report in.garuda/jobs/<id>.md, and a notification (OSC 9 or the bell) when stdout is a terminal.
launchd (macOS)
Section titled “launchd (macOS)”/schedule HH:MM on macOS asks a second question: “Run the job at 01:00 with launchd?”. Only the chat
offers it (createJob needs the launchd option), so no other caller and no test installs an agent. Yes:
~/Library/LaunchAgents/dev.garuda.job.<id>.plist:ProgramArguments=/usr/bin/caffeinate -i <login shell> -lic "cd <root> && exec <node> <garuda script> run <id> --from-launchd"(the single binary has no script). The login shell ($SHELLif zsh or bash, else/bin/zsh) with-licreads.zprofileand.zshrc, so the API keys andGARUDA_MODELcome from the user’s setup; no key is written to a file.caffeinate -istops idle sleep while the job runs.StartCalendarIntervalwith Month, Day, Hour and Minute of the next HH:MM: it fires once. If the Mac sleeps then, launchd starts the job at the next wake.RunAtLoadfalse. Output goes to.garuda/jobs/<id>.log.launchctl bootout(an earlier agent, errors ignored), thenlaunchctl bootstrap gui/<uid> <plist>. The job file keepslaunchd: { label, plist, when }.- The run removes the agent at its very end, after the report and a macOS notification (
osascript, the text as an argument, never as script text): delete the plist, thenlaunchctl bootout, which also ends the run’s own process. A run from the agent that does not start (done, running, broken file) removes the agent too, and so does a manualgaruda runof a job that has one. /jobs cancel <id>: removes the agent and setsstopped;garuda run <id>can still run it.- Waking the Mac at a set time needs
sudo pmset schedule wake "<date>"; Garuda never runs sudo.
The Batch API (0.7)
Section titled “The Batch API (0.7)”For a Claude model, /schedule asks “Use the Batch API for this job?” (preview: half price, the uneven
wait, the step limit, the switch time). Yes sets batch: true, finishBy: "07:00" and
stepLimitMinutes: 20 in the job file (the user may change both). At run time the CLI builds a
DeadlineClient: the Batch API until 15 minutes before the next finishBy, then the normal API for the rest
of the job; before that, a step that waits more than the step limit is cancelled and runs on the normal
API, and the next step tries the Batch API again. The run prints the switch time with its date, the batch
id of each step and a line per minute of waiting. The result keeps modelCalls: { batch, normal, slow, switchedAt? }, and the report shows them.
Why the step limit: the first measurement waited about 3 minutes per step, but the next day one batch
stayed in_progress for more than 6 hours. Only the slow steps pay full price. Costs are exact per
response: batch responses at half the token price.
Proof of work (jobs/proof.ts, 0.11)
Section titled “Proof of work (jobs/proof.ts, 0.11)”detectTestCommand(root, profiles)at/schedule: atestscript in package.json (pnpm, yarn or npm by the lock file; not npm’s placeholder), else the first profile’stest, elsenode --testwhen the root ortest/holds*.test.{js,mjs,cjs}files. It goes into the job file astest(the user may change it) and into the question’s preview.- Before the turn,
testsBefore(the CLI, after the runtime is built) runs it withruntime.runCheck: the sandbox and bash’s policy, outside the permission engine, 10 minutes at most. Thengit checkout -- .andgit clean -fdq -e /<link>…(cleanArgs) put the worktree back at its base, so test output never reaches the commit. The-ekeeps the links: a symlink is not a folder, so anode_modules/line in.gitignoredoes not protect it (fixed in 0.12; before, a job’s tests after the change could not findvitest). - After the commit,
proveJob(infinishJob, not after Ctrl-C) runs the tests again, thenriskFlags(pure): STOP = notdone, an error, tests fail after; look = no test command, test files deleted or changed, manifests or lock files, CI/container/.envfiles, more than 20 files or 500 lines, denied calls, tests fixed by the job, no file changed. - The review (
job.reviewnot false, and a commit):runtime.askModel(reviewerSystem(stack), reviewPrompt(...)), one request with no tools, outside the conversation. Since the Codex review of 1370184 it is under the session’s token budget: it reserves its whole size plus 8,192 output tokens first (no room: a clear error, recorded asreviewError), every attempt is metered, and its usage goes into the session (side_usagerecord). The role: a principal engineer and a domain expert in the stack (stackOf: languages of the changed files, then the profiles’ labels). The input: the job prompt, the files, the test results (the failing output), the flags andgit diff base branch(cut at 60,000 characters).parseReviewreadsVERDICT: ready | needs a look; no verdict line counts as “needs a look”. A failed review is recorded (reviewError), never an error of the job, and the verdict is “needs a look” (Codex review of 1370184: before, a clean job with a failed review said “ready to merge”, although nobody reviewed it). jobVerdict: a failed review or any STOP flag → needs a look; else the review’s verdict; with no review, ready only with no flag. The result keepsproof(verdict, test runs with the last 30 lines, flags, review text and cost). The report puts Verdict, Tests, Risk flags and the review first; the notification says “ready to merge” or “needs a look”.- The builder’s role is in
jobPrompt: a staff engineer, small focused changes, tests for each change.
The night shift (jobs/night.ts, cli/nightCommand.ts, 0.11)
Section titled “The night shift (jobs/night.ts, cli/nightCommand.ts, 0.11)”/scheduleputs each new job in the queue (queue: true; the question says so).nightQueue(root): the jobs that arescheduled, in the queue and without a launchd agent of their own, oldest first.garuda night [--at HH:MM] [--parallel n]waits (Ctrl-C cancels), thenrunQueueruns the jobs up toparallelat a time (default 3, at most 10). Each job is its own process,garuda run <id>(processRunner: the host executor, in the project, with this process’s environment so the API keys come along, output appended to.garuda/jobs/<id>.log, 24 h at most). The worktrees keep the jobs apart; each job still does its own proof of work and report, and does not notify.- At the end the command reloads the job files and writes one digest,
.garuda/jobs/night-<date>.md(nightDigest): a row per job (verdict, tests after, files, cost with the review, branch). Costs keep their uncertainty (review of 3a9f51b;jobCost,sumCosts,formatCostinjobs/text.ts): an unknown part makes the cost “unknown”, never $0.0000; a minimum part (the run’scostIncomplete, orproof.reviewCostIncompletewhen a review attempt had no reported usage) makes it “≥ $X (incomplete)”; a job that started but has no result makes the total a minimum. Then the jobs that need a look with their flags and the review’s summary. It sends one notification (“N job(s), M ready to merge”) when stdout is a terminal. /jobs delete <id>(jobs/remove.ts,Runtime.deleteJob): refuses a running job;jobLeftoversfinds the files, the worktree and the branch (merged = an ancestor of HEAD); one question (an unmerged branch adds “Yes, but keep the branch”); then the launchd agent,git worktree remove --forceandprune,git branch -Donly when chosen, and the files./jobsmarks queued jobs (“in the night queue”) and verdicts.- The queue’s launchd agent (
launchd.ts:AgentSpec,nightSpec,specPlist,installSpec,removeSpec,nightAgentTime):/schedule HH:MMon macOS asks “Start at HH:MM with launchd?” with three answers: the whole night queue (the agentdev.garuda.night.<project>-<hash>runsgaruda night --from-launchd, log.garuda/jobs/night.log; an earlier agent of the project is unloaded and replaced), only this job (the 0.7 agent; the job leaves the queue), or no.garuda night --from-launchdsends a macOS notification (osascript, the text as an argument) and removes the agent last./jobsshows the agent’s time (read from its plist);/jobs cancel nightremoves it.
Safety
Section titled “Safety”- The user approves the list once, with the rules in view; nothing else can be approved later.
- Deny rules, sensitive files and
.git/writes still win over the job’s rules (the engine’s order holds). - The job never touches the user’s checkout, index or stash; its commit runs no repository hooks (a cloned repo’s hook would run outside the sandbox).
- Commands run only in the OS sandbox; there is no host fallback for jobs.
Measure the Batch API on longer jobs (the hard suite with --batch on). Measure how often a step hits the
step limit (modelCalls.slow in the job results) before a change of the default.
test/jobs.test.ts: the permissions block; the unattended engine; msUntil; createJob (the file, the
question, No, a dirty checkout’s base, no git, no sandbox); a whole run in the worktree with a fake model
(an approved edit on the branch, a denied write in the report, links and sessions not committed, the
checkout unchanged, a done job not run again); /schedule and /jobs in the chat; the launchd plist, the
second question, install and /jobs cancel (launchctl recorded, not run).
test/batch.test.ts: the batch client (a batch of one, overloaded and expired results, an abort cancels the
batch, half prices); eval tasks at the same time; the DeadlineClient (the switch time, an abort is not a
switch, a slow step and the retry at the next step); the batch id, the wait lines and a failed status check.