Skip to content

Latest commit

 

History

History
725 lines (613 loc) · 39.2 KB

USAGE.md

File metadata and controls

725 lines (613 loc) · 39.2 KB

Using Errand

Run commands from a local workspace on a configured runner. For installation and the first run, start with the README.

Command conventions

Frequent execution flags follow familiar CLI conventions: -d is --detach, -e is --env, -w is --workdir, and SSH-style -L is --forward. errand kill -f requests --force, and GC accepts -n for --dry-run; setup and peer mutation use the same -f and -n forms, while peer discovery uses -a for --all. Safety boundaries, transport details, and mutating workspace options remain long-form so their meaning stays explicit. Use --help at the relevant level, for example errand peers --help or errand gc jobs --help.

Exit status

Errand returns the remote process's exit code. If that code is zero but the transaction fails, Errand exits 120. A secondary transaction failure is reported without replacing a nonzero process exit code. Detaching successfully returns zero for the detach action, before the remote command has finished.

If the runner stops answering while you follow a job, Errand does not wait out the job's runtime limit. A runner writes a heartbeat on every open log stream every few seconds, so a connection that goes quiet is noticed within 15 seconds and Errand reconnects, resuming the log where it stopped. When it cannot get the stream back for 60 seconds it stops, says the runner is unavailable and the job's state is unknown, prints the errand status and errand attach commands for the job, and exits 120. It never reports that as the job failing: the job may still be running, may have finished, or may have been lost with the runner. If the runner still answers but cannot serve the log, Errand asks it for the job's state and reports that instead. A Ctrl-C the runner never confirmed is called out along with the errand kill command, so it is not mistaken for a stopped job.

Workspace selection and run preferences

Run errand from a Git worktree for automatic snapshot selection. A non-Git directory requires an explicit .errandignore policy or --include-all. Errand always refuses a filesystem root, and snapshotting your home directory requires --include-all. The client prints the selected file count and byte total before remote admission. Use --no-snapshot when a command needs no local files; Errand then skips local content inspection and runs it in an empty remote workspace. Errand still records the invocation directory's identity so retained changes can be applied there safely. Because the remote workspace is empty, --workdir may only name its root.

Before submission, Errand shows the full local workspace and command-directory paths. Job status describes the workdir relative to the runner's workspace, using workspace root for its root; local paths are not remote execution paths.

For a workspace containing several repositories, place this marker and an explicit .errandignore at the shared root:

# .errand.toml
[workspace]
root = true

[run]
peer = "mac-mini"             # optional; alias lives in personal config

[changes]
apply_on_success = true

Errand discovers the nearest marked ancestor, snapshots from there, and runs the command in the caller's relative directory. --workspace-root PATH selects the same boundary explicitly. Automatic discovery trusts only a marker and directory owned by the caller in a directory that is not group- or world-writable; use the explicit flag for intentionally shared workspaces. A marker chooses only the boundary; it does not bypass .errandignore, Git selection, home-directory protection, or the filesystem-root refusal. [changes].apply_on_success is read only from the selected workspace root. For --no-snapshot, the current directory is the configuration root; Errand does not climb to an ancestor marker when no local workspace is transferred. Set apply_on_success = true in ~/.config/errand/config.toml for a personal default. Explicit --apply or --no-apply wins over workspace and personal configuration. Applying is a property of the submitted job, independent of whether its logs are currently being observed. --detach --apply and Ctrl-D during an applying run hand completion to a detached local worker, which waits for success and applies into the originating workspace. attach remains observation-only and never chooses or changes that policy. If the worker or client machine stops, other CLI commands do not restart it. Once the remote job finishes, run errand fetch --apply HANDLE from its originating workspace to recover and apply its retained changes. Healthy workers continue normally, including their existing retries for temporary connection failures. If a completed job produced no changes, fetch --apply records completion without changing workspace files.

