Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1024,10 +1024,12 @@ codebase-memory-mcp config set watcher_enabled false # stop the watcher thre
codebase-memory-mcp config set watch_non_git true # also poll non-git project roots (default: false)
codebase-memory-mcp config set index_max_files 250000 # optional per-index source-file limit
codebase-memory-mcp config set index_max_source_mb 16384 # optional per-index source-size limit
codebase-memory-mcp config set index_max_rss_mb 8192 # optional worker-tree charged-memory limit
codebase-memory-mcp config set index_max_duration_seconds 3600 # optional per-request duration
codebase-memory-mcp config reset auto_index # reset to default
```

The two `index_max_*` settings default to `off`. Exceeding one fails the complete
The four `index_max_*` settings default to `off`. Exceeding one fails the complete
index attempt rather than publishing a partial graph; an existing serving index
is preserved. See [Index resource limits](docs/INDEX_RESOURCE_LIMITS.md).

Expand Down
10 changes: 7 additions & 3 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ Current keys:
| `watch_non_git` | `false` | Also poll project roots that are **not git repositories**. By default the watcher only follows git projects, so a project indexed from a plain directory is never refreshed after its first index — reindex it manually with `index_repository`. Set `true` to poll such roots on the same adaptive cadence with a file-tree scan: the indexer's own discovery walk (same skip lists, `.gitignore` and `.cbmignore` rules) hashed over each file's path, size and mtime. Any change reindexes once; paths the indexer skips (including cbm's own `.codebase-memory/` output) never trigger. The first poll after the daemon starts reindexes each such project once, since nothing records which tree state the index holds. The scan walks the whole tree every poll, so it costs more than git polling on very large trees. Read once when the daemon starts, like `watcher_enabled`. |
| `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. |
| `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. |
| `index_max_rss_mb` | `off` | Optional maximum charged memory in MiB for the complete contained index-worker process tree (`64..1048576`). On macOS this is phys_footprint, not resident size. |
| `index_max_duration_seconds` | `off` | Optional maximum wall-clock duration in seconds for the whole index request, including crash/hang recovery (`1..86400`). Like `index_max_rss_mb`, it stops a worker only before it starts to publish the new index; see [INDEX_RESOURCE_LIMITS.md](INDEX_RESOURCE_LIMITS.md). |

For a watched project, `index_status` names the polling method in `watch.strategy`:
`git` (HEAD and dirty-state polling), `tree` (a non-git root polled by the
Expand Down Expand Up @@ -135,11 +137,13 @@ has not run yet).
> `auto_index` still runs, and `index_repository` stays available for manual
> reindexing.

The two `index_max_*` settings are independent and disabled by default. They
The four `index_max_*` settings are independent and disabled by default. They
apply to explicit indexing, automatic indexing, and watcher re-indexing, but not
to `cross-repo-intelligence`, which does not scan repository source files.
Equality is allowed; exceeding either setting fails the complete index request
and preserves any previously serving database. See
Equality is allowed; exceeding any setting fails the complete index request and
preserves any previously serving database. Worker RSS covers descendants and is
not the same as the internal `CBM_MEM_BUDGET_MB` allocation budget. Total
duration is independent of the existing 15-minute no-log-progress timeout. See
[Index resource limits](INDEX_RESOURCE_LIMITS.md) for counting, validation, and
error-response details.

Expand Down
73 changes: 69 additions & 4 deletions docs/INDEX_RESOURCE_LIMITS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Index resource limits

Index resource limits are optional operator controls for repositories whose
discovery breadth is not known in advance. They are disabled by default so
existing large-repository workloads retain their current behavior.
discovery breadth or worker runtime is not known in advance. They are disabled
by default so existing large-repository workloads retain their current behavior.

## Discovery settings

Expand All @@ -23,6 +23,61 @@ Values use base-10 integers. MiB means 1,048,576 bytes. Empty values, zero,
negative values, suffixes, trailing characters, and values outside the stated
ranges are rejected without changing the stored value.

## Worker settings

| Key | Default | Accepted value | Protects |
|---|---:|---:|---|
| `index_max_rss_mb` | `off` | `off` or `64..1048576` | Charged memory of the complete worker process tree |
| `index_max_duration_seconds` | `off` | `off` or `1..86400` | Wall-clock duration of the whole index request |

```bash
codebase-memory-mcp config set index_max_rss_mb 8192
codebase-memory-mcp config set index_max_duration_seconds 3600
```

RSS is the charged memory of the contained worker and every descendant, not
the worker's allocation budget and not peak memory. On macOS that quantity is
`phys_footprint` (the same number `cbm_mem_charged()` enforces), not
`resident_size`, which still counts pages the allocator has already handed
back. Linux and Windows use RSS / working set. This hard watchdog is
separate from the internal `CBM_MEM_BUDGET_MB` soft budget. The supervisor
samples the tree at most once every 250 milliseconds so the watchdog does not
turn full process-table enumeration into a busy loop.

Duration is per request: the clock starts at the first worker spawn and is
not reset when crash/hang recovery starts a later attempt. Continuous log
progress does not reset it. It is independent of the existing 15-minute quiet
timeout, which still identifies a worker that stops making progress. A
duration limit shorter than that quiet timeout kills a hung worker before hang
quarantine can name the in-flight file, so the next attempt may hang on the
same file.

Equality is allowed. The first RSS or elapsed-duration observation above its
limit starts the existing graceful-to-force process-tree shutdown. CBM reports
terminal only after the tree is quiescent or a bounded containment failure is
explicitly surfaced. Resource termination is not retried and does not
quarantine a source file.

A worker limit never stops a worker after it has started to publish. The
supervisor enforces the limits, but only the worker replaces the serving
database, so the two coordinate through a per-attempt publication claim that
exactly one of them can win. The worker claims it immediately before it
replaces the serving database; the supervisor claims it before it stops the
worker for a limit. If the limit is reached first, the worker can no longer
publish and the previous index stays untouched. If the worker claimed first,
the limits no longer apply to that attempt: the new index is published and the
rest of the attempt, including the persistence artifact export, runs to
completion. The supervisor then logs `index.supervisor.resource_limit_after_publication`
instead of stopping it. With both worker limits `off` there is no claim.

If RSS is enabled and three consecutive probes cannot obtain any trustworthy
tree measurement while the root worker is still running, CBM fails closed with
`code=resource_probe_failed`. A probe is untrustworthy as soon as one member of
the tree cannot be measured: only a process that exits between the listing and
its measurement is skipped, because a total without a live member would
under-report. On Linux the probe reads every `/proc/<pid>/stat` to find the
tree's members, so a `/proc` mounted with `hidepid=1` makes it fail closed.

## Counting and failure semantics

`index_max_files` counts a file only after it passes directory pruning, ignore
Expand Down Expand Up @@ -55,6 +110,15 @@ The previous database remains available because publication occurs only after a
complete discovery and successful staged build. If no previous database exists,
`serving_index_preserved` is false.

Worker limit failures use the same shape with `stage=worker`,
`resource=rss_bytes` and `unit=bytes`, or `resource=duration_ms` and
`unit=milliseconds`. RSS measurement failures use `code=resource_probe_failed`
and omit `observed`, `limit`, and `unit` because no trustworthy observation was
available. For a worker failure `serving_index_preserved` describes the whole
request: it is true only when a servable index existed when the request began,
no attempt of the request (including an earlier one that crashed and was
recovered) claimed publication, and that index still serves.

## Trust and compatibility

Limits are read from the CLI-managed `_config.db`; they are not MCP request
Expand All @@ -64,5 +128,6 @@ parent policy.

These settings do not replace or increase `auto_index_limit`, change the 512 MiB
single-file cap, alter workspace-root authorization, or affect
`cross-repo-intelligence`. With both settings `off`, discovery follows the
existing unbounded path.
`cross-repo-intelligence`. With all settings `off`, discovery follows the
existing path and the supervisor performs no periodic RSS probe or total-duration
termination.
3 changes: 2 additions & 1 deletion scripts/test-runtime.sh
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ _cbm_test_runtime_daemon() {
# cbm_mcp_index_policy_add_to_args: every key in cbm_index_policy_key_at, and
# nothing else.
cbm_test_index_worker_policy_json() {
printf '%s' '"_cbm_index_policy":{"index_max_files":"off","index_max_source_mb":"off"}'
printf '%s' '"_cbm_index_policy":{"index_max_files":"off","index_max_source_mb":"off"'
printf '%s' ',"index_max_rss_mb":"off","index_max_duration_seconds":"off"}'
}

cbm_test_runtime_cleanup() {
Expand Down
11 changes: 9 additions & 2 deletions src/cli/cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -8101,6 +8101,9 @@ static const config_key_def_t CONFIG_KEYS[] = {
{CBM_CONFIG_UI_PORT, "9749", "Port for the graph UI listener when enabled"},
{CBM_INDEX_CONFIG_MAX_FILES, "off", "Max accepted source files per index, or off"},
{CBM_INDEX_CONFIG_MAX_SOURCE_MB, "off", "Max accepted source MiB per index, or off"},
{CBM_INDEX_CONFIG_MAX_RSS_MB, "off", "Max worker process-tree charged memory MiB, or off"},
{CBM_INDEX_CONFIG_MAX_DURATION_SECONDS, "off",
"Max wall-clock seconds for the whole index request, or off"},
};

/* #1558: ui_enabled and ui_port were reachable ONLY by hand-editing
Expand All @@ -8127,8 +8130,12 @@ static bool config_key_is_ui(const char *key) {
}

static bool config_key_is_index_policy(const char *key) {
return key && (strcmp(key, CBM_INDEX_CONFIG_MAX_FILES) == 0 ||
strcmp(key, CBM_INDEX_CONFIG_MAX_SOURCE_MB) == 0);
for (size_t index = 0; key && index < cbm_index_policy_key_count(); index++) {
if (strcmp(key, cbm_index_policy_key_at(index)) == 0) {
return true;
}
}
return false;
}

static int config_index_policy_write(cbm_config_t *config, const char *key, const char *value) {
Expand Down
51 changes: 45 additions & 6 deletions src/daemon/application.c
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,11 @@ struct cbm_daemon_application_job {
bool async_owned;
bool worker_started;
application_index_watch_owner_t *watch_owners;
uint64_t request_started_ms;
/* What a worker resource failure of this job is reported against; set
* before the first attempt, folded over every attempt (mcp_internal.h). */
bool request_facts_begun;
cbm_mcp_index_request_facts_t request_facts;
cbm_daemon_application_job_t *next;
};

Expand Down Expand Up @@ -378,8 +383,17 @@ static void application_worker_diagnostic(const char *line, void *context) {
static int application_worker_start_default(void *context, const char *args_json,
size_t memory_budget_bytes, const char *marker_file,
const char *quarantine_file,
uint64_t duration_origin_ms,
cbm_daemon_application_worker_t *worker_out) {
(void)context;
cbm_index_resource_policy_t resource_policy;
char error[CBM_SZ_256] = {0};
if (!cbm_mcp_index_policy_from_internal_args(args_json, &resource_policy, error,
sizeof(error))) {
cbm_log_error("daemon.index.policy", "error", error);
*worker_out = NULL;
return -1;
}
application_default_worker_t *worker = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*worker));
if (!worker) {
return -1;
Expand All @@ -389,9 +403,9 @@ static int application_worker_start_default(void *context, const char *args_json
const char *root = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(args_doc), "repo_path"));
worker->project = application_index_project_key(root, args_json);
yyjson_doc_free(args_doc);
int result = cbm_index_worker_start_with_log(args_json, memory_budget_bytes, false, marker_file,
quarantine_file, application_worker_diagnostic,
worker, &worker->handle);
int result = cbm_index_worker_start_with_policy_log(
args_json, memory_budget_bytes, &resource_policy, false, marker_file, quarantine_file,
application_worker_diagnostic, worker, duration_origin_ms, &worker->handle);
if (result != 0) {
application_worker_destroy_default(NULL, worker);
*worker_out = NULL;
Expand Down Expand Up @@ -1539,6 +1553,17 @@ static void application_cancelled_result(cbm_index_worker_result_t *result) {
result->tree_quiesced = true;
}

/* Only an enabled worker limit can end a job in a worker resource failure, the
* one response the request facts feed; without one nothing is read. */
static void application_job_request_facts_begin(cbm_daemon_application_job_t *job) {
cbm_index_resource_policy_t policy;
char error[CBM_SZ_256] = {0};
if (cbm_mcp_index_policy_from_internal_args(job->args_json, &policy, error, sizeof(error)) &&
cbm_index_policy_worker_enabled(&policy)) {
cbm_mcp_index_request_facts_begin(job->args_json, &job->request_facts);
}
}

static application_attempt_status_t application_job_run_attempt(cbm_daemon_application_job_t *job,
const char *marker_path,
const char *quarantine_path,
Expand Down Expand Up @@ -1572,10 +1597,17 @@ static application_attempt_status_t application_job_run_attempt(cbm_daemon_appli
}

cbm_daemon_application_worker_t worker = NULL;
if (job->request_started_ms == 0) {
job->request_started_ms = cbm_index_worker_now_ms();
}
if (!job->request_facts_begun) {
job->request_facts_begun = true;
application_job_request_facts_begin(job);
}
application_tmp_lock();
int start_result =
application->worker_ops.start(application->worker_ops.context, job->args_json,
memory_budget_bytes, marker_path, quarantine_path, &worker);
int start_result = application->worker_ops.start(
application->worker_ops.context, job->args_json, memory_budget_bytes, marker_path,
quarantine_path, job->request_started_ms, &worker);
application_tmp_unlock();
if (start_result != 0 || !worker) {
return application_job_cancel_requested(job) ? APPLICATION_ATTEMPT_CANCELLED
Expand Down Expand Up @@ -1763,13 +1795,20 @@ static application_attempt_decision_t application_consume_attempt(
application_attempt_disposition(job, attempt);
application_record_attempt(attempt, &execution->last_result, &execution->have_last_result,
execution->last_log);
cbm_mcp_index_request_facts_record(&job->request_facts, &attempt->result);
if (disposition == CBM_MCP_SUPERVISED_RESULT_SUCCESS) {
execution->response = attempt->result.response;
attempt->result.response = NULL;
execution->successful = application_index_response_succeeded(execution->response);
application_attempt_free(attempt);
return APPLICATION_ATTEMPT_DECISION_SUCCESS;
}
if (disposition == CBM_MCP_SUPERVISED_RESULT_RESOURCE_FAILURE) {
execution->response = cbm_mcp_index_worker_resource_response(
job->args_json, &attempt->result, &job->request_facts);
application_attempt_free(attempt);
return APPLICATION_ATTEMPT_DECISION_STOP;
}
if (disposition == CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL) {
execution->unsafe_terminal = true;
execution->supervision_failed =
Expand Down
2 changes: 1 addition & 1 deletion src/daemon/application.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ typedef void *cbm_daemon_application_update_worker_t;
typedef struct {
void *context;
int (*start)(void *context, const char *args_json, size_t memory_budget_bytes,
const char *marker_file, const char *quarantine_file,
const char *marker_file, const char *quarantine_file, uint64_t duration_origin_ms,
cbm_daemon_application_worker_t *worker_out);
cbm_index_worker_poll_t (*poll)(void *context, cbm_daemon_application_worker_t worker,
const cbm_index_worker_result_t **result_out);
Expand Down
6 changes: 6 additions & 0 deletions src/daemon/bootstrap.c
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ static bool bootstrap_worker_argv_exact(int argc, char *const argv[]) {
}
next += 2;
}
if (next < argc && bootstrap_arg_is(argv[next], "--index-worker-publish-fence")) {
if (next + 1 >= argc || !argv[next + 1] || !argv[next + 1][0]) {
return false;
}
next += 2;
}
return next == argc;
}

Expand Down
Loading
Loading