Merge pull request #2412 from DeusData/fix/activation-unknown-daemon-is-foreign

fix(cli): never stop a daemon whose cache is not confirmed as ours
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-09-30 22:21:59 +02:00
commit 0f52d30c29
3 files changed
+203 -12

No files matched your search

+60 -12
View File
@@ -226,6 +226,7 @@ typedef struct {
static cbm_cli_activation_ops_t g_cli_activation_test_ops;
static bool g_cli_activation_test_ops_set = false;
static const char *g_cli_activation_runtime_parent_for_test = NULL;
static bool g_cli_activation_scope_cache_unreadable_for_test = false;
static void cli_activation_diagnostic(const cbm_cli_activation_ops_t *ops, const char *message) {
const char *diagnostic = message ? message : CLI_ACTIVATION_REFUSED_MESSAGE;
@@ -335,6 +336,10 @@ const char *cbm_cli_activation_runtime_parent_for_test(void) {
return g_cli_activation_runtime_parent_for_test;
}
void cbm_cli_set_activation_scope_cache_unreadable_for_test(bool unreadable) {
g_cli_activation_scope_cache_unreadable_for_test = unreadable;
}
static const char *cli_activation_action_text(cbm_daemon_runtime_activation_action_t action) {
switch (action) {
case CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL:
@@ -541,6 +546,7 @@ typedef enum {
CLI_ACTIVATION_SCOPE_ACTIVE = 0,
CLI_ACTIVATION_SCOPE_NOTHING_TO_REPLACE,
CLI_ACTIVATION_SCOPE_FOREIGN_COHORT,
CLI_ACTIVATION_SCOPE_UNCONFIRMED,
CLI_ACTIVATION_SCOPE_ERROR,
} cli_activation_scope_t;
@@ -566,9 +572,43 @@ static void cli_activation_log_guard_decision(const cli_activation_production_co
* anything at all (a --skip-binary install without an index reset publishes
* nothing), and whose cohort is active. Admission with an immediate deadline
* is the cheapest authoritative read of the active lifetime record: OK means
* the cohort is ours or empty, CONFLICT names the active identity (its cache
* fingerprint is filled for every conflict kind), BUSY means another
* activation holds maintenance and the barrier waits for it as before. */
* the cohort shares this exact identity (cache included) or is empty,
* CONFLICT names the active identity (its cache fingerprint is filled for
* every conflict kind), BUSY means another activation or a session start held
* the group, so the active identity was never read.
*
* Only a POSITIVE match may drain (user decision 2026-09-28, "unknown =
* foreign, keep it"): the daemon's activation-shutdown handler verifies the
* requester's build, never its cache, so this decision is the only thing
* that keeps another HOME's install/update from stopping the account's live
* daemon. A busy group or an unreadable active cache used to fall through to
* "ours" and stop every session behind that daemon. */
static cli_activation_scope_t cli_activation_scope_unconfirmed(
cli_activation_production_context_t *context, const char *scope, const char *reason) {
const char *action = cli_activation_action_text(context->action);
(void)snprintf(context->scope_detail, sizeof(context->scope_detail),
"active cohort ownership unconfirmed (%s); scope=%s; nothing stopped", reason,
scope);
cli_activation_log_guard_decision(context, "unconfirmed_cohort", NULL);
/* Name the daemon being left: a read-only status probe (no cohort, no
* HELLO, no admission) that works across build skew. */
cbm_daemon_runtime_status_t daemon;
if (cbm_daemon_runtime_request_status(context->endpoint, &context->identity,
CLI_ACTIVATION_CONTROL_TIMEOUT_MS, &daemon)) {
printf("Leaving the running CBM daemon untouched (pid %lu, version %s): this %s could "
"not confirm that it serves the cache this %s targets (%s).\n",
(unsigned long)daemon.daemon_pid, daemon.semantic_version, action, action, reason);
} else {
printf("Leaving active CBM sessions untouched: this %s could not confirm that they "
"serve the cache it targets (%s).\n",
action, reason);
}
printf("If it is yours, close its CBM sessions and run `codebase-memory-mcp daemon stop` "
"to stop it yourself.\n");
(void)fflush(stdout);
return CLI_ACTIVATION_SCOPE_UNCONFIRMED;
}
static cli_activation_scope_t cli_activation_resolve_scope(
cli_activation_production_context_t *context) {
const char *runtime_dir = cbm_daemon_ipc_endpoint_runtime_dir(context->endpoint);
@@ -594,20 +634,27 @@ static cli_activation_scope_t cli_activation_resolve_scope(
if (lease) {
return CLI_ACTIVATION_SCOPE_ERROR;
}
if (g_cli_activation_scope_cache_unreadable_for_test) {
conflict.active_cache_fingerprint[0] = '\0';
}
switch (status) {
case CBM_VERSION_COHORT_OK:
case CBM_VERSION_COHORT_BUSY:
return CLI_ACTIVATION_SCOPE_ACTIVE;
case CBM_VERSION_COHORT_BUSY:
return cli_activation_scope_unconfirmed(
context, scope, "the session group was busy, so its cache identity could not be read");
case CBM_VERSION_COHORT_CONFLICT:
break;
default:
return CLI_ACTIVATION_SCOPE_ERROR;
}
if (!conflict.active_cache_fingerprint[0] ||
strcmp(conflict.active_cache_fingerprint, context->cache_fingerprint) == 0) {
/* Same cache namespace, another build or version: that IS the daemon
* this activation replaces. An unreadable active cache stays in scope
* rather than silently exempting a same-namespace daemon. */
if (!conflict.active_cache_fingerprint[0]) {
return cli_activation_scope_unconfirmed(context, scope,
"its cache identity could not be read");
}
if (strcmp(conflict.active_cache_fingerprint, context->cache_fingerprint) == 0) {
/* Positively confirmed: same cache namespace, another build or
* version. That IS the daemon this activation replaces. */
return CLI_ACTIVATION_SCOPE_ACTIVE;
}
(void)snprintf(context->scope_detail, sizeof(context->scope_detail),
@@ -636,9 +683,10 @@ static int cli_activation_production_reserve(void *opaque, cbm_cli_activation_lo
}
if (scope != CLI_ACTIVATION_SCOPE_ACTIVE) {
/* Nothing in the target namespace is being replaced, or the only
* active cohort serves another namespace: hold no maintenance,
* admission, lifetime or startup lock (each wakes or blocks the other
* namespace's sessions) and send no drain request. */
* active cohort serves another namespace, or its ownership could not
* be confirmed: hold no maintenance, admission, lifetime or startup
* lock (each wakes or blocks the other namespace's sessions) and
* send no drain request. */
if (!cli_activation_log_event(context, "quiesce_skipped", context->scope_detail)) {
return CLI_ERR;
}
+6
View File
@@ -505,6 +505,12 @@ void cbm_cli_set_activation_ops_for_test(const cbm_cli_activation_ops_t *ops);
void cbm_cli_set_activation_runtime_parent_for_test(const char *runtime_parent);
const char *cbm_cli_activation_runtime_parent_for_test(void);
/* Internal integration-test seam: the activation scope read reports the active
* cohort's cache fingerprint as unreadable (blank), exactly what the scope
* decision sees when that field cannot be recovered. false restores the real
* read. Not a command-line or environment override. */
void cbm_cli_set_activation_scope_cache_unreadable_for_test(bool unreadable);
/* ── Subcommands (wired from main.c) ─────────────────────────── */
/* install: copy binary, install skills, install editor MCP configs, ensure PATH.
+137
View File
@@ -18,11 +18,13 @@
#include <cli/cli.h>
#include <cli/progress_sink.h>
#include <daemon/bootstrap.h>
#include <daemon/ipc.h>
#include <daemon/runtime.h>
#include <daemon/version_cohort.h>
#include <foundation/constants.h>
#include <foundation/log.h>
#include <foundation/platform.h>
#include <foundation/private_file_lock.h>
#include <foundation/sha256.h>
#include <mcp/mcp.h>
#include <mcp/index_supervisor.h>
@@ -2037,6 +2039,139 @@ TEST(cli_install_skip_binary_unchanged_in_host_namespace_quiesces_nothing) {
ASSERT_TRUE(nothing_drained);
PASS();
}
/* cli_scope_install with the activation's stdout captured into out. */
static int cli_scope_install_captured(cli_scope_fixture_t *fixture, const char *home,
const char *cache, const char *bin_dir, bool skip_binary,
char *out, size_t out_size) {
out[0] = '\0';
FILE *capture = tmpfile();
int saved_stdout = capture ? dup(STDOUT_FILENO) : -1;
bool redirected = false;
if (capture && saved_stdout >= 0) {
fflush(stdout);
redirected = dup2(fileno(capture), STDOUT_FILENO) >= 0;
}
int rc = redirected ? cli_scope_install(fixture, home, cache, bin_dir, skip_binary) : -1;
if (redirected) {
fflush(stdout);
(void)dup2(saved_stdout, STDOUT_FILENO);
}
if (saved_stdout >= 0) {
close(saved_stdout);
}
if (capture) {
rewind(capture);
size_t count = fread(out, 1, out_size - 1U, capture);
out[count] = '\0';
fclose(capture);
}
return rc;
}
/* The notice an activation prints when it leaves a daemon it could not
* confirm as its own: names the daemon by pid and tells the user how to stop
* it themselves. Never the "Stopping ..." banner of a drain. */
static bool cli_scope_kept_notice_names_host(const char *output, pid_t host) {
char pid_text[48];
snprintf(pid_text, sizeof(pid_text), "(pid %ld, version %s)", (long)host, CBM_VERSION);
return strstr(output, "Leaving the running CBM daemon untouched") != NULL &&
strstr(output, pid_text) != NULL &&
strstr(output, "codebase-memory-mcp daemon stop") != NULL &&
strstr(output, "Stopping active CBM sessions") == NULL;
}
/* (e) USER DECISION 2026-09-28, "unknown = foreign, keep it": the scope read
* reaches the active cohort but cannot read its cache identity. Ownership is
* unconfirmed, so the sandbox install must leave the host daemon serving,
* stop no session, and say which daemon it left and how to stop it. The
* install publishes a binary (no --skip-binary): an activation that replaces
* nothing never consults the cohort at all (test d). */
TEST(cli_install_foreign_home_unreadable_cohort_cache_keeps_host_daemon) {
cli_scope_fixture_t fixture;
bool ready = cli_scope_fixture_start(&fixture, "unread");
char foreign_home[512];
char foreign_cache[576];
char foreign_bin[640];
char activation_log[704];
cli_scope_foreign_paths(&fixture, foreign_home, foreign_cache, foreign_bin, activation_log);
bool prepared = ready && cbm_mkdir_p(foreign_home, 0700);
char output[16384];
output[0] = '\0';
cbm_cli_set_activation_scope_cache_unreadable_for_test(true);
int install_rc = prepared
? cli_scope_install_captured(&fixture, foreign_home, foreign_cache,
foreign_bin, false, output, sizeof(output))
: -1;
cbm_cli_set_activation_scope_cache_unreadable_for_test(false);
bool host_serving = prepared && cli_scope_host_serving(&fixture);
bool notice = cli_scope_kept_notice_names_host(output, fixture.host);
const char *events = read_test_file(activation_log);
bool completed = events && strstr(events, "\"phase\":\"completed\"") != NULL;
bool nothing_drained = events && strstr(events, "cohort drained") == NULL;
int host_exit = cli_scope_fixture_finish(&fixture);
ASSERT_TRUE(ready);
ASSERT_TRUE(host_serving);
ASSERT_EQ(host_exit, 0);
ASSERT_TRUE(nothing_drained);
ASSERT_TRUE(notice);
ASSERT_EQ(install_rc, 0);
ASSERT_TRUE(completed);
PASS();
}
/* (f) Same decision, busy group: another activation holds the cohort
* maintenance gate, so the scope read cannot reach the active identity at
* all. Unconfirmed ownership keeps the host daemon; the sandbox install
* completes without draining anyone. The gate is held through the product's
* own lock-directory handle on the name version_cohort.c uses. */
TEST(cli_install_foreign_home_busy_cohort_keeps_host_daemon) {
cli_scope_fixture_t fixture;
bool ready = cli_scope_fixture_start(&fixture, "busy");
char foreign_home[512];
char foreign_cache[576];
char foreign_bin[640];
char activation_log[704];
cli_scope_foreign_paths(&fixture, foreign_home, foreign_cache, foreign_bin, activation_log);
cbm_private_lock_directory_t *lock_directory = NULL;
cbm_private_file_lock_t *maintenance = NULL;
bool held = ready &&
cbm_daemon_ipc_private_lock_directory_new(fixture.endpoint, &lock_directory) ==
CBM_PRIVATE_FILE_LOCK_OK &&
cbm_private_file_lock_try_acquire(
lock_directory, "cbm-version-cohort-maintenance-v1.lock",
CBM_PRIVATE_FILE_LOCK_EX, &maintenance) == CBM_PRIVATE_FILE_LOCK_OK;
bool prepared = held && cbm_mkdir_p(foreign_home, 0700);
char output[16384];
output[0] = '\0';
int install_rc = prepared
? cli_scope_install_captured(&fixture, foreign_home, foreign_cache,
foreign_bin, false, output, sizeof(output))
: -1;
bool host_serving = prepared && cli_scope_host_serving(&fixture);
bool released =
maintenance && cbm_private_file_lock_release(&maintenance) == CBM_PRIVATE_FILE_LOCK_OK;
if (lock_directory) {
cbm_private_lock_directory_close(lock_directory);
}
bool notice = cli_scope_kept_notice_names_host(output, fixture.host);
const char *events = read_test_file(activation_log);
bool completed = events && strstr(events, "\"phase\":\"completed\"") != NULL;
bool nothing_drained = events && strstr(events, "cohort drained") == NULL;
int host_exit = cli_scope_fixture_finish(&fixture);
ASSERT_TRUE(ready);
ASSERT_TRUE(held);
ASSERT_TRUE(host_serving);
ASSERT_EQ(host_exit, 0);
ASSERT_TRUE(nothing_drained);
ASSERT_TRUE(notice);
ASSERT_EQ(install_rc, 0);
ASSERT_TRUE(completed);
ASSERT_TRUE(released);
PASS();
}
#endif
TEST(cli_install_force_quiesces_active_cohort_before_replacing_binary) {
@@ -16504,6 +16639,8 @@ SUITE(cli) {
RUN_TEST(cli_install_binary_into_foreign_home_never_drains_host_cohort);
RUN_TEST(cli_install_into_host_namespace_still_drains_host_cohort);
RUN_TEST(cli_install_skip_binary_unchanged_in_host_namespace_quiesces_nothing);
RUN_TEST(cli_install_foreign_home_unreadable_cohort_cache_keeps_host_daemon);
RUN_TEST(cli_install_foreign_home_busy_cohort_keeps_host_daemon);
#endif
RUN_TEST(cli_install_force_quiesces_active_cohort_before_replacing_binary);
RUN_TEST(cli_install_dir_and_skip_config_stage_first_install_safely);