Skip to content

feat(cluster): add qualification, staging, planning, and deployment - #4

Closed
jonathan308 wants to merge 138 commits into
feat/cluster-v2-trustfrom
feat/cluster-v2-planning
Closed

jonathan308 wants to merge 138 commits into
feat/cluster-v2-trustfrom
feat/cluster-v2-planning

Conversation

@jonathan308

Copy link
Copy Markdown
Owner

Stack position

2/4 of the backend-first Cluster v2 split requested in jundot#3118.

Depends on the trust foundation in feat/cluster-v2-trust / upstream jundot#3155. This draft exists on the fork so its incremental diff and CI can be validated before the dependency merges upstream.

Scope

  • model inventory and manifest exchange
  • automatic model synchronization over enrolled SSH or local download
  • protected remote rsync paths compatible with macOS system openrsync
  • per-node model paths and portable cross-account path handling
  • verified staging and shard/sidecar inventory
  • RAM-proportional pipeline planning
  • tensor/pipeline topology recommendations
  • signed preview/apply re-planning
  • deployment activation and registry migration

Deliberately excluded

  • launcher/rank-control/teardown implementation (stack item 3)
  • serving, cancellation, telemetry, and cache lifecycle (stack item 4)
  • Phase split and Cluster v2 UI
  • DS4, ANE, MTP, native kernels, scheduler/sampling optimizations
  • updater, release, repository, and branding changes

Validation before draft

  • full local suite: 10,559 passed, 102 skipped
  • focused inventory/staging/planning/replan suite: 88 passed after the rsync hardening
  • Ruff and git diff --check: passed

@jonathan308
jonathan308 changed the base branch from feat/cluster-v2-trust to main August 26, 2026 12:32
@jonathan308 jonathan308 reopened this Aug 26, 2026
@jonathan308
jonathan308 changed the base branch from main to feat/cluster-v2-trust August 26, 2026 12:57
aywengo and others added 24 commits August 27, 2026 09:45
Make the audio upload limit configurable through settings, environment variables, CLI, and the admin API while preserving the existing 100MB default.
Adds first-class Qwen3.8 Flash Next and Qwen4-Exp support, including multimodal preprocessing, QSA cache handling, and external PLE SSD mmap loading.
…t#3174)