ps, status, and doctor inspect saved apply state and existing worker ownership without restarting workers. An unfinished apply with no active owner is reported as needs recovery. This does not mean no files were changed: a crash can occur after installation but before completion was recorded. Explicit fetch --apply uses the transaction journal and current workspace checks to resolve that state safely. Conflicts and permanent failures retain their error details for review.

The default ps listing includes jobs needing apply recovery even after remote execution finishes. Explicit peer, workspace, and --last filters still apply. status can show local apply state when the runner is unreachable, while reporting remote execution as unknown and returning a failure exit code. When only local evidence is available, JSON omits unavailable remote receipt fields rather than filling them with empty values. An expired job receipt is shown in ps without failing a successful fleet listing or suggesting that its missing changes can be fetched. An unreachable peer is queried once for its listing; local recovery information does not trigger further requests there. doctor warns about interrupted applies across this client's saved jobs. JSON reports the same apply state without extra diagnostic chatter on stdout. These reports reflect the state observed when each command runs.

[run].peer chooses a preferred configured peer for new runs. --on or --url wins over this preference; an unknown workspace alias fails instead of silently using the personal default. errand config shows the effective run settings and their sources; errand config --json provides the same information for scripts. Inspection stays local and does not resume automatic applications. Named profiles in personal or workspace config can supply peer, workdir, and apply preferences: errand --profile build -- go test ./.... CLI flags override profiles; errand config --profile build explains the result. See configuration for syntax and precedence.

Local jobs

Available since v0.1.2. Run a command in a separate workspace on this machine:

errand setup --local
errand --on local -- make test

The command receives a snapshot with the same file-selection rules as a remote job. Its ordinary workspace edits stay in the job; your original checkout changes only through apply. The process still has your account's permissions, so this is a workspace copy, not a security sandbox.

Start attached, press Ctrl-D if you want to stop watching, then use the printed local/JOB_ID handle:

errand ps --on local --last 5
errand attach local/JOB_ID
errand fetch local/JOB_ID
errand fetch --apply local/JOB_ID

Exit codes, retained artifacts, named caches, explicit -d, and --apply / --no-apply follow the same rules as remote jobs. Each new invocation starts a new job workspace; attaching resumes observation, not execution in a completed workspace. Nothing automatically falls back to local execution.

For a local development server, connect directly to its listening port. Local host jobs share your machine's ports; if you use forwarding, choose a different local port, such as -L 8080:3000, to avoid binding the server's port. See local-only setup and local target configuration.

Persistent workspaces

Ordinary runs use a fresh, ephemeral workspace. To keep files across jobs, create a persistent workspace explicitly, then select it with --workspace or an explicit profile:

errand workspaces create --on mac-mini experiment
errand --on mac-mini --workspace experiment -- make test
errand --on mac-mini --workspace experiment -- make test
errand workspaces --on mac-mini
errand ps --on mac-mini --workspace experiment --last 5
errand df --on mac-mini --verbose
errand workspaces rm --on mac-mini experiment

Creation uploads your current working files using the usual snapshot selection and configuration rules. create --no-snapshot starts empty instead. A missing workspace name on a run is an error; it never creates a workspace implicitly. Names start with a letter and contain up to 64 letters, digits, hyphens, or underscores; names that look like workspace IDs are reserved. They are scoped to your authenticated identity on that runner.

Subsequent runs use the files already there, at the same runner-side path. They do not upload your latest local edits. Each command gets its own job handle, logs, exit status, and retained results. Detaching and attaching work as usual. The workspace survives failed commands and daemon restarts; interrupted commands are not replayed. Multiple jobs can use the same workspace, within the runner's normal concurrency and queue limits. For example, keep a development server running while another job runs tests. Killing or finishing one job leaves the others running. Removal is refused until every job has finished cleanup, including queued jobs.

Jobs share the actual files and cache directories. Errand does not serialize file writes or determine whether commands are read-only; use separate workspaces when commands need independent files. workspaces and df --verbose show every job currently holding the workspace.

