The tiny build-consumption client for the marshalld build server — submit a build job, bounded long-poll for its result, ping the daemon identity, and preflight registry-plus-liveness in one call; consumption only, never provisioning or enrolment
Resources
1Install
npx skillscat add cuioss/plan-marshall/build-server-client Install via the SkillsCat registry.
Build Server Client Skill
The consumption surface for marshalld, the machine-global plan-marshall
build server. A build-dispatching context loads this skill to hand a build to the
daemon and long-poll for its result; enrolment and daemon lifecycle live in the
separate user-invocable manage-build-server control skill (the anti-laundering
wall). This skill is script-deterministic — every verb is a deterministic
executor call, no LLM judgement, no logic in this prose the script does not
enforce.
The client is deliberately tiny and fail-soft: when the daemon is down,
unregistered, or an impostor owns the socket, submit returns degraded and the
caller falls back to an in-process build. A build against a machine with no daemon,
or an unregistered project, behaves byte-identically to today.
Enforcement
Base contract: See manage-contract.md for shared enforcement rules, TOON output format, and error-response patterns.
Execution mode: Run the verbs via the executor; parse the TOON output (status, job_status, preflight, daemon, reason) and route accordingly.
Prohibited actions:
- Never
run_in_backgroundorsleepthewait. The wait is a foreground, server-side bounded long-poll — a harness-reaped wait costs ONE re-poll (re-issuewait), never the whole build. Backgrounding it re-introduces exactly the harness-kill loss the daemon exists to remove. - Do not start, register, unregister, or otherwise provision the daemon from this skill — that surface lives ONLY in
manage-build-server. This skill never mutates the registry, the socket, the pidfile, or the daemon lifecycle. - Do not invent script arguments not listed in the Canonical invocations section below.
- Do not trust a daemon response without the S3 identity check — the script performs the socket-owner uid check + version handshake; an impostor socket is treated as unreachable (fallback), never trusted.
Constraints:
- Strictly comply with all rules from persona-plan-marshall-agent, especially tool usage and workflow step discipline.
- All script output uses TOON format (see
plan-marshall:ref-toon-format). - The entry-point script (
build_server.py) is invoked only throughpython3 .plan/execute-script.pywith the 3-part notation.
The call model — submit, then bounded long-poll
- submit — the client S3-verifies the daemon (socket-owner uid check +
version handshake), sends the job spec (the exact executor-formcommand, theexec_pathtree root, theproject_path, and theplan_id), and on
acceptance writes the daemon-assignedjob_idto the change-ledger
(kind=job). That ledger row is what lets a rebuilt or harness-reaped session
RE-ATTACH —waitwith no--job-idrecovers the id from the latestkind=job
row for the plan. An unreachable daemon or impostor socket returnsdegraded(reason=…); a verifier rejection returnsrefused(reason=…). - wait — ONE server-side bounded long-poll (the caller chooses
--bound,
defaulting to the foreground-safe 300s ceiling). The daemon holds the connection up tobound
seconds; on a terminal result it returns the full status-TOON, and on bound
expiry it returns a LIVErunningstatus — the caller re-issueswait. The
wait NEVER returns an empty / timeout-shaped body on bound expiry.
The status-TOON contract (two hard properties + killed)
A wait result carries job_status — the daemon's own status vocabulary. Two
properties are load-bearing:
- Bound expiry is a live
runningstatus, never a timeout. Awaitthat
reaches itsboundwithout a terminal result returnsjob_status: runningwithelapsed/eta/last_progress— a caller can always tell "still working"
from "finished", and re-issueswait. There is no timeout-shaped empty body. killedis its own terminal state. A job whose child was reaped externally
(harness kill, daemon restart) rendersjob_status: killedwith the messageexternally killed — not flaky, do not blind-retry. It is NEVER folded intofailure, so the caller never blind-retries a harness kill as if it were a
flaky build.
Terminal statuses are success / failure / timeout / killed; running andqueued are non-terminal.
Fallback semantics
submit is fail-soft. Every reason that prevents reaching a trusted daemon is adegraded return with a named reason and fallback: in_process, so the caller
falls back to an in-process build (and records the degradation) rather than
failing the build:
reason |
Meaning |
|---|---|
socket_absent |
No socket — the daemon is down. |
impostor_socket |
The socket is owned by another uid (S3) — untrusted, treated as down. |
unreachable |
Connect / I/O failed, or a malformed response frame. |
version_mismatch |
The daemon answered a different protocol version. |
handshake_failed |
The daemon did not answer a clean ping. |
Interaction audit logging (non-silent)
Every submit and wait interaction is logged to the plan work log at a
captured level, through the same plan_logging substrate the manage-logging
work verb writes through — so no interaction outcome is emitted only at an
uncaptured Python log level and silently lost. This closes the field-observed
defect class where a degraded fallback or a refused submit vanished because
it surfaced only below the captured threshold.
- Normal outcomes log at INFO. A queued
submitacceptance and await
result each write one entry carrying thejob_id— the same id written to the
change-ledgerkind=jobrow, so the work log, the server-side interaction
audit, and the ledger all correlate on onejob_id. - Every fallback and refusal logs at WARNING. No
degraded(fallback) orrefusedbranch returns without a captured entry naming thereason. - Plan-less builds are silent by design. When no
--plan-idis supplied the
submission is recorded under theNO_PLANsentinel (so the ledgerkind=job
row is never null), but there is still no per-plan work log to write to, so
the logging is a no-op. The sentinel is truthy, so_audit_logtests for it
explicitly rather than relying on falsiness — it is a routing/ledger value,
never a plan-directory selector. - The build-execute routing seam's own resolution line lands here too. The
routing decision ([BUILD-SERVER] resolved build (requested=…, resolved=routed|in_process|fail-loud, reason=…))
is written to the same plan work log through the same substrate, at the same
INFO-normal / WARNING-on-fallback levels, and is additionally mirrored to
stderr beside[EXEC]so a plan-less build stays observable. The emitting
module isscript-shared/scripts/build/_build_execute_factory.py— see it for
the format string and the level rule (not restated here). - Secrets discipline. A logged entry carries ONLY non-secret correlation
fields —job_id/job_status/reason/notation/attached/elapsed/eta. It NEVER contains the raw--commandargv,exec_path/project_path, env, or any spec field that may carry secrets.
Preflight (F2) — one deterministic call
preflight is the single call the init phase branches on. It returns exactly one
of three outcomes, doing NO daemon round-trip when the project is unregistered:
disabled— the project is not registered; the build server is off for this
project (no socket is touched).ready— the project is registered AND a verified handshake succeeded.down— the project is registered but the daemon is unreachable; carries the
namedreason(same vocabulary as the fallback table).
Scripts
Script: plan-marshall:build-server-client:build_server
| Verb | Purpose |
|---|---|
submit |
S3-verify + submit a job; write job_id to the ledger; degraded/refused on failure |
wait |
One bounded long-poll; terminal or live running status-TOON; re-attach via the ledger |
ping |
Report the daemon identity (version + pid) or down + reason |
preflight |
One call: disabled | ready | down + reason |
Canonical invocations
The canonical argparse surface for build_server.py. The plugin-doctor analyzer
(_analyze_manage_invocation.py) reads this section as source-of-truth for themanage-invocation-invalid and missing-canonical-block rules. Consuming docs
xref this section by name instead of restating the command inline. See
`pm-plugin-development:plugin-script-architecture` cross-skill-integration.md § "Script invocation in documentation".
build_server — submit
python3 .plan/execute-script.py plan-marshall:build-server-client:build_server submit \
--command '["python3", "/tree/.plan/execute-script.py", "NOTATION", "run"]' \
[--exec-path EXEC_PATH] [--project-path PROJECT_PATH] [--plan-id PLAN_ID]--command is the executor-form argv as a JSON array of strings. --exec-path
defaults to --project-path; --project-path defaults to the current working
directory.
build_server — wait
python3 .plan/execute-script.py plan-marshall:build-server-client:build_server wait \
[--job-id JOB_ID] [--plan-id PLAN_ID] [--bound BOUND]With no --job-id, the verb re-attaches via the latest kind=job ledger row for--plan-id. --bound defaults to 300 seconds.
build_server — ping
python3 .plan/execute-script.py plan-marshall:build-server-client:build_server pingbuild_server — preflight
python3 .plan/execute-script.py plan-marshall:build-server-client:build_server preflight \
[--project-path PROJECT_PATH]Related
manage-build-server— the operator control surface (enrol/drop a project, daemon lifecycle); this client never provisions.manage-change-ledger— the append-only ledger this client writeskind=jobre-attach rows to at submit time.manage-locks— the machine-global build-queue slot substrate the daemon and the in-process fallback share.