diff --git a/src/daemon/bootstrap.h b/src/daemon/bootstrap.h index 8b1e797dd..9cc322c95 100644 --- a/src/daemon/bootstrap.h +++ b/src/daemon/bootstrap.h @@ -93,6 +93,9 @@ typedef struct { cbm_daemon_runtime_connect_result_t connect_result; bool daemon_spawned; char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; + /* #2277: filled by the client on a CONFLICT only — who holds the + * endpoint and how to clear it (cbm_daemon_conflict_remedy_format). */ + char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE]; } cbm_daemon_bootstrap_result_t; /* A probe distinguishes an absent endpoint from a reserved endpoint whose diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index efa41a020..8d1d8af94 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -2857,6 +2857,73 @@ bool cbm_daemon_runtime_request_status(const cbm_daemon_ipc_endpoint_t *endpoint return true; } +/* "; CBM sessions using it: pids 1, 2 and 3 more" — empty when none are known. */ +static bool remedy_sessions_format(const cbm_daemon_runtime_status_t *active, char *out, + size_t out_size) { + out[0] = '\0'; + size_t listed = active->client_count; + if (listed > CBM_DAEMON_CONTROL_CLIENT_CAP) { + listed = CBM_DAEMON_CONTROL_CLIENT_CAP; + } + size_t used = 0; + for (size_t i = 0; i < listed; i++) { + int written = snprintf(out + used, out_size - used, "%s%lu", + i == 0 ? "; CBM sessions using it: pids " : ", ", + (unsigned long)active->client_pids[i]); + if (written < 0 || (size_t)written >= out_size - used) { + return false; + } + used += (size_t)written; + } + if (listed > 0 && active->committed_clients > listed) { + int written = snprintf(out + used, out_size - used, " and %lu more", + (unsigned long)(active->committed_clients - listed)); + if (written < 0 || (size_t)written >= out_size - used) { + return false; + } + } + return true; +} + +bool cbm_daemon_conflict_remedy_format(const cbm_daemon_runtime_status_t *active, char *out, + size_t out_size) { + if (!out || out_size == 0) { + return false; + } + out[0] = '\0'; + static const char install_hint[] = + "Usually another editor or agent session (for example Codex or Claude Code) " + "launched a different codebase-memory-mcp install: close it, or point every client " + "at one binary (compare `which -a codebase-memory-mcp` with each client's MCP " + "config)."; + int written; + if (!active) { + written = snprintf(out, out_size, + "Run `codebase-memory-mcp daemon status` to see the active daemon and " + "the sessions using it. %s `codebase-memory-mcp daemon stop` retires " + "the daemon once no session uses it.", + install_hint); + } else { + char sessions[160]; + if (!remedy_sessions_format(active, sessions, sizeof(sessions))) { + return false; + } + const char *lifetime = + active->permanent ? "permanent" : "session-managed, exits when its last session closes"; + written = snprintf(out, out_size, + "Active daemon: pid %lu, version %s, %s%s%s. %s " + "`codebase-memory-mcp daemon stop` retires the daemon once no session " + "uses it.", + (unsigned long)active->daemon_pid, active->semantic_version, lifetime, + active->stopping ? ", already stopping" : "", sessions, install_hint); + } + if (written < 0 || (size_t)written >= out_size) { + out[0] = '\0'; + return false; + } + return true; +} + bool cbm_daemon_runtime_request_stop(const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_build_identity_t *identity, uint32_t timeout_ms, diff --git a/src/daemon/runtime.h b/src/daemon/runtime.h index 7a31dab61..d9c3db8e9 100644 --- a/src/daemon/runtime.h +++ b/src/daemon/runtime.h @@ -301,6 +301,17 @@ bool cbm_daemon_runtime_request_stop(const cbm_daemon_ipc_endpoint_t *endpoint, uint32_t timeout_ms, cbm_daemon_runtime_stop_result_t *result_out); +/* #2277: the actionable half of a version/build conflict refusal. The + * conflict text itself (cbm_daemon_conflict_format) is wire-validated + * byte-for-byte across generations and must not change, so the remedy is a + * separate, client-side sentence built from the cross-build STATUS probe: + * which daemon holds the endpoint (pid, version, lifetime), which CBM + * sessions keep it alive, and how to clear it. active may be NULL when the + * status probe did not answer. Returns false (out empty) on truncation. */ +#define CBM_DAEMON_CONFLICT_REMEDY_SIZE 640U +bool cbm_daemon_conflict_remedy_format(const cbm_daemon_runtime_status_t *active, char *out, + size_t out_size); + /* Performs the complete guarded first-participant handoff, starts listening * synchronously, then owns both that participant claim and its * accept/connection threads. All config scalar/text data is copied. endpoint diff --git a/src/main.c b/src/main.c index 65e041217..fd3370c04 100644 --- a/src/main.c +++ b/src/main.c @@ -45,6 +45,10 @@ enum { MAIN_MAX_PORT = 65536, MAIN_PATH_CAP = 4096, MAIN_CONNECT_TIMEOUT_MS = 1000, + /* #2277: the conflict-remedy STATUS probe runs only after a refusal, so it + * gets the same budget as `daemon status`: the daemon hashes the peer's + * (~300 MB) image before answering, which can exceed a 1 s connect budget. */ + MAIN_CONFLICT_STATUS_PROBE_TIMEOUT_MS = 3000, MAIN_STARTUP_TIMEOUT_MS = 10000, /* Backstop for waiting out a held startup transition — see * main_local_transition_acquire. Not a budget for healthy contention: a busy @@ -1698,6 +1702,10 @@ static bool main_semver_newer(const char *candidate, const char *active) { return false; } +/* A conflict refusal plus its remedy (#2277), joined by one space. */ +#define MAIN_CLIENT_FAILURE_DETAIL_CAP \ + (CBM_DAEMON_CONFLICT_MESSAGE_SIZE + CBM_DAEMON_CONFLICT_REMEDY_SIZE + 1U) + /* A client that cannot reach the daemon must SAY SO, in the caller's own * protocol. An MCP client speaks JSON-RPC over stdout, and the old path * returned EXIT_FAILURE having written nothing at all: agents saw a transport @@ -1721,7 +1729,7 @@ static bool main_semver_newer(const char *candidate, const char *active) { * just the one that happened to be fixed first. */ static void main_report_client_failure(cbm_daemon_process_role_t role, const char *detail) { if (cbm_daemon_process_role_requires_client(role)) { - char escaped[CBM_DAEMON_CONFLICT_MESSAGE_SIZE * 2]; + char escaped[MAIN_CLIENT_FAILURE_DETAIL_CAP * 2]; size_t out = 0; for (size_t i = 0; detail[i] && out + 2 < sizeof(escaped); i++) { unsigned char c = (unsigned char)detail[i]; @@ -1746,10 +1754,35 @@ static void main_report_client_failure(cbm_daemon_process_role_t role, const cha static void main_report_client_bootstrap_failure(cbm_daemon_process_role_t role, const cbm_daemon_bootstrap_result_t *result) { - main_report_client_failure(role, (result && result->message[0]) - ? result->message - : "CBM daemon connection failed before the session was " - "established"); + const char *message = (result && result->message[0]) + ? result->message + : "CBM daemon connection failed before the session was " + "established"; + char detail[MAIN_CLIENT_FAILURE_DETAIL_CAP]; + (void)snprintf(detail, sizeof(detail), "%s%s%s", message, + (result && result->remedy[0]) ? " " : "", result ? result->remedy : ""); + main_report_client_failure(role, detail); +} + +/* #2277: a conflict is a legitimate refusal (another build owns the account + * daemon), but it must name that daemon and the way out. known_active is a + * STATUS answer the caller already holds; without one this probes STATUS + * (cross-build by design) itself. When nothing answers, the remedy falls + * back to pointing at `daemon status`. */ +static void main_conflict_remedy_probe(const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, + const cbm_daemon_runtime_status_t *known_active, char *out, + size_t out_size) { + cbm_daemon_runtime_status_t probed; + const cbm_daemon_runtime_status_t *active = known_active; + if (!active && endpoint && identity && + cbm_daemon_runtime_request_status(endpoint, identity, MAIN_CONFLICT_STATUS_PROBE_TIMEOUT_MS, + &probed)) { + active = &probed; + } + if (!cbm_daemon_conflict_remedy_format(active, out, out_size)) { + out[0] = '\0'; + } } /* Client bootstrap with the upgrade policy: a CONFLICT against a PERMANENT @@ -1766,10 +1799,13 @@ static cbm_daemon_bootstrap_status_t main_client_bootstrap_with_upgrade( return status; } cbm_daemon_runtime_status_t active; - if (!cbm_daemon_runtime_request_status(config->endpoint, config->identity, - MAIN_CONNECT_TIMEOUT_MS, &active) || - !active.permanent || + bool active_known = cbm_daemon_runtime_request_status(config->endpoint, config->identity, + MAIN_CONNECT_TIMEOUT_MS, &active); + if (!active_known || !active.permanent || !main_semver_newer(config->identity->semantic_version, active.semantic_version)) { + main_conflict_remedy_probe(config->endpoint, config->identity, + active_known ? &active : NULL, result->remedy, + sizeof(result->remedy)); return status; } if (report_lifecycle) { @@ -1786,9 +1822,16 @@ static cbm_daemon_bootstrap_status_t main_client_bootstrap_with_upgrade( !drain.accepted) { (void)fprintf(stderr, "codebase-memory-mcp: the active daemon did not accept the " "upgrade drain; run `codebase-memory-mcp daemon stop`\n"); + main_conflict_remedy_probe(config->endpoint, config->identity, &active, result->remedy, + sizeof(result->remedy)); return status; } - return cbm_daemon_bootstrap_execute(config, result); + status = cbm_daemon_bootstrap_execute(config, result); + if (status == CBM_DAEMON_BOOTSTRAP_CONFLICT) { + main_conflict_remedy_probe(config->endpoint, config->identity, NULL, result->remedy, + sizeof(result->remedy)); + } + return status; } /* One-shot CLI commands execute through the shared daemon, exactly like MCP @@ -1825,6 +1868,9 @@ static char *main_local_cli_daemon_execute(const char *tool_name, const char *ar (void)fprintf(stderr, "error: %s\n", bootstrap.message[0] ? bootstrap.message : "no CBM daemon connection for CLI execution"); + if (bootstrap.remedy[0]) { + (void)fprintf(stderr, "hint: %s\n", bootstrap.remedy); + } return NULL; } if (bootstrap.daemon_spawned && cbm_log_get_level() <= CBM_LOG_INFO) { @@ -2870,13 +2916,17 @@ int main(int argc, char **argv) { char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; bool formatted = cohort_status == CBM_VERSION_COHORT_CONFLICT && cbm_daemon_conflict_format(&cohort_conflict, message, sizeof(message)); + char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE] = ""; if (cohort_status == CBM_VERSION_COHORT_CONFLICT) { (void)cbm_version_cohort_log_conflict(&cohort_conflict); + main_conflict_remedy_probe(local_endpoint, &local_identity, NULL, remedy, + sizeof(remedy)); } - (void)fprintf(stderr, "codebase-memory-mcp: %s\n", + (void)fprintf(stderr, "codebase-memory-mcp: %s%s%s\n", formatted ? message : "CLI exact-build admission could not be verified; retry " - "after active CBM operations exit"); + "after active CBM operations exit", + remedy[0] ? " " : "", remedy); goto local_cli_cleanup; } main_local_maintenance_context_init(&maintenance_context); @@ -3198,11 +3248,17 @@ int main(int argc, char **argv) { bool formatted = client_cohort_status == CBM_VERSION_COHORT_CONFLICT && cbm_daemon_conflict_format(&client_cohort_conflict, message, sizeof(message)); + char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE] = ""; if (client_cohort_status == CBM_VERSION_COHORT_CONFLICT) { (void)cbm_version_cohort_log_conflict(&client_cohort_conflict); + /* Hooks fail open on a tight budget; they skip the extra probe. */ + if (role != CBM_DAEMON_PROCESS_HOOK_CLIENT) { + main_conflict_remedy_probe(endpoint, &identity, NULL, remedy, sizeof(remedy)); + } } - (void)fprintf(stderr, "codebase-memory-mcp: %s\n", - formatted ? message : "client exact-build admission failed"); + (void)fprintf(stderr, "codebase-memory-mcp: %s%s%s\n", + formatted ? message : "client exact-build admission failed", + remedy[0] ? " " : "", remedy); if (role == CBM_DAEMON_PROCESS_HOOK_CLIENT && client_cohort_status == CBM_VERSION_COHORT_CONFLICT) { main_hook_report_conflicted_daemon(hook_dialect); diff --git a/tests/test_daemon_version.c b/tests/test_daemon_version.c index 89982546a..4f368d357 100644 --- a/tests/test_daemon_version.c +++ b/tests/test_daemon_version.c @@ -9,6 +9,7 @@ #include "test_framework.h" #include "daemon/daemon.h" +#include "daemon/runtime.h" #include "daemon/service.h" #include "daemon/service_internal.h" #include "foundation/compat.h" @@ -277,6 +278,87 @@ TEST(daemon_hello_version_conflict_exposes_active_and_requested_builds) { PASS(); } +/* #2277: a version/build conflict refusal must say WHICH daemon holds the + * endpoint and how to clear it. The reporter saw only "close all CBM sessions + * and commands", with no pid, no hint that a live agent session (Codex) had + * launched an older install, and no mention of `daemon stop`. */ +static cbm_daemon_runtime_status_t version_test_active_status(const char *version, bool permanent, + uint32_t pid) { + cbm_daemon_runtime_status_t status; + memset(&status, 0, sizeof(status)); + status.permanent = permanent; + status.daemon_pid = pid; + (void)snprintf(status.semantic_version, sizeof(status.semantic_version), "%s", version); + (void)snprintf(status.build_fingerprint, sizeof(status.build_fingerprint), "%s", BUILD_A); + return status; +} + +TEST(daemon_conflict_remedy_names_session_managed_daemon_and_its_sessions) { + cbm_daemon_runtime_status_t active = version_test_active_status("0.10.3", false, 4242); + active.committed_clients = 2; + active.client_count = 2; + active.client_pids[0] = 5151; + active.client_pids[1] = 6262; + char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE]; + + ASSERT_TRUE(cbm_daemon_conflict_remedy_format(&active, remedy, sizeof(remedy))); + ASSERT_NOT_NULL(strstr(remedy, "pid 4242")); + ASSERT_NOT_NULL(strstr(remedy, "version 0.10.3")); + ASSERT_NOT_NULL(strstr(remedy, "session-managed")); + ASSERT_NOT_NULL(strstr(remedy, "5151, 6262")); + ASSERT_NOT_NULL(strstr(remedy, "which -a codebase-memory-mcp")); + ASSERT_NOT_NULL(strstr(remedy, "codebase-memory-mcp daemon stop")); + PASS(); +} + +TEST(daemon_conflict_remedy_names_permanent_daemon_and_truncated_session_list) { + cbm_daemon_runtime_status_t active = version_test_active_status("0.12.0", true, 77); + active.committed_clients = CBM_DAEMON_CONTROL_CLIENT_CAP + 3U; + active.client_count = CBM_DAEMON_CONTROL_CLIENT_CAP; + for (uint32_t i = 0; i < CBM_DAEMON_CONTROL_CLIENT_CAP; i++) { + active.client_pids[i] = 1000U + i; + } + char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE]; + + ASSERT_TRUE(cbm_daemon_conflict_remedy_format(&active, remedy, sizeof(remedy))); + ASSERT_NOT_NULL(strstr(remedy, "pid 77")); + ASSERT_NOT_NULL(strstr(remedy, "permanent")); + ASSERT_NOT_NULL(strstr(remedy, "1007 and 3 more")); + ASSERT_NOT_NULL(strstr(remedy, "codebase-memory-mcp daemon stop")); + PASS(); +} + +TEST(daemon_conflict_remedy_reports_a_stopping_daemon) { + cbm_daemon_runtime_status_t active = version_test_active_status("0.10.3", false, 99); + active.stopping = true; + char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE]; + + ASSERT_TRUE(cbm_daemon_conflict_remedy_format(&active, remedy, sizeof(remedy))); + ASSERT_NOT_NULL(strstr(remedy, "pid 99")); + ASSERT_NOT_NULL(strstr(remedy, "already stopping")); + PASS(); +} + +TEST(daemon_conflict_remedy_without_status_points_at_daemon_status) { + char remedy[CBM_DAEMON_CONFLICT_REMEDY_SIZE]; + + ASSERT_TRUE(cbm_daemon_conflict_remedy_format(NULL, remedy, sizeof(remedy))); + ASSERT_NOT_NULL(strstr(remedy, "codebase-memory-mcp daemon status")); + ASSERT_NOT_NULL(strstr(remedy, "which -a codebase-memory-mcp")); + ASSERT_NOT_NULL(strstr(remedy, "codebase-memory-mcp daemon stop")); + PASS(); +} + +TEST(daemon_conflict_remedy_fails_closed_when_truncated) { + cbm_daemon_runtime_status_t active = version_test_active_status("0.10.3", false, 4242); + char tiny[16]; + + ASSERT_FALSE(cbm_daemon_conflict_remedy_format(&active, tiny, sizeof(tiny))); + ASSERT_STR_EQ(tiny, ""); + ASSERT_FALSE(cbm_daemon_conflict_remedy_format(&active, NULL, 0)); + PASS(); +} + TEST(daemon_hello_rejects_each_abi_mismatch) { cbm_daemon_build_identity_t active = version_test_identity("2.4.0", BUILD_A); cbm_daemon_build_identity_t requested = active; @@ -613,6 +695,11 @@ SUITE(daemon_version) { RUN_TEST(daemon_build_fingerprint_hashes_exact_executable_bytes); RUN_TEST(daemon_hello_accepts_only_the_exact_active_build_identity); RUN_TEST(daemon_hello_version_conflict_exposes_active_and_requested_builds); + RUN_TEST(daemon_conflict_remedy_names_session_managed_daemon_and_its_sessions); + RUN_TEST(daemon_conflict_remedy_names_permanent_daemon_and_truncated_session_list); + RUN_TEST(daemon_conflict_remedy_reports_a_stopping_daemon); + RUN_TEST(daemon_conflict_remedy_without_status_points_at_daemon_status); + RUN_TEST(daemon_conflict_remedy_fails_closed_when_truncated); RUN_TEST(daemon_hello_rejects_each_abi_mismatch); RUN_TEST(daemon_hello_fails_closed_without_an_exact_build_fingerprint); RUN_TEST(daemon_conflict_log_is_durable_private_and_rotates);