Job handles still support fetch, fetch --apply, fetch --apply --conflicts, and fetch --output. Each job's retained result is an immutable comparison with the workspace's creation snapshot, including accumulated edits from earlier jobs. Applying it uses the originating directory and the conflict-safe merge rules described below. Fetching an old result neither updates nor deletes the live workspace. Use --no-apply when you want to keep iterating there without applying job results locally. With concurrent writers, a retained result may include a sibling's edits. Collection checks the files it reads and can fail if they change during capture; it is not an atomic workspace snapshot. For a stable final result, wait for writers to finish, then run a final command such as errand --workspace experiment -- true and fetch that job.

Persistent development loop

Keep the server on your runner, edit locally, and push changes to the same workspace. For a pnpm project, add a profile to .errand.toml:

[profiles.dev.run]
peer = "mac-mini"
workspace = "app-dev"
workdir = "."

From the project root, create the workspace and prepare the application once:

errand workspaces create --profile dev app-dev
errand --profile dev -- pnpm install --frozen-lockfile
errand --profile dev -- pnpm build:packages
errand --profile dev --forward 3000 -- pnpm dev

Use your project's build and development commands and actual server port. Omit build:packages if your project does not need a separate shared-package build. If the application needs local environment files, add their paths to the profile before starting it:

[profiles.dev.env]
files = [".env.local"]

Keep secret files excluded with .gitignore or .errandignore. Files are loaded explicitly; env.pass remains available for exported shell variables. See environment files for precedence and supported syntax. Dependencies and build outputs remain in the persistent tree between jobs, including ignored files. Named caches are optional for this loop.

Leave the server attached in that terminal and open http://localhost:3000 in your local browser. In a second terminal, edit files in the same checkout:

errand push --profile dev --apply

Push delivers the local source changes into the running workspace. The application's existing file watchers can rebuild and hot reload without a server restart. Push completion confirms file application, not application readiness; check the server output and browser. If shared-package edits need a separate build, run errand --profile dev -- pnpm build:packages after push. Restart the development command when the application requires it.

Runs do not upload edits automatically. Plain push stages without applying; --apply remains explicit even if the profile enables automatic result apply. Push uses three-way merging and refuses conflicts by default. Use a separate workspace when you need independent files for another development session.

Watch local edits

Keep the development server in its terminal, and run this in another:

errand push --watch --apply --profile dev

Watch performs an initial push and repeats it when selected local files change. --watch controls repetition; --apply still controls whether the running workspace is updated. push --watch continuously stages changes without applying them. All other push options retain their meanings, including a selected changed PATH, --include-all, workspace and peer overrides, and profiles. A selected path with no changes is reported as unchanged and watch keeps waiting.

Save bursts are batched with a short quiet period. Only one push runs at a time; edits arriving during it are collected for the next pass. The display shows watching, sending, pending edits, and reconnecting, with the usual transfer receipts after each push. On a terminal the status line updates in place; redirected output uses plain lines. With --json, stdout contains newline-delimited push receipts using the existing fields, while status messages go to stderr. Idle watching produces no transfer receipts. Completion confirms file application, not a successful build or application readiness.

Ctrl-C stops watching and waits for an in-flight push to finish. The remote application keeps running. A second interrupt forces exit; restarting push recovers any uncertain apply before sending new changes. Transient connection failures retry with backoff. Conflicts stop watch. --apply --conflicts first applies clean changes and eligible text conflict markers, then stops and reports the conflicts. Resolve the files explicitly before restarting watch.

Watch pins the workspace ID and checkout identity. A removed or replaced checkout, changed selection policy, or removed workspace stops the session. Reusing a workspace name cannot redirect an existing watch. Concurrent commands remain caller-managed writers; watch does not fetch remote edits or resolve application build dependencies automatically.

