diff --git a/packages/mosaic/framework/tools/tmux/send-message.sh b/packages/mosaic/framework/tools/tmux/send-message.sh index 8b0d753c..d397907f 100755 --- a/packages/mosaic/framework/tools/tmux/send-message.sh +++ b/packages/mosaic/framework/tools/tmux/send-message.sh @@ -31,7 +31,11 @@ # EXIT CODES # 0 delivered (submitted) or queued (agent busy; will process when free) # 1 tmux target not found -# 2 message still appears to be an unsubmitted draft after retries +# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL +# input prompt could not be located to confirm the message actually landed. +# Delivery is NEVER inferred from absence of evidence: if we cannot positively +# see the input box clear of the message (or the queued banner), we fail loud +# so the sender learns immediately instead of a silent worker->lead stall. # 3 usage error set -uo pipefail @@ -93,8 +97,14 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" - # would otherwise accumulate forever. sleep 0.5 -# 2) Submit, then verify; flush with another Enter if it is still a draft. -status="sent" +# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is +# still a draft. Success requires positive evidence — the queued banner, OR the +# REPL input box located AND clear of our message tail. The historical bug was +# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched +# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered" +# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only +# upgrade to delivered on positive evidence; anything we cannot confirm fails loud. +status="unconfirmed" for attempt in $(seq 1 $((RETRIES + 1))); do "${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter sleep 1.2 @@ -103,20 +113,28 @@ for attempt in $(seq 1 $((RETRIES + 1))); do if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then status="queued"; break fi - # Draft heuristic: the prompt glyph line still carries our message tail. - # (Submitted messages scroll up into history; a draft stays on the ❯ line.) + # Locate the REPL input box (prompt glyph). If we cannot see it, we have NO + # evidence of submission state — stay UNCONFIRMED and retry; never infer delivery. promptline=$(printf '%s' "$pane" | grep -E '❯|^>|│ >' | tail -1) + if [ -z "$promptline" ]; then + status="unconfirmed"; continue + fi + # Input box located AND still carrying our tail => unsubmitted draft. Flush + retry. + # (Submitted messages scroll up into history; a draft stays on the ❯ line.) if [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; then status="draft"; continue fi + # Input box located AND clear of our tail => positively submitted. This is the + # only path to success besides the queued banner. status="delivered"; break done [ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; } case "$status" in - delivered) echo "✓ delivered to $TARGET"; exit 0 ;; - queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;; - draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;; - *) echo "✓ sent to $TARGET (submission state indeterminate; verify with -v)"; exit 0 ;; + delivered) echo "✓ delivered to $TARGET"; exit 0 ;; + queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;; + draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;; + unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input prompt not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;; + *) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;; esac 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 86972646..bbc67d62 100755 --- a/packages/mosaic/framework/tools/tmux/test-send-message-socket.sh +++ b/packages/mosaic/framework/tools/tmux/test-send-message-socket.sh @@ -29,8 +29,8 @@ capture_default() { require_tmux -tmux -L "$SOCKET" new-session -d -s "$TARGET" -c "$TMPDIR" 'bash --noprofile --norc -i' -tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'bash --noprofile --norc -i' +tmux -L "$SOCKET" new-session -d -s "$TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i' +tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i' "$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out sleep 0.2 @@ -52,7 +52,7 @@ fi # 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' + tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i' done pids=() for i in $(seq 1 "$CONC_N"); do diff --git a/packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh b/packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh new file mode 100755 index 00000000..b1f9bbfe --- /dev/null +++ b/packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# test-send-message-verdict.sh — locks the fail-loud verdict logic of the patched +# send-message.sh against three real tmux-pane fixtures on a throwaway socket: +# +# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter +# (text scrolls to history, box clears) => exit 0 "✓ delivered". +# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact +# historical FALSE POSITIVE: pre-patch it printed "✓ delivered" +# exit 0; post-patch it MUST fail loud (exit 2, stderr +# "could not confirm submission"). +# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the +# input line) => exit 2, stderr "unsubmitted draft". +set -uo pipefail + +HERE=$(cd -- "$(dirname -- "$0")" && pwd) +SEND="$HERE/send-message.sh" +SOCKET="verdict-test-$RANDOM-$$" +TMP=$(mktemp -d) +trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n %s\n' "$1" "$2"; } + +command -v tmux >/dev/null 2>&1 || { echo "tmux required" >&2; exit 1; } + +# --- Fixture 1: a submitting REPL with a ❯ prompt box (interactive bash, glyph PS1). +# readline strips bracketed-paste markers just like a real agent REPL; Enter +# executes (text -> scrollback), leaving a fresh empty `❯ ` box. +tmux -L "$SOCKET" new-session -d -s repl -c "$TMP" \ + 'PS1="❯ " exec bash --noprofile --norc -i' +sleep 0.3 +out=$("$SEND" -L "$SOCKET" -t "=repl" -m "verdict fixture one delivered ok" 2>"$TMP/e1"); rc=$? +if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -qF "✓ delivered"; then + ok "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" +else + no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]" +fi + +# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this +# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2. +tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \ + 'PS1="sh-noglyph$ " exec bash --noprofile --norc -i' +sleep 0.3 +if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then + no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])" +else + rc=$? + if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then + ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)" + else + no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]" + fi +fi + +# --- Fixture 3: a ❯ box that never submits (sleep ignores stdin; TTY echo keeps the +# pasted tail sitting on the ❯ line) => draft => exit 2. +tmux -L "$SOCKET" new-session -d -s draft -c "$TMP" \ + 'printf "❯ "; exec sleep infinity' +sleep 0.3 +if out=$("$SEND" -L "$SOCKET" -t "=draft" -r 1 -m "verdict fixture three stuck unsubmitted draft" 2>"$TMP/e3"); then + no "draft: unsubmitted message must NOT report success" "expected exit 2, got 0 (out=[$out])" +else + rc=$? + if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e3"; then + ok "draft: stuck ❯-line message => exit 2 + 'unsubmitted draft'" + else + no "draft: stuck ❯-line message => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e3")]" + fi +fi + +echo "---" +echo "PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ]