Worker setup status
SetupStatus and ~/.mp-status.json — what a worker's setup script writes inside the container, and what the control plane reads back.
A worker sets itself up on first boot. It fixes ownership, writes credentials, sets up the shell,
installs dotfiles, links skills, clones repositories and runs postCreateCommand. Then, on every
boot, it starts code-server and runs postStartCommand. The control plane needs to show that
progress and to know when the worker is ready, but the work happens in a shell script inside the
container.
The solution is a file. The script overwrites a small JSON document at a fixed path after each transition, and the control plane reads it back. The script cannot import anything, and the reader may run minutes later on another machine, so changing this shape is a migration, not an edit. Module references: types, naming.
Where
export const WORKER_STATUS_FILE = ".mp-status.json"
export function workerStatusPath(home: string): string {
return `${home.replace(/\/+$/, "")}/${WORKER_STATUS_FILE}`
}The file is relative to the user's home, not absolute. The remote user is a project setting, so
the absolute path is only known once you know who the worker runs as. That is why there is a
function and not a constant. Trailing slashes on home are tolerated:
workerStatusPath("/home/vscode") // "/home/vscode/.mp-status.json"
workerStatusPath("/home/vscode/") // "/home/vscode/.mp-status.json"The generated scripts currently run as vscode, so in practice the file is
/home/vscode/.mp-status.json. That is the path the scripts write, and the path Spunto Cloud's
node agent cats. It lives in the home directory, which is on a persistent volume, rather than in
/tmp. When a Docker-in-Docker feature starts dockerd in the background, /tmp's mount
visibility can change for sibling processes, which silently drops status writes.
Shape
export type RepoSetupState = "pending" | "cloning" | "done" | "error"
export type LifecycleState = "pending" | "running" | "done" | "error" | null
export type SetupStatus = {
phase: "initializing" | "credentials" | "dotfiles" | "cloning" | "lifecycle" | "ready" | "error"
repos: { name: string; state: RepoSetupState }[]
postCreate: LifecycleState
postStart: LifecycleState
error?: string
timings?: Record<string, number>
}Prop
Type
timings
Each phase transition stamps a millisecond timestamp inside the container. That gives one clock, unbiased by the control plane's poll interval. The keys are milestones, in order:
initializing → credentials → shell → dotfiles → skills → cloning → postCreate → postStart → readyskills is stamped by the generated script when the organization has skill sources. The SetupStatus
doc comment does not list it yet. Every key is optional, because a project without credentials,
dotfiles, skills, repositories or lifecycle commands never stamps the corresponding milestone. A
reader therefore derives a segment's duration as "next present milestone minus this one", and
never assumes that the next key exists.
The stamps accumulate in a separate file, /home/vscode/.mp-timings, and are first-write-wins
per key. A restart re-runs only the start script, so it never overwrites the original spawn timings.
Phases vs. milestones
phase is coarser than the timing keys. Some milestones do not change it:
| Milestone stamped | phase written |
|---|---|
initializing | initializing |
credentials | credentials (only when there are credentials to write) |
shell | unchanged (credentials or initializing) |
dotfiles | dotfiles |
skills | unchanged (dotfiles, credentials or initializing) |
cloning | cloning, with repos moving pending → cloning → done one by one |
postCreate | lifecycle, postCreate: "running" then "done" |
postStart | lifecycle, postStart: "running" then "done" |
ready | ready |
Example documents
During the clone, with timings spliced in:
{"phase":"cloning","repos":[{"name":"acme/app","state":"cloning"}],"postCreate":"pending","postStart":null,"timings":{"initializing":1759828331021,"credentials":1759828331310,"shell":1759828331588,"cloning":1759828332104}}Ready:
{"phase":"ready","repos":[{"name":"acme/app","state":"done"}],"postCreate":"done","postStart":null,"timings":{"initializing":1759828331021,"credentials":1759828331310,"shell":1759828331588,"cloning":1759828332104,"postCreate":1759828339877,"ready":1759828351402}}After a failure in postCreateCommand:
{"phase":"error","repos":[{"name":"acme/app","state":"done"}],"postCreate":"running","postStart":null,"timings":{"initializing":1759828331021,"credentials":1759828331310,"shell":1759828331588,"cloning":1759828332104,"postCreate":1759828339877},"error":"setup phase postCreate failed (exit 1)"}The file is written without a trailing newline, as a single printf '%s'.
How the script writes it
The generated script never builds JSON at run time from user data. Each status is serialized in
TypeScript (JSON.stringify of a SetupStatus, which satisfies the type) and embedded as a literal.
At run time, a small shell function adds the timings and overwrites the file:
_mp() {
local j="$1" t=''
if [ -s "$_MP_TFILE" ]; then t="$(cat "$_MP_TFILE" 2>/dev/null || true)"; fi
if [ -n "$t" ]; then j="${j%?},\"timings\":{$t}}"; fi # splice before the closing brace
_MP_LAST="$j"
printf '%s' "$j" > /home/vscode/.mp-status.json || true
}
_mp_stamp cloning
_mp "{\"phase\":\"cloning\",\"repos\":[{\"name\":\"acme/app\",\"state\":\"cloning\"}],\"postCreate\":\"pending\",\"postStart\":null}"On first start the file is created with touch and chmod 666, so that steps running as the user
can write it too.
Failure
The script runs under set -e with an EXIT trap. On a non-zero exit before ready, the trap
takes the last status written, replaces phase with "error", appends an error message, writes
the file, and then sleeps 5 seconds so that a poller (Spunto Cloud polls every 1.5 s) can read it
before the container exits. The message is MP_FAIL_MSG when the failing step set one (for
example, a clone on a branch that does not exist). Otherwise it is
setup phase <phase> failed (exit <code>). If nothing had been written yet, the trap writes:
{"phase":"error","repos":[],"postCreate":null,"postStart":null,"error":"<message>"}The message is spliced into the JSON without escaping. That is why the generator only uses single
quotes in MP_FAIL_MSG.
Restart
The file sits on the persistent home volume. On a restart, it still says "ready" from the
previous run. When the project has a postStartCommand, the start script therefore first rewrites
a non-ready status (phase: "lifecycle", postStart: "pending"), so that a watcher waiting on
postStart does not see the stale ready before this start's command has even begun.
Who reads it
- Poll-based control planes (Spunto Lite, and Spunto Cloud's legacy path in which the whole setup runs as the container's CMD) read the file periodically and map it to the worker's status.
- Agent-orchestrated workers (Spunto Cloud, RFC 0017) have no status file. The container
runs a passive CMD (
buildContainerScript), and the agent executes each phase ofbuildSetupPlanitself. EachSetupPhasecarries theSetupStatusto persist when it starts and when it finishes (statusStart/statusEnd), and the agent pushes it directly. The shape is the same, which is why it is defined once, here.
Treat every field as versioned. A worker built by an old image or an old package version may still
be running and writing the old shape, and a control plane upgraded today has to read it. timings
was added as optional for exactly that reason. Add fields as optional, never rename phase values,
and never make an existing field required.
Related: the worker status vocabulary
SetupStatus is what the container says about its setup. The status of the worker as a whole
(provisioning, building, ready, exited, unknown…) is a different vocabulary. It is owned by
the control plane rather than written by the container. It also lives in ./types, as
WORKER_STATUSES, WORKER_STATUS_TRANSITIONS and WorkerStatusMeta. See the
types module.
Project spec
The portable project file (kind "spunto/project") — every field, a complete example, legacy compatibility, and what deliberately does not travel.
Names and paths
Every container, volume, network, image and file name in ./naming, with its real value, and why changing one renames things that are already running.