Watch sends changed source entries and compact metadata against the retained source checkpoint. Unchanged running files are never used to reconstruct source. The CLI and daemon must run the same version. Local native notifications use kqueue on macOS and inotify on Linux. Watch setup reports exhausted OS watch/file-descriptor limits as errors; it does not silently fall back to continuous full-tree polling.

Send local edits into a persistent workspace

Fetch addresses a job's fixed result. Push addresses the continuing workspace:

errand fetch --apply mac-mini/JOB_ID
# Edit locally, then inspect the staged transfer or apply it remotely.
errand push --on mac-mini --workspace experiment
errand push --on mac-mini --workspace experiment --apply
errand --on mac-mini --workspace experiment -- make test

Plain push freezes and stages the selected local snapshot without changing the runner's working files. --apply merges it into the workspace. Both directions use clean-or-refuse three-way merging; --apply --conflicts explicitly permits conflict markers and clean sibling changes. Existing markers are ordinary file contents. There is no transfer --force or implicit fetch before push. Concurrent commands remain caller-managed writers.

Push requires --workspace NAME or a selected profile's run.workspace, and the originating checkout on the machine that created it. Job handles are fetch targets, not push targets. Workspace names are resolved to immutable IDs before transfer; deletion and recreation cannot redirect an in-flight push.

Push supports --on, --url, --profile, --workspace-root, --include-all, --json, and an optional complete changed PATH to limit application. Staging describes the complete selected snapshot, but reuses verified file contents in the runner's snapshot cache and uploads only missing bodies. Workspace creation and successful uploads populate that cache. A cold or disabled cache requires all changed bodies; eviction or corrupt cached content during transfer safely retries the same frozen snapshot. Cache cleanup failures do not prevent that retry. The optional PATH limits application, not snapshot selection. Fetch downloads the retained change bundle. Push uses the normal snapshot selection rules and the workspace's fixed cache bindings. Artifact declarations retain remote outputs; they do not opt ignored local files into an upload. Changing snapshot policy requires a new workspace. Apply is explicit even when run configuration specifies changes.apply_on_success = true.

For persistent workspaces, fetch/apply tracks the last accepted remote source, and push/apply tracks the last accepted local source independently. Destination-only edits never become the merge base. A partially conflicted apply advances clean paths while keeping the old base for conflicted paths. A later job returning to the creation state can therefore undo previously accepted remote changes, even when its creation-relative result is empty. Omitting an artifact from a later job's retention policy does not delete an earlier fetched artifact. Plain fetch and --output continue to expose that job's immutable retained result. There is no direct live-workspace fetch command.

Repeating push after an uncertain apply response completes that exact request before accepting another snapshot, including when repeated without --apply. The CLI identifies the recovered push; run push again to send newer local edits. Completed retries do not overwrite later job edits. If the runner collected a stage before receiving its apply request, the client uploads the frozen source again. After an ordinary staged push, push --apply uses the acknowledged stage without another upload, or prepares a new transfer if local files changed. Repeating a successful fetch/apply with the same job and selection replays its recorded outcome; use a new job result to fetch subsequent remote work.

Transfer output

Push and fetch use the same summary format on stderr:

errand: push: applied 3 changed paths to workspace experiment on mac-mini; 12 KiB transferred in 0.42s
errand: fetch: staged 3 changed paths from mac-mini/JOB_ID; 8.0 KiB transferred in 0.31s

staged means files are available for inspection. applied means the file application completed; it does not check application readiness or hot reload. Fetch uses exported for --output. Recovered pushes are identified separately and still require another push to send newer local edits.

Counts refer to changed paths, including directory changes represented by one root. Staging reports the staged roots (or the requested fetch selection), export reports the selected roots, and apply reports accepted roots from the application outcome. A repeated persistent apply can replay that recorded outcome. Conflicts exit nonzero and never print a success summary.