Adds production Qwen3.8 Flash Next text/image support with prefix-cache parity, Lightning MTP, oQ conversion, and PLE SSD mmap handling.
…xt (jundot#3200)

Qwen3.8 Flash Next uses a dedicated VLM Lightning MTP path that intentionally bypasses the generic mlx-lm compatibility whitelist. Allow qwen4_exp checkpoints with embedded MTP weights through the admin compatibility and save gates.
jundot and others added 27 commits September 7, 2026 00:15
Bumps [ddgs](https://github.com/deedy5/ddgs) from 9.15.0 to 9.16.0.
- [Release notes](https://github.com/deedy5/ddgs/releases)
- [Commits](deedy5/ddgs@v9.15.0...v9.16.0)

---
updated-dependencies:
- dependency-name: ddgs
  dependency-version: 9.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
_extract_numeric_answer falls back to the last number in the response
when the model does not emit the "#### N" marker. Its fallback pattern
-?[\d,]+(?:\.\d+)? also matches a bare comma, because [\d,]+ needs only
one character from the class. Any comma after the final digit therefore
became the last "number" in the text, and .replace(",", "") turned it
into an empty string.

"The total is 72 clips, altogether." extracted "" instead of "72", so
check_answer returned False on a correct response and the question was
scored wrong (parse_error on the external-API path).

Anchor the pattern on a digit at both ends: -?\d(?:[\d,]*\d)?(?:\.\d+)?.
Thousands separators still match, since they sit between digits. The
"####" branch uses the same pattern.

All 1,319 gold answers in omlx/eval/data/gsm8k_test.jsonl extract
identically before and after, so scoring keys are untouched.
…e benchmark (jundot#3434)

`OMLX_QWEN35_QMM_NAX_VARIANT=6` selected a 64x64x64 tile with wm=1,
wn=4, i.e. a 16-column per-simdgroup N tile. On M5 Max / mlx 0.32.2 it
returns wrong output: max abs error 5-8 against stock `quantized_matmul`
at every tested shape, bit width (4/8) and group size, while variants
0-5 agree with stock to within a bf16 ulp. Remove the instantiation and
the C++ table entry, validate the env knob in Python (out-of-range or
non-numeric values warn once and fall back to variant 0 instead of
raising from inside a prefill), and add a hardware test that checks
every bundled tile against stock for q4/q8 x gs64/gs128.

benchmarks/bench_qwen35_nax_qmm_variants.py times stock vs each bundled
tile at Qwen4-Exp GDN/attention projection shapes. Results on M5 Max
(mlx 0.32.2, ms per call, lower is better):

  q8/gs64            T     stock     v0     v2   v2/stock
  gdn.in_proj_qkv  8192    18.97  18.14  15.01     0.79
  gdn.in_proj_z    8192    10.18  10.25   9.18     0.90
  attn.q_proj      8192    19.57  19.38  17.16     0.88
  attn.o_proj      8192    10.06  10.17   9.76     0.97
  q4/gs64
  gdn.in_proj_qkv  8192     8.14   7.99   8.96     1.10
  attn.q_proj      8192    17.24  17.11  15.96     0.93

Variant 2 (bm=128) wins 5-20% at 8-bit but is mixed at 4-bit, so the
default stays at variant 0 (the stock tile); the knob and the benchmark
are there for per-model tuning.


Claude-Session: https://claude.ai/code/session_01FUwVt6F1YHbTLvNvmAGEvU

Co-authored-by: Claude Fable 5.1 <[email protected]>
jundot#3469)

* test: pin MLX_ENABLE_TF32=0 under pytest so fp32 parity tests hold on M5

MLX 0.32.2 runs fp32 GPU matmuls through the M5 tensor unit at TF32
precision by default (128x128 fp32 GPU-vs-CPU max abs error 3.5e-2 with
TF32, 1.9e-5 without). Four existing gathered-vs-dense QSA parity tests
assert 2e-5 in fp32 and fail on an untouched tree on M5 hardware.
tests/conftest.py now pins it off for the test session only; production
servers still run with TF32 enabled.

* perf(qwen4): route Qwen4ExpRMSNorm through mx.fast.rms_norm

Qwen4ExpRMSNorm spelled RMS norm out in MLX ops -- fp32 upcast, optional
grouping, y * rsqrt(mean(y^2) + eps) * (1 + w) -- where mx.fast.rms_norm
exists. The class is built at 11 sites: the two hyper-connection norms per
layer, the attention query/key layernorms, the indexer norms and the PLE
layer, so the hand-rolled chain ran a few hundred times per forward.

rms_norm takes a 1-D weight, so the grouped case (hc_norm: 10240 in four
groups of 2560) cannot hand it the per-group scale; it normalises over the
group axis and applies the scale after. The scale stays fp32 either way --
rounding (1 + w) to bf16 costs the full half-ULP bound, ~3.9e-3 relative.

Not bitwise: the kernel sums the squares in a different order than mx.mean,
so a value occasionally lands on the far side of a bf16 rounding boundary.
Measured over 3.1M elements per weight magnitude: 1-8 ppm of elements move,
never more than one bf16 ULP, and the rate does not track weight scale. For
comparison, running the same row through a 1-row versus a 4-row forward
already moves 2.4% of elements by up to 64 ULP, so this sits far below
variation the model exhibits between one request and the next -- a bar the
new tests assert directly rather than assume.

* perf(qwen4): dispatch each decoder layer to the GPU as it is built

With one async_eval after the whole forward, the GPU sits idle while Python
builds the next ~8.5k-node decode graph and MLX encodes it. Calling
mx.async_eval on each decoder layer's output during the layer loop (decode
and verify rows only, <= 64 rows) lets the GPU execute layer i while the host
builds layer i+1. Scheduling only: outputs are bit-identical, which the ABBA
confirms (identical cycle counts and acceptance in every arm).

Qwen3.8-Flash-Next-oQ4e-mtp on an M5 Max, interleaved ABBA:
  serial decode (MTP off): +18..22% tok/s (code 5k 44.6 -> 53.5)
  Lightning MTP depth 3:   -11.5% ms/cycle (44.1 -> 39.1), 65.8 -> 75.9 tok/s at code 5k

Disable with OMLX_QWEN4_EAGER_DISPATCH=0.

* perf(qwen4): fused hyper-connection kernels for decode and verify rows

Qwen4ExpGatedResidual._forward builds ~30 MLX nodes per call and runs 97
times per forward; under Lightning MTP the host-side kernel encoding of that
graph, not its GPU time, dominates a verify cycle. Three row-batched Metal
kernels replace it for batch*seq <= 16: a per-stream RMS norm (bit-identical to
mx.fast.rms_norm), the down and inject projections split over four K-slices
with the silu / 2*sigmoid epilogues folded in, and the up projection with the
sigmoid gate, stream mix and mean. The affine unpack helpers come from
hc_projection; 4/5/6/8-bit gs64 weights are supported, with separate bit widths
for the down and inject projections. Results stay within a few bf16 ULP of the
canonical path and at least as close to fp32; the path fails closed.

Qwen3.8-Flash-Next-oQ4e-mtp on an M5 Max, interleaved pinned-depth ABBA on top
of eager dispatch: MTP depth 3 -6.5% ms/cycle (39.2 -> 36.6), 68.7 -> 75.3
tok/s mean over four prompts. Serial decode is GPU-bound after eager dispatch
and does not change.

Disable with OMLX_QWEN4_HC_FUSED=0.

* fix(qwen4_exp): require hidden % 512 for the fused hyper-connection path

The fused down projection walks each K-slice (= hidden) in blocks of
32 lanes x 2 packs x 8 values = 512 elements for 4/5-bit weights with
no tail handling, but compatible() only asked for a multiple of 256.
A hidden size such as 768 (4-bit) or 1280 (5-bit) passed the check and
the last block read past the slice: 77 and 88 bf16 ULP off the
canonical path on an M5 Max. 6/8-bit weights (block 256) and the
shipped checkpoint (hidden 2560) were unaffected.

Require hidden % 512 == 0 for every bit width and drop the redundant
(hc_count * hidden) % 512 check. Tests: compatible() rejects 768/1280,
and the parity sweep now also covers hidden 512 and 1536.

* perf(qwen4_exp): handle partial final blocks in the fused hyper-connection kernels

Instead of gating the fused path on hidden % 512, let it take any
hidden_size that is a multiple of 64 (the quantisation group and the up
kernel's grid unit):

* norm kernel: PER = ceil(H / 256) with a k < H guard on the loads and
  the store;
* down and inject loops: the main loop stops at SLICE - SLICE % BLOCK
  and a lane-guarded extra iteration covers the remainder. The unpack
  helpers read exactly VPT values and VPT * BITS / 8 weight bytes, so no
  lane touches memory past its slice. The inject loop's separate
  "safe" last block goes away: hc_qdot_safe is byte-for-byte hc_qdot
  in the shared header, so the loop shape is now the same as the down
  loop's.

For the shipped geometry (hidden 2560) every tail is zero and the guards
fold away: old and new kernels are bit-identical for 4/5/6/8-bit weights
at 1, 4 and 16 rows.

compatible() also logs once, at INFO, when a Qwen4-Exp model stays on the
canonical path because of its layout, so a future checkpoint that misses
the fused kernels is visible in the server log rather than just slower.

Tests: parity sweep at hidden 512/768/1152/1344/1536 x 4/5/6/8-bit
(tails of 64, 128, 256 and 320 elements across the three kernels), norm
bit-identity at 768/1152/1344, rejection below 64, and the one-time log.

* fix(qwen4_exp): do not log the exact-hybrid projection as a fused-path gap

With Lightning MTP off, qwen4_exp.load_weights installs upstream's exact
hybrid projection on every eligible hyper-connection (91 of 97 in the
shipped checkpoint), and those modules keep their compiled single-token
path. compatible() must still step aside for them, but that is a runtime
choice, not a checkpoint layout, so the one-time "kernels not used for
this model" line no longer fires for it. It still fires for a real
combined input_inject_weight layout.

* perf(qwen4_exp): run the fused hyper-connection kernels ahead of the exact hybrid projection

With Lightning MTP off, qwen4_exp.load_weights flags 91 of the 97
hyper-connections in the shipped checkpoint with upstream's exact hybrid
projection and compiles their single-token _forward. compatible() used
to step aside for those modules, so in serial decode the fused kernels
ran on 6 modules and the other 91 paid a compiled call plus its own
kernels each token.

Let the fused path take precedence for the rows it covers. _forward is
untouched and keeps the hybrid projection for everything else. Serial
decode on the M5 Max, interleaved env A/B on this branch (High Power,
MTP off, 512 tokens):

  code 5k     55.2 -> 62.8 tok/s (+13.8%)
  code 41k    50.6 -> 56.8 tok/s (+12.2%)
  prose 5.8k  54.9 -> 62.4 tok/s (+13.6%)
  prose 34k   51.1 -> 57.6 tok/s (+12.8%)

MTP verify is unchanged (the hybrid is never installed with MTP on).
Serial outputs now match the canonical path to a few bf16 ULP, as the
MTP path already did, instead of bit-exactly.
)

* feat(cluster): add v2 node identity module

NodeIdentity (uuid4 node_id, scutil-derived friendly_name, schema_version 1)
persisted atomically at 0600 to ~/.omlx/cluster/identity.json, with friendly
-name collision repair (-2 suffix), rename(), and corruption handling that
never silently rotates node_id. Offline unit tests cover persistence,
collision, rename, and corrupt-file behavior.

* feat(cluster): add v2 DeviceRegistry for paired/discovered devices

DeviceRegistry persists paired devices atomically (0600, schema_version 1)
to devices.json while discovered-but-unpaired devices stay memory-only.
Merge API dedupes on node_id, never downgrades paired trust, and is shaped
to accept discovery PeerRecords directly. Fails closed on corruption like
ClusterRegistry. Existing ClusterRegistry untouched.

* feat(cluster): add v2 PeerRecord + DiscoveryService (mDNS/multicast/manual/tailscale)

Layered peer discovery appended to omlx/cluster/discovery.py without touching
the existing dns-sd Bonjour code: _omlx._tcp.local. via optional
python-zeroconf (absence disables mDNS only), exo-style IPv6 link-local
HELLO/WASSUP on ff12::6f6d:6c78/53413 with per-interface joins, nonce-echo
self-drop, higher-node_id-initiates-contact, blake2s cluster-hash filter
(log-once on mismatch), verification probes against /api/cluster/node_id with
node_id-mismatch address drops, RTT-based link classification, and
discovered/suspect(6s)/dead(30s) liveness with on_change callbacks.
multicast_ok reports whether a foreign HELLO arrived within 5s so the UI can
warn about macOS Local Network permission. 30 offline tests; sockets, prober,
clock, interfaces, tailscale, and zeroconf all injected.

* feat(cluster): wire v2 discovery endpoints and service into server

New omlx/cluster/discovery_routes.py with GET /api/cluster/node_id (public,
per-IP token-bucket rate limited, 0600 identity only) and GET
/api/cluster/devices (admin-gated; {paired, discovered, self} plus
multicast_ok/mdns_available signals so the UI can warn about macOS Local
Network denial instead of showing silent zeros). discovery_router registered
in _register_cluster_routes behind the distributed-inference exposure flag
only. Identity + device registry configured at startup; DiscoveryService
started/stopped in the server lifespan when the feature is enabled
(OMLX_DISCOVERY=0 opts out). zeroconf added as the 'cluster' extra.

* test(cluster): cover mDNS announce/failure paths; whitelist v2 test hooks in seams lint

* cluster v2: code-based pairing + enrollment bridge (Module B)

PairingManager drives the existing fail-closed SSH TOFU primitives
(ssh_keys install_authorized_key / add_verified_peer_host_key) instead of
the 3-step copy-paste relay: 6-digit code shown on the joiner, 10-minute
validity, 3 attempts then 10-minute lockout, blake2s(code+node_id) on the
wire, 32-byte cluster_key wrapped under PBKDF2-HMAC-SHA256 (100k iters),
JSON-lines audit log, deny + unpair with SSH trust revocation.

Endpoints under /api/cluster: pair/request + pair/status (public, pinned
by the out-of-band code) and pair/approve + pair/deny + DELETE
devices/{node_id} (admin). Wired in server.py alongside the existing
cluster routers; legacy relay endpoints kept working and marked
advanced/legacy. Module A registry/identity are feature-detected with
schema-compatible local fallbacks until that branch lands.

* cluster v2: offline pairing tests (loopback, lockout, expiry, unpair, legacy)

Two-manager loopback happy path via injected transport; wrong-code 3-strike
lockout (correct code refused inside window, code expired after it, attempts
reset when a lockout ends inside validity); expiry; deny; unpair revocation
(device store + key store + legacy enrolled-nodes + SSH material); key/device
store persistence, 0600, fail-closed corruption; Module A registry bridge
feature-detection; endpoint error mapping (400/403/404/410/422/423/502/503);
legacy 3-step relay route registration + HMAC token non-regression.

* cluster: add GET /api/cluster/discovery/health (seam a)

Pinned shape from Agent C's fixture: multicast_rx_within_5s,
last_multicast_rx_at, mdns_active, transport. DiscoveryService now
records a wall-clock timestamp alongside the monotonic hello marker and
exposes last_multicast_rx_at / mdns_active / transport_summary(). Admin-
gated like /devices; fixture shape matched exactly, no UI changes
needed.

* cluster: wire pairing onto Module A DeviceRegistry (seam b)

- server.py startup: configure identity + device registry BEFORE the
  pairing manager and pass get_device_registry() as registry=, so pairing
  approvals persist into the real registry instead of the fallback store.
- DeviceRegistryBridge.put_paired: detect mark_paired first and call it
  with keyword fields. The real DeviceRegistry.mark_paired takes kwargs,
  and its merge() persists only already-paired devices — routing an
  approval through merge would have left new peers memory-only.
- load_node_identity: Module A ships load_or_create as a module-level
  function (not a NodeIdentity classmethod); call it with the base_path
  identity.json so pairing and discovery share one identity.
- Tests: bridge driven against the real DeviceRegistry (persistence,
  unpair); full pairing suite green.

* cluster: surface pending pair requests in /api/cluster/devices (seam c)

PairingManager.pending_requests() rows are merged into the discovered
list with state 'awaiting_approval' (pending state wins for a node that
is both discovered and awaiting approval), matching the wizard fixture
devices_pending_approval.json. Module A keeps endpoint ownership; the
pairing manager is looked up lazily and its absence changes nothing.

* cluster: settings-level cluster_name via persisted cluster.json (seam e)

~/.omlx/cluster/cluster.json ({schema_version, cluster_name}, atomic
0600 write) is the settings-level cluster name: server startup feeds it
into DiscoveryConfig, and the node_id probe reports it even when the
discovery service is disabled. Missing/malformed file falls back to
'omlx' — pre-config behavior unchanged.

* cluster-v2 discovery: macOS resilience overhaul + manual peer add

Field-diagnosed on the Studio/M5 test pair (macOS 26.5.2, TB5 direct link):

- _sync_interfaces now drops vanished interfaces and detects interface
  renumbering (TB hotplug), re-joining the group under the new ifindex.
  Previously a stale ifindex silently wedged sends with EHOSTUNREACH
  forever (observed: 7,085 failures in 9 min, then total silence).
- HELLO sends now carry the egress interface as the destination scope id
  (4-tuple) instead of mutating IPV6_MULTICAST_IF on the shared socket.
- WASSUP unicast replies preserve the HELLO source's scope id; without it
  replies to link-local senders have no route and the handshake could
  never complete even when multicast flowed.
- Background loops run under a supervisor that logs CRITICAL and restarts
  them on crash, and warns + restarts on unexpected clean exit; the
  datagram handler is exception-guarded; the loop rebuilds the socket
  after 3 consecutive all-failed HELLO rounds and clears its join state
  on exit so a supervised restart starts clean.
- Per-interface/per-peer rate-limited failure logging (60s) ends the
  one-line-per-second-per-interface log spam.
- New GET /api/cluster/discovery/health/detail (loop liveness, socket
  state, joined interfaces, TX error/counters) so 'no peers' is
  distinguishable from 'discovery dead'. The pinned wizard fixture shape
  on /discovery/health is untouched.
- New POST /api/cluster/devices/manual {ip, port?} — deterministic
  peer add over the Thunderbolt 10.0.0.x pair (or any IP), verified
  through the standard HTTP probe path, admin-gated + rate-limited.
- /api/cluster/devices self row now reports real local addresses instead
  of a hardcoded empty list.

Tests: 17 new (scoped 4-tuple sends, scope-preserving replies, renumber
heal, vanished-if drop, TX rate limiting, fail-round socket reset, socket
rebuild liveness, supervised crash restart, health detail, manual-add
route incl. 400/503 paths). 62/62 discovery tests pass; 421/421 across
discovery+wizard+pairing+exposure scope.

* cluster-v2 discovery: backoff socket rebuilds, flag suspected Local Network blocks

Follow-up to 5ab14c1 from live field observation on the M5 node: when
the wedge is process-level (macOS Local Network permission denial — fresh
processes with the same interpreter send/receive fine while the server
gets EHOSTUNREACH on every interface), per-second socket rebuilds cannot
help and only spam the log (1 warning/sec indefinitely).

- Socket rebuilds now back off exponentially (2^n seconds, capped at
  60s); the streak warning logs once per streak instead of every round.
- After 6 fruitless rebuilds the service logs CRITICAL with actionable
  guidance (Local Network settings / restart / manual-add fallback) and
  sets local_network_blocked_suspected, exposed via
  /api/cluster/discovery/health/detail. Any successful TX round or
  foreign HELLO RX clears the flag and the backoff schedule.
- 88/88 discovery + wizard tests pass.

* cluster v2: carry coordinator caps through pair/status; detect TB RDMA in local caps

- join_status() (the poll path the joiner UI actually drives) now returns
  the same coordinator block approve() returns — caps + ssh key — so
  complete_join persists real capabilities instead of empty caps
- local_caps() detects the Thunderbolt RDMA fabric via rdma_ctl status +
  ibv_devices (fail-safe, macOS-only, injectable runner): thunderbolt
  flag when rdma_* devices exist, jaccl when RDMA is also enabled — the
  wizard's RDMA check row and jaccl-vs-ring selection key off these

Tests: +3 (join_status caps/key, rdma fabric detection on/off/failing);
cluster slice 1561 pass 0 fail

* cluster v2: fix rdma device parse (ibv_devices output is whitespace-indented)

* cluster v2: non-downgrading device-registry merge

A discovery HELLO carries structurally complete but value-empty caps
({chip:'', ram_gb:0.0,...}); DeviceRegistry.merge truthiness-replaced the
pairing-exchanged record, so a few seconds after pairing every paired
device silently lost its chip/ram/fabric flags and its routable addrs —
the wizard then planned with ram_gb=0 and dropped the node. Merge is now
field-wise: real values win, backends union, fabric flags sticky-true,
addrs union (capped), and records without flags keep their shape.

* fix(cluster): paired device rows carry paired=true in /api/cluster/devices

Registry rows never carried the flag (only the self record set it), so
wizard consumers keyed on device.paired -- the per-Mac memory-contributes
role list and the device card's Pair/Paired state -- treated an already
paired peer as unpaired: the peer's role row was missing and the card
kept offering Pair after a successful pairing. List membership is now
reflected on the row itself; UI fixtures already expected this contract.

* Rehydrate trusted peer endpoints after reboot

* Reconnect paired peers through system Python

* Expose and force paired peer probes

* Discover Tailscale peers from macOS app

* Keep paired discovery heartbeats responsive

* Heartbeat verified discovery candidates

* fix(cluster): make pairing and SSH enrollment fail closed

* test(cluster): keep injected RDMA probes host-independent

* fix(cluster): serialize pairing completion and roll back partial trust

* fix(cluster): rate-limit public pairing endpoints

---------

Co-authored-by: jonathan308 <[email protected]>
…fest endpoint

- deployment.py: path_map field, model_path_for(), schema v2 (v1 decodes to
  empty map), path_map rides the worker contract, decode_worker_path_map()
- registry.py: deployments.json schema v1->v2 in-memory migration, persisted
  atomically on load, migrated_from reported
- planner.py: ShardPlan.path_map passthrough (excluded from plan_hash)
- routes.py: path_map on plan/deployment requests, placement signature folds
  path_map when present, staging honors per-node destinations
- launch.py: preflight probes each node at its own path_map path
- inference_worker.py: rank resolves its model path from the signed contract
- modelsync.py: ModelSyncManager (status/sync, auto|rsync|download), manifest
  builder + GET /api/cluster/models/{model_id}/manifest, progress events
- tests: 32 new offline tests (manifest fixture, path_map, v1 migration,
  sync decisions with mocked transports)
…oder in worker test harness

- modelsync.build_rsync_argv now builds its -e command from
  ssh_policy.cluster_ssh_options() (seam test compliance: no literal ssh
  argv outside the shared policy)
- test_cluster_inference_worker harness fakes decode_worker_path_map next to
  decode_worker_contract; the fake plan string is not a real contract
allocate_layers_proportional (exact Fraction arithmetic, min one layer per
node, deterministic tie-breaks) plus plan_proportional_pipeline, sharing the
assignment/fit/hash tail with plan_unequal_pipeline via _finish_pipeline_plan.
The balanced planner's hash payload is byte-identical to before. Routes gain
an additive allocation field ('balanced' default) on plan/deployment/replan
requests; proportional is pipeline-only.
…n→reload)

Extract the activation pipeline into _activate_and_report so the replan
endpoint and the explicit /deployments approval share one code path. The
replan endpoint derives nodes/hosts/backend from the current deployment
when not posted, renders a signed preview with a placement diff, and on
approval runs the quiescence-gated unload (engine-pool prepare_cluster_
reload) whose distributed supervisor teardown is verified before the
registry swap and eager reload.
Agent E flagged the seam: ClusterReplanRequest had no path_map and the
replan derivation dropped the current deployment's per-node paths,
silently reverting a heterogeneous-path cluster to same-path resolution.
Add path_map to ClusterReplanRequest, derive it from the current
deployment when not posted (tracked in the 'derived' map), and pass it
into the effective ClusterDeploymentRequest. Explicit override (including
an empty map) wins. Tests updated + carry-forward coverage added.
The catalogue tie-break preferred pipeline at equal width because it
survives slow links. When the paired registry shows every requested peer
reachable over JACCL/Thunderbolt RDMA, tensor parallelism is the better
recommendation — it splits per-token compute across the group.

- _splits/assess_model/catalogue_for_cluster gain prefer_tensor (default
  False: API behavior unchanged for unknown links)
- catalogue endpoint derives it from the paired device registry: every
  non-self requested node must be paired with jaccl/thunderbolt caps;
  unknown or unpaired peers fail closed to pipeline
- 4 new tests; 33 catalogue + 1702 cluster/model tests green
macOS announces Thunderbolt interfaces as fe80::…%en10; mlx's address
parser cannot read the zone id and ranks died at startup with
'Can't parse address fe80::…%en10:56758'. _validate_ip now strips the
zone (machine-local by definition, never valid on the wire) and the
hostfile orders routable addresses ahead of link-local fallbacks, so a
Mac with configured 10.x Thunderbolt IPs never advertises the ambiguous
one first. 3 tests pin the exact failure.
@jonathan308
jonathan308 force-pushed the feat/cluster-v2-planning branch from 059ec4a to 3218bf1 Compare September 8, 2026 03:33
@jonathan308
jonathan308 deleted the feat/cluster-v2-planning branch September 20, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.