1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
//! Register a freshly created git worktree with trusty-search, BM25 + KG only (#5060).
//!
//! Why: a worktree only became searchable when a managed session was LAUNCHED
//! in it — `core::session_launch::register_project_index` runs at launch, not
//! at creation. Most worktrees never reach that point in a state anyone
//! queries, so of the 70+ live worktrees for this repo only ~4 had a
//! registered index (#5045). A bare `search` from an unindexed worktree
//! answers `404 unknown index`, which reads as permanent and pushes agents
//! into silent grep fallbacks. Indexing at CREATION closes that window.
//!
//! What: [`index_new_worktree_in_background`] is the one entry point. It
//! spawns a detached OS thread and returns immediately, then registers the
//! worktree's index with `skip_vector: true` — BM25 and KG only, no
//! embeddings.
//!
//! Why BM25+KG and not a full index (owner ruling, 2026-08-06): a worktree
//! differs from its base checkout by a small diff. Exact text (BM25) and the
//! symbol graph (KG) are branch-specific and must be worktree-accurate;
//! conceptual similarity is not — it does not change because a branch moved a
//! few functions. So the expensive lane is built once on the base checkout and
//! the cheap lanes are built per worktree.
//!
//! Why detached and never synchronous: a cold BM25+KG build of this workspace
//! (5,865 tracked files, 75,282 chunks) took **35.0 s** measured end-to-end
//! against the live daemon — 33.5 s to `lexical: ready`, 1.6 s more to `graph:
//! ready`. That is nowhere near unnoticeable, so there is no
//! sync-under-threshold path: worktree creation and session launch never wait
//! on indexing at any repo size.
//!
//! Disk cost, which dominates the wall-clock cost (#5065 review): every index
//! trusty-search creates is COLOCATED — `create_index_handler` hardcodes
//! `colocated: true`, so the store lands in `<worktree>/.trusty-search/`. Indexing
//! at creation therefore multiplies on-disk index storage by the number of LIVE
//! worktrees. `skip_vector` does not shrink that much: it suppresses
//! `hnsw.usearch`, but the corpus and KG live in `index.redb`, which has no
//! embeddings table, and the redb is the bigger file. Measured on this repo:
//!
//! | store | `index.redb` | `hnsw.usearch` |
//! |---|---|---|
//! | base checkout (full index) | 2.19 GiB | 777 MiB |
//! | a `skip_vector` worktree | 1.48 GiB | 1.8 KiB (empty) |
//! | a vector-bearing worktree | 1.00 GiB | 121 MiB |
//!
//! So the BM25+KG ruling buys back the HNSW file and roughly nothing else: a
//! worktree index still costs ~1–1.5 GiB. At the 59 live worktrees this repo
//! carries that is a peak on the order of 60–90 GB.
//!
//! Budget, stated so the tradeoff is explicit rather than discovered: this is
//! PEAK usage, not a leak. The store lives inside the worktree directory, so
//! `git worktree remove` reclaims it in full, and `session_manager::search_gc`
//! plus the #2033 orphan sweep reclaim the daemon-side registration. The cost
//! scales with worktrees you are actively keeping, and a fleet large enough to
//! matter is already spending comparable disk on the checkouts themselves.
//! Gating creation-time indexing on repo size, or routing worktree indexes to
//! non-colocated storage, are the two levers if that budget stops holding;
//! neither is taken here (the second needs a trusty-search change).
//!
//! Idempotence: the daemon's `POST /indexes` is find-or-create and its reindex
//! trigger is freshness-gated (skipped when the index already holds chunks
//! indexed within the last hour), so a second call against an already-indexed
//! worktree costs two short HTTP round trips and no walk.
//!
//! Deletion: already handled, and deliberately not re-implemented here.
//! `session_manager::search_gc` derives the SAME id from the SAME
//! `resolve_project_root` + `derive_index_id` pair and deletes it at
//! decommission, with a periodic orphan sweep behind it (#2033).
//!
//! Test: `worktree_index_tests.rs`.
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
/// Why an index was or was not registered for a newly created worktree.
///
/// Why: [`index_new_worktree`] runs on a detached thread where a return value
/// would be dropped, so the outcome would otherwise be observable only by
/// scraping logs — and the "do not fail open silently" requirement needs each
/// skip reason to be distinguishable in a test. Returning a typed outcome lets
/// the decision logic be exercised synchronously and off the daemon.
/// What: `Registered` carries the derived index id and means the daemon
/// CONFIRMED the index with a 2xx; the other variants name the specific reason
/// nothing is known to have been registered.
/// Test: `skips_path_that_is_not_a_worktree`, `skips_missing_path`,
/// `reports_unconfirmed_rather_than_registered_when_the_daemon_never_answered`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorktreeIndexOutcome {
/// The daemon confirmed an index under this id (created, or already
/// present — `POST /indexes` is find-or-create).
Registered(String),
/// The id was derived and a registration was REQUESTED, but the daemon
/// never confirmed it: unreachable, non-2xx, or suppressed because this is
/// a test process. The worktree is NOT known to be indexed — #5045 measured
/// the daemon-absent case at ~94% of worktrees, so this is the common
/// outcome, not an exotic one, and nothing may treat it as success.
RegistrationUnconfirmed {
/// The derived index id. Reported so a log line can name it, never so
/// a caller can pin it as if the index existed.
index_id: String,
/// Which of the three non-confirmations happened.
reason: trusty_common::search_index::IndexRegistration,
},
/// The path does not exist, or is not a directory.
PathMissing,
/// The path exists but is not a git worktree — a plain clone, or no repo
/// at all. Only worktrees are indexed here; a plain checkout's index is
/// the operator's own opt-in decision, not something worktree creation
/// may make on their behalf.
NotAWorktree,
/// The index id derived to the empty string (a filesystem-root path).
EmptyIndexId,
}
/// Fire-and-forget: register `worktree_path` with trusty-search, BM25+KG only.
///
/// Why: worktree creation and session launch must never wait on indexing — a
/// 35 s embed-free walk (see the module doc) in front of a shell prompt is a
/// worse bug than the missing index it fixes. Spawning here rather than at
/// each call site keeps that guarantee in ONE place, so a future caller cannot
/// accidentally add a blocking one.
/// What: spawns a detached OS thread running [`index_new_worktree`] and
/// returns immediately. The thread is never joined; every failure inside it is
/// logged and swallowed.
///
/// How a failure surfaces (#5065 review): in this process, as a `warn` naming
/// the outcome — [`index_new_worktree`] distinguishes a confirmed registration
/// from a requested-but-unconfirmed one, so the log never claims a success it
/// did not observe. It does NOT surface through `tm doctor`'s existing `search`
/// check: that probes only the index id derived from the CURRENT project
/// directory (`daemon::doctor::check_search` / `expected_search_index_id`), so
/// it sees nothing for the other ~58 worktrees — which is the blind spot #5045
/// documents. #5093 adds the pin-resolution check that actually sweeps; this
/// module deliberately adds no second doctor check.
/// Test: `background_entry_point_routes_through_spawn_detached`; the
/// never-block guarantee itself is pinned by
/// `spawn_detached_returns_before_work_finishes`, and the decision logic by
/// the [`index_new_worktree`] tests.
pub fn index_new_worktree_in_background(worktree_path: PathBuf) {
// #5069: this worktree's index is BM25+KG only, so the base checkout it was
// cut from has to carry the embedding lane or semantic search has no index
// to route to. Its own detached thread — the base facet is the expensive
// index, and a failure there must not delay or mask this one.
crate::core::base_facet_index::ensure_base_facet_indexed_in_background(worktree_path.clone());
spawn_detached(move || {
let outcome = index_new_worktree(&worktree_path);
match outcome {
WorktreeIndexOutcome::Registered(id) => {
info!(
worktree = %worktree_path.display(),
index_id = %id,
"worktree index registered (BM25+KG, no vector lane) — #5060"
);
}
WorktreeIndexOutcome::RegistrationUnconfirmed { index_id, reason } => {
warn!(
worktree = %worktree_path.display(),
index_id = %index_id,
?reason,
"worktree index REQUESTED but not confirmed — the worktree is \
not known to be indexed (#5065)"
);
}
other => {
debug!(
worktree = %worktree_path.display(),
outcome = ?other,
"worktree index not registered"
);
}
}
});
}
/// Run `work` on a detached OS thread and return without waiting for it.
///
/// Why: the one place the never-block guarantee is enforced, and the only
/// place it can be TESTED. Asserting the property on
/// [`index_new_worktree_in_background`] directly proves nothing under the test
/// harness: `trusty_common::search_index` refuses daemon writes from a test
/// process (#4255), so the work finishes in microseconds and a `.join()` — the
/// natural "let's return the outcome to the caller" refactor — passes a timing
/// assertion unnoticed. Routing through a helper whose work the test supplies
/// lets the test make that work slow on purpose, so a join fails loudly.
/// What: `std::thread::spawn` with the handle dropped. Dropping a `JoinHandle`
/// detaches the thread rather than waiting on it, so the caller returns as
/// soon as the OS has the thread queued.
/// Test: `spawn_detached_returns_before_work_finishes`.
pub(super) fn spawn_detached<F: FnOnce() + Send + 'static>(work: F) {
std::thread::spawn(work);
}
/// Synchronous body of [`index_new_worktree_in_background`].
///
/// Why: split out so the guards are testable without a spawned thread and
/// without a live daemon — the registration call itself is a no-op under the
/// test harness (`trusty_common::search_index` refuses daemon writes from a
/// test process, #4255), so every branch below is exercised for real while the
/// operator's registry stays untouched.
/// What: two guards, then registration. (1) the path must exist and be a
/// directory; (2) it must be a git WORKTREE, not a plain clone — see
/// [`is_git_worktree`]. Then delegates to
/// `trusty_common::search_index::ensure_project_indexed_reporting` with
/// `skip_vector: true` and the default `allow_sensitive_path: false`, and maps
/// its report onto this module's outcome: `Confirmed` becomes `Registered`,
/// everything else becomes `RegistrationUnconfirmed`. An empty derived id needs
/// no guard of its own — the shared helper performs the identical check and
/// reports `index_id: None`, which maps to `EmptyIndexId`.
///
/// Opt-in model: this adds no new bypass and no new grant. `POST /indexes`
/// enforces trusty-search's hard denylist (credential dirs, `.env`, OS-temp
/// prefixes) and `allow_sensitive_path: false` keeps every one of those checks
/// armed, exactly as `session_launch::register_project_index` already does for
/// the same directories. What this narrows relative to that caller is the
/// input: only a path trusty-mpm itself just created as a worktree reaches
/// here, never an arbitrary cwd.
/// Test: `skips_missing_path`, `skips_path_that_is_not_a_worktree`,
/// `registers_index_for_real_worktree`, `is_idempotent_for_same_worktree`,
/// `reports_unconfirmed_rather_than_registered_when_the_daemon_never_answered`.
pub fn index_new_worktree(worktree_path: &Path) -> WorktreeIndexOutcome {
if !worktree_path.is_dir() {
warn!(
worktree = %worktree_path.display(),
"worktree index skipped: path does not exist or is not a directory"
);
return WorktreeIndexOutcome::PathMissing;
}
if !is_git_worktree(worktree_path) {
debug!(
worktree = %worktree_path.display(),
"worktree index skipped: not a git worktree"
);
return WorktreeIndexOutcome::NotAWorktree;
}
// `skip_vector: true` is the whole point (see the module doc): BM25 + KG
// are built worktree-accurate, embeddings are not built at all. The
// defaulted `allow_sensitive_path: false` mirrors `register_project_index`
// (#2914) — a worktree is always a real directory inside a real repo, never
// a legitimately OS-temp root, so the denylist stays armed.
let opts = trusty_common::search_index::IndexOptions::default().with_skip_vector(true);
let report = trusty_common::search_index::ensure_project_indexed_reporting(worktree_path, opts);
let Some(index_id) = report.index_id else {
warn!(
worktree = %worktree_path.display(),
"worktree index skipped: derived index id is empty"
);
return WorktreeIndexOutcome::EmptyIndexId;
};
match report.registration {
trusty_common::search_index::IndexRegistration::Confirmed => {
WorktreeIndexOutcome::Registered(index_id)
}
reason => WorktreeIndexOutcome::RegistrationUnconfirmed { index_id, reason },
}
}
/// Is `path` the root of a git WORKTREE (as opposed to a plain clone)?
///
/// Why: this module must index worktrees and only worktrees. Registering an
/// index for a plain checkout is the operator's own opt-in decision — worktree
/// creation may not make it on their behalf, and a permissive
/// "is there a `.git` anywhere above me" test would do exactly that for any
/// directory handed to it by mistake.
/// What: a worktree's `.git` is a FILE containing a `gitdir:` pointer, while a
/// primary clone's `.git` is a DIRECTORY. Testing the file-ness of
/// `path/.git` distinguishes the two with one stat and no subprocess. A
/// submodule also uses a `.git` file; that is an accepted (and harmless)
/// overlap, since nothing in trusty-mpm hands a submodule root to this module.
/// Test: `is_git_worktree_matches_git_on_disk_shapes` (worktree `.git` file =>
/// true, clone `.git` directory => false), `skips_path_that_is_not_a_worktree`
/// and `skips_plain_directory` cover the two refusal paths as reached through
/// `index_new_worktree`.
pub fn is_git_worktree(path: &Path) -> bool {
path.join(".git").is_file()
}
#[cfg(test)]
#[path = "worktree_index_tests.rs"]
mod tests;