Bytes measure multipart transfer bodies sent or received during this invocation, including archive framing and metadata, excluding HTTP headers and control requests. They describe the transferred snapshot or bundle, even when PATH limits application. Reusing a staged upload or cached download reports zero; re-uploading a collected stage during recovery counts the new upload. Elapsed time includes local preparation, transfer, and application.

Plain push still prints only its transfer ID on stdout; plain fetch and export print only the resulting path. Both accept --json, with matching status and transfer fields (changed_paths, transferred_bytes, elapsed_ms). Push keeps its existing receipt fields; fetch includes path. Transfer failures include error, and conflicts include conflicts and materialized. Counts on failure are partial and do not establish completion. Flag or target-resolution errors are reported on stderr before a transfer report is available.

df includes local transfer storage under changes and remote transfer storage under its workspace (df --verbose shows a transfers column). Use gc changes --older-than 7d locally or gc changes --on mac-mini --older-than 7d on a runner to collect old staging and unreferenced source bodies. --dry-run previews collection. Accepted checkpoints, creation source bodies, pending applications, and the latest apply receipt stay protected so an uncertain response remains retryable. Removing a remote workspace removes its remote transfer state, while local state remains available for retained job results. Transfer staging is bounded by the existing change byte limit and 4,096 attempts per relationship; collect old staging when full. Definitively rejected workspace creation removes its local origin snapshot. An uncertain creation outcome preserves that snapshot; check workspaces before retrying. GC preserves damaged transfer state, continues collecting healthy relationships, and reports partial progress with a nonzero exit status. Transfer state for a workspace whose directory was moved or deleted is kept and reported as skipped, not failed; GC collects it again if the directory returns. df reports incomplete inventory instead of waiting for a busy local transfer; gc changes --dry-run counts such relationships as protected. Concurrent inventory and dry-run reads do not mark one another as active transfers. Uploads to the same workspace wait for the preceding upload to finish; commands remain independent. df includes temporary upload bytes in workspace transfer storage, including an upload whose target workspace has just been removed.

Whole source snapshots and retained source bodies use the workspace byte limit; individual deltas and attempt staging use the change byte limit. An unchanged large source file therefore does not consume the delta allowance. Historical source bodies still consume storage, and pinned bodies cannot be reclaimed by GC.

Profiles and configuration still provide peer, environment, workdir, forwarding, and apply preferences. Persistent workspace selection requires the explicit --workspace flag or run.workspace in an explicitly selected profile. Creation freezes ignore rules and named-cache bindings; restored cache directories stay live inside the persistent tree. Ignored files generated inside the tree survive for later jobs, but returning them still requires artifact declarations. Per-run artifact flags can override retention. Creation-time artifacts are inherited during reuse. Explicit flags or a selected profile can override them; later ambient config changes do not replace the workspace's defaults. Explicit conflicting cache bindings are rejected. Cache paths are real directories shared by members of that workspace. Changed trees are saved when the final successful member returns. Independent workspaces can use the same named caches concurrently, with the normal responsibility to coordinate writes to shared hardlinked files. Errand does not rewrite embedded absolute paths or tool configuration. See named caches.

df includes persistent workspace storage. Job and cache GC never collect the persistent tree. workspaces rm explicitly removes an idle workspace and its creation snapshot, leaving existing job receipts and retained results available under their normal retention rules. Listing, creation, and removal accept --json; all accept --on or --url and work with local, SSH, and tailnet peers.

df --verbose (-v) breaks workspace storage into working files, the creation base, transfer data, and metadata. Named caches and job storage are listed separately, so shared caches are never charged again to each workspace. Sizes are logical regular-file bytes, not unique disk allocation; filesystem clones can share physical blocks. Cache sizes reflect the last lease release, not live growth.

ps --workspace NAME filters jobs before the runner's listing limit. It keeps the usual active-only default; add --all (-a) or --last N (-n N) for history. The filter accepts an ID too, including after workspace removal while receipts remain. Names select the current workspace: recreating a name does not include jobs from the previous workspace. Across peers, missing names match no jobs. --json includes each job's workspace_id.

