Build coordinator — internal module notes
Historical implementation notes. The durable feature spec is ../BUILD_COORDINATOR.md, which specifies what the content-addressed build coordinator does (the trace model, the storage state machine, the resolution algorithm, the recipe protocol). This doc records how the implementation is carved into modules under
src/build/: for each module, what it is responsible for, which other modules it depends on, and a pseudocode sketch dense enough to prove the interfaces in thesrc/build/*.hspike are sufficient to implement every module in parallel. Where a sketch exposed a gap, it is called out inline and collected in Interface findings.
The public surface is <kit/build_coord.h> (coordinator + recipe-side client) plus a
kit build driver command, gated by KIT_BUILD_ENABLED, mirroring the
<kit/cas.h> + kit cas precedent. Everything below is internal to src/build/
except the composition root src/api/build.c and the driver glue.
Layering and the dependency graph
The build layer sits entirely on the public kit surface — the content store
(<kit/cas.h>), digests (<kit/hash.h>), the canonical writer/heap/slice core
(<kit/core.h>), and signed packages (<kit/package.h>) — and adds exactly one
thing the CAS lacks: a mutable per-target trace index plus the resolution
algorithm that drives it. Every id it computes is a plain BLAKE2b-256 of canonical
bytes, which is a CAS blob id (kit_blob_info().id), so need-overlay maps,
argv vectors, and deepset closure nodes content-address themselves through the
ordinary CAS as ordinary blobs (self-verifying content). Trace bodies are
content-addressed too, but live in build/trace/ rather than cas/, because a
trace is a claim (not self-verifying) — the criterion that sorts the two stores
is self-verifiability, not content-addressedness.
Internal modules form a strict DAG (no cycles — verified against the #include
sets in the spike). Arrows point "depends on":
┌─────────────────────────────────────────┐
L6 composition │ src/api/build.c (KitStatus, ctx->diag)│
└───────────────┬─────────────────────────┘
│ uses everything below
L5 recipe execution runner ───────────────► bundle (trace sharing)
│ │ │ │
L4 algorithm resolve │ │ │
│ │ │ │
L3 coordinator state coord ◄──────────────────┘
┌──┴───┬────────┬─────────┐
L2 I/O + remote store remote (client ── recipe side, L1-only)
│
L1 pure value/byte ┌── trace ──┐ cfg defn protocol
logic (no I/O) │ │
L0 foundation └──────── build.h ───────────────────────┘
(constants, ids, result convention, diag)
| Layer | Modules | Property |
|---|---|---|
| L0 foundation | build |
constants, BUILD_OK/ERR, magics, build_id_eq, build_target_key, build_diagf. No internal deps. |
| L1 pure logic | cfg, defn, trace, protocol, client† |
byte/value transforms only: emit / parse / hash / encode / decode. No I/O, no CAS, no coordinator. Unit-testable in isolation. |
| L2 store + remote | store, remote |
the on-disk state machine and the untrusted fetch rung. Touch the filesystem (via host vtables) and the CAS; interpret no trace semantics beyond trace. |
| L3 coordinator | coord |
the KitBuildCoordinator context: process-lifetime memos, the jobs semaphore, per-target futures, base-input probes. |
| L4 algorithm | resolve |
the 3-phase resolve() + build chain + materialization ladder + leafset union/refresh. |
| L5 execution | runner, bundle |
Phase 3 (spawn + service a recipe, record both traces) and signed trace import/export. |
| L6 composition | src/api/build.c |
maps internal int → KitStatus, routes detail through ctx->diag, owns section-buffer sizing. The public functions in <kit/build_coord.h> live here. |
† client is L1 by dependency (it only needs protocol + a transport) but is a
peer process role, not part of the coordinator. It backs the public
kit_build_client_* recipe-side API.
Result/error convention (inherited from src/dist). Internal modules return
int (BUILD_OK / BUILD_ERR); parse/emit modules additionally take a
char* err, size_t errcap sink. Only src/api/build.c knows KitStatus and
ctx->diag. All ids are lowercase hex on the wire and in every canonical body.
One id invariant the whole layer leans on: for any canonical byte string b,
kit_blob_info(&info, b, len); info.id equals the BLAKE2b-256 that names b as a
CAS blob. So "compute the overlay-id/argv-id/trace-id" and "store the blob and
read its id back" agree by construction — a trace can reference an overlay map
by id and later recover the exact bytes with kit_cas_get_blob. Effective config
maps are hashed for resolution identity but are not trace content. Pure index
keys that are never stored as content (target-key, glob-result-hash) use the
same hash over a domain-separated buffer.
L0 — build (foundation)
Role. Shared vocabulary for every other module: hash length, the BUILD_OK/ERR
convention, the canonical magics (kit-build-shallow 1, …-deep 1, …-record 1,
…-config 1, …-argv 1, …-traces 1, kit-build 1), the fixed row capacities,
byte-exact id equality, the per-target index-key derivation, and the diag shim.
Depends on. <kit/cas.h> (hash length), <kit/hash.h>, <kit/core.h>. No
internal deps.
Pseudocode.
int build_target_key(KitSlice name, uint8_t out[32]):
// target-key = BLAKE2b("kit build target v1" || name); a pure index key,
// never stored, so hash a domain-separated scratch buffer directly.
buf = stack[ sizeof(DOMAIN)-1 + name.len ] // bounded by BUILD_TARGET_MAX
memcpy(buf, BUILD_TARGET_KEY_DOMAIN, ...); append name
KitBlobInfo bi; kit_blob_info(&bi, buf, len); memcpy(out, bi.id, 32)
return BUILD_OK
void build_diagf(ctx, fmt, ...): // mirror cas_diagf
if !ctx || !ctx->diag: return
va_start; format into a bounded stack buffer; ctx->diag(ctx->diag_user, msg); va_end
build_id_eq is the existing inline. No interface gap.
L1 — cfg (propagated config + local argv)
Role. Canonical propagated-config maps and local argv vectors. Effective
propagated maps are hashed for the in-memory resolution identity but traces keep
only consumed observations. Explicit need overlay maps and local argv vectors
are serialized by value as CAS blobs so an imported shallow trace can replay a
need exactly. Pure value logic: init / set / get / overlay / canonical emit /
parse / id. Entries hang off caller storage (the DistTree growable-buffer
pattern, no VLAs).
Depends on. build (L0), <kit/core.h> (KitWriter, KitHeap),
<kit/cas.h> (kit_blob_info), <kit/build_coord.h> (KitBuildKV overlay pairs).
Pseudocode.
int build_config_set(cfg, key, val):
if key.len > BUILD_KEY_MAX-1 || val.len > BUILD_VAL_MAX-1: return BUILD_ERR
i = lower_bound(cfg.entries, key) // keep sorted, dedup-on-set
if i found and equal-key: overwrite value; else insert (grow if n==cap → ERR if no room)
return BUILD_OK
int build_config_overlay(base, overrides[], n, out): // the `need` overlay
copy base into out (already canonical/sorted)
for kv in overrides: build_config_set(out, kv.key, kv.value) // re-sorts/dedups
return BUILD_OK
int build_config_emit(cfg, w): // canonical, byte-stable
write "kit-build-config 1\n"
for e in sorted(cfg.entries): write e.key, ' ', e.value, '\n' // sort enforced here
return kit_writer_status(w)
int build_config_id(heap, cfg, out[32]):
kit_writer_mem(heap, &w); build_config_emit(cfg, w)
bytes = kit_writer_mem_bytes(w, &len); KitBlobInfo bi; kit_blob_info(&bi, bytes, len)
memcpy(out, bi.id, 32); kit_writer_close(w); return BUILD_OK
int build_config_parse(data, len, out, err, errcap):
require first line == BUILD_CONFIG_MAGIC else parse-error (→ caller treats absent)
for each "key value" line: enforce strictly-increasing key, no dups → build_config_set
// non-canonical ordering / unknown shape / dup = BUILD_ERR
build_argv_* mirror this but order is significant (positional vector, magic
kit-build-argv 1, one arg per line, no sort). The empty argv has a fixed
argv-id = hash of just the magic line — the default when a request supplies none.
No interface gap.
L1 — defn (build definition)
Role. Parse the build-definition file (kit-build 1) mapping each target name →
one recipe path, and expose a by-name lookup. The coordinator reads it to resolve a
target. The only definition-derived input of a target is its resolved
recipe-id, refreshed by recomputing it through the live definition
(build_defn_find → recipe path → hash) — there is no whole-definition source
leaf, so editing an unrelated stanza does not bust unrelated targets, and a repoint
to a same-content recipe is correctly a no-op. The definition carries no argv —
local argv comes entirely from the request.
Depends on. build (L0), <kit/core.h>. Pure parse + lookup, no I/O.
Pseudocode.
int build_defn_parse(data, len, out, err, errcap):
out.bytes = data; out.len = len // retained so coord can hash file as a source leaf
require first line == "kit-build 1"
loop: expect "[target <name>]" then "recipe <path>"
validate name (BUILD_TARGET_MAX) and path (build path-valid), append stanza
enforce sorted-unique target names (canonical) else BUILD_ERR
return BUILD_OK
const BuildTargetDefn* build_defn_find(defn, name):
binary-search defn.targets by name; return match or NULL
Interface note. out.bytes aliases the caller's buffer; the coordinator must
keep the definition bytes alive for the coordinator's lifetime (it does:
coord.defn_bytes). The recipe-id is not here — coord hashes the recipe file
named by recipe_path. No interface gap.
L1 — trace (trace bodies + target record)
Role. The immutable, content-addressed heart of the cache: emit / parse / id for the shallow trace, the deep trace, the deepset closure node, and the mutable target record. Pure byte logic — strict, byte-stable INI-style text; any non-canonical input (unknown key, dup row, mis-ordering) is a parse error, and a parse error is treated as absent, never as a match (fail safe). Sections hang off caller-provided growable buffers.
Depends on. build (L0), <kit/core.h>. No I/O — store persists/loads these,
resolve matches against them.
Pseudocode.
int build_shallow_emit(t, w, err, errcap):
write "kit-build-shallow 1\n"
write "target ", t.target, '\n'; "recipe ", hex(t.recipe), '\n'
"output ", hex(t.output), '\n'; "argv ", hex(t.argv), '\n'
write "[config]\n"; for k in sort(configs): write observation(k), '\n'
write "[source]\n"; for s in sort_by_path(sources): require path-valid; write s.path, ' ', s.absent?"-":hex(s.blob), '\n'
write "[glob]\n"; for g in sort_by_pattern(globs): write g.pattern, ' ', hex(g.result_hash), '\n'
write "[dep]\n"; for d in sort_by(name,overlay,argv): write d.name,' ',hex(d.overlay_id),' ',hex(d.argv_id),' ',hex(d.output_tree),'\n'
return kit_writer_status(w)
int build_deep_emit(t, w, err, errcap): // no [dep], no [config], no inline closure
write "kit-build-deep 1\n"; target/recipe/output; "root-config ", hex(t.root_config)
"argv ", hex(t.argv); "deepset ", hex(t.deepset) // one pointer to the closure DAG
int build_deepset_emit(t, w, err, errcap): // one node of the closure DAG
write "kit-build-deepset 1\n"; "target ", t.target; "recipe ", hex(t.recipe)
"[source]\n"; for s in sort_by_path(sources): write s.path, ' ', s.absent ? "-" : hex(s.blob), '\n'
"[glob]\n"; for g in sort_by_pattern(globs): write g.pattern, ' ', hex(g.result_hash), '\n'
"[child]\n"; for c in sort(children): write hex(c), '\n' // direct deps' deep-set-ids
void build_trace_id(body, len, out[32]): KitBlobInfo bi; kit_blob_info(&bi, body, len); memcpy(out, bi.id, 32)
// build_deepset_id is the same hash; a deepset is stored as an ordinary CAS blob, so deep-set-id == blob-id.
int build_record_prepend(rec, row): // MRU, per-kind cap
drop any existing row equal in (kind, trace_id)
shift rows down; rec.rows[0] = row
truncate the run of rows with row.kind to KIT_BUILD_RECORD_CAP (other kind untouched)
Parsers are the strict inverse; build_*_parse reject non-canonical bytes with
BUILD_ERR. No interface gap — emit takes KitWriter, parse fills caller
buffers, exactly the dist_tree/dist_manifest shape.
L1 — protocol (recipe wire format)
Role. The recipe command set (config-get, source, glob, need) and its
length-prefixed framing, as pure encode/decode over a KitBuildConn. No I/O, no
transport: the coordinator's runner drives the server side, client drives the
recipe side, both calling these codecs. Every request returns a value and logs a
dependency; an input read but not requested is invisible to the cache.
Depends on. build (L0), <kit/core.h>, <kit/build_coord.h> (KitBuildKV,
KitBuildConn).
Wire format. This is the source of truth for build_proto_* and the protocol
goldens. The KitBuildTransport vtable hands codecs a complete frame body. For
byte-stream transports, the transport wraps each body as:
u32le body_len
u8[body_len] body
body_len must be <= BUILD_FRAME_MAX (65536). In-process transports may pass
the body directly, but must enforce the same cap. The body format is:
- integers are fixed-width little-endian (
u8,u16le,u32le,u64le); strisu16le lenfollowed bylenraw bytes, no trailing NUL;- decoders reject trailing bytes, truncated fields, unknown command/status values,
count overflow beyond caller storage, and command-specific length overflow
(
BUILD_TARGET_MAX,BUILD_KEY_MAX,BUILD_VAL_MAX,BUILD_PATH_MAX,BUILD_PATTERN_MAX); - text bytes are not escaped by the protocol. The producer for each semantic field is responsible for rejecting bytes the corresponding canonical format cannot store (for v1: no embedded NUL or newline in target/path/key/value/argv).
Request bodies:
| Command | Body |
|---|---|
CONFIG_GET |
u8 cmd, str key |
SOURCE |
u8 cmd, str workspace_path |
GLOB |
u8 cmd, str pattern |
NEED |
u8 cmd, str target, u16 noverrides, {str key, str value}*, u16 argc, {str arg}* |
NEED_SUBMIT |
same as NEED |
NEED_AWAIT |
u8 cmd, u64 token |
Response bodies begin with u8 status. ERROR is valid for any command and has
the common body u8 ERROR, u16 kit_status, str message; clients return the
KitStatus value and may surface message through diagnostics.
Non-error response bodies:
| For command | Status | Body |
|---|---|---|
CONFIG_GET |
OK |
u8 OK, str value |
CONFIG_GET |
UNSET |
u8 UNSET |
SOURCE |
OK |
u8 OK, u8[32] blob_id, str realpath |
SOURCE |
ABSENT |
u8 ABSENT |
GLOB |
OK |
u8 OK, u16 npaths, {str path}* (one batch) |
GLOB |
GLOB_END |
u8 GLOB_END |
NEED |
OK |
u8 OK, u8[32] output_tree, str materialized_path |
NEED_SUBMIT |
OK |
u8 OK, u64 token |
NEED_AWAIT |
OK |
u8 OK, u8[32] output_tree, str materialized_path |
Glob is the only multi-frame response: zero or more GLOB/OK path-batch frames,
terminated by exactly one GLOB/GLOB_END. Empty globs send only GLOB_END.
Every other command is exactly one request body and one response body.
Pseudocode.
int build_proto_encode_req(req, buf, cap, n):
cursor c over buf (cap ≥ BUILD_FRAME_MAX)
put u8 req.cmd
if cmd==CONFIG_GET/SOURCE/GLOB: put_slice(req.arg)
if cmd==NEED or cmd==NEED_SUBMIT:
put_slice(req.arg) // target
put u16 noverrides; for kv: put_slice(kv.key), put_slice(kv.value)
put u16 argc; for a: put_slice(a)
if cmd==NEED_AWAIT:
put u64 req.token
*n = c.len; return (c overran) ? BUILD_ERR : BUILD_OK
int build_proto_decode_resp(buf, len, cmd, out, glob_cb, glob_user):
out.status = get_u8
if status==ERROR: out.error_status=get_u16; out.text=get_slice; return OK
switch cmd:
CONFIG_GET: if status==UNSET return OK(present=0); else out.text = get_slice
SOURCE: if status==ABSENT return OK(present=0); get_bytes(out.id,32); out.text = get_slice
NEED, AWAIT: get_bytes(out.id,32); out.text = get_slice // tree-id + path
NEED_SUBMIT: out.token = get_u64 // coordinator-assigned
GLOB: u32 m = get_u32; repeat m: p = get_slice; if glob_cb(user, p) != 0 break // streamed
return malformed ? BUILD_ERR : BUILD_OK
build_proto_decode_req fills caller-provided ovr_storage / argv_storage for a
need/need-submit, and reads req.token for need-await. Frames are bounded by
BUILD_FRAME_MAX.
Required tests before dependents start.
- golden hex frames for every request and every response shape above, including
ERROR,SOURCE/ABSENT, emptyGLOB, multi-batchGLOB, andNEEDwith both overlays and argv; - malformed corpus: truncated integer, truncated string, trailing byte, unknown
command/status, over-cap frame, count larger than caller storage, wrong response
shape for the command, non-final data after
GLOB_END; - round-trip property: valid structs encode→decode byte-identically after re-encoding.
No interface gap.
L1 — client (recipe-side, src/build/client.c)
Role. Implements the public kit_build_client_* API a recipe links (or reaches
through the in-process transport / a kit helper subcommand). Each call encodes a
request with protocol, writes it over the transport, reads the response, decodes
it. It is the mirror of runner's service loop and shares zero coordinator state.
Depends on. protocol (L1), <kit/build_coord.h> (KitBuildTransport,
KitBuildClient, KitBuildResult), <kit/core.h>.
Pseudocode.
struct KitBuildClient { ctx; const KitBuildTransport* t; KitBuildConn* conn; uint8_t frame[BUILD_FRAME_MAX]; }
KitStatus kit_build_client_open(ctx, transport, out):
c = alloc; c.t = transport; endpoint = getenv(KIT_BUILD_ENV_SOCK)
transport->dial(transport->user, endpoint, &c.conn); *out = c; return KIT_OK
KitStatus kit_build_client_source(c, path, blob[32], realpath):
build_proto_encode_req({SOURCE, path}, c.frame, ...); transport->write_frame(conn, frame, n)
transport->read_frame(conn, frame, cap, &n); build_proto_decode_resp(frame, n, SOURCE, &resp, 0,0)
if resp.status != OK: return KIT_<err>; memcpy(blob, resp.id, 32); *realpath = resp.text; return KIT_OK
KitStatus kit_build_client_need(c, req, out): // req carries overlay + argv
encode NEED(req.target, req.config, req.argv); round-trip; decode → out.output_tree, out.path
Interface note (gap → see findings #1). client is a real translation unit with
no internal header in the spike. That's fine (its interface is <kit/build_coord.h>),
but the parallel-work plan must assign it an owner. Listed here so it isn't lost.
L2 — store (on-disk state machine)
Role. The build store under <store>/build/ and its crash-safe rules:
immutable content objects (trace/, materialized cache/) written tmp→fsync→atomic-
rename; the one mutable, ordering-sensitive object (the target record/) updated by
read-modify-write into tmp then atomic rename; recipe sandboxes under tmp/. Owns
content-keyed path layout (<pp>/<id>), atomicity, and the record RMW. It interprets
no trace semantics beyond calling trace's emit/parse.
Depends on. build (L0), trace (L1, for BuildTargetRecord and record
emit/parse), <kit/cas.h> (the shared CAS, blob/tree ops), <kit/build_coord.h>
(KitBuildStoreIo atomicity extras, KitCasHost for file_io).
Pseudocode.
int build_store_put_trace(s, body, len, out_id[32]): // idempotent content write
build_trace_id(body, len, out_id)
build_store_trace_path(s, out_id, final, …)
if exists(final): return BUILD_OK // re-derivation is a no-op
io->make_temp_dir(build/tmp, tmpdir); tmp = tmpdir/"t"
cas_host->file_io->open_writer(tmp, &w); w.write(body,len); w.close()
io->sync_path(tmp); io->rename(tmp, final); io->sync_path(parent(final))
return BUILD_OK
int build_store_record_update(s, key[32], name, kind, trace_id[32]):
L = io->lock ? io->lock(key) : NULL // optional advisory lock
build_store_record_load(s, key, name, &rec) // missing/garbage → empty rec (fail safe)
build_record_prepend(&rec, {kind, trace_id}) // MRU + cap, from trace.h
kit_writer_mem(heap,&w); build_record_emit(&rec,w,…); bytes = mem_bytes(w,&n)
write bytes to build/tmp/rec-XXXX; sync; build_store_target_path(s,key,final); io->rename(tmp, final); sync parent
if L: io->unlock(L); return BUILD_OK
int build_store_cache_materialize(s, tree_id[32], path_out, cap): // CAS → cache/
if kit_cas_verify_tree(cas, tree_id) != KIT_OK: return BUILD_ERR // bytes absent → caller climbs ladder
io->make_temp_dir(build/tmp, stage)
kit_cas_materialize_tree(cas, tree_id, stage) // verifies each blob, applies modes
build_store_cache_path(s, tree_id, final, cap); io->rename(stage, final); io->sync_path(parent)
copy final into path_out; return BUILD_OK
int build_store_ingest_output(s, out_dir, out_tree_id[32], path_out, cap):
kit_cas_add_tree_from_dir(cas, out_dir, out_tree_id) // hashes+stores every file → tree id
return build_store_cache_materialize(s, out_tree_id, path_out, cap) // install into cache/ (idempotent)
int build_store_sandbox_new(s, sandbox_out, scap, out_dir_out, ocap):
io->make_temp_dir(build/tmp, sandbox_out) // run-<n>/
cas_host->mkdir_p(sandbox_out + "/out"); copy "<sandbox>/out" → out_dir_out; return BUILD_OK
Interface notes.
build_store_openmust create thebuild/{trace,target,cache,tmp}/skeleton — it has theKitCasHost(mkdir_p) andstore_rootto do so. OK.build_store_cache_lookupis a pure existence probe (io->list_diror a stat viafile_io); it returnsBUILD_ERRon a miss so the caller climbs the ladder.- A subtlety worth a comment in the
.c:cache/<pp>/<tree-id>/is a directory rename, somake_temp_dir+ populate +renamemust be directory-atomic on every supported host (POSIXrename(2)over an empty/no dest is; the Windows adapter must emulate). This is a host-contract note, not a header change. No header gap.
L2 — remote (untrusted object fetch)
Role. The trustless rung of the materialization ladder: when an object is absent locally and an object-remote is configured, render the user's fetch-recipe argv template, spawn it via the exec host into a temp file, verify the bytes against the requested content id, and only then install into the CAS. A corrupt/malicious mirror fails the hash check and is discarded. Remotes are tried in order.
Depends on. build (L0), <kit/build_coord.h> (KitBuildExec,
KitBuildObjectRemote), <kit/cas.h> (install + verify).
Pseudocode.
int build_remote_fetch(ctx, exec, remotes[], n, cas, tmp_dir, kind, id[32]):
if n == 0: return BUILD_ERR
kit_hex_encode(hex, id, 32); pp = hex[0:2]
for r in remotes:
out = tmp_dir + "/fetch"
argv = render(r.fetch_argv_template, {kind: kind==BLOB?"blob":"tree", pp, id:hex, out})
exec->spawn(argv, …, &proc); exec->wait(proc, &code); if code != 0: continue
bytes = file_io->read_all(out)
if kind==BLOB:
kit_blob_info(&bi, bytes.data, bytes.len); if !id_eq(bi.id, id): discard; continue
kit_cas_add_blob(cas, bytes.data, bytes.len, &bi) // re-derives same id
else: // tree manifest: store as blob, then it can be verified/materialized by id
store tree manifest bytes; if resulting tree-id != id: discard; continue
return BUILD_OK
return BUILD_ERR
Interface note (minor → findings #3). The tree case needs an "ingest a tree
manifest (not a directory) whose id I already know, and verify it equals id" CAS
operation. kit_cas_add_blob stores raw bytes (the manifest is canonical bytes);
verifying the stored tree then needs its referenced blobs present too. In practice a
tree fetch is followed by blob fetches for its entries on demand during
materialize. The fetch primitive itself only needs to install + id-check the
manifest bytes; materialize already verifies the whole tree before use. The header
suffices; the .c documents the two-step (tree manifest, then per-blob) fetch.
L3 — coord (coordinator context, memos, concurrency)
Role. Owns the KitBuildCoordinator and all process-lifetime state — no globals.
Memoizes every base-input probe (source hash, glob, config-by-id, argv-by-id,
recipe-id), every interned deepset node (by deep-set-id, with a refresh-valid
cache so a shared subtree is checked once), and every target resolution, so a
diamond builds once and a file hashes once. Owns the jobs semaphore, the per-target
futures (cross-path memo and in-flight dedup) built on the optional
KitBuildSched, and the per-target pulled set that makes the lazy trace-remote
pull idempotent. Does not contain the resolution algorithm (that's resolve) or
recipe execution (runner).
Depends on. build, cfg, defn, store, trace (all included by the spike
header), <kit/build_coord.h> (host vtables, options), <kit/cas.h>.
Pseudocode.
KitStatus build_coord_open(ctx, host, store_root, opts, out):
c = alloc; c.ctx_storage = *ctx; c.ctx = &c.ctx_storage; c.host = *host; c.opts = *opts
kit_cas_open(ctx, host->cas_host, store_root + "/cas", &c.cas)
build_store_open(ctx, c.cas, host->store_io, host->cas_host, store_root, &c.store)
c.defn_bytes = file_io->read_all(opts->build_def_path) // tracked source bytes
build_defn_parse(c.defn_bytes.data, .len, &c.defn, err, …)
c.jobs_limit = (host->sched && opts->jobs > 0) ? opts->jobs : (host->sched ? host_default : 1)
init memos (source/glob/config/argv/targets hash maps over ctx->heap)
if host->sched: c.lock = sched->mutex_new(); c.jobs_sem = sem_new(c.jobs_limit)
*out = c; return KIT_OK
int build_coord_source_hash(c, path, out_blob[32], present): // memoized
lock; if path in c.sources: copy; unlock; return OK
fd = file_io->read_all(workspace_root + "/" + path)
if absent: *present = 0; memo (path → ABSENT); else kit_blob_info(&bi, fd.data, fd.len); out_blob = bi.id; *present = 1
memo insert; unlock; return OK
int build_coord_glob(c, pattern, out_result_hash[32], cb, cb_user): // memoized
lock; if pattern in c.globs: replay matches to cb; copy hash; unlock; return OK
matches = host glob over workspace (sorted); for each: source_hash(match)
result_hash = build_glob_result_hash(sorted (path, blob) listing) // BLAKE2b of canonical listing
memo insert; replay to cb; unlock; return OK
int build_coord_config_by_id(c, id[32], out): // replay path
lock; memo hit? copy. else: kit_cas_get_blob(cas, id, &fd); build_config_parse(fd.data, .len, out, …)
kit_cas_release(cas, &fd); memo; unlock
// build_coord_argv_by_id mirrors with build_argv_parse
int build_coord_recipe_id(c, target, out[32]):
d = build_defn_find(&c.defn, target); if !d: return BUILD_ERR
build_coord_source_hash(c, d->recipe_path, out, &present) // recipe-id = hash(recipe file bytes)
return present ? BUILD_OK : BUILD_ERR
int build_coord_deepset_load(c, deepset_id[32], out_node): // Phase-1 replay
if id in c.deepsets: *out_node = memoized; return BUILD_OK
kit_cas_get_blob(cas, deepset_id, &fd) // (remote blob fetch first if absent + remote)
else return BUILD_ERR // absent => Phase 1 falls through
build_deepset_parse(fd.data, .len, &ds, ...)
for child_id in ds.children: build_coord_deepset_load(c, child_id, &child) or return ERR // recurse+intern
node = intern(target, recipe, direct leaves, child nodes); c.deepsets[id] = node; *out_node = node
int build_coord_trace_remote_pull_once(c, target, out_pulled_now): // lazy shared-trace pull
if c.opts.n_trace_remotes == 0 or target in c.pulled: *out_pulled_now = 0; return BUILD_OK
c.pulled.add(target); *out_pulled_now = (build_trace_remote_pull(c, target) == BUILD_OK)
return BUILD_OK
// ---- concurrency ----
void build_coord_jobs_acquire(c): if c.jobs_sem: sem_wait(c.jobs_sem) // no-op in sequential mode
void build_coord_jobs_release(c): if c.jobs_sem: sem_post(c.jobs_sem)
int build_coord_target_intern(c, target, cfg_id, argv_id, out_future, is_fresh):
key = (target, cfg_id, argv_id); lock
if key in c.targets: *out_future = existing; *is_fresh = 0
else: f = new future(state=PENDING); c.targets[key] = f; *out_future = f; *is_fresh = 1
unlock; return OK
int build_coord_target_await(c, f, out): // futures over sched cond
lock; while f.state == PENDING: sched->cond_wait(f.cond, c.lock)
ok = (f.state == DONE); if ok: *out = f.result; unlock; return ok ? OK : ERR
void build_coord_target_complete(c, f, res): lock; f.result=*res; f.state=DONE; cond_broadcast(f.cond); unlock
void build_coord_target_fail(c, f): lock; f.state=FAILED; cond_broadcast(f.cond); unlock
In sequential mode (host->sched == NULL): jobs_* are no-ops, the lock is a
no-op, and a fresh future is completed inline before any other caller can observe it,
so await never blocks — futures degrade to a plain memo exactly as the public
header promises.
Interface notes.
build_glob_result_hash(the canonical(path, blob-id)listing → 32-byte hash) is used by bothcoordandtracematching. Today it is implied insidebuild_coord_glob. Findings #4: give it a name inbuild.h(a pure helper) sotracematching andcoordagree byte-for-byte.- The jobs semaphore is built in
coordonKitBuildSched's mutex+cond (the vtable has no semaphore primitive — correct; a counting semaphore is trivially a mutex+cond+int).c.jobs_semisvoid*for that reason. OK.
L4 — resolve (the three-phase algorithm)
Role. build_resolve(T, cfg, argv, chain) → {output-tree, path, leafset}, doing
the least work necessary: Phase 1 deep fast path (same argv-id and matching
scope-projected config observations, did sources
move?), Phase 2 shallow path (config/argv/sources moved — guard consumed keys + direct
leaves, re-resolve recorded deps by their recorded ids, compare output tree-ids),
Phase 3 run the recipe (delegated to runner). Owns the per-path build chain
(cycle detection), the materialization ladder, and leafset union/refresh.
Depends on. coord (L3, memos + futures + store access), cfg (L1), trace
(via coord/store), runner (Phase 3 — see note), build (L0).
Pseudocode.
int build_resolve(c, T, cfg, argv, chain, out): // INLINE (top-level + fused need)
if build_chain_extend(c, chain, T, cfg.id, argv.id, &chain', err) != OK: diag(err); return BUILD_ERR // cycle
build_coord_target_intern(c, T, cfg.id, argv.id, &f, &fresh)
if !fresh: return build_coord_target_await(c, f, out) // memo / dedup (no pop — chain is immutable)
rc = resolve_phases(c, T, cfg, argv, chain', out) // 1 → 2 → 3 below, on THIS thread
if rc == OK: build_coord_target_complete(c, f, out) else build_coord_target_fail(c, f)
return rc
int build_dispatch(c, T, cfg, argv, chain, &f_out, err): // ASYNC (need-submit)
if build_chain_extend(c, chain, T, cfg.id, argv.id, &chain', err) != OK: return BUILD_ERR // cycle, fail fast
build_coord_target_intern(c, T, cfg.id, argv.id, &f, &fresh)
if fresh:
if build_coord_spawn(c, worker, pack(c,T,cfg,argv,chain',f)) != OK: // no sched → resolve inline now
rc = resolve_phases(c,T,cfg,argv,chain',&r); rc==OK ? complete(f,r) : fail(f)
*f_out = f; return BUILD_OK // returns WITHOUT awaiting
// worker(args): rc = resolve_phases(...); rc==OK ? build_coord_target_complete(f,r) : build_coord_target_fail(f)
resolve_phases(c, T, cfg, argv, chain, out):
key = build_target_key(T); rec = store_record_load(key, T)
if rec empty: build_coord_trace_remote_pull_once(c, T, &pulled); if pulled: rec = store_record_load(key, T)
// ---- Phase 1: deep fast path -------------------------------------------------
for trace_id in rec where kind==DEEP, newest-first:
D = load+parse deep trace (absent/garbage → skip)
if id_eq(D.root_config, cfg.id) and id_eq(D.argv, argv.id):
if build_coord_deepset_load(c, D.deepset, &node) != OK: continue // blob absent → fall through
build_leafset_refresh(c, node, &all_match) // rehash/reglob + recompute recipe-id
if all_match and build_materialize(c, D.output, out->path, …) == OK:
out->output_tree = D.output; out->leafset = node; return BUILD_OK
// materialize MISS → keep scanning, ultimately Phase 3 (no rebuild inside materialize)
// ---- Phase 2: shallow path ---------------------------------------------------
for trace_id in rec where kind==SHALLOW, newest-first:
S = load+parse shallow trace (absent/garbage → skip)
if !id_eq(S.recipe, recipe_id(T)): continue // recipe edit/repoint (live defn)
if !id_eq(S.argv, argv.id): continue // local config differs
if any observation in S.configs differs from cfg: continue
if any direct source/glob leaf of S changed (refresh): continue
ok = 1; children = []
for (dep, overlay_id, dargv_id, recorded_tree) in S.deps: // may fan out on threads
dcfg = overlay(cfg, config_by_id(overlay_id)); dargv = argv_by_id(dargv_id) // recompute effective config
r = build_resolve(c, dep, dcfg, dargv, chain, &child) // replay the need exactly
if r != OK or !id_eq(child.output_tree, recorded_tree): ok = 0; break
children.push(child.leafset) // child deepset nodes
if ok:
build_leafset_union(c, direct_node(S), children, &node) // builds + stores the root deepset
write_deep_trace(c, T, cfg.id, argv.id, node.id, S.output) // refresh → next call hits Phase 1
if build_materialize(c, S.output, out->path, …) != OK: continue // bytes gone → Phase 3
out->output_tree = S.output; out->leafset = node; return BUILD_OK
// ---- Phase 3: run the recipe -------------------------------------------------
return build_run_recipe(c, T, cfg, argv, chain, out) // runner.h
int build_materialize(c, tree_id, path_out, cap): // PURE locator — never runs a recipe
if build_store_cache_lookup(store, tree_id, path_out, cap) == OK: return OK // on disk
if build_store_cache_materialize(store, tree_id, path_out, cap) == OK: return OK // local CAS → cache
if opts.n_object_remotes and build_remote_fetch(…, tree_id) == OK: // remote (trustless)
return build_store_cache_materialize(store, tree_id, path_out, cap)
return BUILD_ERR // bytes gone → caller falls to Phase 3
int build_chain_extend(c, parent, T, cfg_id, argv_id, &out, err, errcap): // cactus stack, no pop
for fr = parent; fr; fr = fr->parent: // walk to the root
if fr==(T,cfg_id,argv_id): render "//a → //b → //a" into err; return BUILD_ERR // cycle
f = arena_alloc(c.build_arena, BuildChainFrame); *f = {parent, T, cfg_id, argv_id}
*out = f; return BUILD_OK
// Immutable + parent-linked: concurrent dispatched siblings each allocate their own frame
// over the shared read-only `parent`; nothing is copied and nothing is popped.
Interface notes.
resolveandrunnerform one strongly-connected component, not a DAG edge:resolvecallsbuild_run_recipe(Phase 3), andrunner's service loop callsbuild_resolvefor eachneed. The "strict DAG" elsewhere holds; this single edge is an acknowledged cycle, broken at the header level only:build_run_recipeis declared inresolve.h(its Phase-3 continuation) and implemented inrunner.c, soresolve.cnever includesrunner.h.materializeno longer participates — it is a pure locator (below), so the only resolve→runner call is the Phase-3 tail.build_materializeis pure (cache → CAS → remote, notarget/cfg/argv/chain): on a total miss it returnsBUILD_ERRandresolvefalls through to Phase 3. This is what removes the oldmaterialize → run_reciperung and guarantees a cache hit's recordedtree-idalways describes the bytes returned.- The
chainis a parent-linked cactus stack ofBuildChainFrames (no VLAs), allocated off a per-build arenakit_buildowns and resets per request. It replaces the old fixed-depth array: concurrent dispatched needs each extend a shared read-only parent (no copy, no pop), and a cycle is detected by walking parent links. Parents outlive their children by construction (the parent only returns once all itsneed-awaits complete), so the arena lifetime is sound. build_dispatchis the async sibling ofbuild_resolve(theneed-submitengine): same intern + 3-phase body, but driven on abuild_coord_spawnworker so submit returns without blocking.build_resolve= inline drive; the recipe fan-out = N×build_dispatchthen N×build_coord_target_await. Both shareresolve_phases; futures dedup means a repeated dep spawns no second worker.
L5 — runner (Phase 3: spawn + service a recipe)
Role. Run a recipe under (cfg, argv): acquire a job slot, stage a sandbox,
spawn via the exec host with KIT_BUILD_SOCK/OUT/TARGET in the env, service its
protocol connection (logging every request as a dep, recursing into build_resolve
for each need), then on success ingest the output tree, write both trace kinds,
prepend them to the record, install the output in the cache, and return. A nonzero
exit propagates failure and writes no trace.
Depends on. coord (L3), resolve (L4, for need recursion + materialize),
protocol (L1, wire codec — runner.c includes it), trace (L1), cfg (L1),
store (via coord), <kit/build_coord.h> (exec + transport vtables).
Pseudocode.
int build_run_recipe(c, T, cfg, argv, chain, out):
if c.opts.verify and a trace says "unchanged": run anyway, compare fresh tree-id, diag on mismatch // verify mode
build_coord_jobs_acquire(c) // bounds *actively running* recipes
build_store_sandbox_new(store, sandbox, out_dir)
listener = transport->listen(&endpoint_name) // per-recipe endpoint ← findings #5
env = CLEAN: { KIT_BUILD_SOCK=endpoint_name, KIT_BUILD_OUT=out_dir, KIT_BUILD_TARGET=T,
workspace_root } + cfg's `env.`-prefixed keys (KIT_BUILD_ENV_PREFIX) as env vars
exec->spawn(recipe_argv = [recipe_path] ++ argv.args, env, cwd=workspace_root, &proc) // host inherits nothing
log = {}; transport->accept(listener, &conn)
build_runner_service(c, conn, T, cfg, chain, &log) // drives protocol; recurses for `need`
transport->close(conn); transport->close_listener(listener)
exec->wait(proc, &code)
build_coord_jobs_release(c)
if code != 0: build_store_sandbox_done(store, sandbox); return BUILD_ERR // NO trace
build_store_ingest_output(store, out_dir, &output_tree, out->path, …)
build_store_sandbox_done(store, sandbox)
put serialized cfg map (→ cfg.id) and argv vector (→ argv.id) into the CAS (kit_cas_add_blob)
build_runner_record_traces(c, T, cfg, argv, &log, output_tree, &node) // builds+stores deepset, both kinds
out->output_tree = output_tree; out->leafset = node; return BUILD_OK
int build_runner_service(c, conn, T, cfg, chain, log):
loop:
transport->read_frame(conn, frame, cap, &n); if EOF/closed: return BUILD_OK // recipe done
build_proto_decode_req(frame, n, &req, ovr_storage, …, argv_storage, …)
switch req.cmd:
CONFIG_GET: build_config_get(cfg, req.arg, &v, &present)
append req.arg to log.config_keys (consumed, set or unset)
reply {present?OK:UNSET, text=v}
SOURCE: build_coord_source_hash(c, req.arg, blob, &present)
append (req.arg, blob, absent=!present) to log.sources
reply present ? {OK, id=blob, text=realpath} : {OK absent, no path} // live workspace path
GLOB: build_coord_glob(c, req.arg, result_hash, collect_paths, &acc)
append (req.arg, result_hash) to log.globs
reply matches in OK batch frames, then a GLOB_END frame // streamed, multi-frame
NEED: dcfg = build_config_overlay(cfg, req.overrides, …); dargv = build_argv_set(req.argv, …)
overlay_id = store(overlay(empty, req.overrides)) // just the delta; effective cfg is derivable
build_coord_jobs_release(c) // ← yield slot while blocked (findings #6)
build_resolve(c, req.arg, &dcfg, &dargv, chain, &r) // inline (no worker)
build_coord_jobs_acquire(c) // ← reclaim before resuming work
record_dep(log, req.arg, overlay_id, dargv.id, &r) // append edge + push child node
reply {OK, id=r.output_tree, text=r.path}
NEED_SUBMIT: dcfg = overlay; dargv = set; overlay_id = store(overlay(empty, req.overrides))
if build_dispatch(c, req.arg, &dcfg, &dargv, chain, &f, err) != OK: reply {ERROR} // cycle/unknown
tok = log.next_token++; log.pending.push({tok, req.arg, overlay_id, dargv.id, f})
reply {OK, token=tok} // does NOT block
NEED_AWAIT: p = find_pending(log, req.token); if none: reply {ERROR}
build_coord_jobs_release(c); build_coord_target_await(c, p.future, &r); build_coord_jobs_acquire(c)
if r failed: reply {ERROR}
record_dep(log, p.dep, p.overlay_id, p.argv_id, &r) // edge logged HERE, on await
remove p from log.pending; reply {OK, id=r.output_tree, text=r.path}
// on EOF: for each still-pending need, exec->kill its in-flight recipes (best-effort); record NO edge
int build_runner_record_traces(c, T, cfg, argv, log, output[32], &out_node):
recipe_id = build_coord_recipe_id(c, T)
build_leafset_union(c, direct_node{T, recipe_id, log.sources, log.globs}, log.child_leafsets, &node)
// builds the root deepset node, emits it, stores it as a CAS blob, yields node.id (= deep-set-id)
S = shallow{T, recipe_id, output, argv.id, log.configs, log.sources, log.globs, log.deps}
emit S → bytes; build_store_put_trace(store, bytes, &sid); build_store_record_update(store, key(T), T, SHALLOW, sid)
D = deep{T, recipe_id, output, cfg.id, argv.id, deepset=node.id} // one pointer, not an inline closure
emit D → bytes; build_store_put_trace(store, bytes, &did); build_store_record_update(store, key(T), T, DEEP, did)
*out_node = node; return BUILD_OK
Interface notes.
- Findings #5 (transport listener). Spawning concurrent recipes needs each to
have its own endpoint so
acceptis unambiguous. The currentKitBuildTransport.accept(user, out)has no endpoint and there is no "create a uniquely-named endpoint" call, while the client side alreadydials the name in$KIT_BUILD_SOCK. Proposal: add a per-recipe listener to the vtable —listen(user, char* name_out, size_t cap, KitBuildListener** out),accept(user, KitBuildListener*, KitBuildConn** out),close_listener. The runner creates a listener per recipe, passesname_outasKIT_BUILD_SOCK, accepts once, then closes. The in-process transport returns a synthetic name and a direct conn. - Findings #6 (jobs slot while blocked on
need). A parent recipe servicing aneedis idle CPU-wise while the child builds; if it keeps its job slot, a build withjobs < max-chain-depthdeadlocks (parent holds the only slot; child can never acquire one). The fix is the release/reacquire aroundbuild_resolveshown above — the jobs semaphore bounds actively running recipes, not in-flight ones. In sequential modejobs_*are no-ops so this is free. This belongs in BUILD_COORDINATOR.md's Parallelism section too. runner.cincludesprotocol.h;runner.hdoesn't need to (no protocol types in its signatures). The leafset interning (soout->leafsetlives for the process) is acoordresponsibility — addbuild_coord_leafset_intern(findings #7) rather than leaking heap ownership intorunner.
L5 — bundle (signed trace sharing)
Role. Export/import traces as signed .kpkg bundles, reusing the
<kit/package.h> manifest + minisign + trust machinery wholesale. A trace is a
claim (not self-verifying), so import is gated by signature/trust; the output bytes
it references stay trustless (verified by tree-id/blob-id on use). Also pulls trace
bundles from configured trace-remotes (the trusted counterpart to remote).
Depends on. coord (L3), store/trace (via coord), <kit/package.h>
(kit_pkg_create/kit_pkg_verify/kit_minisig_*/kit_trust_*), <kit/build_coord.h>.
Pseudocode.
int build_bundle_export(c, opts):
stage = make_temp_dir() // assemble the bundle payload tree
claims = []
for T in opts.targets:
rec = store_record_load(key(T), T)
for row in rec (newest candidates): copy build/trace/<row.trace_id> → stage/trace/<id>
parse the body → collect referenced overlay-id, argv-id (and output-tree for the claim row)
claims.push({T, row.kind, row.trace_id, output_tree})
for each referenced overlay-id/argv-id: kit_cas_get_blob → stage/cas/blob/<id> // required for replay
if opts.include_outputs: also copy referenced output trees + their blobs into stage/cas/
build_bundle_manifest_emit(claims, &w) → stage/manifest "kit-build-traces 1"
kit_pkg_create({ root_dir: stage, format: opts.format, sk: opts.sk, keyid: opts.keyid,
name:"traces", out_path: opts.out_path }) // signs the whole tree
return BUILD_OK
int build_bundle_import(c, opts, result):
unpack = make_temp_dir()
kit_pkg_verify({ pkg_data, pkg_len, format, pubkey_bytes, trusted_keys, tofu, unpack_dir: unpack }, &vr)
if !verified: return BUILD_ERR // signature/trust failure
read unpack/manifest → build_bundle_manifest_parse → claims
for blob in unpack/cas/blob/*: kit_cas_add_blob(cas, bytes) // overlays/argv/(outputs), hash-checked
for c in claims:
install unpack/trace/<c.trace_id> via build_store_put_trace // id re-checked on store
build_store_record_update(store, key(c.target), c.target, c.kind, c.trace_id) // prepend
result.n_traces = |claims|; result.keyid = vr.keyid; result.tofu_pin = vr.tofu_pin; result.tofu_pk = vr.tofu_pk
return BUILD_OK
int build_trace_remote_pull(c, T): // trusted fetch, mirrors remote.c
for tr in opts.trace_remotes:
out = tmp/"bundle"; argv = render(tr.fetch_argv_template, {target: T, out}); exec->spawn; exec->wait
if code != 0: continue
build_bundle_import(c, { pkg_data: read(out), trusted_keys: tr.trusted_keys, tofu: tr.tofu }, &res)
if ok: return BUILD_OK
return BUILD_ERR
// build_bundle_manifest_emit/parse: canonical "kit-build-traces 1" rows
// <target> <kind> <trace-id> <output-tree-id>, sorted, mirroring dist_manifest_*
Interface notes.
- The "signed
kit-build-tracesmanifest" is realized by packaging a staging tree that contains the traces manifest file (plus bodies + blobs);kit_pkg_create's ownkit-package 3signature covers that whole tree, so trusting the package signature transitively trusts thekit-build-tracesmanifest. No new signing primitive is needed. No header gap — the manifest emit/parse are pure (bundle.halready has them). - Import correctness leans on the storage state machine:
build_store_put_tracere-derives and re-checks the trace-id, andkit_cas_add_blobre-derives blob ids, so even a mis-built bundle cannot install a body under the wrong key.
L6 — src/api/build.c (composition root)
Role. Implements the public <kit/build_coord.h> coordinator + export/import entry
points by calling the internal modules, mapping BUILD_OK/ERR (and the err
buffers) to KitStatus + ctx->diag, and owning the sizing of every caller-provided
section buffer (the trace/record/config growable arrays) so no inner module allocates
policy. Gated by KIT_BUILD_ENABLED; a stubbed-out build compiles these to
KIT_UNSUPPORTED like config_stubs.c.
Depends on. Everything (it is the root). The recipe-side kit_build_client_* are
implemented in client.c but composed here under the same KIT_BUILD_ENABLED gate.
Pseudocode.
KitStatus kit_build(coord, req, out):
BuildConfig cfg over stack/heap storage; for kv in req.config: build_config_set(cfg, kv.key, kv.value)
build_config_id(heap, &cfg, &cfg.id)
BuildArgv argv; build_argv_set(&argv, req.argv, req.argc); build_argv_id(heap, &argv, &argv.id)
reset coord per-build arena (chain frames + dispatched-worker tracking live here)
BuildResolved r; rc = build_resolve(coord, req.target, &cfg, &argv, /*chain=*/NULL, &r) // root: no parent
join any outstanding dispatched workers; if rc != OK: return build_status_from(coord->ctx, rc)
memcpy(out->output_tree, r.output_tree, 32); strcpy(out->path, r.path); return KIT_OK
kit_build_traces_export/import thinly wrap build_bundle_export/import;
kit_build_client_* live in client.c.
Concurrency model (cross-cutting)
- One host thread per in-flight resolution.
runnerservices a recipe's connection with blocking transport reads on its thread; a fresh(T,config,argv)is dispatched onto aKitBuildSchedworker (build_coord_spawn). Parallelism comes from two places now: independent targets (multi-target requests, Phase-2 dep re-resolution) and a single recipe's fan-out —need-submitdispatches each dep concurrently, the recipeneed-awaits the tokens. The fusedneedresolves inline (no worker) for the one-at-a-time case. jobssemaphore bounds actively-running recipes, not in-flight ones. A recipe blocked inside aneed/need-awaitreleases its slot and reacquires after (findings #6), preventing chain-depth deadlock.- Futures dedup shared targets.
build_coord_target_internreturns a shared future for a repeated(T, config-id, argv-id); concurrent submits/needs await one resolution and spawn at most one worker. The memo and the futures are the same table. - Determinism under concurrency.
need-awaittakes a specific token (no await-any), and a dep edge is recorded on await, so the awaited set — hence the recorded deps and output — is a deterministic function of inputs, never of completion order. The sorted[dep]section is byte-identical regardless of submit/await order. - Cycle detection is per-path, on the parent-linked
BuildChainFramecactus stack; the global memo handles dedup, the chain handles cycles. A shared dep appears in many chains, never twice in one; concurrent siblings share a read-only parent prefix. - Sequential degrade.
host->sched == NULL⇒jobs_limit = 1,jobs_*and the lock are no-ops, fresh futures complete inline, no thread is ever spawned — and every module above is otherwise identical.
Interface findings
Concrete adjustments the pseudocode surfaced. All applied to the src/build/*.h
spike, the public <kit/build_coord.h>, and BUILD_COORDINATOR.md as of this revision; the entries
below are the rationale of record. None block starting the L0/L1 modules.
clientmodule ownership. The recipe-sidekit_build_client_*need a real translation unit (src/build/client.c) with no internal header — assigned an owner in Wave B below so it isn't mistaken for part ofcoord. (No header change; planning note only.)build_run_recipeprototype placement. (Applied —resolve.h,runner.h.)resolve↔runneris a deliberate mutual recursion.build_run_recipeis now declared inresolve.h(the Phase-3 continuation) and implemented inrunner.c;runner.hcarries onlybuild_runner_service/build_runner_record_traces. Avoids an awkwardresolve.h → runner.hinclude.- Tree fetch in
remote. (Applied —remote.hcomment.) The two-step tree fetch (manifest bytes id-checked + installed, then per-blob fetch on demand duringmaterialize) is documented; the function signature is sufficient. build_glob_result_hashinbuild.h. (Applied —build.h.) Added as a pure helper over a new foundationBuildPathBlobpair type, so the sole producer of the[glob]value is separately unit-testable and byte-stable.- Transport listener (public header change). (Applied —
<kit/build_coord.h>.) Added the per-recipe endpoint toKitBuildTransport:listen(user, char* name_out, size_t cap, KitBuildListener** out),accept(user, KitBuildListener*, KitBuildConn** out),close_listener. Concurrent recipes each get a uniquely-named endpoint; the clientdialside was already there. - Jobs slot released while blocked on
need. (Applied —coord.h,runner.h,BUILD_COORDINATOR.md §Parallelism+ therun_recipesketch.) The semaphore bounds actively running recipes; a recipe blocked on aneedyields its slot, preventing ajobs < chain-depthdeadlock. build_coord_leafset_intern. (Applied —coord.h.)runner/resolvehand back a process-livedBuildLeafSet*(BuildResolved.leafsetis borrowed-for-the-process); the interning entry point lives incoordrather than leaking heap ownership upward.
Design-review decisions (a second pass that pressed on correctness and seams;
all applied to the spike headers, <kit/build_coord.h>, and BUILD_COORDINATOR.md):
- Deep-trace soundness vs. recipe changes. (Applied —
trace.h,coord.h,resolve.h,defn.h.) The flat deep trace checked source/glob leaves but never recipe identity, so a recipe edit (T's own or any descendant's) with unchanged sources produced a stale Phase-1 hit. Fix:recipe-idis a per-node scalar on the deepset, refreshed by recomputing through the live definition. Content-only (same-content repoint = no-op) and transitive (a descendant's recipe change busts the root). The whole-definition source leaf is removed — only the resolved recipe-id is a definition-derived input. - Structural deepset closure adopted now. (Applied —
trace.hBuildDeepSet,coord.hnode +deepsetsmemo +build_coord_deepset_load,resolve.hunion/refresh,build.hBUILD_DEEPSET_MAGIC.) The closure is a Merkle DAG ofkit-build-deepset 1nodes stored as CAS blobs (deep-set-id== blob-id — self-verifying content, socas/, notbuild/). The deep trace holds onedeepset <id>pointer. Refresh is O(changed frontier) via the validity memo.BuildLeafSetis now that in-memory node (was a flat source/glob list). - Absent source leaf. (Applied —
trace.hBuildSourceLeaf.absent, protocol/runner.) An absentsourceread is recorded (<path> -) so creating the file later busts the trace — the source analogue of consumed-while-unset. materializeis a pure locator. (Applied —resolve.h.) Droppedtarget/cfg/argv/chain; returnsBUILD_ERRon a total miss andresolvefalls through to Phase 3. Removes thematerialize → runneredge and the rebuild-returns-a-different-tree hazard.- Lazy trace-remote pull. (Applied —
coord.hbuild_coord_trace_remote_pull_once+pulledset;resolve.hPhase-0 hook.) A clean-checkout record miss attempts one signed-bundle pull (idempotent per target) before Phase 3, so shared traces can actually be hit. - Clean recipe environment +
env.*. *(Applied —<kit/build_coord.h>KitBuildExecKIT_BUILD_ENV_PREFIX,runner.h.)*spawn'senvis the recipe's COMPLETE environment (host inherits nothing); declared env comes from trackedenv.-prefixed config, so it is hermetic and cache-visible.
- Glob responses span frames. (Applied —
protocol.hBUILD_RESP_GLOB_END.) A glob match set larger than one 64 KB frame streams as batch frames terminated by an END frame, instead of silently truncating. - Future-based
need(concurrent fan-out). (Applied —<kit/build_coord.h>KitBuildNeedToken+need_submit/need_await;protocol.hNEED_SUBMIT/NEED_AWAIT+ token fields;coord.hbuild_coord_spawn;resolve.hbuild_dispatch+ cactusBuildChainFrame;runner.hBuildPendingNeed.) A recipe submits its fan-out (eachneed-submit→ dispatched on a worker, returns a token) thenneed-awaits the tokens, so independent deps build concurrently; the fused blockingneedstays for the one-at-a-time case. Three correctness constraints: the dep edge is logged on await (un-awaited submits are speculative, cancelled at exit); await is token-specific (no await-any) so the awaited set stays input-deterministic; and the chain becomes a parent-linked cactus stack so concurrent siblings extend a shared read-only prefix without copying. Sequential mode resolves submit eagerly inline (transparent).
- Source reads stay live. (Decision, not a code change.)
sourcereturns the live workspace path (no staging); workspace immutability for a build's duration is therefore load-bearing for soundness, documented inBUILD_COORDINATOR.md §Determinism. - In-process recipes run on a
schedthread. (Supported — the integration test substrate; see BUILD_TESTING.md.) The framed protocol needs the recipe to run concurrently with the service loop; an in-process recipe gets that from aKitBuildSchedthread. Only the sequential (sched == NULL) in-process case is deferred (it would need a direct-dispatch bypass). KitBuildStatsobservability. (Applied —<kit/build_coord.h>,coord.h.) Six cumulative counters (deep/shallow/recipe, materialize-miss, object-fetch, trace-pull) on the coordinator, bumped underlockby resolve/runner/remote/ bundle and read viakit_build_stats. Tests snapshot-and-diff to assert which path served each request; doubles as user build introspection.
Suggested parallel implementation order
The DAG makes the seams obvious; teams can take whole layers independently behind the spike headers.
- Wave A (no deps, fully unit-testable now):
build,cfg,defn,trace,protocol. Each gets atest/build/corpus mirroringtest/dist(round-trip emit→parse, non-canonical-input rejection, id stability). Resolve findings #4 first. - Wave B (on Wave A):
store(state-machine tests with a fakeKitBuildStoreIo),remote(fakeKitBuildExecreturning good/corrupt bytes),client(loopback in-process transport against a scripted server). - Wave C (on B):
coord(memo + future + sequential-mode tests withsched == NULL). Settle findings #5/#7 here. - Wave D (on C):
resolvethenrunnertogether (they share the recursion seam, findings #2/#6) andbundle. End-to-end smoke via the in-process transport: cold build → no-op rebuild → comment-edit (Phase 2 hit) → config flip — exactly the worked example. - Wave E:
src/api/build.ccomposition + thekit builddriver command +KIT_BUILD_ENABLEDgating, then the Makefile test targets.
Parallel-agent handoff contracts
Each module owner should treat its header as the contract and this table as the
acceptance gate. A module is "done" only when its focused tests land with it and no
downstream module has to reinterpret its bytes, ownership rules, or error behavior.
Keep module tests targeted (make test-build-<module> once wired); do not require an
end-to-end coordinator to validate L0-L2 work.
| Module | Owner edits | May depend on | Must provide | Required focused tests | Must not do |
|---|---|---|---|---|---|
build |
src/build/build.c, src/build/build.h comments only if needed |
public hash/core/CAS APIs | build_target_key, build_glob_result_hash, build_diagf; stable constants |
fixed id vectors for target-key and glob-result; sorted path/blob listing; invalid/overflow inputs | no filesystem, no coordinator state, no allocation except bounded scratch/writer |
cfg |
src/build/cfg.c, src/build/cfg.h |
build |
sorted propagated map; positional argv; canonical emit/parse/id; overlay | emit golden bytes and ids; parse rejects duplicates/out-of-order/unknown forms; overlay replaces and preserves sort; empty argv id vector | no CAS I/O, no ambient config/env reads, no hidden defaults |
defn |
src/build/defn.c, src/build/defn.h |
build |
strict kit-build 1 parser and lookup |
canonical definition golden; sorted-unique target rejection; unrelated stanza edit does not affect find(T) result |
no recipe hashing, no source dep recording, no argv defaults |
trace |
src/build/trace.c, src/build/trace.h |
build |
shallow/deep/deepset/record emit/parse/id/prepend | golden bodies+ids for all four formats; absent source row; non-canonical rejection; record MRU dedup and per-kind cap | no store/CAS access, no matching against live workspace |
protocol |
src/build/protocol.c, src/build/protocol.h |
build, public slices |
exact wire codec specified above | golden hex frames; malformed corpus; glob multi-frame decode; round-trip re-encode | no transport I/O, no malloc, no coordinator side effects |
client |
src/build/client.c, public <kit/build_coord.h> only if a real API gap appears |
protocol, KitBuildTransport |
public kit_build_client_* round-trips; borrowed response slices valid until next call |
loopback scripted server for each public call; error mapping; absent source; glob callback streaming; close releases conn | no coordinator/store access, no filesystem, no process spawning |
store |
src/build/store.c, src/build/store.h |
build, trace, public CAS, host vtables |
path layout; trace put/get; target record RMW; cache materialize; sandbox lifecycle | fake KitBuildStoreIo tests for idempotence, malformed-record-as-empty, crash injection, cache verify, sandbox cleanup |
no trace matching, no recipe execution, no remote fetch |
remote |
src/build/remote.c, src/build/remote.h |
build, public CAS, KitBuildExec |
fetch-template rendering; verified blob/tree/deepset install; try remotes in order | fake exec good/corrupt/missing; hash mismatch rejected; second remote succeeds; deepset fetched as blob | no trust decisions, no trace import, no network API beyond exec |
coord |
src/build/coord.c, src/build/coord.h |
cfg, defn, store, trace, public CAS |
context open/close; source/glob/config/argv/recipe memos; deepset intern/load; futures; stats; jobs semaphore | fake workspace memo counts; config/argv by-id load; recursive deepset load; sequential degrade; future dedup; stats bumps under lock | no resolution phase policy, no recipe spawn loop |
resolve |
src/build/resolve.c, src/build/resolve.h |
coord, cfg, trace; calls build_run_recipe by declared seam |
chain/cycle detection; Phase 1/2/3 selection; materialization ladder; leafset refresh/union | deep hit; shallow hit after changed dep with same output; recipe-id bust; materialize miss; cycle path; exact stats deltas | no process I/O, no protocol service loop |
runner |
src/build/runner.c, src/build/runner.h |
coord, resolve, protocol, trace, cfg |
spawn environment; protocol service; dep log; job-slot release across needs; trace recording | scripted transport/exec: config/source/glob/need logs; nonzero exit writes no trace; need-submit/await determinism; jobs=1 deep chain | no Phase 1/2 matching policy, no signed bundle logic |
bundle |
src/build/bundle.c, src/build/bundle.h |
coord, store, public package/CAS |
signed trace export/import; trace remote pull | package round-trip; untrusted signer rejected; tofu pin result; imported trace has overlay/argv/deepset blobs; bad trace id rejected | no object remote fetch, no recipe execution |
api |
src/api/build_coord.c or chosen composition TU, Makefile gating |
all internal modules | public coordinator, stats, trace import/export, stubs under disabled gate | public API smoke using fake host; unsupported gate; status/diag mapping | no policy hidden in driver |
driver |
driver/cmd/build_coord.c or final command name, driver tables |
public API only | CLI parses flags, supplies hosted vtables, exposes shell helper subcommands for recipes | run.sh subprocess/socket smoke; helper commands call client API; clean env behavior |
no internal src/build/* includes |
Cross-agent rules.
- Public or internal header changes require updating this table and the owning module's tests in the same commit.
- If a module needs a new callback or field from another module, add a failing focused test at the seam first; do not tunnel around the boundary with internal includes.
- All canonical bytes must get checked-in golden tests before any downstream module
uses them (
cfg,trace,protocol, bundle manifest). - A downstream owner may stub an upstream module only at the documented function boundary; stubs must be removed before the wave is marked done.