Operator control surface for the marshalld build server — enrol/drop a project in the machine-global registry (the opt-in enable signal and anti-laundering wall), manage the daemon lifecycle (start, stop, drain, status, install, upgrade) version-pinned to the verified bundle copy, and inspect the daemon's per-project interaction-audit log (read-only)
Resources
1Install
npx skillscat add cuioss/plan-marshall/manage-build-server Install via the SkillsCat registry.
Manage Build Server Skill
The operator's control surface for marshalld, the machine-global plan-marshall
build server. This skill is script-deterministic — every verb is a deterministic
executor script call, no LLM judgement. It owns two responsibilities: project
enrolment (the opt-in registry) and the daemon lifecycle (start/stop/drain/
status/install/upgrade). Build consumption (submit/wait/ping/preflight) lives in
the separate build-server-client skill — this skill never submits work.
marshalld is strictly opt-in: registration IS the enable signal. There is no
config knob and nothing git-tracked. A project is served by the daemon only after
an operator runs register here; an unregistered project's builds never touch the
daemon or its socket and behave byte-identically to a machine with no build server.
Enforcement
Base contract: See manage-contract.md for shared enforcement rules, TOON output format, and error-response patterns.
Execution mode: Run the control verbs via the executor; parse the TOON output for status / running and route accordingly.
Prohibited actions:
- Do not read, write, or mutate the machine-global
registry.json, the daemon socket, or the pidfile directly — every mutation goes through the script API so the registry's atomic write + audit-line invariant holds. - Do not invent script arguments not listed in the Canonical invocations section below.
- Do not add daemon lifecycle logic to any other skill — this skill is the single owner of start/stop/drain/register/unregister.
marshall-stewardcarries a read-only status pointer only.
Constraints:
- This skill is user-invocable ONLY. It MUST NEVER be resolved into a dispatch's
skills[](see the anti-laundering wall below). register/unregistermutate onlyregistry.json(plus its audit line) — never source, never.plan/plan state.
The anti-laundering wall (S1)
register and unregister are the operator-interactivity wall for the build
server. Registration is a deliberate, human-driven enrolment action — so it lives
ONLY in this user-invocable control skill and is NEVER reachable from a dispatched
agent's skills[]. A plan cannot enrol itself onto the served set, and the daemon
never resolves what to run: it verifies every submit positionally against the
project's existing registration (executor path inside the verified tree, notation
allowlist, argument schema) and refuses anything off-template. The interpreter is
NOT part of that registration — no registration field holds one. command[0] is
checked daemon-wide as an argv shape: against an explicit interpreter pin when the
caller supplies one (exact path or basename), and with no pin — what the shipped
daemon runs — only as a bare canonical python3 / python name carrying no path
separator, so the daemon resolves the binary from its own server-side PATH
rather than following a client-supplied location such as /tmp/x/python3. That
is defence-in-depth on argv shape; the containment boundary remains the owner-only
socket (0600 inside a 0700 state dir). The
control surface (enrolment) and the consumption surface (build-server-client
submit/wait) are deliberately split across two skills so enrolment can never be
laundered through a build dispatch.
Default registration scope
register populates each project's scope fields so a plain enrolment yields a
routable project rather than an inert empty-scope entry. When --container /--notation are omitted, registration stores canonical defaults:
notation_allowlist— the routable build notations (Maven, Gradle, npm,
Python), derived from the single source of truth shared with the daemon's
build-routing seam, so a build tool that is routable is default-allowlisted
from the same edit.worktree_containers— the canonical worktree location every plan uses,<root>/.plan/local/worktrees.
Re-running register is the repair path for an already-registered project
whose scope is empty: it backfills the missing defaults without hand-editingregistry.json. Per-field precedence is explicit CLI value > existing non-empty
stored value > computed default, so re-registration is idempotent — it backfills
empty fields, preserves any deliberately-customised non-empty values, and lets an
explicit --container / --notation override both the stored value and the
default.
Platform constraint (WSL2)
marshalld requires a POSIX runtime (Unix domain sockets, fork/setsid
double-forking, ppid==1 re-parenting). Supported platforms are macOS and Linux;
on Windows, plan-marshall runs exclusively inside WSL2 with the entire runtime
in-distro. One distro is one machine: each distro has its own ~/.plan-marshall/,
registry, and daemon, and wsl --shutdown / reboot / idle timeout stops the
daemon (a down status is routine on Windows — the init preflight re-asks). The
full statement lives in doc/user/installation.adoc § Prerequisites — see there,
not duplicated here.
Daemon state layout
All daemon state lives under the machine-global home root
(~/.plan-marshall/marshalld/, overridable via PLAN_MARSHALL_HOME), created0700:
| Path | Contents |
|---|---|
socket |
Unix domain socket (0600, owner-only) |
daemon.pid |
Running daemon pid |
daemon.log |
Daemon log (rotated to daemon.log.1 past a size cap) |
registry.json |
Machine-global project registry (0600) |
registry-audit.log |
Append-only registration audit |
lifecycle-audit.log |
Append-only start/stop/drain/install/upgrade audit |
interaction-audit.log |
Append-only per-request interaction audit (0600) |
journal/ |
Durable job specs, results, and ETA history |
job-logs/ |
Per-job captured build logs |
The interaction audit is a central append-only log — the natural third
sibling of registry-audit.log and lifecycle-audit.log — which answers "who
asked the daemon to do what, and how did it turn out?". It carries two record
kinds, discriminated by an explicit kind field:
kind |
Written when | Fields |
|---|---|---|
interaction |
Every request the daemon dispatches (ping / submit / wait) |
op, project_root, plan_id, job_id, request_status, timestamp (plus a non-secret reason) |
job_fate |
A job terminalizes — after the journal records its result, and once per job the restart replay forces to killed |
job_id, fate, project_root, plan_id, timestamp |
request_status is the request's response status (e.g. queued for an
accepted submit) — it is deliberately not called outcome, because it says
nothing about how the job itself ended. fate is the job's terminal status
(success / failure / timeout / killed); a fate the daemon cannot
substantiate is recorded and rendered as unknown, never a terminal value and
never queued.
The fate is emitted into this log rather than resolved by a read-time join
against the journal because the two stores have deliberately different retention:
terminal journal entries are GC'd after an hour, while audit records are kept for
days. A join could therefore answer the fate question only for the first hour of
a record's life. No record of either kind ever carries a secret-bearing spec
field — a fate record's attribution copies only project_root and plan_id out
of the stored spec. Retention is bounded and GC'd on every daemon start, parallel
to the journal's bounded-retention model.
Lifecycle operations
- start — launch the daemon detached, version-pinned to the copy of
marshalldco-located with this control skill (the verified bundle / plugin-cache
version, never a project-local executor an attacker could tamper with — S5).
Refuses to launch a second daemon when one is already live (idempotent). - stop (forced kill) — send
SIGTERM, then escalate toSIGKILLafter a grace
window, then remove the socket and pidfile. Usestopwhen the daemon is wedged. - drain (graceful) — request a graceful shutdown (
SIGTERM) and wait for the
daemon to exit on its own, never escalating toSIGKILL. A job still in flight is
recorded in the journal and replayed askilledon the next start — never
silently lost, never blind-resumed. Preferdrainfor planned restarts. - status — ping the daemon over its socket and report the running version, the
daemon's in-flight / queued job counts, and the binary the running process is
actually executing (running_binary_path, read from the live process) alongside
the resolve-now path a fresh start would launch (resolved_binary_path).binary_divergesflags a stale daemon — one still executing an older pinned copy
after a plugin-cache bump — and an undeterminable running provenance is reported
asunknown, never the resolved-now path (S5, D4). Reportsdownwith a
named reason when unreachable. Also reports whether the caller's project is
registered. - install — idempotent version-pinned start (a no-op when already running).
- upgrade — drain the running daemon, then start the verified version (S7).
Crash recovery. A crashed daemon leaves a stale socket and pidfile; the nextstart liveness-probes the recorded pid and, finding it dead, cleans the stale
state and binds fresh. A daemon restart replays the journal: terminal results
survive, and any job that was in flight when the daemon died is marked killed
(never silently resumed). Log rotation is automatic — daemon.log rotates todaemon.log.1 once it passes its size cap, so daemon logging never grows unbounded.
Scripts
Script: plan-marshall:manage-build-server:manage_build_server
| Verb | Purpose |
|---|---|
register |
Enrol a project in the machine-global registry (the enable signal) |
unregister |
Drop a project from the registry |
start |
Start the daemon detached, version-pinned |
stop |
Force-stop the daemon (SIGTERM then SIGKILL) |
drain |
Gracefully stop the daemon (no SIGKILL) |
status |
Report running version, in-flight/queued counts, running vs resolved binary provenance (divergence flagged; unknown never the resolved path) |
install |
Idempotent version-pinned start |
upgrade |
Drain then start the verified version |
logs |
Read-only, project-scoped view of the daemon's interaction-audit log |
Script: plan-marshall:manage-build-server:marshalld — the daemon binary,
launched by start (never invoked directly by an operator).
Canonical invocations
The canonical argparse surface for manage_build_server.py. The plugin-doctor
analyzer reads this section as source-of-truth for markdown notation occurrences.
register
python3 .plan/execute-script.py plan-marshall:manage-build-server:manage_build_server register \
[--root ROOT] [--container DIR] [--notation NOTATION]--container and --notation are repeatable. --root defaults to the caller's
main checkout. When --container / --notation are omitted, registration
populates canonical default scope and re-running register backfills an empty
existing entry — see Default registration scope above.
unregister
python3 .plan/execute-script.py plan-marshall:manage-build-server:manage_build_server unregister \
[--root ROOT]start / stop / drain / status / install / upgrade
python3 .plan/execute-script.py plan-marshall:manage-build-server:manage_build_server start
python3 .plan/execute-script.py plan-marshall:manage-build-server:manage_build_server stop
python3 .plan/execute-script.py plan-marshall:manage-build-server:manage_build_server drain
python3 .plan/execute-script.py plan-marshall:manage-build-server:manage_build_server status
python3 .plan/execute-script.py plan-marshall:manage-build-server:manage_build_server install
python3 .plan/execute-script.py plan-marshall:manage-build-server:manage_build_server upgradelogs
python3 .plan/execute-script.py plan-marshall:manage-build-server:manage_build_server logs \
[--root ROOT] [--limit LIMIT]Read-only inspection of the daemon's central interaction-audit.log, filtered to
the caller project (the derived project-scoped view) — it never mutates the log.--root defaults to the caller's main checkout; --limit returns the N most
recent records (default 50), ordered oldest-first within the returned window
(so records[0] is the oldest of the window, not the newest). When the log is
absent or unreadable the verb returns an explicit empty records list with a
named reason (log_absent / log_unreadable; fails closed).
Each returned record is rendered, not echoed verbatim, so a per-request row
can never be misread as a job record. Every row leads with its kind; aninteraction row carries op, job_id, project_root, plan_id,request_status, fate, timestamp (plus reason when present), and ajob_fate row carries job_id, project_root, plan_id, fate, timestamp.
On an interaction row the two status columns are distinct: request_status is
how the request was answered, and fate is the job's outcome, joined by job_id
from the job-fate rows in the same log. A job with no fate record yet — and any
row written by an older daemon that predates these field names — renders an
explicit unknown rather than a silently missing field, the same fail-closed
discipline as log_absent / log_unreadable.
Related
build-server-client— the build-consumption surface (submit/wait/ping/preflight); this skill never submits work.manage-locks— the machine-global build-queue slot substrate the daemon's scheduler coordinates against.marshall-steward— carries a read-only daemon-status pointer into this skill; no daemon logic lives there.