Attached sessions and forwarding

An attached terminal can detach at any time with Ctrl-D and later resume with errand attach HANDLE. Ctrl-C keeps its Unix meaning: it sends SIGINT to the remote command, and a second Ctrl-C force-kills it. Interactive detachment returns 0 for the detach action; it is not the unfinished job's exit status. Non-terminal EOF is ignored, so scripts remain attached unless they request --detach explicitly.

To read a job's output without following it, add --no-follow: attach prints what the job has written so far and exits 0, leaving the job alone, with no Ctrl-C forwarding and no workspace apply. That answers "did the service start?" from a script or an agent:

errand attach --no-follow --tail 100 gb10/JOB_ID
errand attach --no-follow --since 10m gb10/JOB_ID | grep "socket connected"

--tail N starts with the last N lines, counted across stdout and stderr in the order the runner saw them, and --since skips output written before a duration ago (10m) or an RFC 3339 time. Both also work on a followed attach, which then continues live from there.

--forward [LOCAL:]REMOTE opens TCP listeners on IPv4 and IPv6 local loopback for the attached session. It is repeatable and can be added when initially running a job or on any later attach. Omitting LOCAL uses the remote port locally. The runner end connects to 127.0.0.1:REMOTE, then [::1]:REMOTE, on the runner itself, so a service bound only to loopback is reachable and should stay that way. Binding 0.0.0.0 is never needed for forwarding and exposes the port to everything that can reach the runner. A connection fails with "connecting to job port" when nothing listens on runner loopback at that port. Ctrl-D closes the attachment's listeners and active connections without stopping the job. Forwarding is not remembered, and --detach --forward is rejected because no attached client would remain to own the listener. Host jobs share the runner's loopback network, so Errand cannot prove that a listening service belongs to the selected host job. Capability-based runners require the separate forward-own action. Forwarded connections carry bytes in both directions while open; EOF or reset on either side closes that connection.

Retained changes, fetch, and apply

After every command, Errand compares the final remote workspace with the submitted snapshot and retains every snapshot-representable created, modified, or deleted path. No declaration is required. Every submitted path is compared. New paths are retained only when allowed by the ignore policy frozen during snapshot selection, so a remote command cannot widen retention by rewriting .errandignore or .gitignore. Git metadata, Errand apply transactions, and transient filesystem nodes such as sockets are excluded. Failed commands retain their changes too, which makes reports, traces, and partial build products available for inspection. A --no-snapshot job starts from an empty baseline, so every file it creates is retained.

A run reports retained changes and the job handle but does not download them unless --apply or apply_on_success requests automatic application after a completely successful transaction. The policy survives explicit --detach and interactive Ctrl-D; failed commands never apply automatically. The originating client records background application progress and errors, which errand status HANDLE reports alongside the changed paths. errand attach HANDLE follows logs and status only; it never downloads or applies workspace changes. errand fetch HANDLE [PATH] stages all changes or any retained path. Applying a selected path requires a retained change root or an ancestor of one; errand fetch --apply HANDLE [PATH] performs a three-way merge from the submitted snapshot, the current local workspace, and the completed remote workspace. Errand applies the result only when the entire selected merge is clean. On conflict, the working tree remains untouched and the staged base and remote trees remain available. errand fetch --apply --conflicts HANDLE [PATH] explicitly chooses a non-clean state: clean changes are applied, text conflicts receive standard conflict markers, and binary or type conflicts keep their local values. It exits nonzero, reports unresolved paths, and keeps the base and remote values in staging for manual resolution. Deletions use the same merge rules; fetching only deleted paths returns the retained bundle.json tombstone metadata because no file exists to return. A different machine can fetch and inspect changes, but cannot apply them without the originating machine's workspace identity record. Change records are scoped by runner endpoint and job ID, so one peer cannot reuse another peer's apply state.

