fix(tools/tmux): unique per-invocation paste buffer; track auto-submit-drafts.sh
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

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.
This commit is contained in:
enhance
2026-07-09 12:22:13 -05:00
parent 851c67c27b
commit c9c07cdeba
4 changed files with 125 additions and 3 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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"