# Task runner (/docs/dev/task-runner)



The task runner is what powers [`lpm run`](/docs/dev/run) and the `services` orchestration in [`lpm dev`](/docs/dev/dev). It reads scripts from `package.json` (and per-task config from `lpm.json > tasks`), builds a topological execution graph, runs tasks in parallel where the graph allows, caches results when configured, and re-executes on file changes when `--watch` is set.

This page covers the design — what the cache key includes, how `--affected` is computed, how parallelism interacts with task dependencies, and where state lives. For the CLI surface, see [`lpm run`](/docs/dev/run).

## Execution model [#execution-model]

```text
[scripts] ──┐
            ├─→ [task graph DAG] ─→ [parallel executor] ─→ [stdout / cache]
[lpm.json] ─┘                            │
                                         ↓
                                   [.bin/ PATH injection]
                                   [pre/post hooks]
                                   [env file loading]
                                   [readiness checks]
```

The runner is built as a DAG over tasks. Each task's `dependsOn` edges become predecessors; the executor processes tasks level by level, running independent tasks concurrently within each level.

## Task definition [#task-definition]

A task can come from two places:

```json title="package.json (the npm-shaped path)"
{
  "scripts": {
    "build": "tsup",
    "test": "vitest run"
  }
}
```

```json title="lpm.json (the cached + DAG-aware path)"
{
  "tasks": {
    "build": {
      "command": "tsup",
      "dependsOn": ["^build"],
      "cache": true,
      "outputs": ["dist/**"],
      "inputs": ["src/**", "package.json"]
    }
  }
}
```

`lpm.json > tasks.<name>` overrides `package.json > scripts.<name>`. The `command` field is optional — when absent, the runner falls back to the package.json script.

