Dev servers and watchers

This page is the morning routine: start every dev server the repo declares in one command, read their interleaved output, start just one, and stop the whole thing when you’re done. The underlying mechanics — what persistent: true actually changes, and how Lattice decides between the live display and a plain stream — are covered in full on Persistent tasks and Output and logging; this page assumes you’ve skimmed those and just want the commands. Scoping a run to one workspace is Selecting what runs’s job in depth — here it’s one flag, --filter.

Declaring a dev task

A dev server or watcher is a task like any other, with two fields set on it in the root tasks map: persistent: true (it never exits, and its output streams live) and, usually, cache: false (it wouldn’t matter — a persistent task is never cacheable regardless — but it documents the intent). This is the actual dev task from this repo’s own lattice.json:

{
  "tasks": {
    "dev": {
      "persistent": true,
      "cache": false
    }
  }
}

Only one workspace here — web (apps/web) — has anything to run for it: its package.json declares "dev": "astro dev". The seven Rust crates in this repo have no dev script and no driver-inferred equivalent, so they’re simply absent from the graph. Confirmed by running the real dry run against this repo:

$ lattice run dev --dry-run
❖ lattice  dry run · dev
  → web:dev  npm run dev

That’s the whole rule: an auto workspace with no command for a task is silently skipped, not an error. A workspace declared "auto": false is held to a stricter bar — if it’s asked to run dev directly and has no matching entry in its own scripts map, the run fails with a named fix instead of silently doing nothing (crates/dagger/src/lib.rs:135-141).

Declaring dev across several workspaces looks like this — a shared package built once, a web app whose dev server needs it built first, and a Rust service with its own watch command:

{
  "workspaces": [
    { "name": "ui-kit", "path": "packages/ui-kit" },
    { "name": "web", "path": "apps/web", "dependsOn": ["ui-kit"] },
    { "name": "api", "path": "services/api", "scripts": { "dev": "cargo watch -x run" } }
  ],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "target/**"]
    },
    "dev": {
      "dependsOn": ["^build", "build"],
      "persistent": true,
      "cache": false
    }
  }
}

web and ui-kit are ordinary JavaScript workspaces (an npm/pnpm/yarn driver infers build and dev from their package.json scripts); api’s Rust driver has no notion of a watch mode, so its dev command is declared explicitly — an explicit scripts entry always wins over whatever a driver would have inferred (crates/lattice-workspace/src/lib.rs:770-773).

Running every dev server at once

lattice run dev

Because the closure of what you’re running includes a persistent task, Lattice forces raw, line-by-line output for the entire run, even sitting at a real terminal — a live spinner display can’t render a process that streams forever (see Output and logging). Every line is prefixed workspace:task:, so with several dev servers running at once their output interleaves in whatever order it actually arrives. For the three- workspace example above, that looks something like this:

ui-kit:build: running
ui-kit:build: done (0.41s)
web:build: running
web:build: done (1.02s)
web:dev: running
api:dev: running
web:dev: Local:   http://localhost:4321/
api:dev:    Compiling api v0.1.0
web:dev: Network: use --host to expose
api:dev:     Finished dev [unoptimized] target(s) in 1.8s
api:dev:      Running `target/debug/api`
api:dev: listening on 0.0.0.0:8080

ui-kit:build and web:build run and finish first because web:dev and api:dev both dependsOn a build — once those are done, both dev servers start and run side by side, each line tagged with which server it came from. Nothing settles into a done line for either dev task; persistent tasks never get one (see the gotcha below). This is lattice run genuinely launching two long-running processes and never terminating on its own — see Stopping everything for how the invocation ends.

Running one dev server only

Add --filter to keep just the workspace you’re working on:

$ lattice run dev --filter web --dry-run
❖ lattice  dry run · dev
  → web:dev  npm run dev

--filter matches on the workspace’s name, as a substring, before the dependency graph is even built — so ui-kit’s build still runs first if web’s dev depends on it, but api is dropped entirely, not started and not waited on. A pattern that matches nothing is a clean no-op, not a failure — confirmed against this repo:

$ lattice run dev --filter zzz
lattice: no workspaces matched filter 'zzz'.

See Selecting what runs for the full matching rules and how filtering interacts with a workspace’s dependencies.

