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.

TOML
[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), base runs 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:

TOML
[tasks.build]
packages = ["apps/*", "packages/*"]
deps = ["^build"]            # every dependency builds first
  • packages is 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, exit 64.

  • Upstream packages are built even when the pattern didn't select them. packages = ["apps/*"] still builds the libraries those apps import.

  • ^name may 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:

Shell
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. ^task still 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)

Shell
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 deps list runs one at a time unless parallel = true;

  • a packages fan-out runs one at a time unless parallel = true.

Nothing runs concurrently unless a task explicitly sets parallel = true. This keeps default behaviour predictable and race-free.

TOML
[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.

TEXT
✗ 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.