Skip to content

Commit f588bc3

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 94119b3 commit f588bc3

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
@@ -744,7 +744,9 @@ explicit value, including `off`, replaces that dimension of a selected
744744
profile. Exceeding an effective limit fails the complete index attempt rather
745745
than publishing a partial graph, and an existing serving index is preserved.
746746
`index_status` reports the latest physical indexing attempt and Git-based
747-
freshness after the first recorded attempt. See
747+
freshness after the first recorded attempt, and while that attempt is failed or
748+
cancelled the graph-answering tools attach a stale-index warning so a rebuild
749+
that failed in the background cannot go unnoticed. See
748750
[Index resource limits](docs/INDEX_RESOURCE_LIMITS.md).
749751

750752
### Environment Variables

‎docs/CONFIGURATION.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ changed-during-index, missing, or unreadable snapshots are `unknown`.
147147
`delete_project` also removes a matching attempt record when no database was
148148
published.
149149
150+
While the recorded attempt is `failed` or `cancelled`, the graph-answering
151+
tools also attach a stale-index warning to their results, so a rebuild that
152+
failed in the background cannot leave a caller trusting an older graph in
153+
silence. A completed rebuild clears it.
154+
150155
## 3. UI Settings
151156
152157
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
@@ -7397,6 +7397,81 @@ cbm_index_attempt_read_status_t cbm_mcp_index_attempt_add_status(yyjson_mut_doc
73977397
return CBM_INDEX_ATTEMPT_AVAILABLE;
73987398
}
73997399

