Troubleshooting
Symptom, cause, fix. For the exact text of every message Lattice prints, see Errors.
Three commands answer most of these, so reach for them before reading further:
lattice run <task> --dry-run # the graph and every resolved command, run nothing
lattice run <task> -v # hashes, cache decisions, and each task's output
lattice version # which binary actually ran
Detection and configuration
A run halts before any task starts, naming a workspace
Error: workspace 'web' has an ambiguous or undeclared driver.
Candidate drivers: pnpm, npm, yarn, bun
Declare the driver in lattice.json, under this workspace:
"engines": { "pnpm": ">=0.0.0" }
Lattice found more than one tool that could run that workspace’s tasks and will not guess between them. It halts the same way when it finds none, and when the only candidates are runtimes, which cannot drive a named task on their own.
To fix it, add the engines entry to that workspace naming the tool that should
run its tasks. The line in the message resolves the halt, but it names the first
candidate that could drive tasks rather than the one your repo uses, so check it
before pasting it. Where nothing could have driven tasks, the message suggests
"auto": false plus a scripts map instead, because no engines entry would
help. See Driver detection.
A workspace with "auto": false halts on one task
Error: workspace 'api' has "auto": false and declares no command for task 'build'. Add the command under this workspace's "scripts" map in lattice.json
Turning detection off means the workspace’s scripts map is the only source of
its commands, and there is no entry for the task you named. An auto workspace
with no command for a task is skipped quietly; a declared one is an error,
because you said what it runs.
Add the entry, or drop "auto": false and let detection find the tool. See
Workspaces.
A task ran in some workspaces and not others
warn web declares scripts but no "build", so the task was skipped. Did you mean "biuld"?
A workspace driven by npm, pnpm, yarn, bun, or deno can run only a task
its manifest declares as a script. A task the manifest does not declare drops out
of the graph, and the run carries on without it. A misspelled script name and a
workspace with nothing to do for the task look the same, so the warning fires
either way.
To fix a typo, correct the name in the manifest. To give the workspace a command
for the task, add the script to its manifest, or add a scripts entry in
lattice.json. If the workspace genuinely has nothing to do for that task, the
run is already correct and there is nothing to fix. Lattice used to invent
npm run build in that case and fail on it.
--filter does not narrow the warning, so a filtered run can name a workspace it
did not select. To see which workspaces the task resolved in, run
lattice run <task> --dry-run. See
What a driver can run.
A task’s resolved command is not the one you expected
An entry in a workspace’s scripts map always wins. Only a task with no entry
there falls back to what the driver would infer, and inference is not universal:
a JavaScript-family driver needs the task name to exist in the manifest’s own
scripts map, and a driver that takes the task name on its command line without
being a task runner — cargo, go, gradle — never infers a command for a
persistent task, because there is no cargo dev. A task runner does infer
one, since it runs the tasks the repo declared to it.
Print what would run:
$ lattice run build --dry-run
❖ lattice dry run · build
→ ui:build pnpm run build
→ api:build cargo build --release
Each line is workspace:task and the exact command the runner would hand to
sh -c, or to cmd /C on Windows. If it is not the command you meant, add or
edit that workspace’s scripts entry.
--dry-run returns before any toolchain is provisioned and before PATH is
assembled, so it shows the command as written, not as it will resolve at run
time — neither against a provisioned tool nor against a binary the project
installed under node_modules/.bin or .venv/bin.
command not found for a tool the project installed
Lattice puts the project’s dependency bin directories on a task’s PATH, so a
task can name eslint, pytest, or turbo directly. It adds only the
directories that exist. Three things break that, in the order worth checking:
The dependencies are not installed. On a fresh clone there is no
node_modules/.bin to add. Run lattice setup, then the task.
The tool is not a dependency of the project and is not on the host PATH
either. Install it as a dependency, or declare it as an
engine with an installCmd and let Lattice provision
it.
The install lives above the repo root. The walk goes from the workspace
directory up to the repo root and stops there, so a dependency directory outside
the repo is never added — only the inherited PATH can reach it.
--dry-run prints the command but not the PATH it will run under, so it
cannot tell these apart. See Environment
variables for
the directories and their order.
unknown field on a key you believe is valid
Error: unknown field `output` in tasks.build (lattice.json line 3, column 31)
Did you mean `outputs`?
Fields accepted here: dependsOn, inputs, outputs, ignore, env, persistent, cache, timeout
The key is not part of the config at that level, and nothing ran. Most of these are the near miss the message already names. The rest are keys that used to be accepted and are not any more.
Delete the key, or write the one the message names. No setting relaxes this. If
the key really is valid, your binary is older than the config: compare
latticeVersion in lattice.json against lattice version.
the task graph has a cycle
Error: the task graph has a cycle
Two or more tasks depend on each other, directly or through other tasks, so there is no order to run them in. Nothing ran.
The message names the graph rather than the edge, and --dry-run prints no
order for a graph it cannot schedule, so trace it by reading dependsOn on each
task involved. A same-workspace edge is a bare task name; a cross-workspace edge
carries ^. See Task graph.
A validation error names a field in lattice.json
Duplicate workspace names, an empty path, an unparseable maxCacheSize, and a
version-only engine string for a tool Lattice cannot version-check are all
caught before any task starts, with the field and the value in the message. See
Errors for every shape and
Configuration for the field reference.
A key appears twice in the same object
Error: duplicate key `build` in tasks (lattice.json line 12, column 3)
Keep one of them: the second replaces the first, so only the last would take effect
Two entries in tasks, engines, a workspace’s engines, or a workspace’s
scripts share a key. Only the last of the two would survive, so the second
entry would drop the first one’s fields. Delete whichever you do not want.
The position is where the parser finished the object, not where the repeat sits.
Read the whole container the message names rather than that one line. Keep
"$schema": ".lattice/schema.json" in the file and your editor underlines the
repeat as you type it.
A command in scripts is never used
Error: workspace 'core' declares a script 'biuld', but 'biuld' is not defined in `tasks`, so nothing would ever run it
Did you mean `build`?
Defined tasks: build, test
A scripts key supplies the command for the root task of the same name, so a key
that matches no task can never run. Correct the key, or add the task to tasks.
An earlier version accepted the typo, ran the command Lattice detected for the
workspace, and said nothing about the override you wrote.
Caching
A task misses the cache every run
Run it with -v and read the two lines Lattice prints per task. -v is the
only place they appear. The live display leaves them out, because a hit shows its
key on the task’s own line and a miss shows up as the task running.
lattice: ui:build: hash 26be571e2ec773a7
lattice: ui:build: cache miss: inputs changed
The cache miss: line names which part of the key moved, which is where to
look. inputs changed with no source edit means an inputs glob is matching
something that rewrites itself on every run: a timestamp, a log, a
.DS_Store. environment changed on an otherwise identical run usually means
the Lattice version moved. dependencies changed is not a problem on its own,
because an upstream change is supposed to reach downstream.
Narrow the glob, add the file to ignore, or drop the env entry whose value
differs between machines. For what each component name covers, see Cache
internals.
Two misses name no component at all. cache miss (nothing cached for this task yet) means the task has never finished here. cache miss (the entry for this key is no longer in the cache) means the key is unchanged and the entry went:
evicted under settings.maxCacheSize, swept by lattice prune, or rejected as
corrupt.
A task hits the cache when the result is stale
The task reads a file, or an environment variable, that nothing declares.
inputs matches only what its globs say, and env reads only the names you
list, so anything outside both is invisible to the key and a stale result
replays. Neither case warns.
Widen the inputs glob or add the name to env. While you investigate, force a
fresh run with --force, which also replaces the stored entry, or --no-cache,
which stores nothing.
A file above the workspace cannot be named by inputs, because inputs is
relative to the workspace directory. A base tsconfig.json, a shared schema
directory, or a root .env goes in globalDependencies instead, and a
repo-wide variable goes in globalEnv:
{
"globalDependencies": ["tsconfig.base.json", "proto/**"],
"globalEnv": ["NODE_ENV"]
}
A task warns about its outputs and is never cached
lattice: warning: api:build: failed to cache outputs: no files matched outputs ["dist/**"], so nothing was cached. Check that the patterns are relative to the workspace, and that the task writes there
The task declares outputs and produced none of them. Lattice refuses to store
an empty artifact, so the task succeeds, nothing is cached, and it runs again
next time. The warning repeats every run.
Three things cause it. The patterns are relative to the workspace directory and
not to the repo root, so outputs on a task every workspace shares has to match
in all of them. The command may be writing somewhere else, which a look at the
directory after a run will settle. Or the command genuinely produces nothing on
this machine, which is common for a build that skips its native step when the
compiler is missing; drop outputs from that task and let the cache entry
record only that it succeeded.
A second wording covers the near miss:
lattice: warning: api:build: failed to cache outputs: outputs ["dist"] matched only empty directories, so nothing was cached. Check that the task writes its files where the patterns point
Here a pattern did match, and what it matched was a directory holding no files.
A bare outputs: ["dist"] covers dist/ itself, so an empty dist/ counts as
a match even when the task produced nothing. Run the task, then look inside the
directory. If it is empty, the command is writing somewhere else, or not writing
at all. For why Lattice refuses to store that archive, see Archive format and
the output
digest.
The run warns about restoring a cache entry
Restoring outputs into a workspace can fail on permissions or disk space. That is a warning, not a failure: the task ran fresh instead, so what is on disk is correct. A cache hit itself is all-or-nothing. The metadata has to parse, the tarball has to open, and its digest has to match what was recorded, so a corrupt entry is a miss and the task reruns rather than replaying something wrong.
lattice prune leaves files behind
❖ removed 0 artifacts, freed 0B
lattice prune reclaims a leftover from an interrupted run only once that
leftover has sat untouched for an hour. It leaves anything younger where it is,
and those bytes keep counting against settings.maxCacheSize until the hour is
up. To free the space now, delete the cache directory. Otherwise run
lattice prune again later.
The wait keeps two lattice processes sharing one cache directory from deleting
each other’s writes. See the one-hour grace
period.
Every task misses the cache after an upgrade
Expected, once. The running Lattice version is part of every key, so a release that changes what a key covers moves every key with it. The first run afterwards re-runs everything. See Why the Lattice version is part of every key.
The miss line says environment changed, or reports nothing cached for the task
yet. Both have the same cause. The old entries are still on disk, and they age
out under settings.maxCacheSize or lattice prune like any others.
The saved figure is larger than the run took
Expected. The saved figure is task time, not wall clock. Each hit adds the
duration the run that wrote its entry spent, whether or not those tasks would
have run at the same moment, so four cached one-minute tasks on independent
branches report 4m 00s saved on a run that took a second. The elapsed time
in front of it is the clock. See Caching.
lattice stats says no runs are recorded
No runs recorded yet. Run a task and this fills in — every run appends one line.
The ledger is a file inside the cache directory, so anything that clears the
cache clears the history too: deleting .lattice, moving settings.cacheDir, or
a CI job that started from an empty cache. A run also appends nothing when it
could not store — --no-cache — or when it scheduled no task at all, such as a
--filter that matched no workspace.
Run a task without --no-cache and stats fills in from that run onward. There
is nothing to recover: the history is a record, not an input, and losing it costs
the numbers rather than a rebuild.
Toolchains
An engine check fails on one machine and passes on another
Error: engine 'node' on PATH is 26.0.0, which does not satisfy the constraint '>=999'
The constraint has a version and no installCmd, so Lattice checks whatever is
on that machine’s PATH and installs nothing. Machines with different tools
disagree.
Either get every machine’s host tool onto the same version, or add an
installCmd so Lattice installs its own copy under .lattice/toolchains/ per
machine. See Engines and provisioning.
An engine is rejected before anything is checked
Error: engine 'frobnicate' in root uses the string form, which carries only a version. 'frobnicate' is not a well-known engine, so Lattice cannot version-check it on its own. Use the object form with a `versionCmd`, like this: "frobnicate": { "version": ">=1.0.0", "versionCmd": "frobnicate --version" }
The bare-string form of an engines entry works only for the tools Lattice
already knows how to version-check. For anything else, use the object form and
give it a versionCmd. Guessing the flag would be worse than asking.
installCmd fails partway through
Provisioning stages the install into a temporary directory, runs installCmd,
version-checks the result, and only then renames the staging directory to its
final content-addressed path. A failure at any step is fatal and names the step.
Rerun the same command. Nothing was pinned, so provisioning starts over and there is no half-installed toolchain to clean up by hand.
--dry-run says nothing about toolchains
--dry-run resolves commands and returns before any engine is validated or
provisioned. An engine problem surfaces when a task actually runs, or up front
from lattice setup, which provisions the root engines first.
To check that a teammate’s toolchain resolves before handing them work, have
them run lattice setup.
lattice setup says dependencies up to date but a dependency is missing
● web dependencies up to date
The marker recording the last install is newer than every lockfile that governs the workspace, so there is nothing to reinstall. A workspace’s lockfiles are the one in its own directory and every one above it up to the repo root. That is what makes a hoisted npm, pnpm, or yarn tree work, where one root lockfile governs every workspace under it.
Lattice used to check the workspace’s own directory alone. In that everyday
layout a workspace directory holds no lockfile, so nothing could invalidate its
marker, and lattice setup reported dependencies up to date however far the
root lockfile had moved. On a version that still behaves that way, run
lattice setup --force to reinstall regardless.
lattice setup fails on an installer that wants to prompt you
lattice: warning: api: `poetry install` failed
lattice setup gives the install command no stdin, so an installer that stops
for a password, a token, or a confirmation reads end-of-file and exits non-zero.
Its own output, printed as the install runs, says what it wanted.
To supply the credential without a prompt, put a token in an environment
variable, configure a credential helper, or write a .netrc. Otherwise run that
installer once by hand outside Lattice, and let the marker cover it afterwards.
The installer used to inherit the terminal and block on a prompt nothing was
displaying, so the command hung with no output until something killed it.
Output
You expected the live display and got plain lines
Lattice uses raw, line-by-line output whenever stdout is not a terminal, CI is
set to any value, -v/--verbose was passed, or settings.loquacious is
true. A run that pulls a persistent: true task into its graph is raw too,
even at a terminal, so a dev server’s output stays visible.
Redirecting or piping fails the terminal check, and that is the usual cause. Nothing forces the live display back on. See Output and logging.
You wanted plain lines and got the live display
Pass -v, set settings.loquacious to true in lattice.json, or set CI in
the environment. Any one of them gives you the raw stream, which is also the
readable form when you are piping lattice run into something else.
The live display shows no hash or cache-miss lines
It never prints them. The trace belongs to the raw stream under -v, where a
hash line and a cache miss: line print per task. In the live display a hit
already carries its abbreviated key on the task’s own line, and a miss shows up
as the task running, so a dim copy above it said the same thing twice. Earlier
releases printed the trace in both modes.
Color shows up where it should not
Color follows the terminal, not the output mode. A -v run at your shell has
colored workspace:task labels and the same run redirected to a file has none.
Escapes in something that is not a terminal mean whatever is running Lattice is
presenting itself as one.
Set NO_COLOR to any value to suppress color everywhere without changing the
layout or the mode.
A run with a dev server in it never finishes
Once a run starts a persistent: true task it waits, streaming that task’s
output, until you interrupt it. Everything else in the graph finishes first and
its results are visible above. No flag changes this: it is what a dev run is.
Four things end such a run:
Ctrl-C, on the first press. The run exits130with no message about the interrupt. A second press exits immediately without finishing the teardown, which matters only when a task left a process holding its output open outside the process group Lattice signals.- A
SIGTERM, which a CI runner sends when someone cancels the job. - Every persistent task in the run exiting on its own. A task marked persistent
by mistake no longer blocks. It gets an
exited (code 0)line, and the run prints its summary. - Any task in the run failing. The run ends rather than leaving the dev server holding it open with nothing left to schedule.
The first two used to need a second press, or a force-kill from the runner. Lattice started listening for a signal only once the graph had drained, so it missed one that arrived while a build was still running. For a run that always terminates, leave the persistent task out of the names you pass. See Run dev servers.
The run warns that a process was left holding its output
lattice: warning: a task left a process holding its output open; it is still running.
The task started something in its own process group. Lattice signals the group it spawned, so that process is out of reach, and it still holds the pipe Lattice reads the task’s output through. Rather than wait for output that will never end, Lattice gives it half a second, says so, and exits.
tauri dev does this with its beforeDevCommand, so a Tauri workspace whose
dev script is tauri dev will show this on every interrupt. The process it
leaves keeps its port. Find it by what it is, not by its parent:
lsof -ti:1420 | xargs kill
Nothing is wrong with your config. A launcher that manages its own process groups is doing so deliberately, and Lattice has no handle on what it started.
A task exits but says it stopped reading its output
lattice: app:build: finished leaving a process that still holds its output open
Shown by -v. The task’s own process exited, but something it started did not:
a Gradle daemon, an MSBuild node, an esbuild service, a watcher it
backgrounded. That process inherited the pipe Lattice reads the task’s output
through, and the pipe stays open until the last thing holding it exits. Lattice
reads for half a second after the task exits, then stops and moves on.
The task is a success, its exit code is its own, and its outputs are cached as usual. Everything it wrote before exiting is reported. Only what the leftover process writes afterwards is dropped, and that is the daemon’s output rather than the task’s.
The daemon keeps running. That is what a build daemon is for, and the next run
is faster because of it, so Lattice does not stop it. Stop it yourself with
whatever the tool provides — ./gradlew --stop, dotnet build-server shutdown
— if you need it gone.
A task cannot depend on your dev task
Error: task 'dev' in workspace 'web' is persistent, so no other task may depend on it
Nothing can wait on a task that never exits, so a persistent task has to be a leaf. If another task needs what the dev server produces, depend on the build step that produces it. See Persistent tasks.
Running
A run stopped and nothing says why
docs:build: running
ui:build: running
lattice: 2 tasks, 0 cached, 0 failed, 1.49s
The run was interrupted. Ctrl-C, or the SIGTERM a CI runner sends when
someone cancels the job, stops the scheduler and terminates each running task’s
whole process group. Those children exit non-zero, and none of those exits is a
task failure. A task the interrupt stopped prints no FAILED line, and the
summary does not count it. The events and the summary agree. They previously did
not. Every task the interrupt stopped printed FAILED above a summary reporting
0 failed.
Nothing prints the word interrupted. The exit code is the only signal, and it is
130, so read $? rather than the summary. A build that genuinely broke exits
1. See Run Lattice in CI.
A FAILED line says less than you expected
ui:build: FAILED (code 3) after 1.02s is the full form. The command ran, and
code 3 is what it returned. Two kinds of failure cannot fill that in. A task a
signal killed, and a task Lattice stopped for overrunning its timeout, have no
exit code, so the line reads ui:build: FAILED after 30.00s. A task that failed
before its command ever started has neither a code nor a run to time, so the
line is the bare word FAILED. Its cache key would not compute, or its shell
would not spawn, and the captured output under the line says which.
--filter ran more or fewer workspaces than you expected
--filter <pattern> is a substring match against a workspace’s name, not
against its path and not a glob. The matches are the roots of the run, so the
graph also holds everything they depend on, transitively, tagged (dependency)
under --dry-run. Nothing that depends on a match is included. A pattern that
matches nothing prints lattice: no workspaces matched filter '<pattern>'. and
exits 0.
Match on the name as declared in lattice.json. If a prerequisite you
expected is missing, check that the depending workspace lists it in its
dependsOn and that the task’s dependsOn entry carries the ^. See
Selecting what runs.
lattice prune refuses to run
Error: no cache size limit set. Pass --max-size, or set settings.maxCacheSize in lattice.json
prune evicts entries until the cache is under a limit, and it will not invent
one. Pass --max-size 2GB, or set settings.maxCacheSize so every run holds
itself to the same budget:
{
"settings": {
"maxCacheSize": "2GB"
}
}
.lattice/schema.json is missing, or your editor shows a stale schema
Any command that opens the project writes the bundled schema when
.lattice/schema.json is absent, and leaves an existing file alone however old
it is, so a copy you customized is never churned.
To pick up a newer schema, delete the file and run any command that loads the
config, such as lattice run build --dry-run. Or rewrite everything:
lattice init --force
schema.json is meant to be committed. It is the one thing under .lattice/
that lattice init does not add to .gitignore.
Start over
Everything under .lattice/ is derived, apart from the schema copy:
| Path | What deleting it costs |
|---|---|
.lattice/cache/ |
Cached task results. Every task reruns cold once. |
.lattice/toolchains/ |
Provisioned engines. Any engine with an installCmd reinstalls the next time it is needed, which is a network fetch and not a config change. |
.lattice/bin/ |
The Lattice versions Lattice manages, present when the repo pins a latticeVersion other than the binary on your PATH. The next command re-downloads and re-verifies the pinned release. |
.lattice/schema.json |
The one file here meant to be committed. If it is gone from disk but still tracked, git status shows it deleted; if it is genuinely gone, the next command that loads the config rewrites it. |
rm -rf .lattice is a complete reset. Nothing under it is needed for
correctness, only for speed and for not reinstalling tools you already have. It
never touches lattice.json at the repo root, and you do not need to rerun
lattice init afterward.
Gather information for a bug report
| Command | What it gives you |
|---|---|
lattice version |
The version of the binary that actually ran, which matters when the repo pins one. |
lattice run <tasks> --dry-run |
The graph and every resolved command, without running or caching anything. |
lattice run <tasks> -v |
Every hash, every cache decision, and each task’s full output. |
For the models behind these symptoms, see Driver detection, Engines and provisioning, Caching, Task graph, and Output and logging. For the message-by-message reference, see Errors.