Skip to content

Commit c47efae

Browse files
committed
feat(mcp): warn on answers served from a failed rebuild
A recorded attempt is only visible to whoever thinks to ask for it. An agent that calls search_graph after a background rebuild failed gets a confident answer from a graph that no longer matches the tree, and nothing in that response says so. While the recorded attempt is failed or cancelled, attach a warning to the answers served from that project's graph, naming the origin, the recorded finish time and the limit that ended the attempt. A JSON payload gains a stale_index_warning field so a structured reader cannot miss it; text and tree payloads gain a trailing content block. The answer itself keeps its position and its content. index_status and check_index_coverage already report freshness themselves and index_repository is the remedy, so none of them is annotated. A queued or running rebuild is not yet a failure and stays silent, a completed rebuild clears the warning, and every failure inside the warning path is silent: a warning that cannot be produced must never damage an answer that is fine. Signed-off-by: 刘冲 <mail@liuchong.dev>
1 parent a50585d commit c47efae

5 files changed

Lines changed: 306 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -683,7 +683,9 @@ explicit value, including `off`, replaces that dimension of a selected
683683
profile. Exceeding an effective limit fails the complete index attempt rather
684684
than publishing a partial graph, and an existing serving index is preserved.
685685
`index_status` reports the latest physical indexing attempt and Git-based
686-
freshness after the first recorded attempt. See
686+
freshness after the first recorded attempt, and while that attempt is failed or
687+
cancelled the graph-answering tools attach a stale-index warning so a rebuild
688+
that failed in the background cannot go unnoticed. See
687689
[Index resource limits](docs/INDEX_RESOURCE_LIMITS.md).
688690

689691
### Environment Variables

‎docs/CONFIGURATION.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ changed-during-index, missing, or unreadable snapshots are `unknown`.
118118
`delete_project` also removes a matching attempt record when no database was
119119
published.
120120

121+
While the recorded attempt is `failed` or `cancelled`, the graph-answering
122+
tools also attach a stale-index warning to their results, so a rebuild that
123+
failed in the background cannot leave a caller trusting an older graph in
124+
silence. A completed rebuild clears it.
125+
121126
## 3. UI Settings
122127

123128
The optional built-in graph UI stores its settings in:

‎docs/INDEX_RESOURCE_LIMITS.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,20 @@ dirty or changed-during-index snapshots, failed Git probes, and corrupt records
189189
are `unknown`. Dirty state is never proof of freshness or staleness. Projects
190190
with no attempt record retain the previous response shape.
191191

192+
A background rebuild that fails reaches only the logs and `index_status`, which
193+
leaves a caller querying an older graph with nothing to warn it. Once a record
194+
is in a `failed` or `cancelled` state, the next answer served from that
195+
project's graph carries a stale-index warning naming the origin, the recorded
196+
finish time, and the limit that ended the attempt. The warning is attached to
197+
`search_graph`, `query_graph`, `trace_path`, `trace_call_path`,
198+
`get_architecture`, `get_code_snippet`, and `search_code`; `index_status` and
199+
`check_index_coverage` already report freshness themselves, and
200+
`index_repository` is the remedy. A JSON payload receives a
201+
`stale_index_warning` field so a structured reader cannot miss it, and a text
202+
or tree payload receives a trailing content block; the answer itself keeps its
203+
position and content. A queued or running rebuild is not yet a failure and
204+
produces no warning, and a completed rebuild clears it.
205+
192206
## Trust and compatibility
193207

194208
Limits are read from the CLI-managed `_config.db`; they are not MCP request

‎src/mcp/mcp.c‎

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5228,6 +5228,81 @@ cbm_index_attempt_read_status_t cbm_mcp_index_attempt_add_status(yyjson_mut_doc
52285228
return CBM_INDEX_ATTEMPT_AVAILABLE;
52295229
}
52305230

