fix(broker,cli): member-DM policy counts real sessions; a rejection is final

Found in the local E2E drill:
- the broker counted connections, not sessions — a reattaching session
  briefly holds two sockets, and it logged live_sessions=3 for a member
  with 2. Count distinct session keys, excluding control-plane.
- in reject mode the daemon retried member_target_requires_session like
  a transient error; it is now classified permanent (row → dead after
  one attempt, both daemon and session sockets).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-09-26 21:15:51 +01:00
parent 567ae4874e
commit dcad3fc093
3 changed files with 16 additions and 9 deletions

View File

@@ -2509,13 +2509,20 @@ async function handleSend(
// so on the wire this only comes from older clients. Policy:
// CLAUDEMESH_MEMBER_DM_POLICY=warn (default: log + count) | reject.
{
const liveSessionsOfTarget = [...connections.values()].filter(
(peer) =>
peer.meshId === conn.meshId &&
peer.memberPubkey === msg.targetSpec &&
!!peer.sessionPubkey &&
peer.sessionPubkey !== msg.targetSpec,
).length;
// Distinct session keys — a reattaching session can briefly hold
// two sockets, and the daemon's control-plane socket isn't a session.
const liveSessionsOfTarget = new Set(
[...connections.values()]
.filter(
(peer) =>
peer.meshId === conn.meshId &&
peer.memberPubkey === msg.targetSpec &&
peer.peerRole !== "control-plane" &&
!!peer.sessionPubkey &&
peer.sessionPubkey !== msg.targetSpec,
)
.map((peer) => peer.sessionPubkey),
).size;
if (liveSessionsOfTarget > 0) {
const policy = process.env.CLAUDEMESH_MEMBER_DM_POLICY === "reject" ? "reject" : "warn";
log.warn("member_target_dm", {

View File

@@ -524,5 +524,5 @@ function defaultLog(level: "info" | "warn" | "error", msg: string, meta?: Record
/** Heuristic: which broker errors are unrecoverable for this id. */
function classifyPermanent(err: string): boolean {
return /payload_too_large|forbidden|not_found|invalid|schema|auth|signature/i.test(err);
return /payload_too_large|forbidden|not_found|invalid|schema|auth|signature|member_target_requires_session/i.test(err);
}

View File

@@ -63,7 +63,7 @@ const SEND_ACK_TIMEOUT_MS = 15_000;
* daemon-WS classifier so behavior is identical regardless of which
* socket the row went out on. */
function classifyPermanent(error: string): boolean {
return /unknown|invalid|forbidden|not_authorized|target_not_found/i.test(error);
return /unknown|invalid|forbidden|not_authorized|target_not_found|member_target_requires_session/i.test(error);
}
export interface ParentAttestation {