Skip to content

Commit 1510233

Browse files
committed
fix(embeddings): self-heal from unresolved Git LFS pointer, don't fail forever
Root cause of the embeddings_status: "failed" incident: a checkout without git-lfs installed leaves the vendored default embedding model (crates/ci-core/assets/potion-code-16m/model.safetensors) as a ~130-byte LFS pointer stub instead of real weights. `include_bytes!` bakes that stub into the binary either way, so the build "succeeds" and the failure only surfaces at runtime, permanently, with no clear diagnosis. - Embedder::load now detects an unusable vendored asset and automatically falls back to a one-time HuggingFace Hub download of the same default model (cached locally afterward), instead of failing forever. This is a functionality/reliability concern, not a privacy one — no code or repo content is ever sent anywhere; only a public static model file is fetched. - New semantic_search.allow_network_fallback config flag (default true) for anyone who wants semantic search to stay strictly zero-network: with it set to false, an unusable vendored asset reports the new embeddings_status: "offline_unavailable" (a known policy outcome) instead of silently attempting a network call or reporting the more generic "failed". - Hardened the infra that produced the incident: session-start-build-ci.sh and the documented cloud Setup Script now attempt `git lfs pull` before building; scripts/mcp-launcher.sh's is_binary_fresh also treats vendored assets as freshness inputs, so a fixed LFS asset invalidates a previously-built stale binary instead of being silently ignored. - Regression test (default_vendored_asset_is_not_an_lfs_pointer) catches this exact failure mode at test time instead of at first-run in production. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X83MzmbseyW5v3j9mka5Sc
1 parent 7c8ff53 commit 1510233

11 files changed

Lines changed: 301 additions & 25 deletions

File tree

.claude/hooks/session-start-build-ci.sh

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,28 @@ if ! command -v cargo >/dev/null 2>&1; then
3131
exit 0
3232
fi
3333

34+
# Resolve any unresolved Git LFS pointer stubs (assets/potion-code-16m/* —
35+
# the vendored embedding model — and .ci-bin/**/ci) BEFORE building. Real
36+
# incident this guards against: a checkout without git-lfs installed leaves
37+
# ~130-byte pointer text in place of real file content; `cargo build` still
38+
# succeeds (it just bakes that pointer text into the binary via
39+
# `include_bytes!`), and the failure only surfaces later, silently, as
40+
# `embeddings_status: "failed"` at runtime — not a build error, so nothing
41+
# here would have caught it otherwise. Every step is best-effort and
42+
# non-fatal (`|| true`) — this hook must degrade, not break the session, and
43+
# `Embedder::load`'s own runtime fallback (network download) plus
44+
# `embeddings_status: "offline_unavailable"` messaging are the safety net if
45+
# this doesn't fully resolve it (e.g. no apt, no network, git-lfs install
46+
# blocked). See docs/cloud-environment-setup.md for the full picture.
47+
if command -v git >/dev/null 2>&1 && grep -q 'filter=lfs' .gitattributes 2>/dev/null; then
48+
if ! git lfs version >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then
49+
apt-get install -y git-lfs >/dev/null 2>&1 || true
50+
fi
51+
if git lfs version >/dev/null 2>&1; then
52+
git lfs pull >/dev/null 2>&1 || true
53+
fi
54+
fi
55+
3456
build_output=$(cargo build --quiet -p ci-cli 2>&1)
3557
build_status=$?
3658

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,15 @@ agent: "tôi cần sửa hàm getUserByEmail"
103103
còn phụ thuộc extension C nào, nên hoạt động giống hệt trên mọi platform release (trước đây
104104
`sqlite-vec` không compile được trên musl libc, khiến bản Linux/Docker bị tắt semantic). Model mặc
105105
định (`minishlab/potion-code-16M`, MIT license) được vendor sẵn vào binary lúc compile
106-
(`crates/ci-core/assets/potion-code-16m/`, qua Git LFS) — load model mặc định không cần mạng, chỉ
107-
model tuỳ biến qua `semantic_search.model` mới tải từ HuggingFace Hub.
106+
(`crates/ci-core/assets/potion-code-16m/`, qua Git LFS) — load model mặc định thường không cần
107+
mạng. Nếu asset vendor bị hỏng/thiếu (vd checkout thiếu `git-lfs` nên còn nguyên LFS pointer thay
108+
vì nội dung thật — không giả định, đã xảy ra thật), `Embedder::load` tự fallback sang tải model
109+
mặc định đó qua HuggingFace Hub 1 lần rồi cache local, thay vì `embeddings_status` treo ở
110+
`"failed"` vĩnh viễn; set `semantic_search.allow_network_fallback: false` để tắt hẳn fallback này
111+
và giữ đúng zero-network tuyệt đối (lúc đó status báo `"offline_unavailable"` thay vì mập mờ). Model
112+
tuỳ biến qua `semantic_search.model` luôn tải từ HuggingFace Hub như trước, không đổi. Lưu ý: đây
113+
chỉ là tải 1 file model tĩnh, công khai — không liên quan tới cam kết "không gọi ra ngoài" của `ci`
114+
(cam kết đó là về code/dữ liệu repo, không phải về việc tải asset).
108115
- **Grep/glob thật, quét trực tiếp trên đĩa**`search(kind="grep")` dùng regex thật (crate `regex`)
109116
+ glob filter (`globset`) qua walker tôn trọng `.gitignore`/`.git/info/exclude` thật (crate
110117
`ignore`), không qua FTS/DB nên phủ được cả file indexer không parse (`Cargo.toml`, `docs/*.md`).