5231+
/* Describe why the recorded attempt left the caller on an older graph. The
5232+
* violation detail is optional: an attempt can fail before any resource is
5233+
* attributed, and the warning still has to say something useful. */
5234+
static void index_attempt_stale_cause(yyjson_val *root, char *out, size_t out_size) {
5235+
out[0] = '\0';
5236+
yyjson_val *failure = yyjson_obj_get(root, "resource_failure");
5237+
yyjson_val *resource =
5238+
failure && yyjson_is_obj(failure) ? yyjson_obj_get(failure, "resource") : NULL;
5239+
const char *name = resource && yyjson_is_str(resource) ? yyjson_get_str(resource) : NULL;
5240+
if (!name) {
5241+
yyjson_val *code = yyjson_obj_get(root, "failure_code");
5242+
if (code && yyjson_is_str(code) && yyjson_get_len(code) > 0) {
5243+
snprintf(out, out_size, " (%s)", yyjson_get_str(code));
5244+
}
5245+
return;
5246+
}
5247+
const char *key = cbm_index_resource_config_key(index_attempt_resource_from_name(name));
5248+
yyjson_val *probe_failed = yyjson_obj_get(failure, "probe_failed");
5249+
yyjson_val *observed = yyjson_obj_get(failure, "observed");
5250+
yyjson_val *limit = yyjson_obj_get(failure, "limit");
5251+
yyjson_val *unit = yyjson_obj_get(failure, "unit");
5252+
if (probe_failed && yyjson_is_bool(probe_failed) && yyjson_get_bool(probe_failed)) {
5253+
snprintf(out, out_size,
5254+
" because the %s measurement could not be taken and the %s limit is enforced "
5255+
"fail-closed",
5256+
name, key);
5257+
return;
5258+
}
5259+
if (observed && yyjson_is_uint(observed) && limit && yyjson_is_uint(limit)) {
5260+
snprintf(out, out_size, " because %s reached %llu %s against the %s limit of %llu", name,
5261+
(unsigned long long)yyjson_get_uint(observed),
5262+
unit && yyjson_is_str(unit) ? yyjson_get_str(unit) : "units", key,
5263+
(unsigned long long)yyjson_get_uint(limit));
5264+
return;
5265+
}
5266+
snprintf(out, out_size, " on the %s limit", key);
5267+
}
5268+
5269+
/* Fires only on a recorded terminal failure, so it costs one small record read
5270+
* per answered query and never probes git or the filesystem. A queued or
5271+
* running rebuild stays silent here and is reported by index_status. */
5272+
static bool index_attempt_stale_warning(const char *project, char *out, size_t out_size) {
5273+
if (!project || !project[0] || !out || out_size == 0) {
5274+
return false;
5275+
}
5276+
yyjson_doc *attempt = index_attempt_read(project, NULL);
5277+
yyjson_val *root = attempt ? yyjson_doc_get_root(attempt) : NULL;
5278+
if (!root || !yyjson_is_obj(root)) {
5279+
yyjson_doc_free(attempt);
5280+
return false;
5281+
}
5282+
yyjson_val *state = yyjson_obj_get(root, "state");
5283+
const char *state_name = state && yyjson_is_str(state) ? yyjson_get_str(state) : "";
5284+
bool cancelled = strcmp(state_name, "cancelled") == 0;
5285+
if (strcmp(state_name, "failed") != 0 && !cancelled) {
5286+
yyjson_doc_free(attempt);
5287+
return false;
5288+
}
5289+
yyjson_val *origin = yyjson_obj_get(root, "origin");
5290+
yyjson_val *finished = yyjson_obj_get(root, "finished_at");
5291+
char cause[CBM_SZ_256];
5292+
index_attempt_stale_cause(root, cause, sizeof(cause));
5293+
snprintf(out, out_size,
5294+
"Stale index warning: the last %s index attempt for project '%s' %s%s%s%s. These "
5295+
"results come from the previously published graph and may not match the current "
5296+
"working tree. Call index_status for the recorded attempt, then index_repository to "
5297+
"rebuild once the cause is addressed.",
5298+
origin && yyjson_is_str(origin) ? yyjson_get_str(origin) : "recorded", project,
5299+
cancelled ? "was cancelled" : "failed",
5300+
finished && yyjson_is_str(finished) ? " at " : "",
5301+
finished && yyjson_is_str(finished) ? yyjson_get_str(finished) : "", cause);
5302+
yyjson_doc_free(attempt);
5303+
return out[0] != '\0';
5304+
}
5305+
52315306
bool cbm_mcp_index_attempt_remove(const char *project) {
52325307
index_attempt_lock_t lock;
52335308
if (!index_attempt_lock_acquire(&lock, project)) {
@@ -12577,6 +12652,103 @@ static void release_request_store(cbm_mcp_server_t *srv) {
1257712652
cbm_mem_collect();
1257812653
}
1257912654

12655+
/* Attach one stale-graph warning to a finished tool result. A JSON payload
12656+
* gains a `stale_index_warning` field so the signal survives structured
12657+
* parsing; text and tree payloads gain a trailing content block instead. The
12658+
* first content item is never displaced, because hook_augment and the smoke
12659+
* tests read it as the payload. */
12660+
static void mcp_result_attach_stale_warning(char **result_io, const char *warning) {
12661+
yyjson_doc *parsed = yyjson_read(*result_io, strlen(*result_io), 0);
12662+
yyjson_mut_doc *document = parsed ? yyjson_mut_doc_new(NULL) : NULL;
12663+
yyjson_mut_val *root =
12664+
document ? yyjson_val_mut_copy(document, yyjson_doc_get_root(parsed)) : NULL;
12665+
yyjson_doc_free(parsed);
12666+
yyjson_mut_val *failed =
12667+
root && yyjson_mut_is_obj(root) ? yyjson_mut_obj_get(root, "isError") : NULL;
12668+
yyjson_mut_val *content =
12669+
root && yyjson_mut_is_obj(root) ? yyjson_mut_obj_get(root, "content") : NULL;
12670+
if (!content || !yyjson_mut_is_arr(content) ||
12671+
(failed && yyjson_mut_is_bool(failed) && yyjson_mut_get_bool(failed))) {
12672+
yyjson_mut_doc_free(document);
12673+
return;
12674+
}
12675+
yyjson_mut_doc_set_root(document, root);
12676+
yyjson_mut_val *item = yyjson_mut_arr_get(content, 0);
12677+
yyjson_mut_val *text =
12678+
item && yyjson_mut_is_obj(item) ? yyjson_mut_obj_get(item, "text") : NULL;
12679+
const char *payload = text && yyjson_mut_is_str(text) ? yyjson_mut_get_str(text) : NULL;
12680+
yyjson_doc *payload_document = payload ? yyjson_read(payload, strlen(payload), 0) : NULL;
12681+
yyjson_val *payload_root = payload_document ? yyjson_doc_get_root(payload_document) : NULL;
12682+
bool attached = false;
12683+
if (payload_root && yyjson_is_obj(payload_root)) {
12684+
yyjson_mut_doc *rewritten = yyjson_doc_mut_copy(payload_document, NULL);
12685+
yyjson_mut_val *rewritten_root = rewritten ? yyjson_mut_doc_get_root(rewritten) : NULL;
12686+
char *rendered = rewritten_root && yyjson_mut_obj_add_strcpy(rewritten, rewritten_root,
12687+
"stale_index_warning", warning)
12688+
? yyjson_mut_write(rewritten, YYJSON_WRITE_ALLOW_INVALID_UNICODE, NULL)
12689+
: NULL;
12690+
yyjson_mut_doc_free(rewritten);
12691+
if (rendered) {
12692+
(void)yyjson_mut_obj_remove_key(item, "text");
12693+
attached = yyjson_mut_obj_add_strcpy(document, item, "text", rendered);
12694+
yyjson_mut_val *structured = yyjson_mut_obj_get(root, "structuredContent");
12695+
if (attached && structured && yyjson_mut_is_obj(structured)) {
12696+
attached =
12697+
yyjson_mut_obj_add_strcpy(document, structured, "stale_index_warning", warning);
12698+
}
12699+
free(rendered);
12700+
}
12701+
} else {
12702+
yyjson_mut_val *extra = yyjson_mut_obj(document);
12703+
attached = extra && yyjson_mut_obj_add_str(document, extra, "type", "text") &&
12704+
yyjson_mut_obj_add_strcpy(document, extra, "text", warning) &&
12705+
yyjson_mut_arr_append(content, extra);
12706+
}
12707+
yyjson_doc_free(payload_document);
12708+
char *replacement =
12709+
attached ? yyjson_mut_write(document, YYJSON_WRITE_ALLOW_INVALID_UNICODE, NULL) : NULL;
12710+
yyjson_mut_doc_free(document);
12711+
if (replacement) {
12712+
free(*result_io);
12713+
*result_io = replacement;
12714+
}
12715+
}
12716+
12717+
/* Tools that answer from the published graph. A stale graph makes their
12718+
* answers quietly wrong, which is exactly the case the warning exists for.
12719+
* index_status and check_index_coverage already report freshness themselves,
12720+
* and index_repository is the remedy, so none of them are annotated. */
12721+
static bool mcp_tool_answers_from_graph(const char *tool_name) {
12722+
static const char *GRAPH_TOOLS[] = {
12723+
"search_graph", "query_graph", "trace_path", "trace_call_path",
12724+
"get_architecture", "get_code_snippet", "search_code",
12725+
};
12726+
for (size_t index = 0; tool_name && index < sizeof(GRAPH_TOOLS) / sizeof(GRAPH_TOOLS[0]);
12727+
index++) {
12728+
if (strcmp(tool_name, GRAPH_TOOLS[index]) == 0) {
12729+
return true;
12730+
}
12731+
}
12732+
return false;
12733+
}
12734+
12735+
/* A background rebuild that fails only reaches the logs and index_status,
12736+
* which leaves a caller querying an older graph with no visible signal. Say so
12737+
* on the next answer served from that graph. Every failure here is silent:
12738+
* a warning that cannot be produced must never turn a good answer into one. */
12739+
static void mcp_result_warn_if_stale(const char *tool_name, const char *args_json,
12740+
char **result_io) {
12741+
if (!args_json || !result_io || !*result_io || !mcp_tool_answers_from_graph(tool_name)) {
12742+
return;
12743+
}
12744+
char *project = get_project_arg(args_json);
12745+
char warning[CBM_SZ_1K];
12746+
if (project && index_attempt_stale_warning(project, warning, sizeof(warning))) {
12747+
mcp_result_attach_stale_warning(result_io, warning);
12748+
}
12749+
free(project);
12750+
}
12751+
1258012752
char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) {
1258112753
/* Phase marks bracket the WHOLE request with no unlabelled gap, so growth
1258212754
* cannot hide between them (CBM_MEM_PHASES=1; see foundation/mem.h). The
@@ -12591,6 +12763,7 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch
1259112763
}
1259212764
cbm_mem_phase_mark("request.dispatch_tool");
1259312765
char *result = dispatch_tool(srv, tool_name, args_json);
12766+
mcp_result_warn_if_stale(tool_name, args_json, &result);
1259412767
cbm_mem_phase_mark("request.scope_end");
1259512768
if (srv) {
1259612769
cbm_mcp_server_request_scope_end(srv);

‎tests/test_mcp.c‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3048,6 +3048,116 @@ TEST(index_attempt_record_rejects_stale_transition_and_recovers_abandoned_job) {
30483048
PASS();
30493049
}
30503050

3051+
/* A rebuild that dies on a resource limit leaves the previously published
3052+
* graph in service. Without a signal on the answers themselves, the caller
3053+
* keeps trusting a graph that no longer matches the tree. */
3054+
TEST(stale_index_warning_reaches_graph_answers) {
3055+
char *cache = th_mktempdir("cbm_stale_warning");
3056+
ASSERT_NOT_NULL(cache);
3057+
const char *saved_cache = getenv("CBM_CACHE_DIR");
3058+
char *saved_copy = saved_cache ? strdup(saved_cache) : NULL;
3059+
ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache, 1), 0);
3060+
3061+
const char *project = "StaleWarningFixture";
3062+
char db_path[700];
3063+
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
3064+
cbm_store_t *setup = cbm_store_open_path(db_path);
3065+
ASSERT_NOT_NULL(setup);
3066+
ASSERT_EQ(cbm_store_upsert_project(setup, project, cache), CBM_STORE_OK);
3067+
cbm_node_t node = {.project = project,
3068+
.label = "Function",
3069+
.name = "StaleProbe",
3070+
.qualified_name = "stale.StaleProbe",
3071+
.file_path = "mod.c",
3072+
.start_line = 1,
3073+
.end_line = 2};
3074+
ASSERT_GT(cbm_store_upsert_node(setup, &node), 0);
3075+
cbm_store_close(setup);
3076+
3077+
cbm_index_resource_policy_t policy;
3078+
cbm_index_policy_init(&policy);
3079+
char error[128];
3080+
ASSERT_TRUE(cbm_index_policy_set_profile(&policy, "strict", error, sizeof(error)));
3081+
char attempt_id[33];
3082+
ASSERT_TRUE(cbm_mcp_index_attempt_begin(project, cache, "watcher", &policy, true, attempt_id));
3083+
ASSERT_TRUE(
3084+
cbm_mcp_index_attempt_transition(project, cache, attempt_id, "running", NULL, NULL, false));
3085+
cbm_index_resource_violation_t violation = {
3086+
.resource = CBM_INDEX_RESOURCE_FILES,
3087+
.observed = 120000,
3088+
.limit = 100000,
3089+
};
3090+
ASSERT_TRUE(cbm_mcp_index_attempt_transition(project, cache, attempt_id, "failed",
3091+
"resource_limit_exceeded", &violation, false));
3092+
3093+
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
3094+
ASSERT_NOT_NULL(srv);
3095+
char json_args[512];
3096+
snprintf(json_args, sizeof(json_args),
3097+
"{\"project\":\"%s\",\"name_pattern\":\".*StaleProbe.*\",\"format\":\"json\"}",
3098+
project);
3099+
char *resp = cbm_mcp_handle_tool(srv, "search_graph", json_args);
3100+
ASSERT_NOT_NULL(resp);
3101+
ASSERT_NOT_NULL(strstr(resp, "\"isError\":false"));
3102+
char *inner = extract_text_content(resp);
3103+
ASSERT_NOT_NULL(inner);
3104+
/* The answer keeps its place at content[0]; the warning rides along in the
3105+
* same object so a structured reader cannot miss it. */
3106+
ASSERT_NOT_NULL(strstr(inner, "StaleProbe"));
3107+
ASSERT_NOT_NULL(strstr(inner, "\"stale_index_warning\""));
3108+
ASSERT_NOT_NULL(strstr(inner, "index_max_files"));
3109+
ASSERT_NOT_NULL(strstr(inner, "120000"));
3110+
ASSERT_NOT_NULL(strstr(inner, "watcher"));
3111+
free(inner);
3112+
free(resp);
3113+
3114+
char default_args[512];
3115+
snprintf(default_args, sizeof(default_args),
3116+
"{\"project\":\"%s\",\"name_pattern\":\".*StaleProbe.*\"}", project);
3117+
resp = cbm_mcp_handle_tool(srv, "search_graph", default_args);
3118+
ASSERT_NOT_NULL(resp);
3119+
ASSERT_NOT_NULL(strstr(resp, "Stale index warning"));
3120+
inner = extract_text_content(resp);
3121+
ASSERT_NOT_NULL(inner);
3122+
ASSERT_NOT_NULL(strstr(inner, "StaleProbe"));
3123+
free(inner);
3124+
free(resp);
3125+
3126+
/* index_status reports the attempt in full and needs no second copy. */
3127+
char status_args[512];
3128+
snprintf(status_args, sizeof(status_args), "{\"project\":\"%s\"}", project);
3129+
resp = cbm_mcp_handle_tool(srv, "index_status", status_args);
3130+
ASSERT_NOT_NULL(resp);
3131+
ASSERT_NULL(strstr(resp, "stale_index_warning"));
3132+
free(resp);
3133+
3134+
/* A rebuild that is merely in flight is not yet a failure. */
3135+
char inflight_id[33];
3136+
ASSERT_TRUE(
3137+
cbm_mcp_index_attempt_begin(project, cache, "explicit", &policy, false, inflight_id));
3138+
ASSERT_TRUE(cbm_mcp_index_attempt_transition(project, cache, inflight_id, "running", NULL, NULL,
3139+
false));
3140+
resp = cbm_mcp_handle_tool(srv, "search_graph", json_args);
3141+
ASSERT_NOT_NULL(resp);
3142+
ASSERT_NULL(strstr(resp, "stale_index_warning"));
3143+
free(resp);
3144+
3145+
ASSERT_TRUE(cbm_mcp_index_attempt_transition(project, cache, inflight_id, "completed", NULL,
3146+
NULL, true));
3147+
resp = cbm_mcp_handle_tool(srv, "search_graph", json_args);
3148+
ASSERT_NOT_NULL(resp);
3149+
ASSERT_NOT_NULL(strstr(resp, "StaleProbe"));
3150+
ASSERT_NULL(strstr(resp, "stale_index_warning"));
3151+
free(resp);
3152+
3153+
cbm_mcp_server_free(srv);
3154+
ASSERT_TRUE(cbm_mcp_index_attempt_remove(project));
3155+
restore_cache_dir(saved_copy);
3156+
free(saved_copy);
3157+
th_cleanup(cache);
3158+
PASS();
3159+
}
3160+
30513161
TEST(index_attempt_freshness_requires_same_clean_git_snapshot) {
30523162
char *cache_created = th_mktempdir("cbm_attempt_fresh_cache");
30533163
char *cache = cache_created ? strdup(cache_created) : NULL;
@@ -11857,6 +11967,7 @@ SUITE(mcp) {
1185711967
RUN_TEST(tool_check_index_coverage_surfaces_lookup_errors);
1185811968
RUN_TEST(tool_index_status_includes_git_metadata);
1185911969
RUN_TEST(index_attempt_record_rejects_stale_transition_and_recovers_abandoned_job);
11970+
RUN_TEST(stale_index_warning_reaches_graph_answers);
1186011971
RUN_TEST(index_attempt_freshness_requires_same_clean_git_snapshot);
1186111972
RUN_TEST(index_attempt_only_project_can_be_deleted);
1186211973

0 commit comments

Comments
 (0)