For full per-task field reference, see [`lpm.json` tasks](/docs/reference/lpm-json#tasksname).

## `dependsOn` semantics [#dependson-semantics]

Two flavors of dependency:

| Form       | Meaning                                                                                                           |
| ---------- | ----------------------------------------------------------------------------------------------------------------- |
| `"build"`  | Same-package dependency. Wait for *this* package's `build` task to finish first.                                  |
| `"^build"` | Upstream-workspace dependency. Wait for `build` to finish in **every workspace member that this one depends on**. |

The `^` prefix is read by [`task_graph::has_upstream_deps`](https://github.com/lpm-dev/rust-client/blob/main/crates/lpm-runner/src/lpm_json.rs). Combined with `workspace:*`, this gives you: "build me only after every internal dep has built."

## Caching [#caching]

Caching is opt-in per task:

```json
{
  "tasks": {
    "build": {
      "command": "tsup",
      "cache": true,
      "outputs": ["dist/**"]
    }
  }
}
```

`cache: true` requires `outputs` to know what to cache. Without `outputs`, caching is silently disabled (the runner has nothing to record).

### Cache key [#cache-key]

The cache key is a SHA-256 hash of:

* The `command` string
* The per-file SHA-256 of every file matching `inputs` globs (default `inputs` covers `src/**`, `lib/**`, `app/**`, `pages/**`, `components/**`, `package.json`, `tsconfig.json`, `tsconfig.*.json`, `*.config.{js,ts,mjs}`)
* The resolved env vars (sorted by key) for the task's `env` mode, if any
* `package.json > dependencies` serialized as JSON (so a declared-dep version bump invalidates the cache)

Any change to any of those flips the key. Same key = cache hit.

Worth being precise about the dep input: it's the **manifest** dep set, not `lpm.lock`. A transitive-only lockfile change (e.g., a sub-dep float that the lockfile picked up) won't invalidate the task cache unless one of the input files actually changed. If you need to gate the cache on lockfile state, list `lpm.lock` in the task's `inputs` globs explicitly.

### Cache layout [#cache-layout]

```text
~/.lpm/cache/tasks/
  {sha256-hex-key}/
    meta.json       ← timing, command, key info, output globs
    stdout.log      ← captured stdout for replay
    stderr.log      ← captured stderr for replay
    outputs.tar.gz  ← archived output files
```

On hit:

1. Restore `outputs.tar.gz` into the project tree.
2. Replay `stdout.log` and `stderr.log` to the current invocation's stdout / stderr.
3. Exit success.

The replay matters — a cached `lpm run build` shows the same logs the real run would have, so you don't end up wondering whether the tool actually ran. The "(cached)" annotation in the runner's progress line tells you why it was instant.

### Bypass [#bypass]

```bash
lpm run build --no-cache       # bypass for one invocation
```

Or remove the `cache: true` from `lpm.json` to disable persistently.

### Remote cache [#remote-cache]

```json title="lpm.json"
{
  "remoteCache": {
    "enabled": true,
    "team": "acme",
    "signature": true
  },
  "tasks": {
    "build": {
      "cache": true,
      "outputs": ["dist/**"]
    }
  }
}
```

Remote cache is opt-in on top of the local task cache. A run checks:

1. Local task cache under `~/.lpm/cache/tasks/`
2. Hosted cache at `remoteCache.url` (default: configured registry + `/v8`)
3. The actual script

Successful scripts always write the local cache first. If remote cache is enabled and not `readOnly`, LPM CLI uploads a portable artifact containing task metadata, stdout, stderr, and `outputs/`. Downloads restore outputs with the same archive hardening as the local cache and replay the captured logs.

Use `LPM_REMOTE_CACHE_TOKEN` in CI, or rely on the token from `lpm login` when the cache endpoint is on the configured LPM.dev Registry origin. Third-party cache hosts never receive the registry login token; they require both `LPM_REMOTE_CACHE_TOKEN` and `LPM_REMOTE_CACHE_SIGNATURE_KEY`. When `signature: true`, set `LPM_REMOTE_CACHE_SIGNATURE_KEY` on every machine that reads or writes the cache. A missing or invalid signature is a cache miss.

Loaded env vars with secret-looking names block remote uploads by default. Use `remoteCache.env.include` only for values that are safe to fold into a shared build cache.

### Manage [#manage]

```bash
lpm cache path tasks           # print cache root
lpm cache clean tasks          # drop the task cache
lpm cache status --json        # local usage + hosted cache status
```

See [`lpm cache`](/docs/packages/cache).

## Parallelism [#parallelism]

```bash
lpm run -p lint test typecheck
```

`-p` / `--parallel` runs scripts concurrently — the runner enforces `dependsOn` edges but otherwise lets independent tasks proceed in parallel. Output is buffered per-task by default; `--stream` prefixes live output instead.

`--no-bail` keeps the runner going past failed tasks and reports the failures at the end. Without it, the first failure stops the run.

Workspace fan-out is separate from per-package task parallelism. `lpm run`, `lpm test`, and `lpm bench` accept `--workspace-concurrency <N>` in workspace mode to cap how many selected members run at once within a topological level. The persistent chain is CLI flag, then `lpm.toml > [workspace].concurrency`, then `~/.lpm/config.toml > workspace-concurrency`, then available host parallelism.

## Workspaces [#workspaces]

Workspace-aware commands accept `--filter`, `--filter-prod`, `--all`, and `--affected`. The set today:

| Command                        | `--all` | `--filter` | `--filter-prod` | `--affected` |
| ------------------------------ | ------- | ---------- | --------------- | ------------ |
| [`lpm run`](/docs/dev/run)     | ✓       | ✓          | ✓               | ✓            |
| [`lpm lint`](/docs/dev/lint)   | ✓       | ✓          | ✓               | ✓            |
| [`lpm fmt`](/docs/dev/fmt)     | ✓       | ✓          | ✓               | ✓            |
| [`lpm check`](/docs/dev/check) | ✓       | ✓          | ✓               | ✓            |
| [`lpm test`](/docs/dev/test)   | ✓       | ✓          | ✓               | ✓            |
| [`lpm bench`](/docs/dev/bench) | ✓       | ✓          | ✓               | ✓            |

`lpm test` and `lpm bench` claim the same flag names from any underlying runner that uses them (notably bun's `--filter`); to forward those to the runner instead of LPM CLI, prefix with `--` — see [Test & bench runners](/docs/dev/test-bench-runners#forwarding-the-same-flag-names-to-the-runner).

Across workspace-aware commands, `--all` is the broad selector and is mutually exclusive with filters and `--affected`. Filters may compose with `--affected`.

The runner walks the workspace graph and selects matching members in topological order:

```bash
lpm run build --all                         # every member, deps-first
lpm run test --filter web                   # one member
lpm run test --filter './apps/*'            # path glob
lpm run test --filter '@scope/*{./apps/web}' # combined name + exact path
lpm run test --filter web --filter api      # union
lpm run test --filter-prod ...shared        # prod graph closure
lpm run test --filter '!web-tests'          # exclusion
lpm run test --filter './apps/*' --workspace-concurrency 2

lpm lint --filter './apps/*' --fail-if-no-match
lpm fmt --filter '@scope/*' --check
lpm check --affected --base develop
```

`lpm lint`, `lpm fmt`, and `lpm check` add `--fail-if-no-match` for CI pipelines that want to catch typo'd filters early. They don't expose `--parallel` or `--no-bail` — workspace fan-out is parallel by default within each topological level. The run continues across all levels even if individual members fail; the overall command exits non-zero after aggregation when any member failed.

Filter grammar is documented in [Workspaces](/docs/packages/workspaces#filter-grammar).

## `--affected` [#--affected]

```bash
lpm run test --affected                     # default base = main
lpm run test --affected --base develop
lpm run test --affected --changed-files-ignore-pattern '**/README.md'
lpm run test --affected --test-pattern '**/*.test.js'
```

Computed by:

1. `git diff --name-only $base...HEAD` against the base ref.
2. Map each changed file to a workspace member (with proper directory boundary checks — `packages/api-client/x.ts` does **not** match the member at `packages/api`).
3. Root-level changes (files outside any member, e.g. workspace `package.json`, `tsconfig.json`) are treated as affecting **every** member.
4. Expand the directly-changed set through dep edges to include transitive dependents.

That last step is what gives you "the API package changed, so the web package that imports it also runs." Use `--filter '[origin/main]'` (the git-ref filter atom) for the directly-changed-only set without the dependents expansion — see [Workspaces](/docs/packages/workspaces#filter-grammar).

Use `--changed-files-ignore-pattern <glob>` to drop noise paths from the git diff before this mapping step. The same setting can be persisted as `lpm.toml > [workspace].changed-files-ignore-pattern`.

Use `--test-pattern <glob>` to mark matching changed files as test-only. The directly changed package still runs, but dependents are not added when a package only changed in test files. Persist defaults as `lpm.toml > [workspace].test-pattern`.

## Watch mode [#watch-mode]

```bash
lpm run dev --watch
```

The runner sets up a file watcher over the task's effective inputs (same globs as the cache key) and re-runs on change. Pairs nicely with `--filter` for "rebuild only the affected member" loops during development.

## PATH injection [#path-injection]

Every script runs with `node_modules/.bin/` prepended to `PATH`, so locally-installed binaries (`tsc`, `vitest`, etc.) resolve. Pre and post hooks (npm convention — `prebuild`, `postbuild`) run automatically when the corresponding scripts exist.

## Env loading [#env-loading]

Before each task starts, the runner:

1. Looks up the env file for this task: `lpm.json > tasks.<name>.env` first, then `lpm.json > env.<name>` script-mode mapping, then `.env` / `.env.local` defaults.
2. Loads the resolved file(s).
3. Validates against `lpm.json > envSchema` (skip with `--no-env-check`).
4. Injects into the script's environment.

CLI override: `--env=<mode>` forces a specific mode for one invocation, regardless of the `lpm.json` mappings.

## Multi-service orchestration [#multi-service-orchestration]

When `lpm.json > services` is non-empty, [`lpm dev`](/docs/dev/dev) starts each service via the runner and waits for readiness:

* Ownership-verified listener on each managed service port
* Optional additional TCP poll on `readyPort`
* Optional additional HTTP poll on `readyUrl`
* `readyTimeout` seconds before failure

Services with `dependsOn` start in topological order. The resolved primary endpoint receives LPM CLI-owned HTTPS, tunnel, and LAN frontends plus the browser-open. A one-service config is implicitly primary; multi-service configs that use those features must mark exactly one service `primary: true`.

See [`lpm dev`](/docs/dev/dev) and [`lpm.json` services](/docs/reference/lpm-json#servicesname).

## See also [#see-also]

* [`lpm run`](/docs/dev/run) — CLI surface
* [`lpm dev`](/docs/dev/dev) — services + dev-server orchestration
* [`lpm cache`](/docs/packages/cache) — task cache management
* [`lpm.json` tasks](/docs/reference/lpm-json#tasksname) — full task config reference
* [Workspaces](/docs/packages/workspaces) — `--filter` grammar
