Skip to content

Commit c263f48

Browse files
committed
feat(index): add opt-in index resource profiles
Six independent keys are an honest interface and a poor default. An operator who only wants indexing to stay within reason has to learn all six, choose a value for each, and keep them consistent as the machine changes. Add index_resource_profile, accepting off, balanced or strict. Balanced guards the host: it lets an index use what the machine can spare and stops a runaway worker before the host is exhausted. Strict holds indexing to the daemon's own budget, so a repository that needs more fails fast and attributed instead of finishing at the host's expense. Any individual key, including an explicit off, replaces that dimension of the selected profile, so a profile is a baseline rather than a lock. The balanced worker ceiling is derived from detected host memory instead of being tabled. Large-repository indexing peaks in the tens of gigabytes, so any round number low enough to feel safe would reject repositories that index successfully today. When host memory cannot be read, balanced falls back to a fixed floor. Profiles also bound directory count, entry count, traversal depth and discovery time. These have no individual keys for the same reason as the storage internals: they are only meaningful inside a composed decision. The resolved profile travels through the same trusted worker contract as the individual limits, so every entry point enforces the same decision. That contract now also carries the resolved profile and the override mask, so the shell fixture standing in for the supervisor sends both. The coverage that proves a forged policy cannot fork a second job asserts that an explicit request joins the auto-index job already running for the same root, and that assertion found the join broken on Windows. A project root reaches the daemon through the platform canonicalizer, which answers in backslash form there, while every tool handler normalizes the separators of the path it canonicalizes. One directory therefore had two spellings, and they met in comparisons that are exact: whether an index request may join the running job, and whether a watch is still live for that root. Both were wrong on Windows, in opposite directions, so aligning either one alone moved the failure rather than removing it. The session context now stores the normalized spelling, and the daemon canonicalizes project roots through a single function that normalizes on the way out. Only the storage side is directly assertable off Windows, since separators are folded on every platform; the watch-liveness side is held by the existing watcher-ownership tests, which is where the second half of this surfaced. Signed-off-by: 刘冲 <mail@liuchong.dev>
1 parent 8d57fa4 commit c263f48

14 files changed

Lines changed: 889 additions & 40 deletions

‎README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,7 @@ codebase-memory-mcp config set auto_index true # auto-index on session
728728
codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index
729729
codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true)
730730
codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true)
731+
codebase-memory-mcp config set index_resource_profile balanced # opt-in composed resource limits
731732
codebase-memory-mcp config set index_max_files 250000 # optional per-index source-file limit
732733
codebase-memory-mcp config set index_max_source_mb 16384 # optional per-index source-size limit
733734
codebase-memory-mcp config set index_max_rss_mb 8192 # optional worker-tree current RSS limit
@@ -737,10 +738,13 @@ codebase-memory-mcp config set index_min_free_disk_mb 4096 # optional free-space
737738
codebase-memory-mcp config reset auto_index # reset to default
738739
```
739740

740-
The six index resource settings default to `off`. Exceeding one fails the
741-
complete index attempt rather than publishing a partial graph; an existing
742-
serving index is preserved. Storage probes also fail closed when an enabled
743-
measurement cannot be completed. See
741+
`index_resource_profile` defaults to `off` and accepts `off`, `balanced`, or
742+
`strict`. The six individual resource settings also default to `off`; an
743+
explicit value, including `off`, replaces that dimension of a selected
744+
profile. Exceeding an effective limit fails the complete index attempt rather
745+
than publishing a partial graph, and an existing serving index is preserved.
746+
Storage probes also fail closed when an enabled measurement cannot be
747+
completed. See
744748
[Index resource limits](docs/INDEX_RESOURCE_LIMITS.md).
745749

746750
### Environment Variables

‎docs/CONFIGURATION.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ Current keys:
8989
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
9090
| `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). |
9191
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. |
92+
| `index_resource_profile` | `off` | Composed index resource baseline: `off`, `balanced`, or `strict`. |
9293
| `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. |
9394
| `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. |
9495
| `index_max_rss_mb` | `off` | Optional maximum current RSS in MiB for the complete contained index-worker process tree (`64..1048576`). |
@@ -122,8 +123,12 @@ Current keys:
122123
> `auto_index` still runs, and `index_repository` stays available for manual
123124
> reindexing.
124125
125-
The six index resource settings are independent and disabled by default.
126-
They apply to explicit indexing, automatic indexing, and watcher re-indexing,
126+
The profile and all six individual resource settings are disabled by default.
127+
An individual stored value replaces its profile dimension; storing `off`
128+
disables only that dimension. Profile-only limits also bound traversed
129+
directories, directory entries, depth, discovery time, final database size,
130+
staging size, and task temporary size. Effective limits apply to explicit
131+
indexing, automatic indexing, and watcher re-indexing,
127132
but not to `cross-repo-intelligence`, which does not scan or publish repository
128133
source indexes. Equality is allowed; exceeding a setting fails the complete
129134
index request and preserves any previously serving database. Worker RSS covers

