@@ -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+
52315306bool 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+
1258012752char *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);
0 commit comments