crates/ci-core/src/config.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,21 @@ pub struct SemanticSearchConfig {
102102
pub model: String,
103103
pub dimensions: usize,
104104
pub index_on_startup: bool,
105+
/// When the vendored default-model asset is unusable (e.g. an unresolved
106+
/// Git LFS pointer left by a checkout that never ran `git lfs pull`/had
107+
/// git-lfs installed — not hypothetical, see the incident this field was
108+
/// added for), `true` (default) lets `Embedder::load` fall back to a
109+
/// one-time HuggingFace Hub download of the same default model, cached
110+
/// locally afterward (`~/.cache/huggingface`) — degrade to *slower on
111+
/// this run only*, not permanently `failed`. `false` keeps semantic
112+
/// search strictly zero-network: embeddings report
113+
/// `embeddings_status: "offline_unavailable"` instead of ever touching
114+
/// the network, until the vendored asset is fixed locally. Either way,
115+
/// this governs recovery from a *broken local asset* only — it does not
116+
/// change plain `search`/`callers`/etc., which never touch the network
117+
/// and never send code/repo content anywhere; that guarantee is
118+
/// independent of this flag.
119+
pub allow_network_fallback: bool,
105120
}
106121

107122
impl Default for SemanticSearchConfig {
@@ -114,6 +129,7 @@ impl Default for SemanticSearchConfig {
114129
model: crate::embedding::DEFAULT_MODEL_ID.into(),
115130
dimensions: 256,
116131
index_on_startup: true,
132+
allow_network_fallback: true,
117133
}
118134
}
119135
}

crates/ci-core/src/embedding.rs

