AtlatestRepositorycourier
1
#!/usr/bin/env python32
"""Off-device driver for the courier send-path duplication repro.4
Acts as a minimal MCP client (the "leader") speaking JSON-RPC over5
courier's stdio. Fires ONE logical `send-message` and models how the6
real leader harness reacts when a send does not return a clean, timely7
success:9
* a per-tool-call timeout (the client stops waiting for the response),10
* on timeout OR courier death, kill + respawn courier and RE-ISSUE the11
same send, up to --retries times (this is the harness /mcp-reconnect12
+ agent-retry loop that turned one logical send into N deliveries).14
The bug is proven by pointing courier's SEND path at mock_telegram.py15
(COURIER_TELEGRAM_API_URL) and counting sendMessage hits: one logical16
send producing >1 delivery == reproduced.18
NOTHING here can reach real Telegram: the api-url override forces every19
request to the local mock.21
Usage:22
send_driver.py --bin <courier> --api-url <mock-base> --logdir <dir>23
[--mode ok|reset|delay:N|status:N|notok]24
[--tool-timeout SEC] [--retries N] [--text STR]25
[--mode-endpoint URL]26
"""27
import argparse28
import json29
import os30
import subprocess31
import sys32
import threading33
import time34
import urllib.request36
CHAT_ID = "331005009" # David's real chat id, per the captured log. Never used37
# live: the api-url override sends only to the mock.40
def set_mock_mode(mode_endpoint, mode):41
data = json.dumps({"mode": mode}).encode()42
req = urllib.request.Request(mode_endpoint, data=data,43
headers={"Content-Type": "application/json"})44
urllib.request.urlopen(req, timeout=5).read()47
def mock_sends(stats_url):48
with urllib.request.urlopen(stats_url, timeout=5) as r:49
return json.load(r)["sends"]52
class Courier:53
"""A single courier subprocess speaking MCP over stdio."""55
def __init__(self, bin_path, env, logdir, gen):56
self.gen = gen57
self.errf = open(os.path.join(logdir, f"courier-{gen}.stderr"), "wb")58
self.proc = subprocess.Popen(59
[bin_path, "serve"],60
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.errf,61
env=env, bufsize=0)62
self._id = 063
self._lock = threading.Lock()65
def _send(self, obj):66
line = (json.dumps(obj) + "\n").encode()67
self.proc.stdin.write(line)68
self.proc.stdin.flush()70
def _rpc(self, method, params, timeout):71
"""Send a request, wait up to `timeout` for the matching response.72
Returns (result_or_error_dict, elapsed). Raises TimeoutError if no73
response arrives in time, or ProcessLookupError if courier dies."""74
with self._lock:75
self._id += 176
rid = self._id77
self._send({"jsonrpc": "2.0", "id": rid, "method": method,78
"params": params})79
result = {}80
done = threading.Event()82
def reader():83
nonlocal result84
for raw in self.proc.stdout:85
try:86
msg = json.loads(raw)87
except Exception:88
continue89
if msg.get("id") == rid:90
result = msg91
done.set()92
return94
t = threading.Thread(target=reader, daemon=True)95
t.start()96
start = time.time()97
ok = done.wait(timeout)98
elapsed = time.time() - start99
if not ok:100
if self.proc.poll() is not None:101
raise ProcessLookupError(f"courier exited rc={self.proc.returncode}")102
raise TimeoutError(f"no response to {method} in {timeout}s")103
return result, elapsed105
def notify(self, method, params):106
self._send({"jsonrpc": "2.0", "method": method, "params": params})108
def initialize(self, timeout):109
res, _ = self._rpc("initialize", {110
"protocolVersion": "2024-11-05",111
"capabilities": {},112
"clientInfo": {"name": "send-driver", "version": "0"},113
}, timeout)114
self.notify("notifications/initialized", {})115
return res117
def call_send(self, text, timeout):118
return self._rpc("tools/call", {119
"name": "send-message",120
"arguments": {"text": text, "to": CHAT_ID},121
}, timeout)123
def alive(self):124
return self.proc.poll() is None126
def kill(self):127
try:128
self.proc.kill()129
self.proc.wait(timeout=5)130
except Exception:131
pass132
try:133
self.errf.close()134
except Exception:135
pass138
def main():139
ap = argparse.ArgumentParser()140
ap.add_argument("--bin", required=True)141
ap.add_argument("--api-url", required=True, help="mock base, e.g. http://127.0.0.1:PORT")142
ap.add_argument("--logdir", required=True)143
ap.add_argument("--stats-url", required=True)144
ap.add_argument("--mode-endpoint", required=True)145
ap.add_argument("--mode", default="ok")146
ap.add_argument("--tool-timeout", type=float, default=30.0)147
ap.add_argument("--retries", type=int, default=0,148
help="max kill+respawn+re-send cycles after a timeout/death")149
ap.add_argument("--text", default="repro test message")150
args = ap.parse_args()152
env = dict(os.environ)153
env["COURIER_TELEGRAM_TOKEN"] = "TESTTOKEN"154
env["COURIER_TELEGRAM_CHAT_ID"] = CHAT_ID155
env["COURIER_TELEGRAM_API_URL"] = args.api_url156
# Sends ENABLED against the mock (this is the whole point).157
env.pop("COURIER_DISABLE_TELEGRAM_SEND", None)158
env["CLAUDE_OPS_LOG_DIR"] = args.logdir159
# Isolate persistent state (relay dir + the send-dedup.state that the160
# fix writes beside it) to this run's logdir, so runs don't cross-161
# contaminate. Every restarted courier gen shares this dir -- exactly162
# how the real leader's restarts share ~/.courier -- so the persistent163
# dedup can do its job across process restarts.164
env["COURIER_RELAY_DIR"] = os.path.join(os.path.abspath(args.logdir), "relays")166
set_mock_mode(args.mode_endpoint, args.mode)167
before = mock_sends(args.stats_url)169
print(f"== driver: mode={args.mode} tool-timeout={args.tool_timeout}s "170
f"retries={args.retries} ==")172
attempts = 0173
gen = 0174
outcome = "UNKNOWN"175
cur = Courier(args.bin, env, args.logdir, gen)176
try:177
cur.initialize(timeout=15)178
while True:179
attempts += 1180
print(f"-- attempt {attempts}: sending (courier gen {gen}) --")181
try:182
res, elapsed = cur.call_send(args.text, timeout=args.tool_timeout)183
txt = ""184
if "result" in res:185
try:186
txt = res["result"]["content"][0]["text"]187
except Exception:188
txt = json.dumps(res["result"])189
else:190
txt = json.dumps(res.get("error", res))191
print(f" tool returned in {elapsed:.1f}s: {txt!r}")192
outcome = "SEND_RETURNED"193
break194
except TimeoutError as e:195
print(f" TIMEOUT: {e}")196
except ProcessLookupError as e:197
print(f" COURIER DIED: {e}")198
# timeout or death: this is where the harness would restart+retry199
if attempts > args.retries:200
outcome = "GAVE_UP_AFTER_RETRIES"201
break202
cur.kill()203
gen += 1204
print(f" -> restart courier (gen {gen}) and re-issue send")205
cur = Courier(args.bin, env, args.logdir, gen)206
cur.initialize(timeout=15)207
finally:208
# Close stdin so courier shuts down cleanly, then reap.209
time.sleep(0.5)210
try:211
cur.proc.stdin.close()212
except Exception:213
pass214
time.sleep(0.5)215
alive = cur.alive()216
cur.kill()218
after = mock_sends(args.stats_url)219
deliveries = after - before220
print(f"== RESULT: logical_sends=1 attempts={attempts} "221
f"deliveries_at_mock={deliveries} outcome={outcome} "222
f"last_courier_alive_before_kill={alive} ==")223
# Machine-readable summary line for scripts.224
print(f"SUMMARY mode={args.mode} deliveries={deliveries} "225
f"attempts={attempts} outcome={outcome}")228
if __name__ == "__main__":229
main()