cuioss

manage-build-server

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)

cuioss 4 Updated 4w ago

Resources

1
GitHub

Install

npx skillscat add cuioss/plan-marshall/manage-build-server

Install via the SkillsCat registry.

SKILL.md

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-steward carries 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 / unregister mutate only registry.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-editing
registry.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), created
0700:

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
    marshalld co-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 to SIGKILL after a grace
    window, then remove the socket and pidfile. Use stop when the daemon is wedged.
  • drain (graceful) — request a graceful shutdown (SIGTERM) and wait for the
    daemon to exit on its own, never escalating to SIGKILL. A job still in flight is
    recorded in the journal and replayed as killed on the next start — never
    silently lost, never blind-resumed. Prefer drain for 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_diverges flags a stale daemon — one still executing an older pinned copy
    after a plugin-cache bump — and an undeterminable running provenance is reported
    as unknown, never the resolved-now path (S5, D4). Reports down with 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 next
start 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 to
daemon.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 upgrade

logs

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; an
interaction row carries op, job_id, project_root, plan_id,
request_status, fate, timestamp (plus reason when present), and a
job_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.