Lines changed: 122 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,18 +88,55 @@ mod imp {
8888
static DEFAULT_TOKENIZER: &[u8] = include_bytes!("../assets/potion-code-16m/tokenizer.json");
8989
static DEFAULT_WEIGHTS: &[u8] = include_bytes!("../assets/potion-code-16m/model.safetensors");
9090

91+
/// True if `bytes` is an unresolved Git LFS pointer stub rather than real
92+
/// file content — happens when `git lfs pull`/the smudge filter never ran
93+
/// during checkout (e.g. git-lfs not installed in the environment). Real
94+
/// model weights are tens of MB; a pointer stub is ~130 bytes starting
95+
/// with this exact line (mirrors `is_lfs_pointer` in
96+
/// `scripts/mcp-launcher.sh`, which checks the same thing for the
97+
/// prebuilt `ci` binary — kept as a separate copy here since that's a
98+
/// shell script and this is compiled into the binary itself). Length-
99+
/// capped so a real, coincidentally-short binary blob is never misread as
100+
/// a pointer. `pub(crate)` (not exported at the module's public surface)
101+
/// purely so the `tests` module below — a sibling of `imp`, not a
102+
/// descendant — can unit-test the length-cap/content-match logic
103+
/// directly instead of only indirectly through `default_vendored_asset_unusable`.
104+
pub(crate) fn is_lfs_pointer_stub(bytes: &[u8]) -> bool {
105+
bytes.len() < 512 && bytes.starts_with(b"version https://git-lfs")
106+
}
107+
108+
/// True if the vendored default-model asset baked into this binary is an
109+
/// unresolved Git LFS pointer stub, not real weights — checked before
110+
/// `Embedder::load` decides whether a network fallback is even needed,
111+
/// and exposed publicly so `ci-server`'s `bootstrap_embeddings` can
112+
/// short-circuit to `EmbedStatus::OfflineUnavailable` without a network
113+
/// attempt when `semantic_search.allow_network_fallback` is `false`.
114+
pub fn default_vendored_asset_unusable() -> bool {
115+
is_lfs_pointer_stub(DEFAULT_WEIGHTS)
116+
}
117+
91118
/// A loaded static embedding model.
92119
pub struct Embedder {
93120
model: StaticModel,
94121
dim: usize,
95122
}
96123

97124
impl Embedder {
98-
/// Load `model_id`. The default model id (`DEFAULT_MODEL_ID`) loads
99-
/// from the bytes vendored into the binary; any other id (a custom
100-
/// model configured via `semantic_search.model`) still resolves via
101-
/// `from_pretrained` — a local path, or a HuggingFace Hub download.
102-
/// Output is L2-normalised so cosine distance behaves well.
125+
/// Load `model_id`. The default model id (`DEFAULT_MODEL_ID`)
126+
/// normally loads from the bytes vendored into the binary — zero-I/O,
127+
/// zero-network. If that fails (most commonly: the vendored asset is
128+
/// an unresolved Git LFS pointer, see `is_lfs_pointer_stub`), this
129+
/// automatically falls back to `from_pretrained`, which downloads the
130+
/// same default model from the HuggingFace Hub once and caches it
131+
/// locally (`~/.cache/huggingface`) — the caller (`bootstrap_embeddings`)
132+
/// is expected to have already checked `semantic_search.allow_network_fallback`
133+
/// via `default_vendored_asset_unusable` before ever calling this, so
134+
/// reaching this fallback here always means the caller already
135+
/// consented to a network attempt. A custom model id (configured via
136+
/// `semantic_search.model`) always resolves via `from_pretrained` — a
137+
/// local path, or a HuggingFace Hub download, same as before this
138+
/// fallback existed. Output is L2-normalised so cosine distance
139+
/// behaves well.
103140
///
104141
/// `dim` is only a hint (from `semantic_search.dimensions` in
105142
/// config) — model2vec-rs exposes no API to query a loaded model's
@@ -109,13 +146,37 @@ mod imp {
109146
/// silently mislabeling every vector this `Embedder` ever produces.
110147
pub fn load(model_id: &str, dim: usize) -> anyhow::Result<Self> {
111148
let model = if model_id == DEFAULT_MODEL_ID {
112-
StaticModel::from_bytes(
149+
match StaticModel::from_bytes(
113150
DEFAULT_TOKENIZER,
114151
DEFAULT_WEIGHTS,
115152
DEFAULT_CONFIG,
116153
Some(true),
117-
)
118-
.map_err(|e| anyhow::anyhow!("load vendored embedding model: {e}"))?
154+
) {
155+
Ok(m) => m,
156+
Err(vendored_err) => {
157+
if is_lfs_pointer_stub(DEFAULT_WEIGHTS) {
158+
tracing::warn!(
159+
"vendored embedding model asset is an unresolved Git LFS \
160+
pointer (git-lfs not installed, or the checkout skipped the \
161+
smudge filter) — falling back to a one-time HuggingFace Hub \
162+
download of '{model_id}', cached locally afterward"
163+
);
164+
} else {
165+
tracing::warn!(
166+
"vendored embedding model failed to load ({vendored_err}) — \
167+
falling back to a one-time HuggingFace Hub download of \
168+
'{model_id}', cached locally afterward"
169+
);
170+
}
171+
StaticModel::from_pretrained(DEFAULT_MODEL_ID, None, Some(true), None)
172+
.map_err(|e| {
173+
anyhow::anyhow!(
174+
"vendored load failed ({vendored_err}); \
175+
network fallback also failed: {e}"
176+
)
177+
})?
178+
}
179+
}
119180
} else {
120181
StaticModel::from_pretrained(model_id, None, Some(true), None)
121182
.map_err(|e| anyhow::anyhow!("load embedding model '{model_id}': {e}"))?
@@ -418,6 +479,13 @@ mod imp {
418479
Ok(())
419480
}
420481

482+
/// Always `false` — there's no vendored asset to be unusable when the
483+
/// `embeddings` feature itself is off; `Embedder::load`'s own stub
484+
/// failure below is what surfaces this build's real limitation.
485+
pub fn default_vendored_asset_unusable() -> bool {
486+
false
487+
}
488+
421489
/// Stub embedder — `load` always fails, so callers keep `None` and degrade.
422490
pub struct Embedder;
423491

@@ -460,9 +528,9 @@ mod imp {
460528
}
461529

462530
pub use imp::{
463-
Embedder, create_chunk_embedding_table, create_embedding_table, embed_pending,
464-
embed_pending_chunks, knn, knn_chunks, prune_orphaned_chunk_vecs, store_chunk_embedding,
465-
store_embedding,
531+
Embedder, create_chunk_embedding_table, create_embedding_table, default_vendored_asset_unusable,
532+
embed_pending, embed_pending_chunks, knn, knn_chunks, prune_orphaned_chunk_vecs,
533+
store_chunk_embedding, store_embedding,
466534
};
467535

468536
#[cfg(test)]
@@ -478,6 +546,49 @@ mod tests {
478546
assert_eq!(symbol_doc("run", "", ""), "run");
479547
}
480548

549+
/// Regression for the exact incident this function was added for: a
550+
/// checkout without git-lfs installed (or one that skipped the smudge
551+
/// filter) leaves `assets/potion-code-16m/model.safetensors` as a ~130-
552+
/// byte Git LFS pointer stub instead of real weights — `include_bytes!`
553+
/// happily bakes that stub into the binary, and `Embedder::load` used to
554+
/// fail permanently (`embeddings_status: "failed"`) with no clear signal
555+
/// why. If this test ever fails, `git lfs pull` didn't run before this
556+
/// crate was built.
557+
#[cfg(feature = "embeddings")]
558+
#[test]
559+
fn default_vendored_asset_is_not_an_lfs_pointer() {
560+
assert!(
561+
!imp::default_vendored_asset_unusable(),
562+
"DEFAULT_WEIGHTS looks like an unresolved Git LFS pointer, not real model weights \
563+
— run `git lfs pull`"
564+
);
565+
}
566+
567+
#[cfg(feature = "embeddings")]
568+
#[test]
569+
fn is_lfs_pointer_stub_detects_pointer_text_not_real_weights() {
570+
let pointer =
571+
b"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n".as_slice();
572+
assert!(imp::is_lfs_pointer_stub(pointer));
573+
assert!(
574+
!imp::is_lfs_pointer_stub(&[0u8; 1000]),
575+
"1000 zero bytes is not a pointer stub by content, regardless of size"
576+
);
577+
}
578+
579+
#[cfg(feature = "embeddings")]
580+
#[test]
581+
fn is_lfs_pointer_stub_length_cap_avoids_false_positive_on_large_content() {
582+
let mut huge = b"version https://git-lfs.github.com/spec/v1".to_vec();
583+
huge.resize(10_000, 0);
584+
assert!(
585+
!imp::is_lfs_pointer_stub(&huge),
586+
"10KB of content must not be misread as a pointer stub even if it starts \
587+
with the marker text — real weights are never this small either way, but \
588+
the cap is the actual safety net, not the content match alone"
589+
);
590+
}
591+
481592
/// Regression for the vendored default model (`include_bytes!` in
482593
/// `Embedder::load`): loads with zero network, produces the right
483594
/// dimensionality, and is L2-normalised. Catches a bad asset path, a

crates/ci-core/src/types.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ pub enum EmbedStatus {
108108
Embedding,
109109
Ready,
110110
Failed,
111+
/// The vendored default-model asset is unusable (e.g. an unresolved Git
112+
/// LFS pointer) and `semantic_search.allow_network_fallback` is `false`,
113+
/// so semantic search stays off rather than reaching the network — a
114+
/// deliberate policy outcome, distinct from `Failed` (an unexpected
115+
/// error). `indexing_status(retry_embeddings: true)` re-checks this the
116+
/// same way it reclaims `Failed`, so flipping the config and retrying
117+
/// recovers without a restart.
118+
OfflineUnavailable,
111119
}
112120

113121
impl EmbedStatus {
@@ -118,6 +126,7 @@ impl EmbedStatus {
118126
Self::Embedding => "embedding",
119127
Self::Ready => "ready",
120128
Self::Failed => "failed",
129+
Self::OfflineUnavailable => "offline_unavailable",
121130
}
122131
}
123132
}

crates/ci-server/src/lib.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,21 @@ pub async fn serve_stdio_with_preset(
138138
Ok(())
139139
}
140140

141+
/// True when semantic search should stop before ever attempting a network
142+
/// call: the configured model is the vendored default, that vendored asset
143+
/// is unusable (see `ci_core::embedding::default_vendored_asset_unusable`),
144+
/// and the config has not opted into a network fallback. Pulled out as a
145+
/// pure function (taking the three already-evaluated booleans, not
146+
/// re-deriving them) so the policy logic is unit-testable without touching
147+
/// the real vendored asset or the network — see the `tests` module below.
148+
fn embeddings_blocked_by_offline_policy(
149+
is_default_model: bool,
150+
vendored_asset_unusable: bool,
151+
allow_network_fallback: bool,
152+
) -> bool {
153+
is_default_model && vendored_asset_unusable && !allow_network_fallback
154+
}
155+
141156
/// Load the embedding model, create the vector table, embed all symbols, and
142157
/// publish the model + status. Runs on the indexer thread after the graph is
143158
/// built (and again from `indexing_status`'s `retry_embeddings` after a prior
@@ -149,6 +164,20 @@ pub fn bootstrap_embeddings(
149164
status: &Arc<RwLock<EmbedStatus>>,
150165
) {
151166
*status.write().unwrap() = EmbedStatus::Downloading;
167+
if embeddings_blocked_by_offline_policy(
168+
semantic.model == ci_core::embedding::DEFAULT_MODEL_ID,
169+
ci_core::embedding::default_vendored_asset_unusable(),
170+
semantic.allow_network_fallback,
171+
) {
172+
tracing::warn!(
173+
"Vendored embedding model is an unresolved Git LFS pointer and \
174+
semantic_search.allow_network_fallback is false — embeddings unavailable this \
175+
run. Run `git lfs pull` to fix the vendored asset, or set \
176+
allow_network_fallback=true to download it instead, then retry_embeddings."
177+
);
178+
*status.write().unwrap() = EmbedStatus::OfflineUnavailable;
179+
return;
180+
}
152181
if semantic.model == ci_core::embedding::DEFAULT_MODEL_ID {
153182
tracing::info!(
154183
"Loading embedding model `{}` (vendored in the binary, no network needed)...",
@@ -306,3 +335,35 @@ fn current_git_head_short(project_root: &std::path::Path) -> Option<String> {
306335
let trimmed = text.trim();
307336
(!trimmed.is_empty()).then(|| trimmed.to_string())
308337
}
338+
339+
#[cfg(test)]
340+
mod tests {
341+
use super::embeddings_blocked_by_offline_policy as blocked;
342+
343+
/// All three conditions must hold — a custom (non-default) model, a fine
344+
/// vendored asset, or an allowed network fallback each independently
345+
/// mean "don't block", only their conjunction does.
346+
#[test]
347+
fn embeddings_blocked_by_offline_policy_only_when_all_three_conditions_hold() {
348+
assert!(
349+
blocked(true, true, false),
350+
"default model + unusable vendored asset + fallback disabled -> blocked"
351+
);
352+
assert!(
353+
!blocked(false, true, false),
354+
"a custom model was never going to use the vendored asset — unaffected"
355+
);
356+
assert!(
357+
!blocked(true, false, false),
358+
"vendored asset is fine — no fallback ever needed"
359+
);
360+
assert!(
361+
!blocked(true, true, true),
362+
"fallback explicitly allowed — proceed to Embedder::load's own fallback"
363+
);
364+
assert!(!blocked(false, false, false));
365+
assert!(!blocked(false, false, true));
366+
assert!(!blocked(false, true, true));
367+
assert!(!blocked(true, false, true));
368+
}
369+
}

crates/ci-server/src/tools/common.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -213,16 +213,20 @@ impl CodeIntelligenceServer {
213213

214214
/// Re-runs the embedding bootstrap in the background when it previously
215215
/// failed (model load, vector-table creation, or embedding all set status
216-
/// to `Failed`). No-op for any other status: `Ready`/`Embedding`/
217-
/// `Downloading` are already done or in flight, and `Disabled` means
218-
/// semantic search isn't turned on in config. Opens its own DB connection
219-
/// so the retry doesn't hold the shared connection mutex for its duration.
216+
/// to `Failed`) or was blocked by offline policy (`OfflineUnavailable` —
217+
/// e.g. the caller since flipped `semantic_search.allow_network_fallback`
218+
/// to `true` or ran `git lfs pull` and wants to try again). No-op for any
219+
/// other status: `Ready`/`Embedding`/`Downloading` are already done or in
220+
/// flight, and `Disabled` means semantic search isn't turned on in
221+
/// config. Opens its own DB connection so the retry doesn't hold the
222+
/// shared connection mutex for its duration.
220223
pub(crate) fn retry_embeddings_if_failed(&self) {
221-
// Claim the retry synchronously (Failed -> Downloading) so two
222-
// overlapping `retry_embeddings` requests can't both spawn a bootstrap.
224+
// Claim the retry synchronously (Failed/OfflineUnavailable ->
225+
// Downloading) so two overlapping `retry_embeddings` requests can't
226+
// both spawn a bootstrap.
223227
{
224228
let mut status = self.embed_status.write().unwrap();
225-
if *status != EmbedStatus::Failed {
229+
if *status != EmbedStatus::Failed && *status != EmbedStatus::OfflineUnavailable {
226230
return;
227231
}
228232
*status = EmbedStatus::Downloading;

0 commit comments

Comments
 (0)