Skip to content

Commit 2a4ca07

Browse files
IMNMVclaude
andcommitted
Stale-session guard for coordination, README entry for the field-bug batch
ClaudeR 0.12.1 / clauder-mcp 0.14.1. Field report: an agent still pointed at a dead R session got success from send_message while the event landed in a coordination log no live agent reads. Root cause: get_r_addin_url keeps the stale session name when zero sessions are alive, so _coord_dir resolved to the dead session directory. New _coord_target resolver runs before every coordination call (send_message, check_messages, wait_for_message, coordination_roster). With no live R session it fails loudly and writes nothing. When the connection re-binds to a different live session it prepends a NOTE saying earlier sends may sit in the old log unseen. wait_for_message also re-checks liveness every poll so a mid-wait death aborts instead of hanging silently. Protocol gains a delivery-guarantees paragraph. README gains the combined Recent Updates entry for the 0.12.x field-bug batch. Three new pytest cases cover dead session, re-bind note, and quiet stable binding (33 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fec73d4 commit 2a4ca07

9 files changed

Lines changed: 1124 additions & 42 deletions

File tree

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Package: ClaudeR
22
Title: R Integration for Claude AI
3-
Version: 0.12.0
3+
Version: 0.12.1
44
Authors@R: person("Nykko", "Vitali", email = "nykvt@icloud.com", role = c("aut", "cre"))
55
Description: Connects RStudio with Claude AI to enable interactive coding sessions.
66
License: MIT + file LICENSE

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ claudeAddin()
5050
<details>
5151
<summary><b>Recent Updates</b> (click to expand)</summary>
5252

53+
- **Shared-connection identity, coordination visibility, and a stale-session guard (R 0.12.1 / clauder-mcp 0.14.1).** Three fixes from a live three-persona field session. (1) Personas sharing one MCP connection were renaming each other, because `set_agent_name` changes the identity of the whole connection. New `as_agent` parameter on `send_message`, `check_messages`, and `wait_for_message` acts as a named persona for one call, with a separate read cursor per name, and the agent intro now states where the current identity came from. (2) Coordination messages bypass R by design, so the console and the Agents panel showed nothing while agents talked. The addin now echoes each new coordination event to the console, appends it to the session log, and shows a live coordination roster with last-seen ages. (3) A bridge still pointed at a dead R session used to report "success" while writing to a coordination log no live agent reads. Coordination calls now fail loudly when no live session exists, and announce it when the connection re-binds to a different live session. Also from pilot 2: `check_cross_references` understands S-prefixed supplement numbering, and the audit protocol documents `unname()` for htest fields and author-plus-year citation matching.
54+
5355
- **Agent identity and cross-restart history (R 0.11.0 / clauder-mcp 0.13.0).** Built from field reports of a multi-day, three-agent session. New `set_agent_name` tool: an agent sets its working name (for example "Claude-Stasis") once, and execution history, message attribution, presence, and its read cursor all carry that name. This fixes the case where several agents or personas share one MCP connection and collapse into a single random id. `get_session_history` gains `include_past`: it parses prior session log files on disk, so the audit of who ran what now survives R restarts. The coordination protocol now makes identity the first step of check-in.
5456

5557
- **Researcher workflow release (R 0.10.0 / clauder-mcp 0.12.0).** Three workflows that paid tools charge for, built on machinery ClaudeR already had. (1) Systematic review screening: two independent AI screeners from different model families judge every abstract against your criteria, and the new `screening_report` tool computes agreement, Cohen's kappa, PRISMA flow counts, and the conflict set, so the human reads only the disagreements. (2) Grant Panel Mode: `grant_panel_prompt(rubric = "nih")` convenes a mock study section, one reviewer per criterion, anchored weaknesses, and a ranked list of revisions that would move the score. (3) Response to Reviewers: `reviewer_response_prompt()` parses a decision letter into a point-by-point registry, reruns analyses so answers carry real computed numbers, gates until every point is answered, and exports the response letter with `export_response_letter()` plus Word comments in the manuscript.

clauder-mcp/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "clauder-mcp"
3-
version = "0.14.0"
3+
version = "0.14.1"
44
description = "MCP server connecting AI assistants to RStudio for interactive R coding and data analysis"
55
readme = "README.md"
66
requires-python = ">=3.10"

clauder-mcp/server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
"url": "https://github.com/IMNMV/ClaudeR",
88
"source": "github"
99
},
10-
"version": "0.14.0",
10+
"version": "0.14.1",
1111
"packages": [
1212
{
1313
"registryType": "pypi",
1414
"identifier": "clauder-mcp",
15-
"version": "0.14.0",
15+
"version": "0.14.1",
1616
"transport": {
1717
"type": "stdio"
1818
}

clauder-mcp/src/clauder_mcp/server.py

Lines changed: 76 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,38 @@ def _coord_format(events: List[Dict[str, Any]]) -> str:
430430
return "\n".join(lines)
431431

432432

433+
_coord_bound: Optional[str] = None # session whose log the last coordination call used
434+
435+
436+
def _coord_target() -> tuple:
437+
"""Resolve which session's log a coordination call will touch.
438+
439+
Returns (note, error), at most one non-None. Coordination logs are keyed
440+
by session name on disk, so a bridge still bound to a dead session would
441+
write to a log no live agent reads while reporting success (field bug:
442+
get_r_addin_url keeps the stale name when zero sessions are alive). Fail
443+
loudly in that case, and say so when the binding moves to a different
444+
live session, because earlier traffic sits in the old log unseen."""
445+
global _coord_bound
446+
get_r_addin_url() # re-binds _target_session to a live session if any exists
447+
if not discover_sessions():
448+
stale = _target_session or _coord_bound or "default"
449+
return (None,
450+
"FAILED: no live R session is running, so this call would use a "
451+
f"stale coordination log (session '{stale}') that no live agent "
452+
"reads. Nothing was written or read. Start the ClaudeR addin in "
453+
"RStudio, confirm with list_sessions, then retry.")
454+
session = _target_session or "default"
455+
note = None
456+
if _coord_bound is not None and _coord_bound != session:
457+
note = (f"NOTE: this connection re-bound from session '{_coord_bound}' "
458+
f"(gone) to live session '{session}' and now uses that session's "
459+
"coordination log. Earlier sends may sit in the old log unseen. "
460+
"Resend anything the other agents did not acknowledge.")
461+
_coord_bound = session
462+
return (note, None)
463+
464+
433465
# --- Annotation job helpers (subprocess-per-row batch mode) ---
434466

435467
def _find_cli_path(tool: str) -> Optional[str]:
@@ -3015,53 +3047,71 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[types.TextCont
30153047
return [types.TextContent(type="text", text="Error: 'body' is required")]
30163048
if isinstance(body, str):
30173049
body = {"text": body}
3050+
note, coord_err = _coord_target()
3051+
if coord_err:
3052+
return [types.TextContent(type="text", text=coord_err)]
30183053
try:
30193054
_coord_append(arguments.get("type", "message"), body,
30203055
to=arguments.get("to", "all"),
30213056
reply_to=arguments.get("reply_to"),
30223057
as_agent=arguments.get("as_agent"))
30233058
except ValueError as e:
30243059
return [types.TextContent(type="text", text=f"Error: {e}")]
3025-
result_contents.append(types.TextContent(
3026-
type="text", text="Message sent to the coordination log."
3027-
))
3060+
reply = "Message sent to the coordination log."
3061+
if note:
3062+
reply = note + "\n" + reply
3063+
result_contents.append(types.TextContent(type="text", text=reply))
30283064
return result_contents
30293065

30303066
elif name == "check_messages":
30313067
persona = arguments.get("as_agent")
3068+
note, coord_err = _coord_target()
3069+
if coord_err:
3070+
return [types.TextContent(type="text", text=coord_err)]
30323071
unread = _coord_unread(as_agent=persona)
30333072
if not unread:
3034-
result_contents.append(types.TextContent(
3035-
type="text", text="No unread coordination events."
3036-
))
3073+
reply = "No unread coordination events."
3074+
if note:
3075+
reply = note + "\n" + reply
3076+
result_contents.append(types.TextContent(type="text", text=reply))
30373077
return result_contents
30383078
if arguments.get("ack", True):
30393079
_coord_set_cursor(max(ev["id"] for ev in unread), as_agent=persona)
3040-
result_contents.append(types.TextContent(
3041-
type="text",
3042-
text=f"{len(unread)} unread event(s):\n" + _coord_format(unread)
3043-
))
3080+
reply = f"{len(unread)} unread event(s):\n" + _coord_format(unread)
3081+
if note:
3082+
reply = note + "\n" + reply
3083+
result_contents.append(types.TextContent(type="text", text=reply))
30443084
return result_contents
30453085

30463086
elif name == "wait_for_message":
30473087
timeout_s = min(float(arguments.get("timeout_s", 300)), 1800.0)
30483088
from_agent = arguments.get("from_agent")
30493089
ev_type = arguments.get("type")
30503090
persona = arguments.get("as_agent")
3091+
note, coord_err = _coord_target()
3092+
if coord_err:
3093+
return [types.TextContent(type="text", text=coord_err)]
30513094
waited = 0.0
30523095
while waited < timeout_s:
30533096
unread = _coord_unread(from_agent=from_agent, ev_type=ev_type,
30543097
as_agent=persona)
30553098
if unread:
30563099
_coord_set_cursor(max(ev["id"] for ev in unread), as_agent=persona)
3057-
result_contents.append(types.TextContent(
3058-
type="text",
3059-
text=(f"Event arrived after {round(waited)}s:\n" +
3060-
_coord_format(unread))
3061-
))
3100+
reply = (f"Event arrived after {round(waited)}s:\n" +
3101+
_coord_format(unread))
3102+
if note:
3103+
reply = note + "\n" + reply
3104+
result_contents.append(types.TextContent(type="text", text=reply))
30623105
return result_contents
30633106
await asyncio.sleep(2)
30643107
waited += 2
3108+
late_note, coord_err = _coord_target()
3109+
if coord_err:
3110+
return [types.TextContent(
3111+
type="text",
3112+
text=f"Aborted after {round(waited)}s of waiting. " + coord_err
3113+
)]
3114+
note = note or late_note
30653115
result_contents.append(types.TextContent(
30663116
type="text",
30673117
text=(f"No matching event within {round(timeout_s)}s. The other "
@@ -3072,9 +3122,15 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[types.TextCont
30723122

30733123
elif name == "coordination_roster":
30743124
stale_after = float(arguments.get("stale_after_s", 900))
3125+
note, coord_err = _coord_target()
3126+
if coord_err:
3127+
return [types.TextContent(type="text", text=coord_err)]
30753128
events = _coord_events()
30763129
if not events:
3077-
return [types.TextContent(type="text", text="No coordination activity yet on this session.")]
3130+
reply = "No coordination activity yet on this session."
3131+
if note:
3132+
reply = note + "\n" + reply
3133+
return [types.TextContent(type="text", text=reply)]
30783134
last: Dict[str, str] = {}
30793135
for ev in events:
30803136
last[ev.get("from", "?")] = ev.get("ts", "")
@@ -3088,9 +3144,10 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[types.TextCont
30883144
lines.append(f" {agent_name}: last seen {round(ago)}s ago{flag}")
30893145
except ValueError:
30903146
lines.append(f" {agent_name}: last seen {ts}")
3091-
result_contents.append(types.TextContent(
3092-
type="text", text="Coordination roster:\n" + "\n".join(lines)
3093-
))
3147+
reply = "Coordination roster:\n" + "\n".join(lines)
3148+
if note:
3149+
reply = note + "\n" + reply
3150+
result_contents.append(types.TextContent(type="text", text=reply))
30943151
return result_contents
30953152

30963153
elif name == "check_cross_references":

clauder-mcp/tests/test_server.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,42 @@ def test_bool_variants(self):
163163
def test_text_accepts_anything(self):
164164
ok, _ = _validate_annotation({**GOOD, "note": "free text, with, commas"}, SCHEMA)
165165
assert ok
166+
167+
168+
# --- _coord_target (stale-session guard) --------------------------------
169+
170+
from clauder_mcp import server as _srv
171+
172+
173+
class TestCoordTarget:
174+
"""A bridge bound to a dead session must not silently write to a log
175+
no live agent reads (field bug, 0.14.1)."""
176+
177+
def test_dead_session_fails_loudly(self, monkeypatch):
178+
monkeypatch.setattr(_srv, "discover_sessions", lambda: [])
179+
monkeypatch.setattr(_srv, "_target_session", "runescape")
180+
monkeypatch.setattr(_srv, "_coord_bound", None)
181+
note, err = _srv._coord_target()
182+
assert note is None
183+
assert err is not None
184+
assert "FAILED" in err and "runescape" in err
185+
186+
def test_rebind_to_live_session_produces_note(self, monkeypatch):
187+
live = [{"session_name": "live", "port": 8790, "token": "t", "pid": 1}]
188+
monkeypatch.setattr(_srv, "discover_sessions", lambda: live)
189+
monkeypatch.setattr(_srv, "_target_session", None)
190+
monkeypatch.setattr(_srv, "_coord_bound", "runescape")
191+
note, err = _srv._coord_target()
192+
assert err is None
193+
assert note is not None
194+
assert "runescape" in note and "live" in note
195+
assert _srv._coord_bound == "live"
196+
197+
def test_stable_binding_is_quiet(self, monkeypatch):
198+
live = [{"session_name": "live", "port": 8790, "token": "t", "pid": 1}]
199+
monkeypatch.setattr(_srv, "discover_sessions", lambda: live)
200+
monkeypatch.setattr(_srv, "_target_session", "live")
201+
monkeypatch.setattr(_srv, "_coord_bound", "live")
202+
note, err = _srv._coord_target()
203+
assert note is None
204+
assert err is None

0 commit comments

Comments
 (0)