expctl is a small Git-backed CLI for handing off asynchronous experiments.
An author publishes an immutable request; a runner reviews, submits, monitors,
and collects it. Git preserves the handoff and its audit trail.
It supports two execution backends: SLURM and direct execution on a Linux
compute node. A request pinned to SLURM can also be run on a single host with
expctl submit --backend local, which leaves the request, the receipt, and the
collected evidence identical. It is not a scheduler, workflow engine, or
experiment tracker.
Every machine needs Git and Python 3.11 or newer. Direct execution requires a
Linux host with /proc, plus bash to run a SLURM script locally. The SLURM
backend also needs sbatch, squeue, sacct, and scancel; node-budget
checks use scontrol.
uv tool install git+https://github.com/xfeng01/expctlUpgrade with uv tool upgrade expctl. For zero-install use, copy
src/expctl/core.py and run python core.py <command>; it uses only the
Python standard library.
Run all commands inside the target Git working tree.
A request is a TOML file that pins the code, the script to run, and the question the run answers. Git carries it from the author to the runner and the results back; expctl never commits or pushes for you.
Once per repository:
expctl init # writes expctl.toml and expctl/; edit, then commit
expctl doctor --backend slurm # on the execution host, or --backend localAuthor a request. new pins the current HEAD, so commit the code the
experiment needs first:
expctl new lr-sweep --title "Learning-rate sweep" # expctl/requests/<today>-lr-sweep.toml
# fill in the question, decision rule, outputs, and metrics, and keep exactly
# one backend table: [slurm] or [local]
expctl validate lr-sweep
git add expctl/requests && git commit -m "Request lr-sweep" && git pushRun it on the execution host:
git pull --ff-only
expctl submit lr-sweep --dry-run # preview the backend, commit, worktree, and command
expctl submit lr-sweep
expctl status lr-sweep --watch # refresh until the job finishes
expctl logs lr-sweep --follow # optional: stream the log
expctl collect lr-sweep # copy logs, extract metrics, record the verdictWhen several machines run requests from one repository, push each receipt as
soon as submit returns:
git add expctl/results && git commit -m "Submit lr-sweep" && git pushA request counts as submitted only once its results/<id>/receipt.json
exists, so a machine that has not pulled the receipt would submit the request
a second time. A job is checked, cancelled, and collected on the machine that
runs it; anywhere else, expctl says where to go, for example
On gpu-server: expctl status lr-sweep.
Conclude:
expctl report lr-sweep # scaffold results/<id>/report.md, then write the conclusion
git add expctl/results && git commit -m "Results for lr-sweep" && git pushWhenever you are unsure what comes next, run expctl with no command. It
lists every experiment that is not reviewed yet, each with the exact command
it needs next:
OPEN EXPERIMENTS
EXPERIMENT ID STATUS NEXT
----------------- --------- -----------------------------
20261014-lr-sweep requested expctl submit lr-sweep
20261012-decoder RUNNING expctl status decoder --watch
20261011-stagger COMPLETED expctl collect stagger
20261009-metric collected expctl report metric
3 reviewed experiments not shown: expctl list --status reviewed
Every command, by workflow stage: expctl --help
To repeat an experiment, expctl rerun lr-sweep copies it to a new ID for the
same code, while expctl new lr-sweep-v2 --from lr-sweep pins the current
HEAD instead. expctl cancel stops a job and records who stopped it and
why, and expctl clean removes a worktree once its results are collected.
expctl --help lists every command grouped by workflow stage.
Complete command reference:
# Show every experiment that is not reviewed yet with the command it needs
# next; several requests to submit or jobs to collect also get the --all form.
expctl
# Initialize expctl in the current repository.
expctl init
# Check repository and backend readiness. --cluster is an alias for
# --backend slurm.
expctl doctor [--backend slurm|local] [--cluster] [--json]
# Create a request from the repository template and fill in Git metadata.
# A bare short name such as lr-sweep is prefixed with today's date. --from
# copies every setting of an existing request but pins the current HEAD.
expctl new <id> [--from ID] [--title TEXT] [--allow-dirty] [--json]
# List, filter, sort, and limit experiment requests.
expctl list [--table | --tsv | --json] [-l | --long] [--status STATUS[,STATUS...]] [--sort newest|oldest] [--limit N | --all] [--no-color]
# Describe one validated request. A terminal gets a summary of its question,
# decision rule, code, backend, outputs, and recorded state with next steps;
# redirected output or --json prints the request as JSON.
expctl show <id> [--json]
# Validate the request, pinned commit, script, and resource policy; a terminal
# also gets a one-screen summary of what was pinned.
expctl validate <id> [--json]
# Validate every request, oldest ID first, and exit non-zero if any is
# invalid; suitable for CI or a pre-commit hook.
expctl validate --all [--json]
# Preview or submit through the backend declared by the request.
# --backend local runs a [slurm] request's pinned script on this host.
expctl submit <id> [--backend slurm|local] [--dry-run] [--worktree-root DIR] [--skip-node-check] [--allow-login-node] [--json]
# Submit every request that has no receipt yet, oldest ID first. Each request
# is validated and submitted on its own; failures are reported and the rest
# continue, and the exit status is non-zero if any failed.
expctl submit --all [--backend slurm|local] [--dry-run] [--worktree-root DIR] [--skip-node-check] [--allow-login-node] [--json]
# Show detailed state once, or refresh until the job finishes.
expctl status <id> [--watch [SECONDS]] [--json]
# Show the last 100 log lines, or keep following new output.
expctl logs <id> [--tail N] [--follow] [--worktree-root DIR]
# Preview or request an audited cancellation.
expctl cancel <id> [--reason TEXT] [--dry-run] [--json]
# Copy logs, extract metrics, and record the execution verdict.
expctl collect <id> [--worktree-root DIR] [--json]
# Collect every submitted request that is not collected yet, oldest ID
# first. Jobs still queued or running are reported and skipped; any other
# failure is reported, the rest continue, and the exit status is non-zero.
expctl collect --all [--worktree-root DIR] [--json]
# Preview or remove the verified worktree after collection.
expctl clean <id> [--dry-run] [--worktree-root DIR] [--json]
# Scaffold results/<id>/report.md from the request, receipt, and metrics.
expctl report <id> [--json]
# Create a new request for the same pinned code. --as also accepts a bare
# short name, which is prefixed with today's date.
expctl rerun <id> [--as NEW_ID] [--reason TEXT] [--json]
# Show version or help.
expctl --version
expctl --help
expctl <command> --help
Every <id> that names an existing request can be shortened. The short name
after the date (cross-probe for 20260829-cross-probe) or any part of the ID
that matches exactly one request is enough, and @ names the newest request in
list order. An ambiguous reference is refused with the matching IDs, and the
expansion is echoed on stderr, for example expctl: cross-probe -> 20260829-cross-probe. PowerShell needs the quoted form '@'.
submit, status, logs --follow, cancel, collect, and clean run on the
Linux execution host. The other commands also work on Windows; non-following
logs works wherever its log path is available.
Human-oriented lifecycle commands show aligned detail and next-step tables in
a terminal. Redirected output stays JSON unless the command emits raw logs;
use --json to force JSON where supported.
The bare expctl overview refreshes states exactly as list does and orders
open experiments by stage: requests to submit, jobs queued or running,
finished jobs to collect, jobs only another machine can check, collected
results awaiting a report, and anything that needs attention (an invalid
request, an unreadable receipt, or a submission without a confirmed job ID).
A local job that runs on another host names that host
(On gpu-server: expctl status decoder), and a SLURM job seen from a host
without squeue or sacct names the cluster. Commands name each request by
its short name when no other request shares it. Redirected output is a JSON
object whose experiments rows carry stage and next; the stages are
submit, running, collect, elsewhere, report, and attention.
Outside an initialized repository, expctl prints the grouped help and the
reason.
In a terminal, list renders an aligned table. Redirected output defaults to
TSV. For example:
EXPERIMENT ID STATUS TITLE
---------------------------- --------- ---------------------------
20260829-stagger-control FAILED Iso-cost stagger control
20260829-decoder-metric PENDING Decoder-induced metric
20260829-decoder-cross-probe RUNNING Decoder block-RoPE probe
SLURM array tasks with mixed states show MIXED. If SLURM is unavailable or
returns no state, list warns once on stderr and keeps affected SLURM rows as
submitted. Local rows are refreshed from the recorded process and status
file. Receipts are never modified by a refresh.
list --long (or -l) adds the backend, job ID, submission time, and
execution host recorded in each receipt; a [slurm] request run with
--backend local shows as local (slurm). The table shows submission times
in local time and leaves out detail columns that are empty in every row;
TSV and JSON always carry all of them, with the recorded ISO timestamps.
list sorts newest requests first by default: rows are ordered by the ID's
date, and same-day requests by the commit time that first added their file
(an uncommitted request file counts as the newest of its day). --sort oldest reverses the order. --status is case-insensitive, may
contain comma-separated values, and may be repeated. Filtering uses refreshed
execution states. Each listing verifies pinned commits and scripts with a
couple of batched git cat-file queries rather than one Git process per
request. The configured or command-line limit is applied after filtering and
sorting. Without a status filter, a limited listing validates and refreshes
only those N request paths. Status-filtered listings scan in bounded batches
and stop once N matches are found; --all performs a complete scan.
For SLURM, status falls back to sacct when squeue is unavailable. For
local runs, it checks the recorded process identity and exit-status file. With
--watch, it stops at a terminal state; redirected or --json watch output
is newline-delimited JSON.
expctl new <id> starts from expctl/templates/request.toml with the current
commit, branch, ID, and worktree name filled in; expctl new lr-sweep --title "Learning-rate sweep" creates <today>-lr-sweep with its title. new lr-sweep-v2 --from lr-sweep copies that request's question, backend,
environment, and outputs, replaces only the ID, branch, commit, and worktree
name, and drops any rerun_of. (rerun is for the same code; new --from
pins the current HEAD.)
new refuses a dirty working tree because uncommitted changes are not part of
the pinned HEAD; --allow-dirty is an explicit override. validate rejects
a request that still carries starter-template placeholder values (title,
question, decision rule, script, log glob, metrics, requirements).
validate --all checks every request file in one pass, with the same batched
Git queries as list, and lists each invalid one with its reason. doctor
exits nonzero when a repository check fails; on the execution host add
--backend local or --backend slurm to require that backend's checks. The
legacy --cluster flag is an alias for --backend slurm.
--skip-node-check applies only to SLURM submissions. It bypasses the
cross-job node-budget check and requires explicit operator authorization.
--allow-login-node lets a local job start on a host that looks like a SLURM
login node; see GPUs and login nodes.
logs reads the submitted worktree while a job is active and the collected
result directory afterward. clean only removes a verified worktree after
collection and refuses one still claimed by an uncollected rerun.
Set a repository-wide default row count in expctl.toml:
[display]
list_limit = 20expctl list --limit N overrides it once, while expctl list --all shows all
matching requests. Existing configurations without display.list_limit
continue to show every request.
expctl.toml is shared, but where worktrees fit often depends on the machine.
Set EXPCTL_WORKTREE_ROOT (for example, to a scratch filesystem on the
cluster) to override worktree.root on one machine; ~ is expanded and a
relative path is resolved against the repository root. submit --worktree-root DIR still overrides both for one submission, and doctor
reports which root is in effect.
A request must contain exactly one backend table.
[slurm]
script = "scripts/train.slurm"
max_concurrent_nodes = 1
[slurm.env]
CONFIG = "configs/run.toml"SLURM submissions use sbatch; status, cancellation, and final accounting use
squeue, scancel, and sacct.
slurm.max_concurrent_nodes is optional. When set, the pinned script must
declare a numeric #SBATCH --nodes (array ranges and %N throttles included)
whose worst case stays within it. Omit it to leave the request's node envelope
unlimited; a non-zero scheduler.max_total_nodes still requires a derivable
count, which is then reserved against the budget.
[local]
script = "scripts/train.py"
args = ["--config", "configs/run.toml"]
gpus = 1
[local.env]
MODE = "evaluation"The local backend runs the pinned script asynchronously in its detached
worktree, captures stdout and stderr in the requested log, and records the
supervisor PID, Linux /proc start identity, and exit status. The script must
be executable in Git (for example,
git update-index --chmod=+x scripts/train.py), and
outputs.log_glob must be one exact path containing {job_id}, not a wildcard.
The receipt also pins the execution hostname: local status, cancellation, and
collection must run on that same host. Cancellation sends SIGTERM to the
recorded process group.
Local mode is intended for a machine where compute resources are already allocated. Apart from the GPU claims below, it provides no queue, resource isolation, or restart after the host reboots.
Every local job, from a [local] request or from --backend local, claims
GPUs on its host and sees only those, through CUDA_VISIBLE_DEVICES:
- A
[local]request claimslocal.gpusGPUs, or every GPU whengpusis left out. A[slurm]request run with--backend localclaims what its#SBATCH --gpus-per-node,--gres=gpu,--gpus, or--gpus-per-tasklines ask for, and none when it asks for none, as under SLURM; it also getsSLURM_GPUS_ON_NODEandSLURM_JOB_GPUS. - The host's GPUs are the
CUDA_VISIBLE_DEVICESthatexpctl submititself runs with, or else every GPUnvidia-smilists. Inside a SLURM allocation that is the allocation's GPUs; on a workstation,CUDA_VISIBLE_DEVICES=2,3 expctl submit <id>keeps GPUs 0 and 1 out of expctl's hands. GPUs are numbered the waynvidia-sminumbers them (CUDA_DEVICE_ORDER=PCI_BUS_IDunless it is already set). - A claim is an
flockin/tmp/expctl-gpu-locksthat the job's supervisor holds, so it covers every repository and user on the host and ends when the job ends, however it ends. When too few GPUs are free,submitrefuses and names the jobs holding them; it does not queue.submit --allsubmits the requests that fit and reports the rest, which keep no receipt and are picked up by the nextsubmit --all. local.envcannot setCUDA_VISIBLE_DEVICES. Whennvidia-smiis missing andCUDA_VISIBLE_DEVICESis unset, a job that needs a number of GPUs is refused, and a[local]request withoutgpuskeeps the environment it was started with.
The receipt, status, and the report record the claimed GPUs, and doctor
lists the host's GPUs and which of them are in use.
A host with sbatch but no slurmd is a SLURM login node, and local
execution there is refused: submit the request to the cluster, or run expctl
inside an allocation (srun --pty bash), where SLURMD_NODENAME is set.
--allow-login-node overrides the check on a host that really is meant to run
jobs. doctor --backend local reports it as Execution host.
expctl submit <id> --backend local--backend local runs a [slurm] request's pinned script on the current host
instead of calling sbatch. Nothing else about the request changes: the same
file, worktree, receipt, and collected evidence work whether a colleague sends
it to the cluster or you run it on one machine.
The #SBATCH preamble stays a block of comments in the script. expctl reads it
to reproduce what the scheduler would have done with it:
- the script runs under
bashin its detached worktree, supervised by a detached process group, withSLURM_SUBMIT_DIR,SLURM_SUBMIT_HOST,SLURM_JOB_ID,SLURM_JOB_NAME,SLURM_JOB_USER, the single-node variables, and[slurm.env]exported to every task; #SBATCH --partition,--account,--cpus-per-task,--ntasks, and--gpus-per-nodeare re-exported asSLURM_JOB_PARTITION,SLURM_JOB_ACCOUNT,SLURM_CPUS_PER_TASK,SLURM_NTASKS/SLURM_NPROCS, andSLURM_GPUS_PER_NODE;#SBATCH --arrayruns its tasks one at a time, in index order, each withSLURM_ARRAY_JOB_ID,SLURM_ARRAY_TASK_ID,SLURM_ARRAY_TASK_COUNT,SLURM_ARRAY_TASK_MIN, andSLURM_ARRAY_TASK_MAX. A%Nthrottle is recorded but not honoured: one host has no queue to spread tasks over;#SBATCH --outputand--errorare expanded the way SLURM expands them (%j,%A,%a,%x,%N,%u,%%, and widths such as%4a), and default toslurm-%j.out, orslurm-%A_%a.outfor an array. Every log is created before anything runs, so a submission fails outright instead of part-way through an array.
The job ID is an eight-digit number drawn on this host; an array task's
SLURM_JOB_ID is <job id>_<task id>. The receipt records backend = "local"
with emulated_backend = "slurm", and collect writes the same per-task
job_id, state, and exit_code rows sacct would have produced, so reports
and metrics come out in the cluster's format. status reads the recorded
process and exit-status file instead of squeue and sacct; cancel sends
SIGTERM to the process group instead of calling scancel, which stops the
running task and every array task after it.
submit warns when a single host cannot reproduce the submission: a script
that could occupy several nodes under SLURM, or #SBATCH --output names that
outputs.log_glob does not match. Apart from GPUs, nothing enforces the
#SBATCH resource request — there is no partition, node isolation, or time
limit.
--backend local on a request that already declares [local] changes nothing,
so submit --all --backend local stays usable in a mixed repository.
--backend slurm refuses a [local] request, and --skip-node-check is
refused with --backend local.
Initialization creates:
expctl.toml
expctl/
|-- requests/<id>.toml immutable experiment request
|-- results/<id>/receipt.json submission identity and lifecycle state
|-- results/<id>/logs/ collected execution output
|-- results/<id>/metrics.json extracted metrics
|-- results/<id>/report.md human conclusion
|-- templates/request.toml request template
The lifecycle is:
requested -> preparing/submitting -> submitted -> [cancel_requested] -> collected -> reviewed
reviewed means results/<id>/report.md exists: expctl report scaffolds it
from the request, receipt, and metrics, and list/status show the state,
but no receipt field is written for it. For SLURM, submission_unknown means
sbatch may have accepted the job but no job ID was safely recorded; reconcile
it with SLURM manually.
- A request pins a full commit, backend script and settings, expected logs, and decision rule. Do not edit it after a receipt exists.
submituses a clean detached worktree at the pinned commit. SLURM requests verify resource policy and record a scheduler job ID; local runs record a process identity and exit-status file. A[slurm]request run with--backend localstill verifies its node envelope, but no node budget is reserved: this host is not the cluster.- Each request gets at most one submission attempt. Never delete a receipt to
resubmit; use
rerunto create a new ID. - An unreadable or structurally invalid receipt blocks worktree reuse and cleanup until its ownership evidence is restored; expctl never assumes a corrupt receipt is unrelated.
collectverifies the submitted worktree and publishes results once.collect --alldoes this for every uncollected receipt and skips jobs that have not finished.logs,collect, andcleanuse the worktree recorded in the receipt, provided it still ends in the request'scode.worktreeand lies outside the repository, so a--worktree-rootgiven tosubmitneed not be repeated. Passing--worktree-rootto them asserts that location instead.cancelrecords the operator, time, and optional reason after the backend accepts the request.cleannever removes an uncollected worktree.expctl.tomldefines repository policy and should be committed.expctlnever runsgit add,git commit, orgit push.
The detailed guide (Chinese) covers every request field,
configuration option, and recovery procedure. For an AI runner, adapt
examples/skill/SKILL.md with project-specific
stopping conditions.
uv run pytest -qMIT — see LICENSE.