Skip to content

Commit 716f1d6

Browse files
committed
fix(hook): surface daemon build conflicts to the hook caller (DeusData#1388)
A hook client that could not join because the active daemon runs a DIFFERENT build reported the conflict on stderr only - and stdout is the only hook channel Claude Code surfaces, so in-session the result was eternal silent skips. Worse, the connect-failure path classified a conflicted daemon as ABSENT and suggested 'daemon start', which cannot heal a build conflict. This is the rc.1-over-0.9.0 experience in DeusData#1388/DeusData#1335: install the new build, the old warm daemon keeps running, and every hook goes quiet. Both failure sites now emit a throttled stdout systemMessage naming the real state (active daemon runs a different build) with the actionable step ('daemon stop', next command starts a matching daemon). The version-cohort conflict path keeps its unconditional stderr detail; the connect-conflict path now prints the formatted conflict to stderr too. Test infrastructure: a CBM_ENABLE_TEST_SEAMS-only env override (CBM_TEST_HOOK_CLIENT_BUILD) lets one binary present a foreign build fingerprint as a hook client, so the conflict is reproducible without a second build. tests/test_hook_conflict_notice.sh (scripts/test.sh step 5d, on the step-5 TEST_SEAMS binary) asserts fail-open exit 0 + the stderr conflict + the stdout systemMessage with restart guidance. RED before the fix (empty stdout), GREEN after, RED again on revert. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1 parent ff891d0 commit 716f1d6

3 files changed

Lines changed: 108 additions & 1 deletion

File tree

‎scripts/test.sh‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,12 @@ CBM_TEST_BINARY="$WATCHDOG_BINARY" bash "$ROOT/tests/test_worker_watchdog.sh"
282282
echo "=== Step 5c: worker error-response transport regression ==="
283283
bash "$ROOT/tests/test_worker_error_response.sh"
284284

285+
# Step 5d: a hook client facing a daemon BUILD CONFLICT must surface a stdout
286+
# systemMessage (stderr is invisible to the hook caller). Needs the TEST_SEAMS
287+
# binary from step 5's build (CBM_TEST_HOOK_CLIENT_BUILD forces the conflict).
288+
echo "=== Step 5d: hook daemon-conflict notice regression (#1388) ==="
289+
bash "$ROOT/tests/test_hook_conflict_notice.sh"
290+
285291
# Step 6: security-strings URL allow-list regression. The MSYS2 CLANG64 toolchain
286292
# bakes its package-tracker URL into the static Windows .exe; the binary string
287293
# audit must allow-list it (Windows-only — Linux smoke never saw it).

‎src/main.c‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1493,6 +1493,24 @@ static void main_hook_report_absent_daemon(const char *hook_dialect) {
14931493
}
14941494
}
14951495

1496+
/* #1388: a version-conflicted daemon is an actionable broken state, unlike a
1497+
* merely absent one - and stdout is the only hook channel Claude Code
1498+
* surfaces, so stderr-only reporting reads as eternal silence in-session.
1499+
* Emit a throttled systemMessage with restart guidance (the misleading
1500+
* "no daemon is running" text pointed at `daemon start`, which cannot heal a
1501+
* build conflict). */
1502+
static void main_hook_report_conflicted_daemon(const char *hook_dialect) {
1503+
if (hook_dialect || !main_hook_absent_notice_due()) {
1504+
return;
1505+
}
1506+
(void)fputs("{\"systemMessage\":\"codebase-memory-mcp: graph augmentation is skipped: the "
1507+
"active CBM daemon runs a different build than this binary (usually an update "
1508+
"was installed while the old daemon kept running). Run `codebase-memory-mcp "
1509+
"daemon stop`, then retry - the next command starts a matching daemon.\"}",
1510+
stdout);
1511+
(void)fflush(stdout);
1512+
}
1513+
14961514
static int main_run_hook_frontend(cbm_daemon_runtime_client_t *client, const char *hook_event,
14971515
const char *hook_dialect) {
14981516
char *input = cbm_hook_augment_read_stdin();
@@ -2148,6 +2166,18 @@ int main(int argc, char **argv) {
21482166
return result == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
21492167
}
21502168

2169+
#ifdef CBM_ENABLE_TEST_SEAMS
2170+
/* #1388 test seam: let one binary present a foreign build fingerprint as a
2171+
* hook client, so the daemon-conflict reporting path is testable without
2172+
* building a second binary. */
2173+
static char seam_hook_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE];
2174+
const char *seam_forced_build = cbm_safe_getenv("CBM_TEST_HOOK_CLIENT_BUILD", seam_hook_build,
2175+
sizeof(seam_hook_build), NULL);
2176+
if (role == CBM_DAEMON_PROCESS_HOOK_CLIENT && seam_forced_build && seam_forced_build[0]) {
2177+
identity.build_fingerprint = seam_hook_build;
2178+
}
2179+
#endif
2180+
21512181
cbm_version_cohort_manager_t *client_cohort_manager = cbm_version_cohort_manager_new(endpoint);
21522182
cbm_version_cohort_lease_t *client_cohort_lease = NULL;
21532183
cbm_daemon_conflict_t client_cohort_conflict;
@@ -2169,6 +2199,10 @@ int main(int argc, char **argv) {
21692199
}
21702200
(void)fprintf(stderr, "codebase-memory-mcp: %s\n",
21712201
formatted ? message : "client exact-build admission failed");
2202+
if (role == CBM_DAEMON_PROCESS_HOOK_CLIENT &&
2203+
client_cohort_status == CBM_VERSION_COHORT_CONFLICT) {
2204+
main_hook_report_conflicted_daemon(hook_dialect);
2205+
}
21722206
(void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager);
21732207
cbm_daemon_ipc_endpoint_free(endpoint);
21742208
return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS : EXIT_FAILURE;
@@ -2181,7 +2215,16 @@ int main(int argc, char **argv) {
21812215
endpoint, &identity, MAIN_HOOK_CONNECT_TIMEOUT_MS, &hook_connect);
21822216
cbm_daemon_ipc_endpoint_free(endpoint);
21832217
if (!hook_client) {
2184-
main_hook_report_absent_daemon(hook_dialect);
2218+
if (hook_connect.status == CBM_DAEMON_RUNTIME_CONNECT_CONFLICT) {
2219+
char conflict_detail[CBM_DAEMON_CONFLICT_MESSAGE_SIZE];
2220+
if (cbm_daemon_conflict_format(&hook_connect.conflict, conflict_detail,
2221+
sizeof(conflict_detail))) {
2222+
(void)fprintf(stderr, "codebase-memory-mcp: %s\n", conflict_detail);
2223+
}
2224+
main_hook_report_conflicted_daemon(hook_dialect);
2225+
} else {
2226+
main_hook_report_absent_daemon(hook_dialect);
2227+
}
21852228
(void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager);
21862229
return EXIT_SUCCESS;
21872230
}