‎docs/INDEX_RESOURCE_LIMITS.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,51 @@ Index resource limits are optional operator controls for repositories whose
44
discovery breadth or worker runtime is not known in advance. They are disabled
55
by default so existing large-repository workloads retain their current behavior.
66

7+
## Resource profiles
8+
9+
`index_resource_profile` accepts `off`, `balanced`, or `strict` and defaults to
10+
`off`. A profile supplies a baseline; each explicitly stored individual
11+
resource key then replaces its dimension. An explicit `off` disables only that
12+
dimension.
13+
14+
| Dimension | `balanced` | `strict` |
15+
|---|---:|---:|
16+
| Accepted files | 500,000 | 100,000 |
17+
| Traversed directories | 250,000 | 20,000 |
18+
| Directory entries | 2,000,000 | 500,000 |
19+
| Directory depth | 128 | 64 |
20+
| Accepted source | 65,536 MiB | 4,096 MiB |
21+
| Discovery duration | 1,800 seconds | 30 seconds |
22+
| Worker duration | 7,200 seconds | 3,600 seconds |
23+
| Final database | 65,536 MiB | 16,384 MiB |
24+
| Staging artifacts | 81,920 MiB | 20,480 MiB |
25+
| Task temporary artifacts | 98,304 MiB | 24,576 MiB |
26+
| Projected cache | 131,072 MiB | 32,768 MiB |
27+
| Free-disk reserve | 4,096 MiB | 4,096 MiB |
28+
29+
The two profiles answer different questions about worker memory, so their RSS
30+
ceilings are derived rather than tabled.
31+
32+
Balanced protects the host. Its ceiling is 80% of detected host memory, which
33+
leaves an index free to use everything this machine can spare and still stops a
34+
runaway worker before the host is exhausted. A fixed number is deliberately not
35+
used here: large-repository indexing peaks in the tens of gigabytes, so any
36+
round ceiling low enough to feel safe would reject repositories that index
37+
successfully with the profile off. When host memory cannot be read, balanced
38+
falls back to the higher of twice the worker soft budget or 8,192 MiB.
39+
40+
Strict protects the budget. Its ceiling is the lower of the worker soft budget
41+
and 8,192 MiB, which is the point of the profile: a repository that needs more
42+
memory than the daemon budgets for itself fails fast with an attributed
43+
`rss_bytes` violation instead of completing at the host's expense. On a large
44+
repository this is expected to fail, and the failure names the limit and the
45+
observed value so the operator can choose `balanced`, raise
46+
`index_max_rss_mb`, or leave the profile `off`.
47+
48+
Both derived values are held inside the same 64 to 1,048,576 MiB range that
49+
`index_max_rss_mb` accepts, and detected host memory can lower but never raise
50+
an explicitly configured `index_max_rss_mb`.
51+
752
## Discovery settings
853

954
| Key | Default | Accepted value | Protects |
@@ -63,6 +108,12 @@ rules, filename and suffix filters, language detection, and the existing
63108
per-file size rule. `index_max_source_mb` sums the filesystem sizes of that same
64109
accepted set.
65110

111+
Profile directory counting includes the request root and each non-skipped
112+
directory admitted for traversal. Entry counting happens before ignore,
113+
language, and file-size filtering. Root depth is zero. The monotonic discovery
114+
deadline is checked before opening a directory and before processing each
115+
entry. Equality is allowed for every dimension.
116+
66117
Equality is allowed. The first file or byte that makes an observed value greater
67118
than its limit stops discovery. CBM discards the partial file list and does not
68119
publish a partial graph as a complete index.

