From c9c07cdeba41cf3735334661fab1d92693e7932f Mon Sep 17 00:00:00 2001 From: enhance Date: Thu, 9 Jul 2026 12:22:13 -0500 Subject: [PATCH] fix(tools/tmux): unique per-invocation paste buffer; track auto-submit-drafts.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream the 2026-07-09 live hot-fix from web1: send-message.sh used a fixed tmux buffer name (__mosaic_send) shared by all concurrent senders on one server — load-buffer overwrote load-buffer and paste-buffer -d deleted underneath peers, cross-delivering or dropping messages. Bit the fleet during the simultaneous restart (standing briefs swapped between sessions). Buffer name is now unique per invocation (PID + nanoseconds). Also brings auto-submit-drafts.sh (coordinator draft-flush watchdog, until now only a loose file in the deployed ~/.config/mosaic/tools/tmux/) under source control, and extends test-send-message-socket.sh with a concurrency lock: 5 parallel sends to distinct panes, asserting no drop and no cross-delivery. The lock fails 3/3 runs against the pre-fix script and passes post-fix. Without this, the next framework redeploy would regress the live fix. --- .../mosaic/framework/tools/tmux/README.md | 4 + .../tools/tmux/auto-submit-drafts.sh | 80 +++++++++++++++++++ .../framework/tools/tmux/send-message.sh | 16 +++- .../tools/tmux/test-send-message-socket.sh | 28 +++++++ 4 files changed, 125 insertions(+), 3 deletions(-) create mode 100755 packages/mosaic/framework/tools/tmux/auto-submit-drafts.sh diff --git a/packages/mosaic/framework/tools/tmux/README.md b/packages/mosaic/framework/tools/tmux/README.md index b8a20c8..e9fff6e 100644 --- a/packages/mosaic/framework/tools/tmux/README.md +++ b/packages/mosaic/framework/tools/tmux/README.md @@ -87,6 +87,10 @@ message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards. - `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch). - `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input). +- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt + drafts on a coordinator pane (default target `mos-claude`); run it as a + long-lived process alongside the coordinator session. +- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`. - `test-send-message-socket.sh` — smoke test for named-socket isolation. ## Distribution diff --git a/packages/mosaic/framework/tools/tmux/auto-submit-drafts.sh b/packages/mosaic/framework/tools/tmux/auto-submit-drafts.sh new file mode 100755 index 0000000..15cc6e7 --- /dev/null +++ b/packages/mosaic/framework/tools/tmux/auto-submit-drafts.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# auto-submit-drafts.sh — watchdog for Claude Code panes that receive channel +# messages but leave them as unsubmitted prompt drafts. Intended for Mos only. +set -uo pipefail + +TARGET="${1:-mos-claude}" +INTERVAL="${INTERVAL:-2}" +STABLE_SECONDS="${STABLE_SECONDS:-4}" +LOG_PREFIX="[auto-submit-drafts:$TARGET]" + +last_prompt="" +first_seen=0 + +prompt_text() { + tmux capture-pane -t "$TARGET" -p 2>/dev/null | python3 -c ' +import sys, re +lines = sys.stdin.read().splitlines() +idx = None +for i in range(len(lines)-1, -1, -1): + if "❯" in lines[i]: + idx = i + break +if idx is None: + raise SystemExit +parts = [] +after = lines[idx].split("❯", 1)[1] +parts.append(after) +for line in lines[idx+1:]: + # Stop at Claude Code separator/border lines. + if "─" in line or "╰" in line or "╭" in line: + break + s = line.replace("\u00a0", " ") + s = re.sub(r"[\x00-\x1f\x7f]", "", s).strip() + if s: + parts.append(s) +text = " ".join(parts).replace("\u00a0", " ") +text = re.sub(r"[\x00-\x1f\x7f]", "", text).strip() +print(text) +' +} + +while true; do + if ! tmux has-session -t "$TARGET" 2>/dev/null; then + echo "$LOG_PREFIX target missing; waiting" >&2 + sleep "$INTERVAL" + last_prompt="" + first_seen=0 + continue + fi + + current="$(prompt_text || true)" + now="$(date +%s)" + + if [[ -z "$current" ]]; then + last_prompt="" + first_seen=0 + sleep "$INTERVAL" + continue + fi + + if [[ "$current" != "$last_prompt" ]]; then + last_prompt="$current" + first_seen="$now" + sleep "$INTERVAL" + continue + fi + + age=$(( now - first_seen )) + if (( age >= STABLE_SECONDS )); then + echo "$LOG_PREFIX submitting stable draft after ${age}s: ${current:0:120}" >&2 + tmux send-keys -t "$TARGET" C-j + sleep 0.8 + tmux send-keys -t "$TARGET" C-m + sleep 2 + last_prompt="" + first_seen=0 + else + sleep "$INTERVAL" + fi +done diff --git a/packages/mosaic/framework/tools/tmux/send-message.sh b/packages/mosaic/framework/tools/tmux/send-message.sh index 90d1a32..8b0d753 100755 --- a/packages/mosaic/framework/tools/tmux/send-message.sh +++ b/packages/mosaic/framework/tools/tmux/send-message.sh @@ -77,10 +77,20 @@ snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g' # 1) Paste the body as a bracketed paste so multi-line content does not submit # line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`. -printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send - +# Buffer name MUST be unique per invocation: concurrent senders on the shared +# tmux server race a fixed name (load overwrites load, -d deletes underneath), +# cross-delivering or dropping messages — bit the fleet on the 2026-07-09 +# simultaneous restart (briefs swapped between sessions). +BUF="__mosaic_send_$$_$(date +%s%N)" +printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" - # -p = bracketed paste when the client supports it; fall back if not. -"${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \ - || "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_TARGET" +"${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \ + || "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \ + || "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null +# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail +# (e.g. the target vanished since the liveness check), delete explicitly — +# named buffers are exempt from tmux's buffer-limit eviction, so orphans +# would otherwise accumulate forever. sleep 0.5 # 2) Submit, then verify; flush with another Enter if it is still a draft. diff --git a/packages/mosaic/framework/tools/tmux/test-send-message-socket.sh b/packages/mosaic/framework/tools/tmux/test-send-message-socket.sh index 1107646..8697264 100755 --- a/packages/mosaic/framework/tools/tmux/test-send-message-socket.sh +++ b/packages/mosaic/framework/tools/tmux/test-send-message-socket.sh @@ -47,4 +47,32 @@ if capture_default | grep -qF "agent socket hello"; then fail "agent-send.sh leaked named-socket message to default tmux server" fi +# Concurrency: parallel senders on one server must not cross-deliver or drop. +# Locks the unique-per-invocation paste buffer (a fixed buffer name raced: +# load overwrote load, -d deleted underneath — messages swapped between panes). +CONC_N=5 +for i in $(seq 1 "$CONC_N"); do + tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'bash --noprofile --norc -i' +done +pids=() +for i in $(seq 1 "$CONC_N"); do + "$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null & + pids+=($!) +done +for pid in "${pids[@]}"; do + wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero" +done +sleep 0.2 +for i in $(seq 1 "$CONC_N"); do + pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p) + printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${i}-END" \ + || fail "concurrent send dropped payload for pane conc-$i" + for j in $(seq 1 "$CONC_N"); do + [ "$j" = "$i" ] && continue + if printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${j}-END"; then + fail "concurrent send cross-delivered payload $j to pane conc-$i" + fi + done +done + echo "ok - named tmux socket send tools"