7400+
/* Describe why the recorded attempt left the caller on an older graph. The
7401+
* violation detail is optional: an attempt can fail before any resource is
7402+
* attributed, and the warning still has to say something useful. */
7403+
static void index_attempt_stale_cause(yyjson_val *root, char *out, size_t out_size) {
7404+
out[0] = '\0';
7405+
yyjson_val *failure = yyjson_obj_get(root, "resource_failure");
7406+
yyjson_val *resource =
7407+
failure && yyjson_is_obj(failure) ? yyjson_obj_get(failure, "resource") : NULL;
7408+
const char *name = resource && yyjson_is_str(resource) ? yyjson_get_str(resource) : NULL;
7409+
if (!name) {
7410+
yyjson_val *code = yyjson_obj_get(root, "failure_code");
7411+
if (code && yyjson_is_str(code) && yyjson_get_len(code) > 0) {
7412+
snprintf(out, out_size, " (%s)", yyjson_get_str(code));
7413+
}
7414+
return;
7415+
}
7416+
const char *key = cbm_index_resource_config_key(index_attempt_resource_from_name(name));
7417+
yyjson_val *probe_failed = yyjson_obj_get(failure, "probe_failed");
7418+
yyjson_val *observed = yyjson_obj_get(failure, "observed");
7419+
yyjson_val *limit = yyjson_obj_get(failure, "limit");
7420+
yyjson_val *unit = yyjson_obj_get(failure, "unit");
7421+
if (probe_failed && yyjson_is_bool(probe_failed) && yyjson_get_bool(probe_failed)) {
7422+
snprintf(out, out_size,
7423+
" because the %s measurement could not be taken and the %s limit is enforced "
7424+
"fail-closed",
7425+
name, key);
7426+
return;
7427+
}
7428+
if (observed && yyjson_is_uint(observed) && limit && yyjson_is_uint(limit)) {
7429+
snprintf(out, out_size, " because %s reached %llu %s against the %s limit of %llu", name,
7430+
(unsigned long long)yyjson_get_uint(observed),
7431+
unit && yyjson_is_str(unit) ? yyjson_get_str(unit) : "units", key,
7432+
(unsigned long long)yyjson_get_uint(limit));
7433+
return;
7434+
}
7435+
snprintf(out, out_size, " on the %s limit", key);
7436+
}
7437+
7438+
/* Fires only on a recorded terminal failure, so it costs one small record read
7439+
* per answered query and never probes git or the filesystem. A queued or
7440+
* running rebuild stays silent here and is reported by index_status. */
7441+
static bool index_attempt_stale_warning(const char *project, char *out, size_t out_size) {
7442+
if (!project || !project[0] || !out || out_size == 0) {
7443+
return false;
7444+
}
7445+
yyjson_doc *attempt = index_attempt_read(project, NULL);
7446+
yyjson_val *root = attempt ? yyjson_doc_get_root(attempt) : NULL;
7447+
if (!root || !yyjson_is_obj(root)) {
7448+
yyjson_doc_free(attempt);
7449+
return false;
7450+
}
7451+
yyjson_val *state = yyjson_obj_get(root, "state");
7452+
const char *state_name = state && yyjson_is_str(state) ? yyjson_get_str(state) : "";
7453+
bool cancelled = strcmp(state_name, "cancelled") == 0;
7454+
if (strcmp(state_name, "failed") != 0 && !cancelled) {
7455+
yyjson_doc_free(attempt);
7456+
return false;
7457+
}
7458+
yyjson_val *origin = yyjson_obj_get(root, "origin");
7459+
yyjson_val *finished = yyjson_obj_get(root, "finished_at");
7460+
char cause[CBM_SZ_256];
7461+
index_attempt_stale_cause(root, cause, sizeof(cause));
7462+
snprintf(out, out_size,
7463+
"Stale index warning: the last %s index attempt for project '%s' %s%s%s%s. These "
7464+
"results come from the previously published graph and may not match the current "
7465+
"working tree. Call index_status for the recorded attempt, then index_repository to "
7466+
"rebuild once the cause is addressed.",
7467+
origin && yyjson_is_str(origin) ? yyjson_get_str(origin) : "recorded", project,
7468+
cancelled ? "was cancelled" : "failed",
7469+
finished && yyjson_is_str(finished) ? " at " : "",
7470+
finished && yyjson_is_str(finished) ? yyjson_get_str(finished) : "", cause);
7471+
yyjson_doc_free(attempt);
7472+
return out[0] != '\0';
7473+
}
7474+
74007475
bool cbm_mcp_index_attempt_remove(const char *project) {
74017476
index_attempt_lock_t lock;
74027477
if (!index_attempt_lock_acquire(&lock, project)) {
@@ -18431,6 +18506,103 @@ static void release_request_store(cbm_mcp_server_t *srv) {
1843118506
cbm_mem_collect();
1843218507
}
1843318508

18509+
/* Attach one stale-graph warning to a finished tool result. A JSON payload
18510+
* gains a `stale_index_warning` field so the signal survives structured
18511+
* parsing; text and tree payloads gain a trailing content block instead. The
18512+
* first content item is never displaced, because hook_augment and the smoke
18513+
* tests read it as the payload. */
18514+
static void mcp_result_attach_stale_warning(char **result_io, const char *warning) {
18515+
yyjson_doc *parsed = yyjson_read(*result_io, strlen(*result_io), 0);
18516+
yyjson_mut_doc *document = parsed ? yyjson_mut_doc_new(NULL) : NULL;
18517+
yyjson_mut_val *root =
18518+
document ? yyjson_val_mut_copy(document, yyjson_doc_get_root(parsed)) : NULL;
18519+
yyjson_doc_free(parsed);
18520+
yyjson_mut_val *failed =
18521+
root && yyjson_mut_is_obj(root) ? yyjson_mut_obj_get(root, "isError") : NULL;
18522+
yyjson_mut_val *content =
18523+
root && yyjson_mut_is_obj(root) ? yyjson_mut_obj_get(root, "content") : NULL;
18524+
if (!content || !yyjson_mut_is_arr(content) ||
18525+
(failed && yyjson_mut_is_bool(failed) && yyjson_mut_get_bool(failed))) {
18526+
yyjson_mut_doc_free(document);
18527+
return;
18528+
}
18529+
yyjson_mut_doc_set_root(document, root);
18530+
yyjson_mut_val *item = yyjson_mut_arr_get(content, 0);
18531+
yyjson_mut_val *text =
18532+
item && yyjson_mut_is_obj(item) ? yyjson_mut_obj_get(item, "text") : NULL;
18533+
const char *payload = text && yyjson_mut_is_str(text) ? yyjson_mut_get_str(text) : NULL;
18534+
yyjson_doc *payload_document = payload ? yyjson_read(payload, strlen(payload), 0) : NULL;
18535+
yyjson_val *payload_root = payload_document ? yyjson_doc_get_root(payload_document) : NULL;
18536+
bool attached = false;
18537+
if (payload_root && yyjson_is_obj(payload_root)) {
18538+
yyjson_mut_doc *rewritten = yyjson_doc_mut_copy(payload_document, NULL);
18539+
yyjson_mut_val *rewritten_root = rewritten ? yyjson_mut_doc_get_root(rewritten) : NULL;
18540+
char *rendered = rewritten_root && yyjson_mut_obj_add_strcpy(rewritten, rewritten_root,
18541+
"stale_index_warning", warning)
18542+
? yyjson_mut_write(rewritten, YYJSON_WRITE_ALLOW_INVALID_UNICODE, NULL)
18543+
: NULL;
18544+
yyjson_mut_doc_free(rewritten);
18545+
if (rendered) {
18546+
(void)yyjson_mut_obj_remove_key(item, "text");
18547+
attached = yyjson_mut_obj_add_strcpy(document, item, "text", rendered);
18548+
yyjson_mut_val *structured = yyjson_mut_obj_get(root, "structuredContent");
18549+
if (attached && structured && yyjson_mut_is_obj(structured)) {
18550+
attached =
18551+
yyjson_mut_obj_add_strcpy(document, structured, "stale_index_warning", warning);
18552+
}
18553+
free(rendered);
18554+
}
18555+
} else {
18556+
yyjson_mut_val *extra = yyjson_mut_obj(document);
18557+
attached = extra && yyjson_mut_obj_add_str(document, extra, "type", "text") &&
18558+
yyjson_mut_obj_add_strcpy(document, extra, "text", warning) &&
18559+
yyjson_mut_arr_append(content, extra);
18560+
}
18561+
yyjson_doc_free(payload_document);
18562+
char *replacement =
18563+
attached ? yyjson_mut_write(document, YYJSON_WRITE_ALLOW_INVALID_UNICODE, NULL) : NULL;
18564+
yyjson_mut_doc_free(document);
18565+
if (replacement) {
18566+
free(*result_io);
18567+
*result_io = replacement;
18568+
}
18569+
}
18570+
18571+
/* Tools that answer from the published graph. A stale graph makes their
18572+
* answers quietly wrong, which is exactly the case the warning exists for.
18573+
* index_status and check_index_coverage already report freshness themselves,
18574+
* and index_repository is the remedy, so none of them are annotated. */
18575+
static bool mcp_tool_answers_from_graph(const char *tool_name) {
18576+
static const char *GRAPH_TOOLS[] = {
18577+
"search_graph", "query_graph", "trace_path", "trace_call_path",
18578+
"get_architecture", "get_code_snippet", "search_code",
18579+
};
18580+
for (size_t index = 0; tool_name && index < sizeof(GRAPH_TOOLS) / sizeof(GRAPH_TOOLS[0]);
18581+
index++) {
18582+
if (strcmp(tool_name, GRAPH_TOOLS[index]) == 0) {
18583+
return true;
18584+
}
18585+
}
18586+
return false;
18587+
}
18588+
18589+
/* A background rebuild that fails only reaches the logs and index_status,
18590+
* which leaves a caller querying an older graph with no visible signal. Say so
18591+
* on the next answer served from that graph. Every failure here is silent:
18592+
* a warning that cannot be produced must never turn a good answer into one. */
18593+
static void mcp_result_warn_if_stale(const char *tool_name, const char *args_json,
18594+
char **result_io) {
18595+
if (!args_json || !result_io || !*result_io || !mcp_tool_answers_from_graph(tool_name)) {
18596+
return;
18597+
}
18598+
char *project = get_project_arg(args_json);
18599+
char warning[CBM_SZ_1K];
18600+
if (project && index_attempt_stale_warning(project, warning, sizeof(warning))) {
18601+
mcp_result_attach_stale_warning(result_io, warning);
18602+
}
18603+
free(project);
18604+
}
18605+
1843418606
char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) {
1843518607
/* Phase marks bracket the WHOLE request with no unlabelled gap, so growth
1843618608
* cannot hide between them (CBM_MEM_PHASES=1; see foundation/mem.h). The
@@ -18445,6 +18617,7 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch
1844518617
}
1844618618
cbm_mem_phase_mark("request.dispatch_tool");
1844718619
char *result = dispatch_tool(srv, tool_name, args_json);
18620+
mcp_result_warn_if_stale(tool_name, args_json, &result);
1844818621
cbm_mem_phase_mark("request.scope_end");
1844918622
if (srv) {
1845018623
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
@@ -6051,6 +6051,116 @@ TEST(index_attempt_record_rejects_stale_transition_and_recovers_abandoned_job) {
60516051
PASS();
60526052
}
60536053

6054+
/* A rebuild that dies on a resource limit leaves the previously published
6055+
* graph in service. Without a signal on the answers themselves, the caller
6056+
* keeps trusting a graph that no longer matches the tree. */
6057+
TEST(stale_index_warning_reaches_graph_answers) {
6058+
char *cache = th_mktempdir("cbm_stale_warning");
6059+
ASSERT_NOT_NULL(cache);
6060+
const char *saved_cache = getenv("CBM_CACHE_DIR");
6061+
char *saved_copy = saved_cache ? strdup(saved_cache) : NULL;
6062+
ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache, 1), 0);
6063+
6064+
const char *project = "StaleWarningFixture";
6065+
char db_path[700];
6066+
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
6067+
cbm_store_t *setup = cbm_store_open_path(db_path);
6068+
ASSERT_NOT_NULL(setup);
6069+
ASSERT_EQ(cbm_store_upsert_project(setup, project, cache), CBM_STORE_OK);
6070+
cbm_node_t node = {.project = project,
6071+
.label = "Function",
6072+
.name = "StaleProbe",
6073+
.qualified_name = "stale.StaleProbe",
6074+
.file_path = "mod.c",
6075+
.start_line = 1,
6076+
.end_line = 2};
6077+
ASSERT_GT(cbm_store_upsert_node(setup, &node), 0);
6078+
cbm_store_close(setup);
6079+
6080+
cbm_index_resource_policy_t policy;
6081+
cbm_index_policy_init(&policy);
6082+
char error[128];
6083+
ASSERT_TRUE(cbm_index_policy_set_profile(&policy, "strict", error, sizeof(error)));
6084+
char attempt_id[33];
6085+
ASSERT_TRUE(cbm_mcp_index_attempt_begin(project, cache, "watcher", &policy, true, attempt_id));
6086+
ASSERT_TRUE(
6087+
cbm_mcp_index_attempt_transition(project, cache, attempt_id, "running", NULL, NULL, false));
6088+
cbm_index_resource_violation_t violation = {
6089+
.resource = CBM_INDEX_RESOURCE_FILES,
6090+
.observed = 120000,
6091+
.limit = 100000,
6092+
};
6093+
ASSERT_TRUE(cbm_mcp_index_attempt_transition(project, cache, attempt_id, "failed",
6094+
"resource_limit_exceeded", &violation, false));
6095+
6096+
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
6097+
ASSERT_NOT_NULL(srv);
6098+
char json_args[512];
6099+
snprintf(json_args, sizeof(json_args),
6100+
"{\"project\":\"%s\",\"name_pattern\":\".*StaleProbe.*\",\"format\":\"json\"}",
6101+
project);
6102+
char *resp = cbm_mcp_handle_tool(srv, "search_graph", json_args);
6103+
ASSERT_NOT_NULL(resp);
6104+
ASSERT_NOT_NULL(strstr(resp, "\"isError\":false"));
6105+
char *inner = extract_text_content(resp);
6106+
ASSERT_NOT_NULL(inner);
6107+
/* The answer keeps its place at content[0]; the warning rides along in the
6108+
* same object so a structured reader cannot miss it. */
6109+
ASSERT_NOT_NULL(strstr(inner, "StaleProbe"));
6110+
ASSERT_NOT_NULL(strstr(inner, "\"stale_index_warning\""));
6111+
ASSERT_NOT_NULL(strstr(inner, "index_max_files"));
6112+
ASSERT_NOT_NULL(strstr(inner, "120000"));
6113+
ASSERT_NOT_NULL(strstr(inner, "watcher"));
6114+
free(inner);
6115+
free(resp);
6116+
6117+
char default_args[512];
6118+
snprintf(default_args, sizeof(default_args),
6119+
"{\"project\":\"%s\",\"name_pattern\":\".*StaleProbe.*\"}", project);
6120+
resp = cbm_mcp_handle_tool(srv, "search_graph", default_args);
6121+
ASSERT_NOT_NULL(resp);
6122+
ASSERT_NOT_NULL(strstr(resp, "Stale index warning"));
6123+
inner = extract_text_content(resp);
6124+
ASSERT_NOT_NULL(inner);
6125+
ASSERT_NOT_NULL(strstr(inner, "StaleProbe"));
6126+
free(inner);
6127+
free(resp);
6128+
6129+
/* index_status reports the attempt in full and needs no second copy. */
6130+
char status_args[512];
6131+
snprintf(status_args, sizeof(status_args), "{\"project\":\"%s\"}", project);
6132+
resp = cbm_mcp_handle_tool(srv, "index_status", status_args);
6133+
ASSERT_NOT_NULL(resp);
6134+
ASSERT_NULL(strstr(resp, "stale_index_warning"));
6135+
free(resp);
6136+
6137+
/* A rebuild that is merely in flight is not yet a failure. */
6138+
char inflight_id[33];
6139+
ASSERT_TRUE(
6140+
cbm_mcp_index_attempt_begin(project, cache, "explicit", &policy, false, inflight_id));
6141+
ASSERT_TRUE(cbm_mcp_index_attempt_transition(project, cache, inflight_id, "running", NULL, NULL,
6142+
false));
6143+
resp = cbm_mcp_handle_tool(srv, "search_graph", json_args);
6144+
ASSERT_NOT_NULL(resp);
6145+
ASSERT_NULL(strstr(resp, "stale_index_warning"));
6146+
free(resp);
6147+
6148+
ASSERT_TRUE(cbm_mcp_index_attempt_transition(project, cache, inflight_id, "completed", NULL,
6149+
NULL, true));
6150+
resp = cbm_mcp_handle_tool(srv, "search_graph", json_args);
6151+
ASSERT_NOT_NULL(resp);
6152+
ASSERT_NOT_NULL(strstr(resp, "StaleProbe"));
6153+
ASSERT_NULL(strstr(resp, "stale_index_warning"));
6154+
free(resp);
6155+
6156+
cbm_mcp_server_free(srv);
6157+
ASSERT_TRUE(cbm_mcp_index_attempt_remove(project));
6158+
restore_cache_dir(saved_copy);
6159+
free(saved_copy);
6160+
th_cleanup(cache);
6161+
PASS();
6162+
}
6163+
60546164
TEST(index_attempt_freshness_requires_same_clean_git_snapshot) {
60556165
char *cache_created = th_mktempdir("cbm_attempt_fresh_cache");
60566166
char *cache = cache_created ? strdup(cache_created) : NULL;
@@ -20645,6 +20755,7 @@ SUITE(mcp) {
2064520755
RUN_TEST(tool_check_index_coverage_surfaces_lookup_errors);
2064620756
RUN_TEST(tool_index_status_includes_git_metadata);
2064720757
RUN_TEST(index_attempt_record_rejects_stale_transition_and_recovers_abandoned_job);
20758+
RUN_TEST(stale_index_warning_reaches_graph_answers);
2064820759
RUN_TEST(index_attempt_freshness_requires_same_clean_git_snapshot);
2064920760
RUN_TEST(index_attempt_only_project_can_be_deleted);
2065020761

0 commit comments

Comments
 (0)