Making a watcher wait for a build

A dev server that imports a shared package’s build output, or that needs its own codegen step to have run, declares that with dependsOn on the dev task itself — not the other way around:

{
  "tasks": {
    "dev": {
      "dependsOn": ["^build", "build"]
    }
  }
}

^build runs build in every workspace this one dependsOn (the shared ui-kit package, in the example above); the bare build runs this workspace’s own build task first. Both are ordinary dependency edges: they have to finish (or restore from cache) before the dev server starts, exactly like any other task’s dependsOn (see Task graph for ^task versus task in general).

The edge can only run in this direction. A persistent task must be a leaf — nothing may depend on it, because nothing can start “after” a process that never finishes:

persistent task 'dev' in workspace 'web' cannot be depended on by other tasks

(crates/dagger/src/lib.rs:193-208.) If some other task genuinely needs what a dev server produces, depend on the build step that produces it, never on the server.

Stopping everything

Once every other task in the run has finished and the dev servers are up, lattice run doesn’t exit — it waits for Ctrl-C, streaming their output the whole time. One Ctrl-C tears every still-running dev server down: on Unix, Lattice sends SIGKILL to each one’s whole process group, so a server launched through a shell (and anything it spawned) dies with it (crates/lattice-runner/src/lib.rs:441-461, :129-144). If nothing else in the run failed, this exits 0 — stopping a dev-server run with Ctrl-C is the normal way to end it, printing the same run summary line as any other run once every server is torn down. The next section captures one of these exits for real.

The gotcha: marked persistent, but it already exited

Lattice never checks a persistent task’s exit status — not when it spawns it, not while it waits for Ctrl-C, not at shutdown. If the command behind a persistent: true task quits right away instead of staying up, Lattice has no way to know and reports nothing wrong. This is easy to hit by accident: a dev server that refuses to start because something is already listening on its port still counts as running, from Lattice’s side. Captured for real against this repo’s own web:dev, with an astro dev server already up on its port:

$ lattice run dev --filter web
web:dev: running
web:dev:
web:dev: > @lattice/web@1.0.0-beta-2 dev
web:dev: > astro dev
web:dev:
web:dev: {"message":"Dev server already running at
http://localhost:4321 (pid 13616)","label":"SKIP_FORMAT","level":"info"}
^C
lattice: 1 tasks, 0 cached, 0 failed, 34.25s

The npm run dev process printed that message and exited immediately — verified separately: its child process was a zombie within a second of starting. Lattice never noticed. It sat there for the next 34 seconds, waiting for Ctrl-C, exactly as if the server were still up, and reported the run as a plain success once stopped. There’s no done or FAILED line to tell you otherwise, ever, for a persistent task — check that the thing you expect to be listening actually is, don’t trust the run summary for it. See Persistent tasks for why this is true by design rather than a bug.

The reverse gotcha: it behaves like a server, but isn’t marked persistent

The other direction is worse. If you forget persistent: true on a task whose command doesn’t exit — a dev server, a --watch build — Lattice treats it like any ordinary task: it waits on the process’s exit code (crates/lattice-runner/src/lib.rs:641-647), which never comes. The run never finishes on its own; it prints workspace:task: running and then nothing else, forever. Two things follow from that, both verified directly:

The task keeps its concurrency permit for as long as the process runs, since the permit isn’t released until the task’s function returns — which it never does. With a small --concurrency, a single forgotten dev-server task can starve everything else waiting for a slot.

Ctrl-C doesn’t get the graceful teardown described above, because Lattice only starts listening for it once a task has actually registered as persistent — and this one never does. Nothing in lattice run installs a signal handler while it’s just waiting on an ordinary child, so Ctrl-C falls through to the OS default: it kills the lattice run process itself, with no chance to run the process-group cleanup. The server process it spawned is not part of that process group teardown and is not killed — it’s simply orphaned, still running, still bound to its port, until you find and kill it by hand.

If a task’s command is meant to keep running, mark it persistent: true. If it’s meant to exit, leave persistent unset. There’s no in-between: the scheduler treats every task as one or the other, and getting it backwards either hides a dead server behind a “successful” run, or leaves a live one running after you thought you’d stopped it.