Persistent tasks
A persistent task is one that isn’t supposed to exit: a dev server, a file
watcher, a log tailer. You declare it with persistent: true on the task in
lattice.json, and that one flag changes caching, output, scheduling, and
shutdown all at once. This page covers that semantics. For the day-to-day
workflow of running one, see Dev servers and
watchers; for output mode detection in general,
see Output and logging.
Declaring one
{
"tasks": {
"build": { "outputs": ["dist/**"] },
"dev": {
"dependsOn": ["build"],
"persistent": true
}
}
}
persistent lives on the PipelineTask alongside dependsOn, inputs,
outputs, env, and cache (crates/lattice-config/src/lib.rs:110-118).
There’s no per-workspace override — it’s a property of the task itself,
true for every workspace that runs it.
It is never cached
pub fn is_cacheable(&self) -> bool {
!self.is_persistent() && self.cache != Some(false)
}
(crates/lattice-config/src/lib.rs:126-129.) persistent: true alone is
enough to disqualify a task from caching — you don’t also need cache: false. That’s the right call anyway: a dev server has no output to hash,
and it doesn’t produce a cacheable “result” in the first place, it produces a
running process.
It forces raw, streaming output for the whole run
If any task in the transitive closure of what you’re running is persistent,
lattice run drops the live TUI and uses the same line-by-line output as CI,
even on a terminal (crates/dagger/src/lib.rs:62-70,
crates/lattice/src/commands/run.rs:95-103). A spinner-based UI can’t render
a process that streams forever, so Lattice doesn’t try — it switches the
entire run, not just the persistent task’s own output, before anything
starts.
Within that raw stream, a persistent task’s output lines print unconditionally,
whether or not you passed -l/--loquacious; other tasks’ output still only
streams live in loquacious mode, and is otherwise buffered and shown only on
failure (crates/lattice-output/src/lib.rs:242-245 and :348-359). This is
the one place persistent reaches past the task itself and changes something
about the rest of the run.
It runs after its dependencies, but doesn’t block on them finishing
A persistent task’s own dependsOn edges behave exactly like any other
task’s — its dependencies still have to complete (or restore from cache)
before it starts. What’s different is what happens once it is started: an
ordinary task holds its slot in the scheduler (and a concurrency permit) for
as long as the process runs, because the scheduler is waiting on its exit
code. A persistent task’s process is spawned, detached, and handed off to a
background reader that streams its output; the scheduler then considers that
task done and moves on to the next work immediately
(crates/lattice-runner/src/lib.rs:616-639). It never occupies a
--concurrency slot for its actual lifetime, and the rest of the graph — and
the lattice run invocation as a whole — doesn’t wait for it to exit.
Nothing may depend on a persistent task
A persistent task must be a leaf of the graph. If another task lists it under
dependsOn, building the graph fails outright:
persistent task 'dev' in workspace 'app' cannot be depended on by other tasks
(crates/dagger/src/lib.rs:193-208.) This makes sense once you think about
what a dependency edge means here: nothing can start “after” a process that
never finishes. If some other task genuinely needs the artifact a dev server
would produce, depend on the build step that produces it, not on the server.
Stopping a run
An ordinary run (no persistent task in its closure) exits as soon as its graph drains — there’s no special signal handling involved.
A run that started a persistent task is different: once every other task has
finished and the persistent one has been spawned, lattice run waits for
Ctrl-C (SIGINT) before it will exit, streaming the persistent task’s output
the whole time it waits (crates/lattice-runner/src/lib.rs:441-461). Press
Ctrl-C and Lattice tears every still-running persistent child down: on Unix
it sends SIGKILL to the child’s whole process group, so a dev server
launched through a shell (and any grandchildren it spawned) dies with it, not
just the immediate child; on other platforms only the direct child process is
killed (crates/lattice-runner/src/lib.rs:129-144). Either way it’s a hard
kill, not a SIGTERM — the process gets no chance to run its own shutdown
hooks. If nothing else in the run failed, this exits 0: stopping a
persistent run with Ctrl-C is the normal way to end it, not a failure.
A persistent task’s exit is never observed
This is the sharp edge worth knowing about. When a task is persistent,
Lattice spawns it, detaches it, and immediately reports it as done
(TaskOutcome::Persistent) without ever looking at its exit status — not
when it’s spawned, not while the run waits for Ctrl-C, and not at shutdown,
where the exit code from reaping it is discarded
(crates/lattice-runner/src/lib.rs:616-639, :132-143). There is no
Finished or Failed event for a persistent task, ever.
That’s fine for a task that actually behaves like a dev server: it isn’t supposed to exit, so there’s nothing to observe. It’s a real problem if you mark a task persistent that actually runs to completion — a build script, a codegen step, anything that’s supposed to exit. If that command fails, or even just finishes and quits, Lattice doesn’t notice either way: the run doesn’t fail, doesn’t move on, and doesn’t tell you. It sits there waiting for Ctrl-C as if the process were still alive.
What makes a good persistent task declaration
Only set persistent: true on a command whose job is to keep running: a dev
server, a --watch build, a log tailer. If the command is meant to exit —
even a slow one — leave persistent unset so Lattice can cache it and can
tell you when it fails. And because persistent tasks must be leaves, put the
setup work a dev server needs (installing dependencies, an initial build) in
its dependsOn, not the other way around — nothing should ever need to
depend on the server itself.