diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03331a758e..0135c659bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,17 +1,50 @@ +# Continuum CI — TypeScript clients gate +# +# ## What this replaced, and why it mattered +# +# This workflow used to run in `working-directory: src` against +# `src/package-lock.json`, calling `npm run build:ts` and `npm run test:crud`. +# Every one of those is gone: `src/` was the Node monolith, retired when the +# substrate became a headless Rust core (#1840), and both scripts left with it. +# +# So it failed on every run — last success 2026-06-07, red continuously from +# 2026-06-09 — with `Cannot find module 'dotenv'`, an error that describes the +# absence of the whole world it was pointed at rather than any defect in the +# code under test. +# +# A check that ALWAYS fails is worse than no check. It cannot distinguish a +# broken PR from a healthy one, so the only thing anyone can learn from it is to +# stop reading CI — which is exactly what happened: work routed around it +# through the Rust and drift-guard workflows for two months. +# +# ## What it does now +# +# Gates the TypeScript clients workspace — the one thing this file was always +# supposed to cover and had stopped being able to see. +# +# `npm run test:clients` was ALREADY in package.json and ALREADY passing +# locally. Nothing called it. That is how `renderBench.spec.ts` and six sibling +# spec files across `apps/web` and `packages/` came to exist, be green on a +# developer's machine, and gate nothing at all — a correct check that nothing +# invokes, which is indistinguishable from having no check until someone greps +# for the caller. +# +# Triggers on `canary` as well as `main`, because canary is where development +# happens; a gate that only watches the stable line learns about breakage after +# it has already been merged. + name: Continuum CI on: push: - branches: [ main ] + branches: [ main, canary ] pull_request: - branches: [ main ] + branches: [ main, canary ] jobs: - validate: + clients: + name: TypeScript clients (typecheck + tests) runs-on: ubuntu-latest - defaults: - run: - working-directory: src steps: - uses: actions/checkout@v4 @@ -21,32 +54,20 @@ jobs: with: node-version: '20' cache: 'npm' - cache-dependency-path: src/package-lock.json + # Root lockfile — npm workspaces hoist, so there is exactly one. + cache-dependency-path: package-lock.json - - name: Install dependencies + # `npm ci` installs the whole workspace from the lockfile, which is what + # makes this job the substrate the tests stand on: without it, every spec + # file fails at COLLECTION with "Failed to load url @continuum/chat-view", + # an error that points a reader at missing source rather than missing deps. + - name: Install workspace run: npm ci - - name: TypeScript compilation - run: | - npm run build:ts - echo "✅ TypeScript compilation passed" - - # Skip full tests for documentation-only PRs - - name: Check if documentation-only PR - id: check_pr - working-directory: . - run: | - if git diff --name-only origin/main..HEAD | grep -qvE '\.(md|txt|yml|yaml)$'; then - echo "skip_tests=false" >> $GITHUB_OUTPUT - else - echo "skip_tests=true" >> $GITHUB_OUTPUT - fi - - - name: Run tests - if: steps.check_pr.outputs.skip_tests != 'true' - run: | - npm run test:crud - echo "✅ CRUD tests passed" - - - name: Validation complete - run: echo "✅ CI validation complete - local precommit hook validates full system" \ No newline at end of file + - name: Typecheck clients + run: npm run typecheck:clients + + # The suite that existed and was never run. Covers apps/web, apps/tui, + # the view packages, and the SDK. + - name: Test clients + run: npm run test:clients diff --git a/.github/workflows/plugin-version-guard.yml b/.github/workflows/plugin-version-guard.yml new file mode 100644 index 0000000000..4af3b17981 --- /dev/null +++ b/.github/workflows/plugin-version-guard.yml @@ -0,0 +1,58 @@ +# Plugin version-bump guard. +# +# Why this exists: Claude Code plugin updates are VERSION-based, not +# content-based. A marketplace install copies the plugin to +# ~/.claude/plugins/cache//// and pins it. `claude +# plugin update` compares the DECLARED version in plugin.json against the +# installed one and never looks at the files. So if scripts change and the +# version does not, every installed copy answers "already at the latest version" +# forever and the fix reaches nobody. `git pull` does not update a plugin. +# +# The failure this guards, measured 2026-08-09: memory-bridge sat at 0.1.0 since +# 2026-07-25 while its scripts gained a persona-id cache and an entire +# session-capture.sh. The installed copy on BigMama had NEITHER — automatic +# per-turn memory capture had never run once on that machine — while the repo +# held working code and the plugin README said the bridge was live. Bumping +# 0.1.0 -> 0.2.0 propagated two weeks of fixes in one command. +# +# Same class as the install-manifest projection guard and the ts-rs binding +# guard: a consumed artifact and its source must not drift apart in silence. +# Here the "artifact" is every developer's installed copy. +# +# NOTE: this runs in CI, not pre-commit. `.githooks/pre-commit` currently invokes +# tests deleted with the Node monolith and is not installed as the active hook +# (core.hooksPath does not point at it), so wiring a gate there would look +# enforced while running never. +name: Plugin Version Guard + +on: + pull_request: + paths: + - 'tools/plugins/**' + - '.github/workflows/plugin-version-guard.yml' + push: + branches: [canary, main] + +concurrency: + group: plugin-version-${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + plugin-version: + name: plugin content changed => version bumped + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + # Need the base commit to diff against, not just the tip. + fetch-depth: 0 + + - name: Check every touched plugin bumped its version + run: | + BASE="${{ github.event.pull_request.base.sha || github.event.before }}" + if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then + echo "no usable base ref (first push / new branch) — nothing to compare" + exit 0 + fi + tools/scripts/check-plugin-version.sh "$BASE" diff --git a/apps/web/package.json b/apps/web/package.json index 8ca260702c..65955bdeff 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,8 +9,8 @@ "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "TZ=UTC vitest run", - "test:watch": "TZ=UTC vitest" + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@continuum/chat-view": "*", diff --git a/core/continuum-core/src/bin/continuum.rs b/core/continuum-core/src/bin/continuum.rs index 21d70f8c1b..3cbb8180dd 100644 --- a/core/continuum-core/src/bin/continuum.rs +++ b/core/continuum-core/src/bin/continuum.rs @@ -57,6 +57,14 @@ async fn run() -> Result<(), String> { eprintln!("{}", usage()); Ok(()) } + // Handled HERE, never dispatched. `version` asks what THIS BINARY is; + // forwarding it to the core answered a different question and, when no + // core was running, answered none at all — the operator asking "what am + // I holding?" got "the substrate refused your command." + "version" | "--version" | "-V" => { + println!("{}", version_line()); + Ok(()) + } "start" => { // Collect once: `args.any(..)` consumes the iterator, so reading a // second flag off it afterwards would silently always be false. @@ -2137,7 +2145,71 @@ mod tests { } } +/// The name this binary was actually INVOKED as, for help text. +/// +/// One binary ships under several names — `uu` (the short canonical one) and +/// `continuum` (the long-form alias kept so existing scripts and docs keep +/// working). Hardcoding "continuum" in the usage text meant `uu --help` printed +/// `usage: continuum ...`: the front door did not know its own name, and every +/// example it gave was a command the reader had not typed. +/// +/// Derived from argv[0] rather than a constant so a new alias is correct the +/// moment it exists, with nothing to remember to update. Falls back to the +/// canonical name when argv[0] is missing or unreadable — an odd exec is not a +/// reason to print nothing. +fn program_name() -> String { + std::env::args_os() + .next() + .map(std::path::PathBuf::from) + .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned())) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "uu".to_string()) +} + +/// This binary's build identity — number, sha, and when it was compiled. +/// +/// Joel's ruling 2026-08-08: versions must ALWAYS auto-increment and display +/// with the sha, in EVERY repo, visible on connection/health/query, because +/// stale binaries have repeatedly poisoned testing. +/// +/// This was UNANSWERABLE from the front door before: `continuum version` fell +/// through to the substrate dispatcher and came back +/// `Unknown command: 'version'` — the CLI could ask the core what IT was and +/// could not say what ITSELF was. That is the exact gap that lets an operator +/// debug a fixed bug with an unfixed binary in their hand. +fn version_line() -> String { + format!( + "{} #{} {} built {}", + program_name(), + option_env!("CONTINUUM_BUILD_NUMBER").unwrap_or("0"), + option_env!("CONTINUUM_BUILD_GIT_SHA").unwrap_or("unknown"), + option_env!("CONTINUUM_BUILD_AT").unwrap_or("unknown"), + ) +} + fn usage() -> String { + let me = program_name(); + format!( + "usage: {me} [json | --key value ...]\n\ + \n\ + Lifecycle:\n \ + {me} start build + run the headless Rust core (detached), wait until ready\n \ + {me} reboot rebuild + relaunch, replacing any running core (~0 downtime)\n \ + {me} stop stop the running core\n \ + {me} version this binary's build number + sha (NOT the core's)\n\ + \n\ + Commands (dispatch to the running core):\n \ + {me} ping\n \ + {me} ping --message hi # --key value, coerced + camelCased automatically\n \ + {me} commands/list # discover commands dynamically (single source)\n\ + \n\ + Env: CONTINUUM_CORE_SOCKET (default /tmp/continuum-core.sock)\n \ + CONTINUUM_START_SCRIPT (override the start script path)" + ) +} + +#[allow(dead_code)] +fn usage_legacy() -> String { "usage: continuum [json | --key value ...]\n\ \n\ Lifecycle:\n \ diff --git a/core/continuum-core/src/inference/llama_server.rs b/core/continuum-core/src/inference/llama_server.rs index 475e4e965c..8fd69b3655 100644 --- a/core/continuum-core/src/inference/llama_server.rs +++ b/core/continuum-core/src/inference/llama_server.rs @@ -851,6 +851,21 @@ pub struct ServingSnapshot { #[serde(default)] #[ts(optional)] pub vision_model: Option, + /// WHICH ENGINE is serving — llama.cpp's own `/props.build_info` + /// (`b-` of the fork it was compiled from), read at + /// reconcile. `None` = nothing served, or a build too old to publish it. + /// + /// Here because "is my fix in the engine that is actually running?" was, until + /// 2026-08-09, answerable only by comparing binary mtimes across machines — and + /// answering it that way produced a wrong attribution (a Rust CORE build number + /// read as the engine's). The engine has published this all along; the daemon + /// simply never asked. See [`LlamaServerControl::engine_build`]. + /// + /// The sha is the load-bearing half: build numbers are ancestor counts, so our + /// fork and upstream can share one and mean different code. + #[serde(default)] + #[ts(optional)] + pub engine_build: Option, /// THE MODEL THIS NODE IS BRINGING UP RIGHT NOW, if a lane is loading. /// /// Before this field the snapshot was BINARY — ready with a model, or not-ready with @@ -907,6 +922,10 @@ impl ServingSnapshot { vision_ready: false, vision_base_url: None, vision_model: None, + // Nothing served → no engine to identify. Never a placeholder string: + // an unknown engine and an engine that says it is "unknown" are + // different facts, and only the first one is true here. + engine_build: None, loading_model: None, } } @@ -1340,6 +1359,13 @@ pub async fn probe_external_serving(timeout: Duration) -> Option-` compiled in from `cmake/build-info.cmake` + /// (`git rev-list --count HEAD` + `git rev-parse --short HEAD` **of the fork**). + /// + /// ## Why the daemon asks + /// + /// 2026-08-09: a per-slot wedge was attributed to "the fork bump in build 4577". + /// 4577 is the Rust CORE's build number; the engine has its own, unrelated one, and + /// the fork engine had not been rebuilt at all. Settling that took comparing + /// **binary mtimes on two machines** — because while llama-server has published its + /// identity all along (here, on `--version`, and as `system_fingerprint` on every + /// completion), nothing on OUR side of the seam ever READ it. The version surface + /// existed; the question could not be asked in our own terms. + /// + /// That is [[silently-unwired-capability]] with the polarity reversed — not a + /// capability we built and failed to wire, but one we DEPEND on, that upstream + /// hands us for free, and that we drop on the floor. The cost is the same: a fact + /// available for the asking gets re-derived by archaeology, and the derivation is + /// wrong often enough to send a diagnosis sideways for an afternoon. + /// + /// The commit sha is what makes the answer load-bearing: build NUMBERS are a count + /// of ancestors, so our fork and upstream can both say `b6789` and mean different + /// code. `b6789-a28ee566c` names exactly one tree. + /// + /// `Ok(None)` = the server answered but publishes no `build_info` (a build too old + /// to carry it). Unverifiable, never a guessed identity — the same contract + /// [`multimodal_support`](Self::multimodal_support) keeps. Default impl returns + /// `Ok(None)` so fakes and remote controls stay honest by construction. + async fn engine_build(&self) -> Result, LlamaServerError> { + Ok(None) + } + /// Prove the GPU DECODE path works, not just that the HTTP server is up. A /// llama-server can answer `/health`, `/v1/models` and `/props` with 200 /// while EVERY `llama_decode` returns 500 "Compute error" — observed live in @@ -2332,12 +2390,51 @@ impl LlamaServerControl for LlamaServerProcess { }) } + async fn engine_build(&self) -> Result, LlamaServerError> { + // Same root-level `/props` as the served window. `build_info` is compiled + // into the binary (`common/build-info.cpp.in`), so it describes the ENGINE + // ON DISK — not the model, not our core, not what we believe we shipped. + let url = format!("{}/props", self.root); + let resp = self + .client + .get(&url) + .timeout(PROBE_TIMEOUT) + .send() + .await + .map_err(|e| LlamaServerError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(LlamaServerError::Unreachable(format!( + "status {}", + resp.status() + ))); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| LlamaServerError::Unreachable(e.to_string()))?; + // Absent field → `Ok(None)`: this build cannot say what it is. An empty + // string is the same absence wearing a value's clothes, so it is filtered + // out rather than published as an identity nobody can look up. + Ok(body + .get("build_info") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string)) + } + async fn served_lanes(&self) -> Result { // Same root-level `/props` as the served window. llama.cpp publishes the // slot count it was launched with (`--parallel` / `n_seq_max`) as the // top-level `total_slots`. A connection error means nothing is up (the // normal pre-spawn state) → Unreachable, which the caller reads as // "lanes OK" so a probe hiccup never relaunches a healthy lane. + // + // MERGE NOTE (canary ← #2213): both this and `engine_build` above read the + // SAME `/props`. They are deliberately two calls, not one shared probe — + // different facts with different absence semantics (an unknown engine is + // `Ok(None)`, an unknown slot count is `Err`), and fusing them is exactly + // what a naive union merge did here. let url = format!("{}/props", self.root); let resp = self .client @@ -4057,6 +4154,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, loading_model: None, }); assert!(!pred(&rx.borrow())); @@ -4074,6 +4173,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, loading_model: None, }); assert!(!pred(&rx.borrow())); @@ -4091,6 +4192,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, loading_model: None, }); let got = tokio::time::timeout(Duration::from_millis(100), rx.wait_for(pred)) diff --git a/core/continuum-core/src/inference/model_commands.rs b/core/continuum-core/src/inference/model_commands.rs index f1deabe422..646336b0fd 100644 --- a/core/continuum-core/src/inference/model_commands.rs +++ b/core/continuum-core/src/inference/model_commands.rs @@ -56,6 +56,21 @@ pub struct InferenceStatusView { pub served_context_window: u32, /// The LoRA genome layers loaded into the serving catalog (sorted paths). pub adapters: Vec, + /// WHICH ENGINE is answering — llama.cpp's `/props.build_info`, i.e. + /// `b-` of the llama.cpp fork the binary was compiled + /// from. `None` = nothing served, or an engine too old to publish it. + /// + /// Surfaced here because this is the query an operator reaches for when serving + /// behaves unexpectedly, and until 2026-08-09 it could not be answered from our + /// own tools at all — settling "is my fork fix in the running binary?" meant + /// comparing binary mtimes across two machines, which produced a wrong + /// attribution (a Rust CORE build number read as the engine's). + /// + /// The sha is the half that decides: build numbers are ancestor counts, so our + /// fork and upstream can both report `b6789` and mean different code. + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub engine_build: Option, } /// Read the canonical serving snapshot and project it. If the daemon has not yet @@ -69,6 +84,7 @@ fn current_status() -> InferenceStatusView { base_url: s.base_url, served_context_window: s.served_context_window, adapters: s.adapters, + engine_build: s.engine_build, } } @@ -92,8 +108,9 @@ impl ActionCommand for AiInferenceStatus { const DESCRIPTION: &'static str = "Report which model the inference engine is serving right now (activeModel), \ whether it is ready, the live serving base URL, the served context window, \ - and the LoRA genome layers loaded. Projected from the serving daemon's \ - canonical snapshot — this is how you confirm which brain is live."; + the LoRA genome layers loaded, and which engine build is answering \ + (engineBuild). Projected from the serving daemon's canonical snapshot — \ + this is how you confirm which brain is live."; type Params = StatusParams; type Output = InferenceStatusView; diff --git a/core/continuum-core/src/modules/serving_consumer.rs b/core/continuum-core/src/modules/serving_consumer.rs index 148d1c9430..4b8c3e53a2 100644 --- a/core/continuum-core/src/modules/serving_consumer.rs +++ b/core/continuum-core/src/modules/serving_consumer.rs @@ -519,6 +519,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); let (suppress_tx, _srx) = watch::channel(Arc::new(HashSet::new())); let (pin_tx, _prx) = watch::channel(None); @@ -741,6 +743,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); let footprint_of: FootprintFn = Arc::new(move |id: &str, window: u32, lanes: u32| { *seen_w.lock() = Some((id.to_string(), window, lanes)); @@ -843,6 +847,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, }); let (suppress_tx, _srx) = watch::channel(Arc::new(HashSet::new())); let (pin_tx, pin_rx) = watch::channel(None); diff --git a/core/continuum-core/src/modules/serving_daemon.rs b/core/continuum-core/src/modules/serving_daemon.rs index baead2f082..d478678fc5 100644 --- a/core/continuum-core/src/modules/serving_daemon.rs +++ b/core/continuum-core/src/modules/serving_daemon.rs @@ -2117,6 +2117,28 @@ impl ServingDaemonModule { } EnsureOutcome::Degraded { .. } => 0, }; + // WHICH engine is answering (#, 2026-08-09). Read from the same `/props` + // as the window, from the live process, so `serving/status` can answer + // "is my fork fix in the binary that is actually running?" without the + // mtime archaeology that produced a wrong attribution once already. + // A read failure is NOT a degrade: identity is diagnostic, and a lane + // that decodes fine while its `build_info` is unreadable is still a + // working lane. It publishes `None` — unknown, never guessed. + let engine_build = match &outcome { + EnsureOutcome::AlreadyServing | EnsureOutcome::Spawned { .. } => { + server.engine_build().await.unwrap_or_else(|e| { + crate::probe!( + class = "serving.reconcile", + desired = desired.as_str(), + error = %e, + "server ready but /props build_info unreadable — engine \ + identity unknown this tick (not a degrade; retries next tick)", + ); + None + }) + } + EnsureOutcome::Degraded { .. } => None, + }; // #106 vision readiness: for a ready lane, resolve the node's VERIFIED // vision endpoint. First the MAIN lane — the row's declared Vision, the // resolved mmproj, and the server's own `/props modalities` must all @@ -2250,6 +2272,7 @@ impl ServingDaemonModule { served_window, target.lanes, vision, + engine_build, ); crate::probe!( class = "serving.reconcile", @@ -2257,6 +2280,10 @@ impl ServingDaemonModule { ready = snapshot.ready, active = snapshot.active_model.as_deref().unwrap_or(""), served_window = snapshot.served_context_window, + // On the reconcile line because that is where an operator already + // looks when serving behaves unexpectedly, and "which engine" is + // the first question a surprising behaviour raises. + engine = snapshot.engine_build.as_deref().unwrap_or(""), "serving reconcile complete", ); // #363: remember the last HEALTHY lane's shape in a record that SURVIVES @@ -3558,6 +3585,9 @@ fn snapshot_from_outcome( // `vision_model` are all projected from this ONE value, so an address can // never be published without the verified flag (or vice versa). vision: Option, + // WHICH engine answered this reconcile — llama.cpp's `/props.build_info`. + // `None` = nothing served, or a build that cannot say what it is. + engine_build: Option, ) -> ServingSnapshot { match outcome { EnsureOutcome::AlreadyServing | EnsureOutcome::Spawned { .. } @@ -3604,6 +3634,9 @@ fn snapshot_from_outcome( vision_ready: vision.is_some(), vision_base_url: vision.as_ref().map(|v| v.base_url.clone()), vision_model: vision.map(|v| v.model_id), + // The engine's own account of itself, carried so a reader never has + // to infer it from a binary's mtime (2026-08-09). + engine_build, } } // Ready outcome but the served window was unreadable (0) → do NOT publish @@ -4598,6 +4631,7 @@ mod tests { base_url: "http://127.0.0.1:58091/v1".to_string(), model_id: "vl-7b".to_string(), }), + Some("b6789-a28ee566c".to_string()), ); assert_eq!(up.active_model.as_deref(), Some("coder-14b")); assert!(up.ready); @@ -4626,6 +4660,16 @@ mod tests { address can never publish without verified readiness" ); assert_eq!(up.vision_model.as_deref(), Some("vl-7b")); + // what this catches (2026-08-09): dropping the engine's own identity on the + // way to the snapshot. `/props` carries `build_info` and the daemon reads it, + // but if it does not SURVIVE to here, "which engine is running?" falls back to + // comparing binary mtimes across machines — which is how a Rust CORE build + // number got read as the engine's and misattributed a wedge. + assert_eq!( + up.engine_build.as_deref(), + Some("b6789-a28ee566c"), + "the engine's own build_info must reach the published snapshot" + ); let already = snapshot_from_outcome( &EnsureOutcome::AlreadyServing, @@ -4634,6 +4678,8 @@ mod tests { 11008, 4, None, + // An engine too old to publish `build_info` — serving is unaffected. + None, ); assert_eq!(already.active_model.as_deref(), Some("coder-14b")); assert!(already.ready); @@ -4647,6 +4693,13 @@ mod tests { already.vision_base_url.is_none() && already.vision_model.is_none(), "no verified endpoint → no address, no model (None-iff-not-ready)" ); + // what this catches: inventing an identity for an engine that did not give + // one. A build too old to publish `build_info` must read as UNKNOWN, never as + // a plausible-looking string a reader would then try to look up. + assert_eq!( + already.engine_build, None, + "an engine that cannot say what it is reads as unknown, not as a guess" + ); // Ready outcome but the served window was unreadable (0) → publish the gap, // NOT a ready snapshot with a zero window a persona would budget against. @@ -4657,6 +4710,7 @@ mod tests { 0, 4, None, + Some("b6789-a28ee566c".to_string()), ); assert_eq!( windowless.active_model, None, @@ -4665,6 +4719,14 @@ mod tests { assert!(!windowless.ready); assert_eq!(windowless.served_context_window, 0); assert_eq!(windowless.lanes, 0, "empty snapshot carries no lanes"); + // what this catches: a snapshot that says nothing is live while still naming + // an engine. Both halves would be read together ("not live, but running + // b6789?"), and the contradiction is worse than the absence — a reader would + // reasonably conclude the lane is up and the flag is stale. + assert_eq!( + windowless.engine_build, None, + "a not-live snapshot claims no engine, even when one answered the probe" + ); let degraded = snapshot_from_outcome( &EnsureOutcome::Degraded { reason: "x".into() }, @@ -4673,6 +4735,7 @@ mod tests { 11008, 4, None, + Some("b6789-a28ee566c".to_string()), ); assert_eq!(degraded.active_model, None, "degraded → nothing live"); assert!(!degraded.ready); @@ -4883,6 +4946,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, loading_model: None, }); let budget = HostBudget { @@ -4948,6 +5013,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, loading_model: None, }); (daemon, plan_window) @@ -5136,6 +5203,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, loading_model: None, } } @@ -5511,6 +5580,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, loading_model: None, }); // A quarter of the plan is a 300% shortfall — far past the margin — but the @@ -5543,6 +5614,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, loading_model: None, }); assert!( @@ -5574,6 +5647,8 @@ mod tests { vision_ready: false, vision_base_url: None, vision_model: None, + // test fixture: no engine identity claimed. + engine_build: None, loading_model: None, }); let budget = HostBudget { diff --git a/install.ps1 b/install.ps1 index afce0a66c6..d0ad2a5528 100644 --- a/install.ps1 +++ b/install.ps1 @@ -86,6 +86,13 @@ try { Mod-Rust Mod-VSBuildTools Mod-CMake + # Beside CMake, not buried in the llama-server build: ninja is what makes the + # cmake CONFIGURE step deterministic across Visual Studio versions (cmake + # auto-picks the newest VS, and "Visual Studio 18 2026" is a generator cmake + # 3.30.x cannot name). Provisioning it here means a plain `cargo build` works + # from a fresh terminal; provisioning it lazily meant it only existed on boxes + # that had already built llama-server with CUDA. + Mod-Ninja Mod-LLVM Mod-CUDA Mod-GhAuth -WantsGrid:$WantsGrid diff --git a/package.json b/package.json index 641a971223..6d209483bd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "continuum", "private": true, - "description": "Continuum \u2014 a headless persona substrate. The Rust core is the server; every UI is an equal, dependent client over the same Commands/Events SDK. `npm start` boots ONLY the headless core (Rust, no desktop). Clients live under apps/ (web, desktop, cli[Rust], mobile, vr, ar, mcp) and attach on demand.", + "description": "Continuum — a headless persona substrate. The Rust core is the server; every UI is an equal, dependent client over the same Commands/Events SDK. `npm start` boots ONLY the headless core (Rust, no desktop). Clients live under apps/ (web, desktop, cli[Rust], mobile, vr, ar, mcp) and attach on demand.", "workspaces": [ "sdk/typescript", "packages/patterns", @@ -36,7 +36,14 @@ "ship": "node scripts/ship.mjs", "shot": "node scripts/shot.mjs", "preview:shot": "node scripts/preview-shot.mjs", - "preview:rec": "node scripts/preview-record.mjs" + "preview:rec": "node scripts/preview-record.mjs", + "deps:ensure": "bash tools/scripts/ensure-node-deps.sh", + "predev:web": "npm run deps:ensure", + "predev:desktop": "npm run deps:ensure", + "prebuild:clients": "npm run deps:ensure", + "prelint:clients": "npm run deps:ensure", + "pretypecheck:clients": "npm run deps:ensure", + "pretest:clients": "npm run deps:ensure" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.214", @@ -49,4 +56,4 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.64.0" } -} \ No newline at end of file +} diff --git a/packages/chat-view/package.json b/packages/chat-view/package.json index bdaaa554a9..128678fbd3 100644 --- a/packages/chat-view/package.json +++ b/packages/chat-view/package.json @@ -11,8 +11,8 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "TZ=UTC vitest run", - "test:watch": "TZ=UTC vitest" + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@continuum/patterns": "*", diff --git a/packages/chat-view/vitest.config.ts b/packages/chat-view/vitest.config.ts new file mode 100644 index 0000000000..e5761ea51f --- /dev/null +++ b/packages/chat-view/vitest.config.ts @@ -0,0 +1,34 @@ +/** + * Vitest config for @continuum/chat-view. + * + * Exists for ONE reason: to pin the timezone where every platform can read it. + * + * Time-of-day rendering is viewer-local by design, so the fixed HH:MM + * assertions in `chatViewModel.spec.ts`, `crossConsumer.spec.ts` and + * `historyProjections.spec.ts` need a pinned zone to be deterministic on any + * runner. That pin used to live in the npm script as `TZ=UTC vitest run`. + * + * POSIX env-prefix syntax is not portable. On Windows, npm runs scripts through + * `cmd.exe`, which has no such form — the shell reads `TZ` as a command and the + * run dies with: + * + * 'TZ' is not recognized as an internal or external command + * + * So this whole suite, and `@continuum/web`'s alongside it, could not be run at + * all by a Windows contributor. It passed on Linux CI and on macOS, which is + * precisely why it survived: a test that only fails on the platform nobody + * checks is indistinguishable from a passing one until someone checks. Found + * 2026-08-08 by running the suite on Windows rather than trusting it. + * + * The runner config is node context on every platform, so the pin belongs here + * — the same reasoning `apps/web/vite.config.ts` already gives for keeping it + * out of the specs. No cross-env dependency: the fix removes a portability + * assumption instead of packaging a tool to satisfy it. + */ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + env: { TZ: 'UTC' }, + }, +}); diff --git a/tools/plugins/memory-bridge/.claude-plugin/plugin.json b/tools/plugins/memory-bridge/.claude-plugin/plugin.json index af837b3d30..9d4e736679 100644 --- a/tools/plugins/memory-bridge/.claude-plugin/plugin.json +++ b/tools/plugins/memory-bridge/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memory-bridge", "description": "Agent memory bridge — automatic relevance-recall at session start (incl. after compaction) plus /remember, /recall, and /share (hand a lesson to another agent), backed by the continuum memory/* substrate. Stops the agent re-forgetting across amnesia resets.", - "version": "0.1.0", + "version": "0.2.0", "author": { "name": "Continuum (BigMama + M5)" } diff --git a/tools/plugins/memory-bridge/README.md b/tools/plugins/memory-bridge/README.md index 25fc8273ee..5eee8fd323 100644 --- a/tools/plugins/memory-bridge/README.md +++ b/tools/plugins/memory-bridge/README.md @@ -67,8 +67,38 @@ is a live probe of a daemon that legitimately restarts; without the cache, every airc outage silently disabled memory for a whole session. ## Status -Live. Both hooks installed and verified end-to-end 2026-08-05 (recall returns -real engrams; capture stores; failure paths produce receipts + context notices). +Both hooks verified end-to-end 2026-08-05 against **this source tree** (recall +returns real engrams; capture stores; failure paths produce receipts + context +notices). -## Install (local dev) -`claude --plugin-dir tools/plugins/memory-bridge` +That sentence used to read "Status: Live", which was a claim this file is not in +a position to make. A README describes the repo; whether the plugin is live +depends on *your install*, and on 2026-08-09 those had been different on BigMama +for two weeks — see below. + +## Install — and why "which install" matters +Two paths, with very different freshness semantics: + +- **Live from the repo (dev):** `claude --plugin-dir tools/plugins/memory-bridge` + Runs the scripts in this tree. `git pull` *is* the update. +- **Marketplace install:** copies the plugin to + `~/.claude/plugins/cache////` and pins it to a git + sha. **Nothing re-syncs it. `git pull` changes nothing.** + +Measured 2026-08-09: the installed copy on BigMama was pinned to `60fa0dbf` +(2026-07-25). Its `lib.sh` had no persona-id cache, and it contained no +`session-capture.sh` at all — so automatic per-turn capture, the entire +"volitional memory isn't memory" point, had never run once on that machine while +this README said the bridge was live. + +So `session-recall.sh` now checks: if the running copy is under a plugins cache +AND its scripts differ from `tools/plugins/memory-bridge/scripts` in the current +checkout, it injects **⚠️ MEMORY BRIDGE STALE** and writes a `stale` receipt. Same +discipline as the rest of this plugin — "installed" must not be indistinguishable +from "working", and "current" must not be indistinguishable from "stale". A fix +that never reaches the executing copy is identical to a fix never written. + +Check which one you are on: +```bash +tail -3 ~/.continuum/memory-bridge/receipts.jsonl # a "stale" line names the frozen path +``` diff --git a/tools/plugins/memory-bridge/scripts/lib.sh b/tools/plugins/memory-bridge/scripts/lib.sh index 688634630f..6e3c343cbe 100644 --- a/tools/plugins/memory-bridge/scripts/lib.sh +++ b/tools/plugins/memory-bridge/scripts/lib.sh @@ -31,6 +31,43 @@ resolve_continuum() { return 1 } +# Resolve the airc BINARY, the same way `resolve_continuum` above resolves ours. +# +# Hooks do NOT inherit the operator's interactive shell. They run under whatever +# environment spawned the agent runtime, and `airc` installs to `~/.local/bin` — +# routinely absent from that PATH. Measured 2026-08-09 on BigMama: `airc status` +# answered instantly in every terminal (daemon at 21h uptime, 1305/1305 acked) +# while the session-recall hook wrote `persona id unresolved (airc status down)` +# for two sessions running. The probe never observed the daemon at all; bare +# `airc` was not a program it could find, and `2>/dev/null` swallowed the +# "command not found" that would have said so. +# +# So engram recall was silently off for every session on this machine, which is +# precisely the invisible-death this bridge exists to prevent — arriving through +# the resolver instead of the daemon. Two answers to "find a binary" in ONE file, +# only one of them robust, is the drift; now there is one shape. +resolve_airc() { + if [ -n "${AIRC_BIN:-}" ] && [ -x "${AIRC_BIN}" ]; then + printf '%s' "${AIRC_BIN}" + return 0 + fi + if command -v airc >/dev/null 2>&1; then + command -v airc + return 0 + fi + local candidate + # `.exe` variants matter: on Windows the installed binary is airc.exe, and a + # bare-name `-x` test does not find it. [[dir-opened-as-file-windows-only]] + for candidate in "$HOME/.local/bin/airc" "$HOME/.local/bin/airc.exe" \ + "$HOME/.cargo/bin/airc" "$HOME/.cargo/bin/airc.exe"; do + if [ -x "$candidate" ]; then + printf '%s' "$candidate" + return 0 + fi + done + return 1 +} + # Resolve the agent's persona id (its airc peer id). $CONTINUUM_AGENT_PERSONA wins # (lets a runtime pin identity); else derive from `airc status`; else the cache. # @@ -47,8 +84,12 @@ resolve_agent_persona() { printf '%s' "${CONTINUUM_AGENT_PERSONA}" return 0 fi - local live cached="$BRIDGE_STATE_DIR/persona-id" - live="$(airc status 2>/dev/null | awk '/^peer_id:/{print $2; exit}')" + local live cached="$BRIDGE_STATE_DIR/persona-id" airc_bin + if airc_bin="$(resolve_airc)"; then + live="$("$airc_bin" status 2>/dev/null | awk '/^peer_id:/{print $2; exit}')" + else + live="" + fi if [ -n "${live:-}" ]; then mkdir -p "$BRIDGE_STATE_DIR" 2>/dev/null && printf '%s' "$live" > "$cached" 2>/dev/null printf '%s' "$live" @@ -61,6 +102,83 @@ resolve_agent_persona() { return 1 } +# WHY resolve_agent_persona failed, for the caller's receipt. +# +# A separate function that RE-DERIVES rather than a variable set inside +# `resolve_agent_persona`: callers invoke it as `$(resolve_agent_persona)`, a +# command substitution, which is a SUBSHELL — anything it assigns dies with it. +# The first version of this fix did exactly that and the receipt came out blank; +# the negative test caught it. Same subshell trap as piping `source`. +# [[absence-rendered-as-positive-fact]] +# +# Re-deriving is cheap (one `command -v`, a few `-x` tests) and only ever runs on +# the failure path, where a fraction of a millisecond buys a receipt that names +# the actual cause instead of guessing at the daemon's health. +# +# "No airc binary" and "airc ran and reported nothing" are different types, not +# two values of one type. Conflating them is what wrote `airc status down` into +# two sessions' receipts about a daemon at 21h uptime. +persona_failure_reason() { + local airc_bin + if ! airc_bin="$(resolve_airc)"; then + printf 'no airc binary on PATH or in ~/.local/bin, ~/.cargo/bin (set AIRC_BIN to pin it)' + return 0 + fi + if [ -z "$("$airc_bin" status 2>/dev/null | awk '/^peer_id:/{print $2; exit}')" ]; then + printf 'airc found at %s but it reported no peer_id — daemon down or not joined' "$airc_bin" + return 0 + fi + # Reached only if the id resolves NOW but did not a moment ago (a daemon that + # came up in between). Say that, rather than inventing a cause. + printf 'airc answers now (transient failure during the earlier probe)' +} + +# stale_install_notice — warn when the RUNNING plugin is a frozen +# copy that has drifted from this repo's source. Prints the notice, or nothing. +# +# There are two install paths with very different freshness semantics, and +# nothing told you which one you were on: +# +# * `claude --plugin-dir tools/plugins/memory-bridge` runs LIVE from the repo — +# always current, a `git pull` is the update. +# * a marketplace install COPIES the plugin to +# ~/.claude/plugins/cache//// and pins it to a +# git sha. Nothing re-syncs it. A `git pull` changes nothing. +# +# Measured 2026-08-09 on BigMama: the installed copy was pinned to 60fa0dbf from +# 2026-07-25 — two weeks stale. Its lib.sh had no persona-id cache, and it had no +# session-capture.sh AT ALL, so automatic per-turn capture (the whole "volitional +# memory isn't memory" point) had never run once on that machine. Meanwhile this +# README said "Status: Live. Both hooks installed and verified end-to-end" — true +# of the repo, false of the running install, and indistinguishable from outside. +# +# That is the plugin's own founding defect one level up. It already refuses to let +# "installed" and "working" be indistinguishable; "current" and "stale" deserve the +# same treatment, because a fix that never reaches the executing copy is identical +# to a fix that was never written. +stale_install_notice() { + local script_dir="${1:-}" repo src f drifted=0 + # Only a cached COPY can be stale. Running from the repo is current by construction. + case "$script_dir" in + */plugins/cache/*) : ;; + *) return 0 ;; + esac + repo="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0 + src="$repo/tools/plugins/memory-bridge/scripts" + # Not the continuum checkout (an agent working in some other repo) — nothing to + # compare against, so say nothing rather than guess. + [ -d "$src" ] || return 0 + # A file MISSING from the install counts as drift: that is exactly how + # session-capture.sh was silently absent for two weeks. + for f in lib.sh session-recall.sh session-capture.sh share.sh; do + [ -f "$src/$f" ] || continue + cmp -s "$src/$f" "$script_dir/$f" 2>/dev/null || drifted=$((drifted + 1)) + done + [ "$drifted" -gt 0 ] || return 0 + printf '⚠️ MEMORY BRIDGE STALE — the plugin actually running is a frozen copy at %s, and %s of its scripts differ from this repo (%s). Fixes committed here are NOT live: a git pull does not update a marketplace-installed plugin. Reinstall the plugin, or run it live with `claude --plugin-dir tools/plugins/memory-bridge`.' \ + "$script_dir" "$drifted" "$src" +} + # bridge_receipt [detail] — durable one-line JSONL receipt. # # The bridge's hooks MUST never break a session, so every failure path exits 0. diff --git a/tools/plugins/memory-bridge/scripts/session-capture.sh b/tools/plugins/memory-bridge/scripts/session-capture.sh index 06b733b787..9bd8ae3303 100755 --- a/tools/plugins/memory-bridge/scripts/session-capture.sh +++ b/tools/plugins/memory-bridge/scripts/session-capture.sh @@ -30,7 +30,7 @@ CONTINUUM="$(resolve_continuum)" || { exit 0 } PERSONA="$(resolve_agent_persona)" || { - bridge_receipt session-capture failed "persona id unresolved (airc down AND no cached id)" + bridge_receipt session-capture failed "persona id unresolved and no cached id — $(persona_failure_reason)" exit 0 } [ -n "${PERSONA:-}" ] || { bridge_receipt session-capture failed "persona id empty"; exit 0; } diff --git a/tools/plugins/memory-bridge/scripts/session-recall.sh b/tools/plugins/memory-bridge/scripts/session-recall.sh index d07ce3408c..ef8baf0d9e 100755 --- a/tools/plugins/memory-bridge/scripts/session-recall.sh +++ b/tools/plugins/memory-bridge/scripts/session-recall.sh @@ -44,8 +44,14 @@ SCOPE_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" PROJECT="$(basename "$SCOPE_DIR")" PERSONA="$(resolve_agent_persona)" || { - bridge_receipt session-recall failed "persona id unresolved (airc status down AND no cached id)" - emit_notice "⚠️ MEMORY BRIDGE DOWN — recall did not run: could not resolve this agent's persona id (\`airc status\` gave nothing and no cached id exists at ~/.continuum/memory-bridge/persona-id). Your engram memory is NOT loaded this session. Fix: start airc, or set CONTINUUM_AGENT_PERSONA." + # State the MEASURED cause, not a presumed one. This notice used to assert + # "`airc status` gave nothing" unconditionally — so on 2026-08-09 it told the + # agent the daemon was down while airc had 21h uptime and 1305/1305 acked. The + # real cause was that the hook's PATH lacks ~/.local/bin, and a notice that + # names the wrong cause sends the reader's whole diagnosis sideways. + WHY="$(persona_failure_reason)" + bridge_receipt session-recall failed "persona id unresolved and no cached id — $WHY" + emit_notice "⚠️ MEMORY BRIDGE DOWN — recall did not run: could not resolve this agent's persona id, and no cached id exists at ~/.continuum/memory-bridge/persona-id. Measured cause: ${WHY}. Your engram memory is NOT loaded this session; treat yourself as amnesiac and say so rather than assuming recall works. Fix: make airc resolvable (AIRC_BIN=/path/to/airc), or set CONTINUUM_AGENT_PERSONA." exit 0 } [ -n "${PERSONA:-}" ] || { @@ -82,6 +88,15 @@ fi bridge_receipt session-recall ok "source=${SOURCE:-startup} max=$MAX bytes=${#OUT}" printf '%s\n' "$OUT" +# Staleness is orthogonal to whether recall WORKED: a frozen copy can recall +# perfectly and still be missing every fix committed since it was installed. So +# this runs on the success path too, and says so where the agent will read it. +STALE="$(stale_install_notice "$SCRIPT_DIR")" +if [ -n "$STALE" ]; then + bridge_receipt session-recall stale "$STALE" + emit_notice "$STALE" +fi + # The Stop hook (capture) has no channel to the agent — its failures would be # invisible forever. Surface the last capture receipt here, where the agent reads. LAST_CAPTURE="$(grep -a '"hook":"session-capture"' "$BRIDGE_STATE_DIR/receipts.jsonl" 2>/dev/null | tail -1)" diff --git a/tools/plugins/memory-bridge/scripts/share.sh b/tools/plugins/memory-bridge/scripts/share.sh index ed2b7f0a1b..fda67a9053 100644 --- a/tools/plugins/memory-bridge/scripts/share.sh +++ b/tools/plugins/memory-bridge/scripts/share.sh @@ -40,9 +40,12 @@ resolve_recipient() { printf '%s' "$raw"; return 0 fi # Name → peer id via airc, best-effort (whois first, then a peers-table scan). - local id - id="$(airc whois "$raw" 2>/dev/null | grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)" - [ -z "$id" ] && id="$(airc peers 2>/dev/null | grep -iF "$raw" | grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)" + # Through resolve_airc, not bare `airc`: this runs from a skill whose PATH is the + # runtime's, not the operator's, and ~/.local/bin is routinely missing from it. + local id airc_bin + airc_bin="$(resolve_airc)" || return 1 + id="$("$airc_bin" whois "$raw" 2>/dev/null | grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)" + [ -z "$id" ] && id="$("$airc_bin" peers 2>/dev/null | grep -iF "$raw" | grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)" printf '%s' "$id" } diff --git a/tools/scripts/check-plugin-version.sh b/tools/scripts/check-plugin-version.sh new file mode 100644 index 0000000000..7b71bb9213 --- /dev/null +++ b/tools/scripts/check-plugin-version.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# check-plugin-version.sh — a plugin's content must never change without its +# version changing. +# +# ## The failure this guards +# +# Claude Code plugin updates are VERSION-BASED. A marketplace install copies the +# plugin to ~/.claude/plugins/cache//// and pins it. +# `claude plugin update` compares the DECLARED version in plugin.json against the +# installed one — it does not look at content. So if the scripts change and the +# version does not, every installed copy answers: +# +# ✔ memory-bridge is already at the latest version (0.1.0). +# +# ...forever, and the fix reaches nobody. `git pull` does not update a plugin. +# +# Measured 2026-08-09: memory-bridge sat at 0.1.0 since 2026-07-25 while its +# scripts gained a persona-id cache and a whole session-capture.sh. The installed +# copy on BigMama had NEITHER — automatic per-turn memory capture had never run +# once on that machine, while the repo held working code and the README said the +# bridge was live. Bumping 0.1.0 → 0.2.0 propagated all of it in one command. +# +# This is [[silently-unwired-capability]] in its deployment form: a fix that never +# reaches the executing copy is identical to a fix never written. The repo keeps +# looking correct, because it is. +# +# ## The gate +# +# If a commit touches any file under a plugin directory, that plugin's +# `.claude-plugin/plugin.json` version MUST also change. Same shape as the +# install-manifest projection guard: the generated/consumed artifact and its +# source cannot drift apart silently. +# +# tools/scripts/check-plugin-version.sh # staged changes (pre-commit) +# tools/scripts/check-plugin-version.sh # a range (CI) +# +# Exits 0 when clean or when no plugin files changed; 1 (loud) on a missed bump. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +PLUGIN_ROOT="tools/plugins" +[ -d "$PLUGIN_ROOT" ] || exit 0 # no plugins in this tree — nothing to guard + +BASE="${1:-}" +if [ -n "$BASE" ]; then + CHANGED="$(git diff --name-only "$BASE"...HEAD -- "$PLUGIN_ROOT" 2>/dev/null)" + DESC="changed since $BASE" +else + CHANGED="$(git diff --cached --name-only -- "$PLUGIN_ROOT" 2>/dev/null)" + DESC="staged" +fi + +[ -n "$CHANGED" ] || exit 0 + +# Which plugins were touched? A path is tools/plugins//... — the marketplace +# manifest at tools/plugins/.claude-plugin/ has no segment, so the `.`-prefixed +# entry is filtered out rather than treated as a plugin called ".claude-plugin". +PLUGINS="$(printf '%s\n' "$CHANGED" \ + | sed -n "s#^$PLUGIN_ROOT/\([^/.][^/]*\)/.*#\1#p" | sort -u)" + +[ -n "$PLUGINS" ] || exit 0 + +FAILED=0 +for plugin in $PLUGINS; do + manifest="$PLUGIN_ROOT/$plugin/.claude-plugin/plugin.json" + if [ ! -f "$manifest" ]; then + echo "✗ $plugin: no $manifest — a plugin without a manifest cannot be versioned or installed" >&2 + FAILED=1 + continue + fi + # Did the version line itself change in this same set? Compare the manifest's + # version before and after rather than trusting that the manifest was touched: + # editing the description is not a release. + if [ -n "$BASE" ]; then + before="$(git show "$BASE:$manifest" 2>/dev/null)" + after="$(git show "HEAD:$manifest" 2>/dev/null)" + else + before="$(git show "HEAD:$manifest" 2>/dev/null)" + after="$(cat "$manifest" 2>/dev/null)" + fi + # A brand-new plugin has no `before` — nothing to bump from, so it passes. + [ -n "$before" ] || continue + v_before="$(printf '%s' "$before" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)" + v_after="$(printf '%s' "$after" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)" + if [ -z "$v_after" ]; then + echo "✗ $plugin: $manifest declares no \"version\" — plugin update has nothing to compare" >&2 + FAILED=1 + continue + fi + if [ "$v_before" = "$v_after" ]; then + echo "✗ $plugin: files $DESC but version is still \"$v_after\"." >&2 + echo " Plugin updates are VERSION-based, not content-based. Every installed copy will" >&2 + echo " report 'already at the latest version' and keep running the OLD scripts — your" >&2 + echo " change reaches nobody, and nothing reports the gap." >&2 + echo " Fix: bump \"version\" in $manifest" >&2 + FAILED=1 + fi +done + +if [ "$FAILED" -ne 0 ]; then + echo "" >&2 + echo "plugin-version gate failed. See the header of tools/scripts/check-plugin-version.sh" >&2 + exit 1 +fi + +exit 0 diff --git a/tools/scripts/ensure-node-deps.sh b/tools/scripts/ensure-node-deps.sh new file mode 100644 index 0000000000..bc7027cab8 --- /dev/null +++ b/tools/scripts/ensure-node-deps.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# ensure-node-deps.sh — install the npm workspace when it is missing or stale. +# +# ## Why this exists +# +# `install.sh` runs `npm install` ONCE, at install time. Nothing re-ran it when +# the manifest moved. So a contributor who installed in June and pulled in August +# had a `node_modules` that silently did not match its own `package.json` — and +# the first symptom was every client spec file failing at COLLECTION with: +# +# Failed to load url @continuum/chat-view. Does the file exist? +# +# which points a newcomer at missing SOURCE, not at their missing deps. Measured +# 2026-08-08 on a real checkout: tree from Jun 17, manifest from Aug 5, `lit` and +# every `@continuum/*` workspace package absent, seven spec files dead. +# +# ## Why HERE and not in the start path +# +# `start-server.sh` is headless Rust by doctrine — "No Node, no TS, no widgets." +# A dependency guard wired there would drag npm into a runtime path that exists +# precisely to avoid it. So this hangs off the CLIENT scripts only (`pre*` hooks +# on dev:web / build:clients / test:clients / typecheck:clients). Someone who +# only ever runs the core pays nothing and never sees this file run. +# +# ## Why mtime and not a checksum +# +# npm writes `node_modules/.package-lock.json` when it materialises the tree, so +# "tree older than lockfile" is exactly the question worth asking, answerable +# without parsing either file or shelling out to npm. A checksum would be more +# precise about CONTENT and no more precise about the thing that actually breaks +# people, which is a tree that predates a manifest change. +# +# Skipped entirely in CI: `npm ci` there is authoritative and already ran, and a +# second install would only add minutes and a chance to disagree with the +# lockfile. + +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +# CI installs from the lockfile deliberately; never second-guess it. +if [ -n "${CI:-}" ]; then + exit 0 +fi + +# Escape hatch for anyone deliberately hand-managing their tree. +if [ -n "${CONTINUUM_SKIP_DEP_CHECK:-}" ]; then + exit 0 +fi + +INSTALLED_MARKER="node_modules/.package-lock.json" +REASON="" + +if [ ! -d node_modules ]; then + REASON="node_modules is missing" +elif [ ! -f "$INSTALLED_MARKER" ]; then + # A node_modules with no marker was not written by a modern npm install — + # treat it as unknown rather than assume it is good. + REASON="node_modules has no install marker" +elif [ package-lock.json -nt "$INSTALLED_MARKER" ]; then + REASON="package-lock.json is newer than the installed tree" +elif [ package.json -nt "$INSTALLED_MARKER" ]; then + REASON="package.json is newer than the installed tree" +fi + +if [ -z "$REASON" ]; then + exit 0 +fi + +# Loud, never silent. A guard that fixes things without saying so teaches the +# operator that installs are magic, and hides a real signal (a lockfile moving +# under them) that is sometimes worth knowing about. +echo "deps: $REASON — running npm install" +npm install --silent +echo "deps: workspace up to date" diff --git a/tools/scripts/generated/manifest.windows.ps1 b/tools/scripts/generated/manifest.windows.ps1 index 453ce89751..521811c815 100644 --- a/tools/scripts/generated/manifest.windows.ps1 +++ b/tools/scripts/generated/manifest.windows.ps1 @@ -14,7 +14,8 @@ $script:ContinuumManifest = [ordered]@{ 'manifest-gen' = @{ order = 28; tier = 3; flags = @('dev'); accept = 'cargo run -q -p manifest-gen -- --check'; source = @{ type = 'command'; run = 'cargo run -q -p manifest-gen' } } 'msvc' = @{ order = 30; tier = 3; flags = @('dev'); accept = 'vswhere -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath'; source = @{ type = 'winget'; id = 'Microsoft.VisualStudio.2022.BuildTools'; override = '--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended' } } 'cmake' = @{ order = 40; tier = 3; flags = @('dev'); accept = 'cmake --version'; source = @{ type = 'archive'; url = 'https://github.com/Kitware/CMake/releases/download/v3.30.5/cmake-3.30.5-windows-x86_64.zip'; version = '3.30.5'; sha256 = '5ab6e1faf20256ee4f04886597e8b6c3b1bd1297b58a68a58511af013710004b'; extract = 'strip-top-dir' }; runtime_path = @('~/.continuum/tools/cmake/bin') } - 'llvm-libclang' = @{ order = 50; tier = 3; flags = @('dev'); accept = 'test-path ~/.continuum/tools/llvm/bin/libclang.dll'; source = @{ type = 'archive'; url = 'https://github.com/llvm/llvm-project/releases/download/llvmorg-18.1.8/clang+llvm-18.1.8-x86_64-pc-windows-msvc.tar.xz'; version = '18.1.8'; sha256 = '22c5907db053026cc2a8ff96d21c0f642a90d24d66c23c6d28ee7b1d572b82e8'; extract = 'members:*/bin/libclang.dll,*/lib/clang/*' }; runtime_path = @('~/.continuum/tools/llvm/bin') } + 'ninja' = @{ order = 45; tier = 3; flags = @('dev'); accept = 'ninja --version'; source = @{ type = 'archive'; url = 'https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-win.zip'; version = '1.12.1'; sha256 = 'f550fec705b6d6ff58f2db3c374c2277a37691678d6aba463adcbb129108467a'; extract = 'flat' }; runtime_path = @('~/.continuum/tools/ninja') } + 'llvm-libclang' = @{ order = 50; tier = 3; flags = @('dev'); accept = 'test-path ~/.continuum/tools/llvm/bin/libclang.dll'; source = @{ type = 'archive'; url = 'https://github.com/llvm/llvm-project/releases/download/llvmorg-18.1.8/clang+llvm-18.1.8-x86_64-pc-windows-msvc.tar.xz'; version = '18.1.8'; sha256 = '22c5907db053026cc2a8ff96d21c0f642a90d24d66c23c6d28ee7b1d572b82e8'; extract = 'members:*/bin/libclang.dll,*/lib/clang/*' } } 'cuda' = @{ order = 60; tier = 3; flags = @('dev'); applies = 'has-nvidia'; accept = 'nvcc --version >= 12.8'; source = @{ type = 'redist'; version = '12.9.1'; manifest = 'https://developer.download.nvidia.com/compute/cuda/redist/redistrib_12.9.1.json'; components = @('cuda_nvcc', 'cuda_cudart', 'libcublas', 'libcurand', 'cuda_nvrtc', 'cuda_cccl') }; runtime_path = @('~/.continuum/cuda-*/Library/bin') } 'build-core' = @{ order = 90; tier = 3; flags = @('dev'); accept = 'continuum-core-server.exe boots past the GPU-detection gate on the target device'; build = @{ features = 'cuda,load-dynamic-ort'; profile = 'release'; crt = 'static'; cmake_generator = 'Visual Studio 17 2022'; cuda_arch = '120'; msvc_host = 'vs2022' } } 'run' = @{ order = 100; tier = 3; accept = 'continuum-core-server binary present + serves TCP 9100' } diff --git a/tools/scripts/generated/manifest.windows.sh b/tools/scripts/generated/manifest.windows.sh index 90ef542ad6..85590909b3 100644 --- a/tools/scripts/generated/manifest.windows.sh +++ b/tools/scripts/generated/manifest.windows.sh @@ -7,18 +7,18 @@ # ============================================================================== # platform: windows -CONTINUUM_MODULES=('rust' 'gh' 'gh-auth' 'airc-firewall' 'manifest-gen' 'msvc' 'cmake' 'llvm-libclang' 'cuda' 'build-core' 'run') +CONTINUUM_MODULES=('rust' 'gh' 'gh-auth' 'airc-firewall' 'manifest-gen' 'msvc' 'cmake' 'ninja' 'llvm-libclang' 'cuda' 'build-core' 'run') -declare -A MOD_ORDER=( ['rust']='10' ['gh']='20' ['gh-auth']='25' ['airc-firewall']='27' ['manifest-gen']='28' ['msvc']='30' ['cmake']='40' ['llvm-libclang']='50' ['cuda']='60' ['build-core']='90' ['run']='100' ) -declare -A MOD_TIER=( ['rust']='0' ['gh']='0' ['gh-auth']='0' ['airc-firewall']='0' ['manifest-gen']='3' ['msvc']='3' ['cmake']='3' ['llvm-libclang']='3' ['cuda']='3' ['build-core']='3' ['run']='3' ) -declare -A MOD_FLAGS=( ['gh-auth']='grid' ['airc-firewall']='grid' ['manifest-gen']='dev' ['msvc']='dev' ['cmake']='dev' ['llvm-libclang']='dev' ['cuda']='dev' ['build-core']='dev' ) +declare -A MOD_ORDER=( ['rust']='10' ['gh']='20' ['gh-auth']='25' ['airc-firewall']='27' ['manifest-gen']='28' ['msvc']='30' ['cmake']='40' ['ninja']='45' ['llvm-libclang']='50' ['cuda']='60' ['build-core']='90' ['run']='100' ) +declare -A MOD_TIER=( ['rust']='0' ['gh']='0' ['gh-auth']='0' ['airc-firewall']='0' ['manifest-gen']='3' ['msvc']='3' ['cmake']='3' ['ninja']='3' ['llvm-libclang']='3' ['cuda']='3' ['build-core']='3' ['run']='3' ) +declare -A MOD_FLAGS=( ['gh-auth']='grid' ['airc-firewall']='grid' ['manifest-gen']='dev' ['msvc']='dev' ['cmake']='dev' ['ninja']='dev' ['llvm-libclang']='dev' ['cuda']='dev' ['build-core']='dev' ) declare -A MOD_APPLIES=( ['airc-firewall']='has-airc' ['cuda']='has-nvidia' ) -declare -A MOD_ACCEPT=( ['rust']='rustc --version' ['gh']='gh --version' ['gh-auth']='gh auth status' ['airc-firewall']='netsh advfirewall firewall show rule name="airc daemon inbound (continuum grid)"' ['manifest-gen']='cargo run -q -p manifest-gen -- --check' ['msvc']='vswhere -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath' ['cmake']='cmake --version' ['llvm-libclang']='test-path ~/.continuum/tools/llvm/bin/libclang.dll' ['cuda']='nvcc --version >= 12.8' ['build-core']='continuum-core-server.exe boots past the GPU-detection gate on the target device' ['run']='continuum-core-server binary present + serves TCP 9100' ) -declare -A MOD_TYPE=( ['rust']='winget' ['gh']='winget' ['gh-auth']='command' ['airc-firewall']='command' ['manifest-gen']='command' ['msvc']='winget' ['cmake']='archive' ['llvm-libclang']='archive' ['cuda']='redist' ) -declare -A MOD_URL=( ['cmake']='https://github.com/Kitware/CMake/releases/download/v3.30.5/cmake-3.30.5-windows-x86_64.zip' ['llvm-libclang']='https://github.com/llvm/llvm-project/releases/download/llvmorg-18.1.8/clang+llvm-18.1.8-x86_64-pc-windows-msvc.tar.xz' ) -declare -A MOD_VERSION=( ['cmake']='3.30.5' ['llvm-libclang']='18.1.8' ['cuda']='12.9.1' ) -declare -A MOD_SHA256=( ['cmake']='5ab6e1faf20256ee4f04886597e8b6c3b1bd1297b58a68a58511af013710004b' ['llvm-libclang']='22c5907db053026cc2a8ff96d21c0f642a90d24d66c23c6d28ee7b1d572b82e8' ) -declare -A MOD_EXTRACT=( ['cmake']='strip-top-dir' ['llvm-libclang']='members:*/bin/libclang.dll,*/lib/clang/*' ) +declare -A MOD_ACCEPT=( ['rust']='rustc --version' ['gh']='gh --version' ['gh-auth']='gh auth status' ['airc-firewall']='netsh advfirewall firewall show rule name="airc daemon inbound (continuum grid)"' ['manifest-gen']='cargo run -q -p manifest-gen -- --check' ['msvc']='vswhere -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath' ['cmake']='cmake --version' ['ninja']='ninja --version' ['llvm-libclang']='test-path ~/.continuum/tools/llvm/bin/libclang.dll' ['cuda']='nvcc --version >= 12.8' ['build-core']='continuum-core-server.exe boots past the GPU-detection gate on the target device' ['run']='continuum-core-server binary present + serves TCP 9100' ) +declare -A MOD_TYPE=( ['rust']='winget' ['gh']='winget' ['gh-auth']='command' ['airc-firewall']='command' ['manifest-gen']='command' ['msvc']='winget' ['cmake']='archive' ['ninja']='archive' ['llvm-libclang']='archive' ['cuda']='redist' ) +declare -A MOD_URL=( ['cmake']='https://github.com/Kitware/CMake/releases/download/v3.30.5/cmake-3.30.5-windows-x86_64.zip' ['ninja']='https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-win.zip' ['llvm-libclang']='https://github.com/llvm/llvm-project/releases/download/llvmorg-18.1.8/clang+llvm-18.1.8-x86_64-pc-windows-msvc.tar.xz' ) +declare -A MOD_VERSION=( ['cmake']='3.30.5' ['ninja']='1.12.1' ['llvm-libclang']='18.1.8' ['cuda']='12.9.1' ) +declare -A MOD_SHA256=( ['cmake']='5ab6e1faf20256ee4f04886597e8b6c3b1bd1297b58a68a58511af013710004b' ['ninja']='f550fec705b6d6ff58f2db3c374c2277a37691678d6aba463adcbb129108467a' ['llvm-libclang']='22c5907db053026cc2a8ff96d21c0f642a90d24d66c23c6d28ee7b1d572b82e8' ) +declare -A MOD_EXTRACT=( ['cmake']='strip-top-dir' ['ninja']='flat' ['llvm-libclang']='members:*/bin/libclang.dll,*/lib/clang/*' ) declare -A MOD_REDIST_MANIFEST=( ['cuda']='https://developer.download.nvidia.com/compute/cuda/redist/redistrib_12.9.1.json' ) declare -A MOD_COMPONENTS=( ['cuda']='cuda_nvcc,cuda_cudart,libcublas,libcurand,cuda_nvrtc,cuda_cccl' ) declare -A MOD_FORMULA=() @@ -27,4 +27,4 @@ declare -A MOD_ARGS=() declare -A MOD_RUN=( ['gh-auth']='gh auth login --hostname github.com --git-protocol https --web' ['airc-firewall']='New-NetFirewallRule -DisplayName '\''airc daemon inbound (continuum grid)'\'' -Direction Inbound -Action Allow -Profile Any' ['manifest-gen']='cargo run -q -p manifest-gen' ) declare -A MOD_BUILD_FEATURES=( ['build-core']='cuda,load-dynamic-ort' ) declare -A MOD_BUILD_PROFILE=( ['build-core']='release' ) -declare -A MOD_RUNTIME_PATH=( ['cmake']='~/.continuum/tools/cmake/bin' ['llvm-libclang']='~/.continuum/tools/llvm/bin' ['cuda']='~/.continuum/cuda-*/Library/bin' ) +declare -A MOD_RUNTIME_PATH=( ['cmake']='~/.continuum/tools/cmake/bin' ['ninja']='~/.continuum/tools/ninja' ['cuda']='~/.continuum/cuda-*/Library/bin' ) diff --git a/tools/scripts/install-manifest.toml b/tools/scripts/install-manifest.toml index 968d7c4e8c..1a39f8086d 100644 --- a/tools/scripts/install-manifest.toml +++ b/tools/scripts/install-manifest.toml @@ -159,13 +159,56 @@ formula = "cmake" [module.sources.linux] type = "apt" # run-verify pending on a linux node package = "cmake" +# WHERE the build shell finds it. This is the windows-vs-unix split, stated as data: +# brew/apt drop cmake straight onto PATH, so unix needs nothing here. The windows +# archive lands in a per-user dir that is on NOBODY's PATH, so without this the +# generic runtime-PATH loop skips it and llama's build.rs dies with "is `cmake` not +# installed?" while cmake sits installed a directory away. It lived as a hardcoded +# fallback inside windows-build-env.sh's CUDA block instead — which meant a +# CPU-only Windows box (no nvcc → block skipped) never got it at all. [module.runtime_path] -# The Windows archive installs to ~/.continuum/tools/cmake; its bin/ MUST be on -# PATH at BUILD time — the llama crate's build.rs shells out to `cmake` to compile -# vendored llama.cpp, and nvcc-free directml builds still need it. brew/apt already -# put cmake on PATH, so this is windows-only. [[windows-build-env-drift]] windows = ["~/.continuum/tools/cmake/bin"] +[[module]] +id = "ninja" +order = 45 +tier = 3 +flags = ["dev"] +# WINDOWS ONLY, deliberately. The defect ninja fixes is a Windows one (see below): +# on unix, cmake's default generator (Unix Makefiles) is never broken, so listing +# macos/linux here would make every contributor on those platforms install a package +# to buy nothing. Adapting to the platform means stating the asymmetry, not smearing +# one platform's workaround across all three. +platforms = ["windows"] +accept = "ninja --version" +# WHY ninja is a first-class module and not an implementation detail: +# +# cmake auto-picks the NEWEST installed Visual Studio generator. On a VS18-2026 box +# that is "Visual Studio 18 2026" — a generator string cmake 3.30.5 does not define +# ("Could not create named generator"), so the build dies on a machine where every +# tool is present and correct. Ninja is generator-version-agnostic, uses the MSVC env +# imported separately, drives nvcc directly (no VS integration, no admin), and matches +# how llama-server is built. So on Windows it is not optional — it is what makes the +# configure step deterministic across VS versions. [[windows-build-env-drift]] +# +# It was previously fetched by a HARDCODED url inside install-llama-server's PowerShell +# module — no pinned version in the manifest, no sha256, and invisible to the +# manifest-gen drift gate. One tool provisioned by a different set of rules than every +# other tool is exactly the drift the manifest exists to prevent, and an unverified +# download is a supply-chain hole regardless of how convenient the url is. +[module.sources.windows] +type = "archive" # official ninja release, no admin +url = "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-win.zip" +version = "1.12.1" +# sha256 computed 2026-08-09 from the official GitHub release asset (275425 bytes). +sha256 = "f550fec705b6d6ff58f2db3c374c2277a37691678d6aba463adcbb129108467a" +extract = "flat" # -> ~/.continuum/tools/ninja/ninja.exe (zip has no top dir) +# cmake resolves ninja through PATH only (it ignores CMAKE_MAKE_PROGRAM from the env), +# so an unlisted ninja means "CMAKE_GENERATOR is not set / unable to find Ninja" even +# with the binary sitting on disk. +[module.runtime_path] +windows = ["~/.continuum/tools/ninja"] + [[module]] id = "llvm-libclang" order = 50 @@ -189,11 +232,6 @@ accept_macos = "test -f /Library/Developer/CommandLineTools/usr/lib/libclang.dyl type = "apt" # run-verify pending on a linux node package = "libclang-dev" accept_linux = "test -f /usr/lib/llvm-18/lib/libclang.so.1 || ldconfig -p | grep -q libclang" -[module.runtime_path] -# The Windows archive installs libclang.dll to ~/.continuum/tools/llvm/bin; on PATH -# so bindgen (llama crate) can load it at build time. mac/linux resolve libclang via -# system paths. [[windows-build-env-drift]] -windows = ["~/.continuum/tools/llvm/bin"] [[module]] id = "cuda" diff --git a/tools/scripts/lib/win-modules.ps1 b/tools/scripts/lib/win-modules.ps1 index 1041fbd1e4..bfac3d1bb9 100644 --- a/tools/scripts/lib/win-modules.ps1 +++ b/tools/scripts/lib/win-modules.ps1 @@ -274,6 +274,46 @@ function Mod-CMake { else { Module-Fail 'CMake' "cmake.exe not found after extract to $dir" } } +function Set-NinjaEnv { + # Ninja must be reachable BY PATH: the cmake crate ignores CMAKE_MAKE_PROGRAM from + # the environment, so with -G Ninja it searches PATH and otherwise fails with + # "unable to find Ninja / CMAKE_MAKE_PROGRAM is not set". Mirrors Set-CMakeEnv. + param([Parameter(Mandatory)][string]$Dir) + if ($env:PATH -notlike "*$Dir*") { $env:PATH = "$Dir;$env:PATH" } +} + +function Mod-Ninja { + # The no-admin CUDA build driver. The "Visual Studio 17 2022" generator needs the + # CUDA VS MSBuild integration (CUDA*.props under VC/BuildCustomizations) to + # enable_language(CUDA) -- and our no-admin CUDA redist does not ship it (it is a + # full-installer component that writes into Program Files). Ninja drives nvcc + # directly, so it needs zero VS integration, and it is generator-version-agnostic + # (the "Visual Studio 18 2026" trap cmake 3.30.x cannot name). + # + # Was a hardcoded Invoke-WebRequest inline in Mod-LlamaServer: no manifest entry, + # no pinned sha256, invisible to the manifest-gen drift gate. One tool provisioned + # by different rules than every other tool is the drift the manifest exists to + # prevent, and an unverified download is a supply-chain hole however convenient + # the url. Now: same source-of-truth, same verification, same guard shape as + # Mod-CMake. + if (Get-Command ninja -ErrorAction SilentlyContinue) { Module-Skip 'Ninja' 'on PATH'; return } + $dir = Join-Path $env:USERPROFILE '.continuum\tools\ninja' + $exe = Join-Path $dir 'ninja.exe' + if (Test-Path $exe) { Set-NinjaEnv $dir; Module-Skip 'Ninja' "present at $dir"; return } + Module-Start 'Ninja' 'downloading ninja (no admin)' + $src = (Get-ManifestModule 'ninja').source # archive: url + version + sha256 + extract + $zip = Join-Path $env:TEMP "ninja-$($src.version).zip" + Invoke-WebRequest -Uri $src.url -OutFile $zip -UseBasicParsing + Assert-Sha256 -Path $zip -Expected $src.sha256 -Name 'Ninja' + New-Item -ItemType Directory -Force $dir | Out-Null + # extract = "flat": the ninja zip has no top-level directory, so it expands + # straight into place (contrast Mod-CMake's "strip-top-dir"). + Expand-Archive -Path $zip -DestinationPath $dir -Force + Remove-Item $zip -ErrorAction SilentlyContinue + if (Test-Path $exe) { Set-NinjaEnv $dir; Module-Done 'Ninja' } + else { Module-Fail 'Ninja' "ninja.exe not found after extract to $dir" } +} + function Mod-LLVM { # libclang.dll for bindgen. From LLVM's OFFICIAL release (clang+llvm # windows-msvc tarball), extracted per-user -- no admin, no Python. @@ -558,14 +598,7 @@ function Mod-LlamaServer { # (Enter-MsvcEnv puts cl.exe on PATH for nvcc's host side). $ninjaDir = Join-Path $env:USERPROFILE '.continuum\tools\ninja' $ninja = Join-Path $ninjaDir 'ninja.exe' - if ($backend -eq 'cuda' -and -not (Test-Path $ninja)) { - Write-Step ' llama-server: fetching ninja (no-admin CUDA build driver)' - New-Item -ItemType Directory -Force $ninjaDir | Out-Null - $nz = Join-Path $env:TEMP 'ninja-win.zip' - Invoke-WebRequest -Uri 'https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-win.zip' -OutFile $nz -UseBasicParsing - Expand-Archive -Path $nz -DestinationPath $ninjaDir -Force - Remove-Item $nz -ErrorAction SilentlyContinue - } + if ($backend -eq 'cuda') { Mod-Ninja } $cmakeArgs = @('-S', $submodule, '-B', $buildDir, '-DCMAKE_BUILD_TYPE=Release', diff --git a/tools/scripts/lib/windows-build-env.sh b/tools/scripts/lib/windows-build-env.sh index ea7ac03c39..1ff94d109e 100644 --- a/tools/scripts/lib/windows-build-env.sh +++ b/tools/scripts/lib/windows-build-env.sh @@ -40,97 +40,6 @@ _mf_runtime="$_wbe_lib_dir/../generated/manifest.${_mf_os}.sh" # long-running core died). Only source it on bash 4+. The manifest solely feeds the # runtime-PATH augmentation below (a Windows/CUDA concern), whose own guard already tolerates # absence — so skipping it on bash 3.2 costs macOS nothing and the boot proceeds to the build. -# ── ONE declared CUDA tree, chosen before anything touches PATH (#6) ──────────────────────── -# -# THIS is where the multi-tree bug actually lived. The manifest's runtime-PATH entries contain -# a `cuda-*` glob; expanding it prepended EVERY provisioned tree, so the last one prepended won -# PATH and therefore decided which `cublas.lib` the linker opened. `CUDA_PATH` named a tree but -# had no say in it. -# -# MEASURED on BigMama 2026-08-07: four trees at two majors, CUDA_PATH resolved to a CUDA 12 -# tree, and `dumpbin //DEPENDENTS` on the linked binary reported cublas64_13.dll — CUDA 13. The -# declaration and the binding disagreed, and the first symptom was a user-facing -# "cublas64_12.dll was not found" at launch, naming a major the binary does not even use. -# -# cuda-13.2 34 libs 170 dlls cuda-13 complete -# cuda-env 9 libs 123 dlls cuda-12 complete -# cuda-toolkit 12 libs 9 dlls cuda-12 import libs, no runtime -# cuda-build-venv 0 libs 0 dlls -- empty -# -# Warning about this was the previous behaviour and it was not enough: a warning leaves the -# next build binding by search order anyway. Choose ONE, deterministically, and let only that -# one onto PATH — then CUDA_PATH, the link, and the runtime all name the same tree by -# construction rather than by luck. -# -# SELECTION, in order, all derived from what is on disk (never a hardcoded tree name): -# 1. linkable — has cuda.lib AND curand.lib, or it cannot satisfy the link at all -# 2. runnable — ships cublas64_.dll, or you link fine and fail at load (cuda-toolkit -# above is exactly this trap: 12 import libs, 9 DLLs, no usable runtime) -# 3. highest major, then most DLLs, then lexicographic — so two machines with the same trees -# always choose the same one, and adding a tree never silently re-points an existing node -# unless it is genuinely newer and complete. -_wbe_cuda_tree="" -_wbe_cuda_major="" -_wbe_cuda_rejected="" -if [ "$_mf_os" = windows ]; then - _wbe_best_rank="" - for _t in "${CONTINUUM_HOME:-$HOME/.continuum}"/cuda-*; do - [ -d "$_t" ] || continue - _has_lib=""; _has_dll=""; _maj="" - for _sub in "Library/lib/x64" "lib/x64"; do - [ -f "$_t/$_sub/cuda.lib" ] && [ -f "$_t/$_sub/curand.lib" ] && _has_lib="$_t/$_sub" - done - for _sub in "Library/bin" "bin"; do - for _c in "$_t/$_sub"/cublas64_*.dll; do - [ -f "$_c" ] || continue - _maj="$(basename "$_c" | sed -n 's/^cublas64_\([0-9]\+\)\.dll$/\1/p')" - [ -n "$_maj" ] && _has_dll="$_t/$_sub" && break - done - [ -n "$_has_dll" ] && break - done - if [ -z "$_has_lib" ] || [ -z "$_has_dll" ]; then - _wbe_cuda_rejected="$_wbe_cuda_rejected $(basename "$_t")($([ -z "$_has_lib" ] && echo no-import-libs || echo no-runtime-dll))" - continue - fi - _ndll=$(find "$_t" -maxdepth 3 -name '*.dll' 2>/dev/null | wc -l | tr -d ' ') - # Zero-padded so string compare orders numerically — no arithmetic on possibly-empty vars. - _rank="$(printf '%03d-%06d-%s' "$_maj" "$_ndll" "$(basename "$_t")")" - if [ -z "$_wbe_best_rank" ] || [ "$_rank" \> "$_wbe_best_rank" ]; then - _wbe_best_rank="$_rank"; _wbe_cuda_tree="$_t"; _wbe_cuda_major="$_maj" - fi - done - if [ -n "$_wbe_cuda_tree" ]; then - echo "▶ CUDA tree: $(basename "$_wbe_cuda_tree") (cuda-$_wbe_cuda_major) — the ONE declared tree${_wbe_cuda_rejected:+; rejected:$_wbe_cuda_rejected}" - # CUDA 13's bundled CCCL headers REFUSE MSVC's traditional preprocessor: - # - # cuda/std/__cccl/preprocessor.h(23): fatal error C1189: #error: MSVC/cl.exe with - # traditional preprocessor is used ... pass `/Zc:preprocessor` to cl.exe - # Error: CompilationFailed { path: "src\reduce.cu" } - # - # MEASURED: this is what the first build on cuda-13 hit. CUDA 12's headers had no such - # check, so the requirement only appears the moment the chooser above correctly prefers the - # newer complete tree — i.e. picking the right tree is what EXPOSED it, not what caused it. - # - # `NVCC_PREPEND_FLAGS` is nvcc's own environment variable, so it reaches EVERY nvcc - # invocation — candle-kernels, cudarc, llama's cmake — without each crate needing its own - # flag plumbing. That matters here because there is no single build.rs to patch: several - # independent crates spawn nvcc, and a per-crate fix would leave the next one broken. - # - # Applied only on cuda-13+; harmless on 12 but conditioning it documents WHOSE requirement - # this is, so nobody later removes it as a mystery flag. Appends rather than overwrites, so - # an operator's own NVCC_PREPEND_FLAGS survives. - if [ "${_wbe_cuda_major:-0}" -ge 13 ] 2>/dev/null; then - case " $NVCC_PREPEND_FLAGS " in - *"/Zc:preprocessor"*) : ;; - *) export NVCC_PREPEND_FLAGS="${NVCC_PREPEND_FLAGS:+$NVCC_PREPEND_FLAGS }-Xcompiler /Zc:preprocessor" ;; - esac - echo "▶ nvcc: -Xcompiler /Zc:preprocessor (cuda-$_wbe_cuda_major CCCL requires the conforming MSVC preprocessor)" - fi - elif [ -n "$_wbe_cuda_rejected" ]; then - echo "⚠ no COMPLETE CUDA tree (needs cuda.lib+curand.lib AND a cublas64_*.dll); rejected:$_wbe_cuda_rejected" >&2 - fi -fi - if [ -f "$_mf_runtime" ] && [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then # shellcheck source=/dev/null source "$_mf_runtime" @@ -141,15 +50,6 @@ if [ -f "$_mf_runtime" ] && [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then # eval expands ~ and the version glob (cuda-*); prepend each existing match once. for _rp_hit in $(eval echo "$_rp"); do [ -d "$_rp_hit" ] || continue - # A cuda-* dir that is NOT the chosen tree never reaches PATH. Without this the glob - # puts every major on PATH and the linker picks by position — the whole bug. - case "$_rp_hit" in - *"/cuda-"*) - if [ -n "$_wbe_cuda_tree" ] && [ "${_rp_hit#"$_wbe_cuda_tree"}" = "$_rp_hit" ]; then - continue - fi - ;; - esac case ":$PATH:" in *":$_rp_hit:"*) ;; *) export PATH="$_rp_hit:$PATH" ;; esac done done @@ -157,52 +57,63 @@ if [ -f "$_mf_runtime" ] && [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then fi fi -# ── Windows: pin cmake + ninja + the generator ────────────────────────────────────────────── -# UNCONDITIONAL on Windows, and deliberately so. This block used to live nested inside the -# CUDA/MSVC import below, behind `command -v nvcc && ! command -v cl.exe`. cmake is a -# core/llama concern, NOT a CUDA one: every cargo build that touches core/llama runs the cmake -# crate, CUDA or not. So on a box where nvcc was not (yet) on PATH — or in a shell that had -# already imported cl.exe — the whole block skipped, CMAKE/CMAKE_GENERATOR never got set, and -# the build died TWELVE MINUTES LATER inside llama's build.rs with "is `cmake` not installed?" -# Measured tonight: cmake IS installed; the guard for a different tool had silently disowned it. -# A precondition for X must not be gated on the presence of Y. -if [ "$_mf_os" = windows ]; then - # The cmake crate (core/llama build.rs) resolves cmake via the CMAKE env var or PATH, but a - # manifest-provisioned cmake lives at ~/.continuum/tools/cmake/bin and is not guaranteed on a - # clean shell's PATH (measured: absent in a clean subshell → "cmake not found"). Point CMAKE at - # the known install (same resolution as install-llama-server.sh) and put its dir on PATH for - # cmake's own sub-tools. - _ccmk="$(command -v cmake 2>/dev/null || echo "${CONTINUUM_HOME:-$HOME/.continuum}/tools/cmake/bin/cmake.exe")" - if [ -x "$_ccmk" ]; then - export CMAKE="$(cygpath -w "$_ccmk" 2>/dev/null || echo "$_ccmk")" - case ":$PATH:" in *":$(dirname "$_ccmk"):"*) ;; *) PATH="$(dirname "$_ccmk"):$PATH"; export PATH ;; esac - fi - # Force a generator cmake actually knows. The cmake crate auto-picks the newest installed VS; on - # a VS18-2026 box that is "Visual Studio 18 2026" — a generator cmake 3.30.x does NOT define - # ("Could not create named generator"). Ninja is version-agnostic, uses the MSVC env imported - # below, and matches the llama-server build. [[windows-build-env-drift]] - _cninja="$(command -v ninja 2>/dev/null || echo "${CONTINUUM_HOME:-$HOME/.continuum}/tools/ninja/ninja.exe")" - if [ -x "$_cninja" ]; then - export CMAKE_GENERATOR="Ninja" - # ninja must be ON PATH: the cmake crate ignores CMAKE_MAKE_PROGRAM env, so with -G Ninja it - # searches PATH ("unable to find Ninja / CMAKE_MAKE_PROGRAM is not set" otherwise). - case ":$PATH:" in *":$(dirname "$_cninja"):"*) ;; *) PATH="$(dirname "$_cninja"):$PATH"; export PATH ;; esac +# ── Build drivers: cmake + ninja (EVERY platform, independent of CUDA) ────────────────────── +# These two used to live INSIDE the CUDA/MSVC block below, which gated them on +# `nvcc present AND cl.exe absent`. Neither has anything to do with CUDA: +# +# * a CPU-only Windows box (no nvcc) skipped the block entirely and got no cmake +# pin, so `cargo build` died with "is `cmake` not installed?" while cmake sat +# installed at ~/.continuum/tools/cmake — a manifest-provisioned tool the build +# could not see; +# * a shell where cl.exe was ALREADY resolvable skipped it for the same reason. +# +# The PATH half is now the manifest's job (`[module.runtime_path]` on cmake/ninja, +# consumed by the generic loop above) — that is where the windows-vs-unix split +# belongs, because it IS a packaging fact: brew/apt put these on PATH, the Windows +# archives do not. What is left here is only what the manifest cannot express: the +# two env vars the cmake crate reads. + +# cmake-rs (core/llama build.rs) resolves cmake from the CMAKE env var, else PATH. +# Point it at whatever the loop above (or the system package manager) resolved, so +# the crate never re-guesses. No fallback path is invented: if cmake is genuinely +# absent, the build fails loudly with the crate's own message rather than pointing +# at a file that isn't there. +if [ -z "$CMAKE" ] && command -v cmake >/dev/null 2>&1; then + _wbe_cmake="$(command -v cmake)" + if [ "$_mf_os" = windows ]; then + export CMAKE="$(cygpath -w "$_wbe_cmake" 2>/dev/null || echo "$_wbe_cmake")" + else + export CMAKE="$_wbe_cmake" fi fi -# ── Windows: import the MSVC toolchain (cl.exe + INCLUDE/LIB) ─────────────────────────────── -# Needed by BOTH consumers, which is why the guard is presence-of-cl.exe and nothing else: -# - nvcc (candle's CUDA kernels) uses cl.exe as its host compiler; -# - the Ninja generator pinned above needs a C/C++ compiler on PATH for ANY llama build. -# The guard used to also require `command -v nvcc`, which meant a Windows box without CUDA got -# no cl.exe and its Ninja build had no compiler at all — CUDA's absence disabling the non-CUDA -# path. The cargo builds run in THIS bash shell (unlike the llama-server cmake build, which runs -# inside a vcvars .bat), so without this the build dies with "Cannot find compiler 'cl.exe'". -# Import once: export INCLUDE/LIB verbatim (only cl.exe reads them) and prepend the EXACT MSVC + -# Windows SDK bin dirs (from vcvars, converted to unix) to PATH — bash's own PATH resolution -# stays intact (we add unix dirs, never overwrite PATH with the Windows one). VS2022 (14.4x) is -# selected explicitly: nvcc 12.x rejects the newer 14.5x/VS18 toolset. -if [ "$_mf_os" = windows ] && ! command -v cl.exe >/dev/null 2>&1; then +# WINDOWS ONLY: force a generator cmake actually knows. The cmake crate auto-picks +# the newest installed Visual Studio; on a VS18-2026 box that is "Visual Studio 18 +# 2026", which cmake 3.30.x does not define ("Could not create named generator") — +# so the configure step fails on a machine where every tool is present and correct. +# Ninja is generator-version-agnostic and uses the MSVC env imported below. +# [[windows-build-env-drift]] +# +# Deliberately NOT applied on unix: cmake's default there (Unix Makefiles) is never +# broken, so pinning Ninja would change every Linux/macOS contributor's build to buy +# nothing. The asymmetry is the point — this is a Windows-specific defect, and the +# fix stays scoped to it. +if [ "$_mf_os" = windows ] && [ -z "$CMAKE_GENERATOR" ] && command -v ninja >/dev/null 2>&1; then + # cmake ignores CMAKE_MAKE_PROGRAM from the env and searches PATH for ninja, which + # the manifest runtime_path above already guarantees. + export CMAKE_GENERATOR="Ninja" +fi + +# ── Windows: import the MSVC toolchain so cargo's CUDA (candle) build finds cl.exe ────────── +# candle compiles CUDA kernels (affine.cu, ...) via nvcc, which needs cl.exe as its host +# compiler plus INCLUDE/LIB. The cargo builds below run in THIS bash shell (unlike the +# llama-server cmake build, which runs inside a vcvars .bat), so without this nvcc fails with +# "Cannot find compiler 'cl.exe' in PATH" and the whole core build dies. Import it once: export +# INCLUDE/LIB verbatim (only cl.exe reads them) and prepend the EXACT MSVC + Windows SDK bin +# dirs (from vcvars, converted to unix) to PATH — nvcc finds cl.exe while bash's own PATH +# resolution stays intact (we add unix dirs, never overwrite PATH with the Windows one). VS2022 +# (14.4x) is selected explicitly: nvcc 12.x rejects the newer 14.5x/VS18 toolset. +if [ "$_mf_os" = windows ] && command -v nvcc >/dev/null 2>&1 && ! command -v cl.exe >/dev/null 2>&1; then _vswhere="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe" _vs="" [ -x "$_vswhere" ] && _vs="$("$_vswhere" -version "[17.0,18.0)" -products '*' \ @@ -228,18 +139,16 @@ if [ "$_mf_os" = windows ] && ! command -v cl.exe >/dev/null 2>&1; then if [ -n "$_vct" ]; then _clb="$(cygpath -u "${_vct}bin\\Hostx64\\x64" 2>/dev/null)"; [ -d "$_clb" ] && PATH="$_clb:$PATH"; fi if [ -n "$_sdkbin" ]; then _sdb="$(cygpath -u "${_sdkbin}x64" 2>/dev/null)"; [ -d "$_sdb" ] && PATH="$_sdb:$PATH"; fi export PATH + # (cmake + ninja are pinned ABOVE, outside this block — they are not CUDA concerns. + # Gating them on nvcc is what left a CPU-only Windows box with no cmake at all.) # CUDA_PATH must be set: cudarc/candle/pocket-tts read it to emit their link-search; without it the # link has NO CUDA search path (measured: LNK1181 cuda.lib). Point it at a cuda-* whose import-lib # dir actually has the libs (a provisioning split can leave the crate-detected dir EMPTY - cuda-env # /Library/lib/x64=0 vs cuda-13.2=12; the #6 provisioning fix unifies them, and this node's cuda-env # was completed by copying the sibling's libs in). candle finds nvcc via PATH independently, so a # libs-only CUDA_PATH is fine (build proven). Real fix: provision ONE complete toolkit (#6). - # Use THE tree chosen above — never a second, independent scan. The old loop listed - # `cuda-env` first and took the first linkable hit, so CUDA_PATH named a CUDA 12 tree while - # PATH (globbed, unordered) handed the linker CUDA 13. One selection, one answer: if these - # two ever disagree again it is a bug in the chooser, not a race between two searches. if [ -z "$CUDA_PATH" ]; then - for _cand in ${_wbe_cuda_tree:+"$_wbe_cuda_tree"}; do + for _cand in "${CONTINUUM_HOME:-$HOME/.continuum}"/cuda-env "${CONTINUUM_HOME:-$HOME/.continuum}"/cuda-*; do for _sub in Library/lib/x64 lib/x64; do if [ -f "$_cand/$_sub/curand.lib" ] && [ -f "$_cand/$_sub/cuda.lib" ]; then export CUDA_PATH="$(cygpath -w "$_cand" 2>/dev/null || echo "$_cand")" @@ -255,75 +164,14 @@ if [ "$_mf_os" = windows ] && ! command -v cl.exe >/dev/null 2>&1; then fi done done - # Only a CUDA build can be hurt by a missing CUDA_PATH. Warning unconditionally would cry - # wolf on every CPU-only Windows box now that this block is no longer behind an nvcc guard. - if [ -z "$CUDA_PATH" ] && command -v nvcc >/dev/null 2>&1; then - echo "⚠ nvcc present but no complete CUDA import-lib dir found (cuda-*/**/{cuda,curand}.lib) — the CUDA core link WILL fail. Provisioning gap (#6)." >&2 - fi - fi - # CONSISTENCY ASSERTION, replacing the warning this used to print. - # - # The old block DETECTED that several majors were provisioned and told the operator to go - # check the binary with dumpbin. That was honest and useless: it named the hazard and left - # the next build binding by search order anyway. Now that exactly one tree reaches PATH, - # the invariant is checkable directly — PATH must offer the SAME major CUDA_PATH declares. - # - # If these disagree, something re-ordered PATH after this file ran (a caller's own export, - # a stale shell, a second toolchain from an installer), and that is the precise condition - # that produced "cublas64_12.dll was not found" for a binary that actually wanted 13. Fail - # loud HERE, where it is one line to read, instead of at load time on someone else's box. - if [ -n "$CUDA_PATH" ] && [ -n "$_wbe_cuda_major" ]; then - _path_major="" - _IFS_SAVE="$IFS"; IFS=':' - for _pd in $PATH; do - for _c in "$_pd"/cublas64_*.dll; do - [ -f "$_c" ] || continue - _path_major="$(basename "$_c" | sed -n 's/^cublas64_\([0-9]\+\)\.dll$/\1/p')" - break 2 - done - done - IFS="$_IFS_SAVE" - if [ -n "$_path_major" ] && [ "$_path_major" != "$_wbe_cuda_major" ]; then - echo "✗ CUDA MAJOR MISMATCH: declared cuda-$_wbe_cuda_major ($(basename "$_wbe_cuda_tree")) but PATH offers cublas64_$_path_major first." >&2 - echo " The linker binds what PATH offers, so the binary would import cuda-$_path_major while" >&2 - echo " CUDA_PATH promises cuda-$_wbe_cuda_major — the exact split that surfaces later as" >&2 - echo " 'cublas64_XX.dll was not found' naming a major the binary does not use." >&2 - echo " Something re-ordered PATH after windows-build-env.sh ran. Fix that, do not build." >&2 - (return 0 2>/dev/null) && return 1 || exit 1 - fi - unset _path_major _pd _c _IFS_SAVE + [ -z "$CUDA_PATH" ] && echo "⚠ no complete CUDA import-lib dir found (cuda-*/**/{cuda,curand}.lib) - core link WILL fail. Provisioning gap (#6)." >&2 fi if command -v cl.exe >/dev/null 2>&1; then - echo "▶ MSVC toolchain imported (cl.exe on PATH for ninja/nvcc/candle)" + echo "▶ MSVC toolchain imported for the CUDA cargo build (cl.exe on PATH for nvcc/candle)" else - echo "⚠ MSVC import ran but cl.exe still unresolved — the cargo build will fail" >&2 + echo "⚠ MSVC import ran but cl.exe still unresolved — the CUDA cargo build will fail" >&2 fi else - echo "✗ Windows needs the VS2022 (14.4x) C++ x64 toolset — it is ninja's compiler and nvcc's host compiler; vswhere/vcvars not found, so the core build will fail. Install via the 'msvc' module." >&2 - fi -fi - -# ── Postcondition: the environment either IS usable or says exactly why not ────────────────── -# The whole point of this file is that `source it, then cargo` works. Until now it could complete -# with cmake unresolvable and print nothing, so the first sign of trouble was a build.rs panic -# twelve minutes and several hundred log lines downstream — "is `cmake` not installed?" when -# cmake was installed the whole time. That is the silently-unwired shape: the work ran, the -# outcome was never checked, and the failure surfaced somewhere that named the wrong cause. -# -# Verify what the next cargo invocation actually needs, name each missing piece at the seam, and -# return non-zero so a caller that checks `$?` gets a signal. Sourced (the documented usage), a -# bare `return` sets $? without killing the caller's shell; guard it so an accidental direct -# execution still exits cleanly instead of erroring on `return` outside a function. -if [ "$_mf_os" = windows ]; then - _wbe_missing="" - command -v cmake >/dev/null 2>&1 || _wbe_missing="$_wbe_missing cmake" - command -v cl.exe >/dev/null 2>&1 || _wbe_missing="$_wbe_missing cl.exe" - [ -n "$CMAKE_GENERATOR" ] || _wbe_missing="$_wbe_missing CMAKE_GENERATOR(ninja)" - if [ -n "$_wbe_missing" ]; then - echo "✗ windows-build-env: environment INCOMPLETE — missing:$_wbe_missing" >&2 - echo " cargo will fail later with a misleading error. Run the 'cmake'/'ninja'/'msvc' install modules." >&2 - unset _wbe_missing - (return 0 2>/dev/null) && return 1 || exit 1 + echo "✗ Windows+CUDA needs VS2022 (14.4x) C++ x64 toolset for nvcc's host compiler; vswhere/vcvars not found — the core build will fail. Install via the 'msvc' module." >&2 fi - unset _wbe_missing fi