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