Use errand fetch --output DIR HANDLE [PATH] (short form -o DIR) to copy retained remote files into a directory of your choice. For example, errand fetch -o ./results HANDLE reports/test.html creates ./results/reports/test.html. Paths stay relative to the submitted workspace; the export contains remote values, without the staging metadata or base tree. The parent directory must exist and DIR must not exist, even as an empty directory or symlink. Errand verifies the selected contents before publishing the complete directory without overwriting an existing destination. Exports work for failed jobs and from a different client machine. They do not merge into the originating workspace or mark changes as applied, and cannot be combined with --apply or --conflicts. Selecting a deleted path has no remote value to export; use plain fetch to inspect its deletion metadata.

Artifacts and caches

Runs summarize configured caches and artifacts on one line. Use --verbose (or -v) before -- to show each cache binding and retained artifact path.

To retain generated outputs that Git or .errandignore excludes, declare exact workspace-relative files or directories:

# .errand.toml
[artifacts]
paths = ["reports", "dist/app"]

Or use errand --artifact reports -- make test; repeat --artifact for multiple paths. --no-artifacts clears configured declarations. Declarations select remote outputs without uploading local ignored files. Failed jobs retain their outputs too; missing outputs are allowed. Fetch, export, and apply use the same retained bundle and conflict rules. See artifact configuration for profiles, precedence, and limits.

To reuse disposable build data on a runner, use --cache compiler=target or configure [caches] with compiler = "target". --no-caches clears configured bindings. Cache contents stay on the runner and are excluded from both input snapshots and fetched results. Every binding uses the same directory-preservation mechanism. For example, [caches] with dependencies = "node_modules" lets a separate errand -- pnpm install supply installed files to a later errand -- pnpm test. Monorepos need explicit bindings for nested installed directories too. Directory groups can declare those package caches together. See named caches for concurrency, hardlink behavior, path-portability limits, recovery, and cleanup.

Git is not required for non-Git snapshots, running jobs, status, logs, or plain fetches. Applying changes needs git merge-file on the client only when both the local and remote sides changed the same text file and a true three-way text merge is required. Missing Git fails that apply safely before installation.

What a job runs with

A job is a plain process on the runner, not a login session. It gets no terminal and no stdin, and the runner does not source shell profiles; use sh -lc '...' when a command depends on them. Its environment starts from a small allowlist of the runner's own variables, then adds declared variables and ERRAND_JOB_ID. When the snapshot came from a Git commit, ERRAND_GIT_COMMIT and ERRAND_GIT_DIRTY are set too; --no-snapshot jobs, non-Git directories, and repositories without a commit don't get them.

On macOS and Linux runners the allowlist is PATH, HOME, USER, LOGNAME, LANG, and TMPDIR, plus XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS when the runner has them. On Linux, errand setup runs the runner as a systemd user service, whose manager sets XDG_RUNTIME_DIR (and, on most distributions, the user bus address), so jobs can use systemctl --user directly. Windows runners pass Windows variables instead, such as USERPROFILE, APPDATA, TEMP, SystemRoot, ComSpec, and PATHEXT, and none of the Unix-only names above.

Jobs share the runner's network, including its loopback. Bind services to 127.0.0.1 and reach them with --forward (see above).

Every ordinary run starts in a fresh workspace that is removed after the job finishes, so a restarted service starts from the files you sent. Keep state that must survive restarts, such as a SQLite database, either in a persistent workspace or in a runner path outside the workspace that the service is configured to use. To avoid reinstalling dependencies on every restart, declare them as a named cache. For npm:

# .errand.toml
[caches]
dependencies = "node_modules"
errand --on mac-mini -- npm install --prefer-offline --no-audit
errand --on mac-mini --detach -- npm start

