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. |
With workspace:*, ^build means: "build this package after each internal dependency finishes its build task."
LPM CLI expands ^task recursively. If you filter one application, its
required upstream tasks still run through the complete workspace dependency
chain. Each upstream workspace member must define the requested task.
Before LPM CLI starts a task process, it checks the complete expanded graph.
The command stops for a missing task, a cycle, ^, or ^^task. No task process
starts after one of these graph errors.
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, the
runner disables caching because it has nothing to record.
All inputs and outputs values must be valid, project-relative globs. LPM
rejects invalid syntax, absolute paths, and parent traversal before it starts
the task.
Cache key
The cache key is a SHA-256 hash of:
- The
commandstring - Each forwarded script argument, with length boundaries
- The per-file SHA-256 of every file matching
inputsglobs - The cache identities of transitive same-project task dependencies
- The declared outputs of direct same-project task dependencies
- The complete
lpm.jsonfile, including dependency edges and output globs - The inherited environment after credential and runtime-hook removal, or the variables selected by
cacheEnv - All environment values loaded from project files, secrets, and schema defaults
- Managed Node and Bun versions, plus fingerprints of the selected executables
- The complete
package.jsoncontract, including scripts and resolution fields - Supported lockfiles in the project root
- The workspace-root configuration and lockfiles during workspace task runs
- The task identity of each reachable
^taskdependency
Any change to any of those flips the key. Same key = cache hit.
To reuse output across machines or CI jobs, declare the inherited variables that affect a task:
{
"tasks": {
"build": {
"cache": true,
"cacheEnv": ["NODE_ENV", "BUILD_TARGET"],
"outputs": ["dist/**"]
}
},
"remoteCache": { "enabled": true }
}With this configuration, changes to LPM_HOME, CI, or GITHUB_RUN_ID do not change the cache key.
If your build uses CI to change behavior, include CI. Include every inherited variable that can affect the result.
Names are exact and case-sensitive. Wildcards are not supported.
If you omit cacheEnv, all inherited values remain cache inputs. An empty list excludes all inherited values from the key.
Project-loaded environment values always remain cache inputs. cacheEnv does not change the task's environment or bypass remote-upload secret checks.
A local dependency identity includes its cache key and each reachable dependency identity. LPM also validates each declared dependency output before a cache hit or cache publication. If an output changes, LPM does not use or publish the downstream cache entry.
LPM canonicalizes package.json before it hashes the manifest. Changes to
scripts, module settings, dependencies, peer metadata, overrides, resolutions,
catalogs, engine and platform selectors, and LPM controls invalidate the cache.
LPM always hashes lpm.lock. If the text lockfile is absent, LPM hashes
lpm.lockb instead. When these ecosystem lockfiles exist, LPM also hashes
them:
package-lock.jsonnpm-shrinkwrap.jsonyarn.lockpnpm-lock.yamlbun.lockbun.lockbdeno.lock
For workspace task runs, LPM also hashes the root package.json, lpm.json,
and pnpm-workspace.yaml files. The root lockfiles use the same rules as the
member lockfiles.
Each ^task identity includes its command, arguments, environment, runtime,
inputs, configuration, manifest, lockfiles, and upstream task identities. An
upstream identity change invalidates the cache of each reachable downstream
task. If LPM cannot calculate an upstream identity safely, it disables caching
for the dependent workspace member.
Cache layout
~/.lpm/cache/tasks/
{sha256-hex-key}/
meta.json ← timing, command, key, output count
stdout.log ← captured stdout for replay
stderr.log ← captured stderr for replay
outputs.tar.gz ← archived output filesOn hit:
- Validate the metadata, logs, archive paths, file types, sizes, and output count.
- Stage all outputs without changing the project.
- Remove declared output files that are absent from the cached result.
- Replace project outputs in one rollback-protected transaction.
- Preserve each archived file's mode and modification time.
- Replay
stdout.logandstderr.logto the current process. - Exit successfully.
LPM treats an incomplete or corrupt local entry as a cache miss. It runs the task and replaces the bad entry. Concurrent writers for one key publish one complete entry.
LPM writes a recovery journal before it replaces an output. If a process stops during restore, the next restore rolls back the incomplete operation first. If an immediate rollback fails, LPM keeps the recovery files and reports their path.
The restore rejects output paths that use an existing symlink or junction. The cache cannot write through that link to a location outside the project. When LPM creates an archive, it also rejects symlink and junction outputs. This rule prevents a restored output from changing its file type.
The replay shows the same logs as a real lpm run build. The runner's
(cached) status tells you why the result was immediate.
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 CLI uploads a portable artifact. The artifact
contains task metadata, stdout, stderr, and outputs/. LPM validates the
complete download before it changes project files. A rejected download leaves
the project unchanged. The restore uses the current project's outputs globs.
It does not trust output declarations from the remote artifact.
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 or inherited env vars with secret-looking names block remote uploads by default.
Use remoteCache.env.include only for values that are safe to store in 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 continues independent tasks after a failure. It does not run a
workspace member if that member's explicit ^task dependency fails. Without
this flag, 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 CLI, 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:
- Selects the environment:
--envfirst, thentasks.<name>.env, thenenv.<name>, then the default files. - 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.
Existing environment files must resolve inside the project directory. The runner rejects configured paths and symlinks that resolve outside the project.
Multi-service orchestration
When lpm.json > services is non-empty, lpm 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 readyTimeoutseconds before failure
Services with dependsOn start in topological order. A readiness failure stops the initial startup. Dependent services do not start.
Each service receives the root managed runtimes, except for runtimes that it selects in its own cwd.
Shutdown stops the complete tracked service process trees.
The resolved primary endpoint receives LPM CLI-owned HTTPS, tunnel, and LAN frontends plus the browser-open.
One service is the implicit primary service. A multi-service configuration must mark one service as primary: true to use these features.
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