SpuntoSpunto/@spunto/build

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

naming/index.ts
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

types/index.ts
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 → ready

skills 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 stampedphase written
initializinginitializing
credentialscredentials (only when there are credentials to write)
shellunchanged (credentials or initializing)
dotfilesdotfiles
skillsunchanged (dotfiles, credentials or initializing)
cloningcloning, with repos moving pending → cloning → done one by one
postCreatelifecycle, postCreate: "running" then "done"
postStartlifecycle, postStart: "running" then "done"
readyready

Example documents

During the clone, with timings spliced in:

/home/vscode/.mp-status.json
{"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 of buildSetupPlan itself. Each SetupPhase carries the SetupStatus to 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.

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.

Edit on GitHub

On this page