‎scripts/test-runtime.sh‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,13 @@ _cbm_test_runtime_daemon() {
109109
# worker in argv; a worker that finds no complete policy refuses to start rather
110110
# than index unbounded. A shell test that spawns `cli --index-worker` itself
111111
# stands in for the supervisor and owes the worker the same object. Mirrors
112-
# cbm_mcp_index_policy_add_to_args: every key in cbm_index_policy_key_at, and
113-
# nothing else.
112+
# cbm_mcp_index_policy_add_to_args: the resolved profile, the override mask
113+
# that says which keys the operator set by hand, every key in
114+
# cbm_index_policy_key_at, and nothing else.
114115
cbm_test_index_worker_policy_json() {
115-
printf '%s' '"_cbm_index_policy":{"index_max_files":"off","index_max_source_mb":"off"'
116+
printf '%s' '"_cbm_index_policy":{"index_resource_profile":"off"'
117+
printf '%s' ',"_cbm_index_override_mask":0'
118+
printf '%s' ',"index_max_files":"off","index_max_source_mb":"off"'
116119
printf '%s' ',"index_max_rss_mb":"off","index_max_duration_seconds":"off"'
117120
printf '%s' ',"index_cache_max_mb":"off","index_min_free_disk_mb":"off"}'
118121
}

‎src/cli/cli.c‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#include "foundation/platform.h"
2222
#include "foundation/constants.h"
2323
#include "foundation/log.h"
24+
#include "foundation/mem.h"
2425
#include "foundation/sha256.h"
2526
#include "cli/client_adapter.h"
2627
#include "mcp/mcp.h" // cbm_mcp_tool_input_schema — CLI flag parser + per-tool --help
@@ -7016,13 +7017,19 @@ bool cbm_config_load_index_policy(cbm_config_t *cfg, cbm_index_resource_policy_t
70167017
return false;
70177018
}
70187019
cbm_index_policy_init(policy);
7020+
const char *profile = cbm_config_get(cfg, CBM_INDEX_CONFIG_RESOURCE_PROFILE, "off");
7021+
if (!cbm_index_policy_set_profile(policy, profile, error, error_size)) {
7022+
return false;
7023+
}
70197024
for (size_t index = 0; index < cbm_index_policy_key_count(); index++) {
70207025
const char *key = cbm_index_policy_key_at(index);
7021-
const char *value = cbm_config_get(cfg, key, cbm_index_policy_default_value(key));
7022-
if (!cbm_index_policy_set(policy, key, value, error, error_size)) {
7026+
const char *value = cbm_config_get(cfg, key, NULL);
7027+
if (value && !cbm_index_policy_set(policy, key, value, error, error_size)) {
70237028
return false;
70247029
}
70257030
}
7031+
cbm_system_info_t system = cbm_system_info();
7032+
cbm_index_policy_finalize(policy, (uint64_t)system.total_ram, (uint64_t)cbm_mem_budget());
70267033
return true;
70277034
}
70287035

@@ -7050,6 +7057,7 @@ static const config_key_def_t CONFIG_KEYS[] = {
70507057
{CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"},
70517058
{CBM_CONFIG_UI_ENABLED, "false", "Serve the graph UI on a loopback HTTP port"},
70527059
{CBM_CONFIG_UI_PORT, "9749", "Port for the graph UI listener when enabled"},
7060+
{CBM_INDEX_CONFIG_RESOURCE_PROFILE, "off", "Index resource profile: off, balanced, or strict"},
70537061
{CBM_INDEX_CONFIG_MAX_FILES, "off", "Max accepted source files per index, or off"},
70547062
{CBM_INDEX_CONFIG_MAX_SOURCE_MB, "off", "Max accepted source MiB per index, or off"},
70557063
{CBM_INDEX_CONFIG_MAX_RSS_MB, "off", "Max worker process-tree RSS MiB, or off"},
@@ -7083,19 +7091,17 @@ static bool config_key_is_ui(const char *key) {
70837091
}
70847092

70857093
static bool config_key_is_index_policy(const char *key) {
7086-
for (size_t index = 0; key && index < cbm_index_policy_key_count(); index++) {
7087-
if (strcmp(key, cbm_index_policy_key_at(index)) == 0) {
7088-
return true;
7089-
}
7090-
}
7091-
return false;
7094+
return cbm_index_policy_is_config_key(key);
70927095
}
70937096

70947097
static int config_index_policy_write(cbm_config_t *config, const char *key, const char *value) {
70957098
cbm_index_resource_policy_t candidate;
70967099
cbm_index_policy_init(&candidate);
70977100
char error[CLI_BUF_256];
7098-
if (!cbm_index_policy_set(&candidate, key, value, error, sizeof(error))) {
7101+
bool valid = strcmp(key, CBM_INDEX_CONFIG_RESOURCE_PROFILE) == 0
7102+
? cbm_index_policy_set_profile(&candidate, value, error, sizeof(error))
7103+
: cbm_index_policy_set(&candidate, key, value, error, sizeof(error));
7104+
if (!valid) {
70997105
(void)fprintf(stderr, "error: %s\n", error);
71007106
return CLI_ERR;
71017107
}

‎src/daemon/application.c‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1999,9 +1999,32 @@ static bool application_index_args_add_policy(cbm_daemon_application_t *applicat
19991999
cbm_log_error("daemon.index.policy", "error", error);
20002000
return false;
20012001
}
2002+
cbm_system_info_t system = cbm_system_info();
2003+
cbm_index_policy_finalize(&policy, (uint64_t)system.total_ram,
2004+
application ? (uint64_t)application->worker_memory_budget_bytes : 0);
20022005
return cbm_mcp_index_policy_add_to_args(document, root, &policy);
20032006
}
20042007

2008+
static char *application_index_args_replace_policy(cbm_daemon_application_t *application,
2009+
const char *args_json) {
2010+
yyjson_doc *source = args_json ? yyjson_read(args_json, strlen(args_json), 0) : NULL;
2011+
yyjson_mut_doc *document = source ? yyjson_doc_mut_copy(source, NULL) : NULL;
2012+
yyjson_doc_free(source);
2013+
yyjson_mut_val *root = document ? yyjson_mut_doc_get_root(document) : NULL;
2014+
if (!root || !yyjson_mut_is_obj(root)) {
2015+
yyjson_mut_doc_free(document);
2016+
return NULL;
2017+
}
2018+
while (yyjson_mut_obj_get(root, "_cbm_index_policy")) {
2019+
(void)yyjson_mut_obj_remove_key(root, "_cbm_index_policy");
2020+
}
2021+
char *rewritten = application_index_args_add_policy(application, document, root)
2022+
? yyjson_mut_write(document, 0, NULL)
2023+
: NULL;
2024+
yyjson_mut_doc_free(document);
2025+
return rewritten;
2026+
}
2027+
20052028
static char *application_auto_index_args(cbm_daemon_application_t *application,
20062029
const char *root_path) {
20072030
yyjson_mut_doc *document = yyjson_mut_doc_new(NULL);
@@ -2286,14 +2309,19 @@ static char *application_index_execute(void *context, const char *root_path,
22862309
if (!session || !root_path || !args_json) {
22872310
return NULL;
22882311
}
2289-
char *project_key = application_index_project_key(root_path, args_json);
2312+
char *trusted_args = application_index_args_replace_policy(session->application, args_json);
2313+
if (!trusted_args) {
2314+
return cbm_mcp_text_result("failed to resolve daemon index resource policy", true);
2315+
}
2316+
char *project_key = application_index_project_key(root_path, trusted_args);
22902317
if (!project_key) {
2318+
free(trusted_args);
22912319
return cbm_mcp_text_result("failed to derive index project identity", true);
22922320
}
22932321
application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE;
22942322
cbm_daemon_application_job_t *job = NULL;
22952323
for (;;) {
2296-
job = application_job_subscribe(session->application, project_key, root_path, args_json,
2324+
job = application_job_subscribe(session->application, project_key, root_path, trusted_args,
22972325
&subscribe_status);
22982326
if (job || (subscribe_status != APPLICATION_JOB_SUBSCRIBE_BUSY &&
22992327
subscribe_status != APPLICATION_JOB_SUBSCRIBE_CANCELLING)) {
@@ -2312,11 +2340,13 @@ static char *application_index_execute(void *context, const char *root_path,
23122340
cbm_mutex_unlock(&session->application->mutex);
23132341
if (queued_cancelled) {
23142342
free(project_key);
2343+
free(trusted_args);
23152344
return cbm_mcp_text_result("index operation cancelled for this session", true);
23162345
}
23172346
cbm_usleep(APPLICATION_JOB_POLL_US);
23182347
}
23192348
free(project_key);
2349+
free(trusted_args);
23202350
if (!job) {
23212351
const char *message = "daemon index coordinator is stopping or unavailable";
23222352
if (subscribe_status == APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT) {
@@ -2430,7 +2460,8 @@ static cbm_daemon_runtime_application_status_t application_set_context(
24302460
char canonical_allowed[APPLICATION_PATH_CAP] = {0};
24312461
bool canonical = cbm_canonical_path(root, canonical_root, sizeof(canonical_root));
24322462
if (canonical && allowed_present) {
2433-
canonical = cbm_canonical_path(allowed, canonical_allowed, sizeof(canonical_allowed));
2463+
canonical =
2464+
cbm_canonical_path(allowed, canonical_allowed, sizeof(canonical_allowed));
24342465
}
24352466
canonical = canonical && application_canonical_directory_exists(canonical_root);
24362467
bool set =

0 commit comments

Comments
 (0)