Graph & parallelism
The dependency graph
deps lists the tasks that must complete before a task runs — these edges form a DAG. Each task runs its deps first, then itself.
[tasks.ci] deps = ["lint", "test", "build"] [tasks."web#build"] run = "vite build" dir = "apps/web" deps = ["ui#build"] # explicit cross-package edge
Explicit cross-package edges (
pkg#task, e.g.ui#build) ship in v1 — they require no graph inference.Shared dependencies are de-duplicated: in a diamond (
ci → a → base,ci → b → base),baseruns once.A dependency cycle is a config error, caught before execution, exit
64.
Topological deps (^task)
The upstream marker ^task means "run this task in the package's dependencies first." tsr reads each package's manifest to build the package dependency graph, then walks it:
[tasks.build] packages = ["apps/*", "packages/*"] deps = ["^build"] # every dependency builds first
packagesis required.^is relative to the package it runs in, and only a fan-out supplies one — on a single-directory task it is a config error, exit64.Upstream packages are built even when the pattern didn't select them.
packages = ["apps/*"]still builds the libraries those apps import.^namemay name a different task than the one declaring it, e.g.deps = ["^codegen"].A shared library is built once, however many dependents need it.
A cycle between packages is a runner error, exit
64.
An edge exists when a declared dependency name matches another workspace package's manifest name — so workspace:*, path = "../ui", replace directives and plain version ranges all work the same way, across npm/bun, cargo, go and python.
Affected detection (--since)
Run a task only where a change could have mattered:
tsr build --since main
The affected set is the packages your changed files live in, plus every package that transitively depends on them. Changing a library selects its dependents; changing an app does not select its libraries.
Changes come from git: commits, unstaged edits and untracked files.
A change outside every package (root config, lockfile, CI workflow) runs everything — it could affect anything, and skipping work is worse than repeating it.
Only the selection narrows.
^taskstill builds a package's dependencies whether or not they changed, so a filtered run stays correct.Nothing affected is a clean exit
0, not an error.A missing repo, unknown ref, or missing git is a runner error, exit
64— never a silent full or empty run.
Resuming a failed run (--resume-from)
tsr build --resume-from packages/ui
Treats every package ordered before packages/ui as already built and runs the rest — including keeping them skipped when a later package reaches them as an ^task upstream dependency. --since and --resume-from compose: a package must survive both filters. Full rules in the CLI reference.
Sequential by default
Execution is sequential by default. Concurrency is opt-in via parallel = true, and the rule is uniform:
a
depslist runs one at a time unlessparallel = true;a
packagesfan-out runs one at a time unlessparallel = true.
Nothing runs concurrently unless a task explicitly sets parallel = true. This keeps default behaviour predictable and race-free.
[tasks.ci] deps = ["lint", "test", "build"] parallel = true # opt into concurrency
Fail-fast
On any failure within a task's batch, tsr fails fast: it stops launching new work and kills still-running siblings, then prints a summary and exits.
✗ ci failed ✓ lint ok 1.2s ✗ test exit 1 3.4s ← failed ⊘ build skipped (killed: sibling failed) exit code: 1
In a parallel batch, "the failure" is whichever child exits non-zero first in wall-clock time; this is non-deterministic across runs and is expected. Fail-fast guarantees at most one failing child's code is reported — and that exact code is what tsr propagates.