Task runner
Parallel, cached, watchable, workspace-aware execution for package.json scripts.
The task runner is what powers lpm run and the services orchestration in lpm 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.
Execution model
[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
A task can come from two places:
{
"scripts": {
"build": "tsup",
"test": "vitest run"
}
}{
"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.
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. Combined with workspace:*, this gives you: "build me only after every internal dep has built."
Caching
Caching is opt-in per task:
{
"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
The cache key is a SHA-256 hash of:
- The
commandstring - The per-file SHA-256 of every file matching
inputsglobs (defaultinputscoverssrc/**,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
envmode, if any package.json > dependenciesserialized 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
~/.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 filesOn hit:
- Restore
outputs.tar.gzinto the project tree. - Replay
stdout.logandstderr.logto the current invocation's stdout / stderr. - 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
lpm run build --no-cache # bypass for one invocationOr remove the cache: true from lpm.json to disable persistently.
Remote cache
{
"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:
- Local task cache under
~/.lpm/cache/tasks/ - Hosted cache at
remoteCache.url(default: configured registry +/v8) - The actual script
Successful scripts always write the local cache first. If remote cache is enabled and not readOnly, LPM 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 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
lpm cache path tasks # print cache root
lpm cache clean tasks # drop the task cache
lpm cache status --json # local usage + hosted cache statusSee lpm cache.
Parallelism
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
Workspace-aware commands accept --filter, --filter-prod, --all, and --affected. The set today:
| Command | --all | --filter | --filter-prod | --affected |
|---|---|---|---|---|
lpm run | ✓ | ✓ | ✓ | ✓ |
lpm lint | ✓ | ✓ | ✓ | ✓ |
lpm fmt | ✓ | ✓ | ✓ | ✓ |
lpm check | ✓ | ✓ | ✓ | ✓ |
lpm test | ✓ | ✓ | ✓ | ✓ |
lpm 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, prefix with -- — see Test & bench runners.
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:
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 developlpm 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.
--affected
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:
git diff --name-only $base...HEADagainst the base ref.- Map each changed file to a workspace member (with proper directory boundary checks —
packages/api-client/x.tsdoes not match the member atpackages/api). - Root-level changes (files outside any member, e.g. workspace
package.json,tsconfig.json) are treated as affecting every member. - 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.
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
lpm run dev --watchThe 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
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
Before each task starts, the runner:
- Looks up the env file for this task:
lpm.json > tasks.<name>.envfirst, thenlpm.json > env.<name>script-mode mapping, then.env/.env.localdefaults. - Loads the resolved file(s).
- Validates against
lpm.json > envSchema(skip with--no-env-check). - 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
When lpm.json > services is non-empty, lpm dev starts each service via the runner and waits for readiness:
- TCP port poll on
readyPort(defaults toport) - HTTP poll on
readyUrl readyTimeoutseconds before failure
Services with dependsOn start in topological order. The primary service receives --https / --tunnel / --network flags from lpm dev and the browser-open.
See lpm dev and lpm.json services.
See also
lpm run— CLI surfacelpm dev— services + dev-server orchestrationlpm cache— task cache managementlpm.jsontasks — full task config reference- Workspaces —
--filtergrammar