‎tests/test_hook_conflict_notice.sh‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env bash
2+
# #1388: a hook client that cannot join because the active daemon runs a
3+
# DIFFERENT build must say so on stdout (systemMessage) - stdout is the only
4+
# hook channel Claude Code surfaces, so stderr-only reporting reads as eternal
5+
# silence in-session. Requires a TEST_SEAMS=1 binary (scripts/test.sh step 5
6+
# builds one): CBM_TEST_HOOK_CLIENT_BUILD forces the client fingerprint.
7+
set -euo pipefail
8+
9+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
10+
BINARY="${ROOT}/build/c/codebase-memory-mcp"
11+
if [[ ! -x "${BINARY}" && -x "${BINARY}.exe" ]]; then
12+
BINARY="${BINARY}.exe"
13+
fi
14+
if [[ ! -x "${BINARY}" ]]; then
15+
echo "missing binary: ${BINARY}" >&2
16+
exit 2
17+
fi
18+
19+
tmpdir="$(mktemp -d)"
20+
cleanup() {
21+
CBM_CACHE_DIR="${tmpdir}/cache" "${BINARY}" daemon stop >/dev/null 2>&1 || true
22+
rm -rf "${tmpdir}"
23+
}
24+
trap cleanup EXIT
25+
26+
CBM_CACHE_DIR="${tmpdir}/cache" "${BINARY}" daemon start >/dev/null 2>&1
27+
payload='{"session_id":"probe","hook_event_name":"PreToolUse","tool_name":"Grep","tool_input":{"pattern":"x","path":"/tmp"},"cwd":"/tmp"}'
28+
forced_build="$(printf 'f%.0s' $(seq 1 64))"
29+
30+
set +e
31+
out="$(printf '%s' "${payload}" | CBM_CACHE_DIR="${tmpdir}/cache" \
32+
CBM_TEST_HOOK_CLIENT_BUILD="${forced_build}" \
33+
"${BINARY}" hook-augment 2>"${tmpdir}/probe.err")"
34+
rc=$?
35+
set -e
36+
37+
if [[ ${rc} -ne 0 ]]; then
38+
echo "hook-augment must fail open (exit 0); got ${rc}" >&2
39+
cat "${tmpdir}/probe.err" >&2
40+
exit 1
41+
fi
42+
if ! grep -q "conflicting CBM process" "${tmpdir}/probe.err"; then
43+
echo "seam did not produce a build conflict - is this a TEST_SEAMS=1 binary?" >&2
44+
cat "${tmpdir}/probe.err" >&2
45+
exit 2
46+
fi
47+
if ! printf '%s' "${out}" | grep -q "systemMessage"; then
48+
echo "conflicted daemon must surface a stdout systemMessage to the hook caller (#1388)" >&2
49+
echo "stdout was: ${out}" >&2
50+
exit 1
51+
fi
52+
if ! printf '%s' "${out}" | grep -q "daemon stop"; then
53+
echo "the systemMessage must carry the daemon-restart guidance (#1388)" >&2
54+
echo "stdout was: ${out}" >&2
55+
exit 1
56+
fi
57+
58+
echo "ok: daemon build conflicts reach the hook caller as a systemMessage"

0 commit comments

Comments
 (0)