Install in its own job, and run it again when dependencies change. A cache is saved only when a job exits 0, and a service usually ends by being killed, so anything a service job installs into the cache is discarded.

npm ci deletes node_modules before installing, so it discards a cached tree; npm install keeps it and only installs what changed. npm's own download cache in the runner's ~/.npm is reused between jobs either way.

Job state and control

Bare errand ps queries every explicitly configured peer, merges the results newest-first by job ULID, and shows active jobs only. The active filter is applied by each runner before its bounded receipt window, so retained terminal jobs cannot hide a long-running job. --all includes terminal receipts; --last N includes all states and applies one global limit after merging. --on and --url explicitly narrow either view to one runner. Bare errand peers, errand df, and errand workspaces follow the same all-configured-peers rule. df groups local runner storage and fetched changes into one local row. df --verbose (-v) adds individual workspace, named-cache, and job storage tables. df --verbose --json includes the corresponding details object; runners must return the requested inventory details. Remote rows also include changes fetched by that runner's OS account. See storage maintenance for accounting and cleanup details. These read-only fleet commands share target selection, concurrent querying, partial-failure reporting, and exit semantics. Commands that mutate runner state remain explicitly single-runner.

Interactive terminals show wrapped job cards with complete commands. Piped or redirected output uses a compact plain table for tools such as grep and head. Both forms show admission and process start times, process duration, project, source snapshot, command, and terminal outcome. WORKDIR appears only when a listed job runs below its workspace root. Git sources are shown as a short commit plus +dirty when applicable; errand ps --json retains exact structured project, workdir, commit, and manifest metadata, including truncation flags. Running durations are measured on the runner, so caller and runner clock offsets do not distort them.

errand status HANDLE shows one job's complete human-readable execution view: runner, state, command, timing, process and transaction outcomes, retained-log availability, retained workspace changes, and relevant next commands. errand status --json HANDLE emits the same owner-visible data as structured JSON. ps remains the multi-job listing; attach, fetch, and kill remain the actions on a selected job.

Client-side workspace identities, downloads, and apply transactions are stored under $XDG_STATE_HOME/errand, or ~/.local/state/errand when XDG_STATE_HOME is unset. XDG_STATE_HOME must be absolute and can point to a writable private location in restricted agent environments. Errand avoids changing an already-secure directory. It attempts to tighten excess read access but only refuses to proceed when ownership, missing owner access, or permissions writable by another user would make the state untrustworthy.

The remote job states have deliberately narrow meanings:

  • staging: admitted and preparing its workspace, but the command has not started.
  • queued: staging is complete and the job is waiting for a running slot. Its process start and duration remain blank.
  • running: the command has started and has not reached a terminal result.
  • exited: the command reached a terminal result, including ordinary nonzero exits and signals forwarded to or raised by the process.
  • killed: Errand terminated the job because of a user request or enforced limit.
  • ambiguous: Errand cannot prove a clean terminal outcome, commonly after restart reconciliation or a receipt persistence failure. It never silently replays the command.

detached is a local attachment condition, not a remote job state. Detaching with Ctrl-D leaves the job unchanged. Ctrl-C sends SIGINT, and a second Ctrl-C sends SIGKILL. errand kill HANDLE requests graceful SIGTERM termination; errand kill --force HANDLE sends SIGKILL. Staging and queued jobs can be cancelled durably before they start. A submission is rejected as busy only when both the configured running slots and bounded queue are full.

Choose a runner by requirements

For commands that can run on several configured machines, use --where:

errand --where 'os=linux,kvm' -- ./run-vm-tests
errand --where 'go' -- go test ./...

Errand filters for the required capabilities, chooses by available job capacity, and prints the selected peer. The returned job handle works with attach, fetch, and other job commands as usual. Use --on when you want a specific machine, including when continuing a persistent workspace.

Requirements also work in workspace defaults and profiles. See automatic runner selection for syntax, selection rules, local opt-in, and retry behavior.