Conversation
Adds the local-network compute cluster (issue #172). CSGLite nodes on one network discover each other over mDNS, pair with a join token or an admission code, and route inference to the member that holds the model. Nodes are identified by a persisted UUID and self-signed certificate pinned at pairing, so an IP change after a reboot needs no action. Node-to-node traffic runs over a separate mTLS listener (:11438); forwarded requests are executed locally and never routed again. - ee/cluster: identity, membership store, mDNS/in-memory discovery, directory with health state machine, estimated-completion-time scheduler with explain output, session affinity, node/cluster engines with failover, management and peer HTTP handlers. - internal/server: host adapter (status, hardware, forwarded inference), getChatEngine/getEmbeddingEngine hooks, /v1/models merge, node response headers, /api/cluster/* routes, OpenAPI. - internal/license: feature.lite.lan_cluster (ungated) and the gated quota.lite.max_cluster_nodes (Community edition: 2 nodes). - CLI: csghub-lite cluster {status,create,join,leave,token,code,nodes, discovered,invite,remove,models,sync,explain,drain,activate,maintenance}. - Web: dashboard cluster node cards and the cluster management page. - Docs: design (docs/guides/lan-cluster-design.md), CLI, env vars, Docker. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
…e headers - Re-probe unpaired nodes every 20 seconds: mDNS reports an instance once, so a node that restarted or joined another cluster went stale or aged out of the discovered list and a token join could not find it. - Fold embedding-mode engines into the node status so a model serving embeddings shows as loaded to the scheduler and the dashboard. - Copy X-CSGLite-Node headers on embeddings and Anthropic responses. - Route to the cluster whenever a peer holds the model, even while that peer is draining, so the caller gets "no cluster node can serve" instead of "model not found locally". - Publish a unique .local host name per node (the DNS packer needs the canonical trailing dot) and give IP-named hosts a distinct default name. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
Machines installed with the same CSGHUB_LITE_CLUSTER_SECRET now find each other and form one cluster with no create or join step. The cluster UUID and join token are derived from the secret (UUIDv5 and HMAC), so every node agrees on them while the secret itself never touches disk or the network. The first node up founds the cluster after a short grace period; later nodes join whichever member they discover; two nodes that found in parallel merge through the same join handshake. An explicit leave pauses automatic formation until the node is joined or created into a cluster again. - config: new `cluster` section (secret, name) with env override; the installers persist CSGHUB_LITE_CLUSTER_SECRET / _NAME via `csghub-lite config set cluster_secret`. - CLI: `config set|get|unset cluster_secret|cluster_name`. - API/UI: `auto_form` fields on the cluster view and a notice on the page. - Fix a data race on the node display name (rename vs. status readers) and keep former members discoverable after leaving so a re-join needs no new multicast announcement. - Docs: env vars, installation, cluster and config CLI, design section 6.1. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
…anted A node with no cluster secret, no join token and no membership now opens no peer listener, joins no multicast group, sends no mDNS queries and runs no polling or gossip goroutines. Networking is switched on by provisioning (secret or token), by an existing membership, by a previous explicit enable, or by the operator's create / join / invite / admission-code actions and POST /api/cluster/enable. A node that leaves on purpose goes dormant again; POST /api/cluster/disable and `csghub-lite cluster disable` do so explicitly. The cluster view and page show the state and offer to turn it on. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
The secret is the operator's choice; anything from four characters is accepted and a warning is logged below twelve, instead of refusing outright. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
- Dashboard no longer shows cluster nodes; the cluster page is the one view. - Nav item and title renamed to "Cluster" / "集群". - Overview table: fewer columns, no wrapping (node name truncates, in-flight folded into status, short GB formatting, short version), IP-style host names hidden, node cap shown as "2 / 2" with a one-line hint. - Generated node names shortened to "node-<6 hex>"; earlier long names are migrated on start. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
Speech recognition and synthesis resolve their engine by model and source through getASREngine / getTTSEngine, shaped like getChatEngine: an explicit cluster or node source, the cluster for models this node lacks (or every model in balanced mode), the local runtime, then a peer when the local load fails. The cluster-backed engines rebuild the upload or JSON body, forward it through the shared scheduler and failover path, and report the executing node in the response headers. Handlers keep their shape; a failure on a routed engine never evicts a local worker. Node status now lists the Python workers (recognition, synthesis, image) as single-slot engines. A peer that answers "not a forwardable inference endpoint" is reported as an outdated node (502) rather than a missing model. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
…leeping Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
When the scheduler chose the local node, the dispatch reservation was released before the local work ran and the local Python workers did not count plain requests, so every concurrent request looked like it hit an idle machine and balanced mode never moved work elsewhere. RouteRaw now hands the caller a LocalChoice whose Release ends the reservation after the local call, the local candidate carries its reservations into ranking, and the audio handlers retain the worker for the duration of a request. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
"modelscope/Qwen/Qwen3.5-2B" now becomes a pull of Qwen/Qwen3.5-2B from ModelScope on the target node instead of an invalid OpenCSG repository name. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
…urce A pull job on a cluster member first looks for an online member that holds the model completely and copies its files over the mTLS cluster channel, verifying size and a SHA-256 trailer, then installs them in the normal model directory layout with the peer's manifest. Only when no member has the model does the job download from the model source. Peers serve /cluster/v1/model-bundle and /cluster/v1/model-file to members only, with path checks confined to the model directory. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
…iles follow only over a fast link A model directory holds the files the download produced plus artifacts the node derived from them (a GGUF converted from safetensors). The peer copy now transfers the manifest-listed files, installs the model, and only then copies derived files in the background, and only when the measured link exceeds 20 MB/s: on a slow link regenerating a conversion beats copying gigabytes. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
A cluster model id is a public id: an OpenCSG model is advertised under its short name, which is not a repository, so syncing it created a pull job for an invalid model id. Node status now carries the repository and artifact source alongside each model, the sync endpoint uses whatever the holding node reports, and PullSpec resolves a local model before parsing the id. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
A poll that did not report back left the member marked as being polled forever: no further poll was scheduled, so its health, address and last error stayed frozen even once it was plainly reachable again. Observed after both machines changed IP at once, where one node kept reporting the other as down at its old address while gossip from that very node was arriving. - A poll in flight longer than 45s is abandoned and polling resumes. - An authenticated request from a member, or an answer it streamed back, is proof of life: it now heals a member written off as down and schedules an immediate poll, so a node whose address changed recovers from the first packet it sends rather than waiting for discovery. - One poll is bounded across every candidate address, so it always reports back before the watchdog fires. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
A member with one stale address and one good one hid the error that mattered behind the stale address's "no route to host", which made a real connection failure impossible to tell apart from an outdated address. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
A routed request went to Host.LocalEngine with no notion of which kind of engine it needed, and the host always answered with the chat engine. An embeddings request that the scheduler placed on the local node was therefore served by a llama-server started without --embeddings, while the same request forwarded to a peer was correct because the peer's own handler picked the embedding loader. The asymmetry was the missing parameter. EngineOptions now carries an EngineKind, zero value chat, and the host dispatches on it. Manager.ChatEngine is renamed RoutedEngine: it has served embeddings since the embedding path started routing, and the old name said otherwise. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
providerRouteIDForSource knew about local, cloud, pools and providers but not about the cluster, so it fell through to "unsupported model source". Every AI app integration scopes its base URL through that function, which meant an app could be pinned to a pool or a provider but never to the cluster or to a single machine: it simply errored. Cluster sources now round-trip through the provider route vocabulary with the same "cluster" and "node:<uuid>" spelling a request body uses, and the route middleware rejects a node that is not a member up front instead of failing later inside the router. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
nodeEngine.forward built a fresh http.Client, and with it a fresh http.Transport, for every forwarded inference request, and unlike the manager's own calls it never closed the idle connections afterwards. No connection was ever reused, so each forwarded request paid a full TLS handshake, and every request left a transport behind holding up to eight idle connections until they timed out. A member now keeps one client, keyed by its pinned certificate fingerprint so that re-pinning replaces it rather than reusing a client that still trusts the old certificate. Unpinned callers (joins and seed probes) still get a throwaway client: they run once and trust any certificate, so pooling them would keep a permissive connection alive for nothing. Dropping a member now releases its connections too, which forgetNode does alongside forgetting its health and addresses. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
Plumbing had accumulated near-copies. The address-and-port idiom was written in seven places with two slightly different answers for a missing port; a helper for exactly that, withPort, had been written in directory.go and never called. The non-2xx-to-peerError conversion was eight identical lines in three functions. Two JSON writer pairs existed in one package, differing only in whether they carried the machine code. itoaInt, containsString and minU64 restated strconv.Itoa, slices.Contains and the builtin min. Also removed: a "now" that was computed and then explicitly discarded, and two var _ = lines left in internal/server/cluster.go to keep imports that nothing needed any more. No behaviour changes; the address helpers make the default-port answer the same everywhere it is reached. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
…try-After Four near-copies of one shape lived in the routed worker engines: build a body, ask routeOrLocal, and either run locally while holding the reservation or decode a peer's answer. Releasing the reservation before the local work ran is what once made balanced mode never move anything, and that invariant was restated four times. It now exists once, in routedCall, with the two engines embedding a shared routedWorker. The two engine resolvers, which differed only in which engine they built, became one. The two Chat implementations differed by three lines. They are now one function, and it always sends num_ctx: a routed Ollama-style chat used to drop it, so the node that loaded the model never heard the requested context size. A 429 from a member is now a cooldown, honouring Retry-After in either header form, rather than an immediate retry on the next request. The provider pool has behaved this way since it was written and the design doc specified it for the cluster too. Local status was cached twice with two two-second windows, and the invalidation after a settings change could only reach one of them. The peer status handler now shares the manager's cache and the second one is gone. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
Where a request runs is decided by its "source" field, whose vocabulary now spans five namespaces owned by three packages. Which one wins was written out four times, once per kind of request, and the copies had drifted: one compared the lowercased source, another compared the raw one, and none of them agreed on what an unrecognised source should do. Adding the cluster to only three of the four is what left AI apps unable to target it. routeForSource is now the single answer to "where does this go". Chat, embeddings and the speech engines all ask it and then build the backend their own kind of request needs, which is the part that genuinely differs. A speech request that names a pool, a provider or the cloud is now refused rather than quietly served here, and the failed-local-load fallback to a member is one predicate instead of three spellings. The perf store moved to its own file. It is persistence of measured throughput with no tie to membership, discovery or the HTTP surfaces, and shared manager.go with them only by accident. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
ganisback
force-pushed
the
feat/ee-lan-cluster
branch
from
September 19, 2026 11:11
a8c521e to
231c806
Compare
The identity test asserted that the node private key is not readable by group or other. Windows has no Unix permission bits, and Go reports 0666 for any file it can write there, so the assertion failed on that platform for a file that was written correctly. Access on Windows is governed by the directory ACL instead, which the mode cannot express. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
Removing the Python embedding runtime left two symbols behind: the script that mirrored the worker's import block, and the sync.Map that cached whether that check had passed. Both were only ever read by the verification functions that went with the runtime, so nothing refers to them now. Unexported package-level declarations are not reported as unused, which is why the build stayed quiet. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
**A peer's file names were trusted.** A model bundle lists the files to copy, and the receiving node joined each name onto its model directory without looking at it. The node serving a file checks the name, but a member that is compromised or simply running something else does not run our sending code, so a name of "../../.." wrote wherever the server user could write. The bundle is now rejected outright when any path is not a plain relative one, and the receiver checks again that what it is about to create stays under the directory it belongs in. **The join token was readable by any page and any machine.** The design said these endpoints were same-origin and loopback only; nothing implemented it. /api/cluster/* answered with a wildcard CORS header and no origin check, so a site the user visited could read the token and put a machine into their cluster, and nothing on the network needed a key because only the inference paths were listed as needing one. Cluster routes are now refused cross-origin and get no wildcard header, they count as remote-authenticated like inference, and the three that hand out the join token or admission code answer over loopback or to a caller holding a key, never on trust alone. **Gossip could grow the cluster past its licensed size.** Joining is capped; merging a peer's member cards was not. The effect was not extra capacity but the opposite: over the cap every node reports itself unlicensed and the scheduler excludes all of them, so one member could take the cluster out of service. The merge now applies the same cap and logs what it ignored. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
A bundle path is relative and forward-slash by definition, but the guard leaned on filepath.IsAbs, which calls "/etc/passwd" relative on Windows. The name was then joined under the destination rather than refused: it stayed inside the directory, so nothing escaped, but the same input meant two different things depending on the platform. A leading slash and a backslash are now refused outright wherever the code runs, which is what the node serving the file already does. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
The first thing on the page was a screenshot of the AI apps grid, which says what one page of the web UI looks like rather than what the project is. The diagram in its place answers the question a reader actually arrives with: what runs where. It has two halves, because the answer differs once there is more than one machine. The screenshot is dropped rather than moved, since nothing else referenced it and a picture of an older UI only goes stale. The cluster also gets a line in the feature list, where it was missing entirely. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
ee/ held one directory of twenty-four flat files, three of which were grab-bags: manager.go ran lifecycle, discovery, status, peer calls, polling, gossip and every membership operation across 1500 lines; engine.go held two engines, the routing errors, the throughput measurement and the model inventory; api.go held twenty-eight handlers alongside the view mapping. Nobody adding a feature could tell where a new declaration belonged, which is how those three files grew. Each file now holds a single subject and says so, and ee/README.md lists what goes where so the next change does not have to guess. The declarations moved by their AST positions, so this is a move and nothing else. The two HTTP surfaces also stop being methods on Manager. /api/cluster hangs off an adminAPI and /cluster/v1 off a peerAPI, both thin faces over the manager, which takes it from 116 methods to 83: adding an endpoint no longer grows the type that runs discovery, gossip and scheduling. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
ee/LICENSE held an English-only draft. This is the licence OpenCSG actually issues, reproduced verbatim from the signed document in both languages, Chinese first because the Chinese text governs where the two differ. It is materially wider than the draft it replaces: production use is defined to include a pilot, a proof of concept or a demo that carries real business rather than only a live deployment; modifying the logo or the UI appearance, and any commercial use, require written notice to OpenCSG whether or not a licence is held; and the termination, warranty, third-party component, precedence and arbitration clauses were absent before. The summary in ee/README.md is updated to match. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
Three changes to ee/LICENSE. The issued Chinese text lost the number on its fourth heading: it read "使用限制" where the English reads "4. Use Restrictions" and every other heading from one to ten is numbered. The English text spelled "except" as "excepct" in the clause that says the Chinese version prevails. Both are corrected here and should be corrected in the source document too. The third is substantive. The licence permits production use only with a valid Enterprise licence, while the product deliberately gives every user a two-node cluster without one, so the code and the licence contradicted each other. A community grant now sits at the top of the file, ahead of the issued text and clearly separated from it: a feature that runs without a licence may be used in production within the limit the software itself enforces for unlicensed users. Everything else in the licence is untouched and still applies. The grant is drafted, not reviewed. It needs legal sign-off before release. Generated with AI Co-Authored-By: csglite <xzhgan@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #172.
Combines the compute boxes on a LAN into one pool, so a request that arrives
at any machine is served by whichever member can answer it soonest. Their IP
addresses change on every reboot, so nothing in the design depends on an
address: members are identified by a UUID pinned to a self-signed certificate,
found again over mDNS, and their addresses are relearned on every poll.
What it does
Forms itself. Every machine installed with the same
CSGHUB_LITE_CLUSTER_SECRETfounds or joins the same cluster with no createor join step: the cluster UUID is derived from the secret, and the secret
itself never goes on the wire. Manual pairing by join token or by an 8-digit
admission code still works for anyone who wants it.
Stays out of the way on one machine. Without that variable, and until an
operator asks for clustering, there is no listener, no multicast and no
polling goroutine. A single machine pays nothing for a feature it is not
using.
Routes on predicted completion time, not round-robin: load time plus
queue plus prompt plus decode, with penalties for thermal throttling, power
caps, CPU contention, RAM pressure, disk activity and foreign GPU use. A
conversation sticks to the node that answered it, and a node that fails is
taken out and retried with backoff.
Copies models between members over the cluster link rather than
re-downloading from the internet, verifying a SHA-256 the sender computes
while streaming. Derived files such as a converted GGUF follow only over a
link fast enough to be worth it.
Speaks the existing APIs. Chat, embeddings, Anthropic messages,
transcription and speech all route through the engine getters that were
already there rather than a second router. A request opts in with
source: "cluster", pins a machine withsource: "node:<uuid>", or namesnothing and is routed only when this node cannot serve it.
Licensing
The feature and the dashboard are open to everyone. Only the node count is
gated:
quota.lite.max_cluster_nodesis 2 without a licence, unlimited withone. The cluster code lives in
ee/under the Enterprise licence; the seaminternal/serverexposes to it is a singleHostinterface.Measured on two machines
An M5 and an M4 on the same 5 GHz network, entry node M5. Single machine is
source: "local", two machines is balanced mode.Text generation, Qwen3.5-2B, 128 tokens per request:
Embedding, Qwen3-Embedding-0.6B, 32 inputs per request:
One machine's throughput is flat whatever the concurrency, so extra requests
only queue; the second machine roughly doubles it and halves time to first
token. Below two concurrent requests clustering costs 16-23% because there is
nothing to spread and a network hop to pay, which is why
local_firstis thedefault and
balancedis opt-in.Peer model copy measured 1.1 GB in 37 seconds. Failover was exercised by
stopping a member: cluster-sourced requests fell back to the surviving node
and a request pinned to the stopped one returned a clear 503.
Review pass
The last six commits are an architecture and duplication review of the
preceding work, with the three real defects it found:
engine, because the kind of engine never travelled with the request. A
llama-server started without
--embeddingscannot answer it.vocabulary did not know
clusterornode:<uuid>and rejected them.full TLS handshake, then abandoned the transport. Members now keep one
pooled client each, which measured 0.42s to 0.05s on the second request.
The rest removes duplication: one owner for the request source vocabulary
instead of four copies of the precedence, one dispatch helper for the routed
speech engines instead of four copies of the same skeleton, one
Chatinstead of two, and one local-status cache instead of two with a stale one
the invalidation could not reach. A 429 from a member is now a cooldown that
honours
Retry-Afterrather than an immediate retry.Known gaps
Deliberately not addressed here, each needing a shared layer across the
Apache and Enterprise packages:
implementations of dispatch, failover and conversation affinity.
Managercarries 116 methods across five files.internal/serverare cluster logic; roughly a thirdof that must stay there because it reads private server state, the rest
belongs in
ee/.ee/LICENSEstill needs a clause permitting production use within thecommunity node quota.
Testing
go test -race ./...passes with no data races,make lintpasses, 83 webtests pass. The cluster package has 39 cases and the server has 8 cluster
integration cases, plus the two-machine runs above.
Generated with AI
Co-Authored-By: csglite xzhgan@gmail.com