fix(tmux): confirm delivery by draft transition, not by prompt glyph (#1257)
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
send-message.sh located the REPL input box with grep -E '❯|^>|│ >'. That set is
Claude Code's box. A pi seat renders a bare U+2500 rule with no glyph, so on every
idle pi seat the capture succeeded, the grep matched nothing, status stayed
"unconfirmed", and the tool exited 2 "may be UNDELIVERED" with the paste and the
Enter both landed. The stderr tells the operator to retry, and that retry is the
duplicate delivery reported against the same tool.
Confirmation is now runtime-agnostic: our message tail sits on the input line
(located by cursor row, no glyph) before Enter and has left it after. That
transition is positive proof of submission.
Absence still proves nothing, which is the guard the 2026-08 fix was reaching for
and got backwards. Two positive checks keep it:
- a prompt box that IS locatable and still carries our tail => draft, exit 2.
This covers the cursor-row blind spot: a cooked pane whose foreground process
never reads stdin echoes the paste through the kernel line discipline and
moves the cursor off it on Enter, which by cursor row alone is indistinguishable
from a real submit.
- no draft ever observed on the input line => unconfirmed, non-zero.
Tests, both red-first against the shipping blob d397907:
test-send-message-glyph-agnostic.sh (new, 6 fixtures) 4/6 -> 6/6
test-send-message-verdict.sh (fixture 2 reshaped, 2b added) 3/4 -> 4/4
Fixture 2 of the verdict suite asserted exit 2 for a glyphless pane that submits
and was labelled "false-positive FIXED". A pi seat is that fixture, so the suite
was locking the bug in. It is reshaped deliberately, and the guard it was credited
with moves to new fixture 2b (glyphless AND non-submitting, raw/no-echo) so the
"never infer delivered from absence" property is tested positively rather than as
a side effect.
Measured on tmux 3.7b (sb-it-1-dt), 3.5a (fomo-lin), and dragon-lin.
Co-authored-by: scooby <[email protected]>
This commit is contained in:
@@ -97,13 +97,34 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
||||
# would otherwise accumulate forever.
|
||||
sleep 0.5
|
||||
|
||||
# 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.
|
||||
# 2) Submit, then POSITIVELY confirm submission by DRAFT TRANSITION, not by prompt
|
||||
# glyph. The historical bug was treating ABSENCE of a draft as delivery; the
|
||||
# 2026-08 fix over-corrected to glyph inference (grep '❯|^>|│ >'), which locates
|
||||
# only Claude Code's box and false-NEGATIVES every glyphless REPL (pi renders a
|
||||
# U+2500 rule, no glyph) — a delivered message reported "UNDELIVERED", driving a
|
||||
# retry that duplicates it. Runtime-agnostic evidence: our message tail sits on
|
||||
# the INPUT line (located by the cursor row, not a glyph) BEFORE Enter, and has
|
||||
# LEFT it AFTER — that transition is positive proof of submission and needs no
|
||||
# glyph. Absence alone still never means delivered: if we never saw our draft on
|
||||
# the input line we stay UNCONFIRMED (wrong/dead pane), and a draft that never
|
||||
# leaves the input line stays a DRAFT (exit 2), preserving both historical guards.
|
||||
_cursor_line() { # echo the pane's current input (cursor) line, glyph-free
|
||||
local cy line
|
||||
cy=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" -F '#{cursor_y}' 2>/dev/null) || return 1
|
||||
[ -n "$cy" ] || return 1
|
||||
"${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null | sed -n "$((cy + 1))p"
|
||||
}
|
||||
_draft_on_input() { # true iff our message tail is sitting on the input line now
|
||||
[ -n "$snippet" ] || return 1
|
||||
printf '%s' "$(_cursor_line)" | grep -qF "$snippet"
|
||||
}
|
||||
|
||||
# Baseline: after the paste, our draft must be on the input line. This is positive
|
||||
# proof we are on the right pane and the paste landed — the anchor the transition
|
||||
# check measures against.
|
||||
saw_draft=0
|
||||
_draft_on_input && saw_draft=1
|
||||
|
||||
status="unconfirmed"
|
||||
for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
||||
@@ -113,20 +134,26 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||
if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then
|
||||
status="queued"; break
|
||||
fi
|
||||
# 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.
|
||||
# POSITIVE draft evidence from a located prompt box, when one exists. This is the
|
||||
# cursor-row check's blind spot: a pane in COOKED mode (a plain shell whose
|
||||
# foreground process never reads stdin) echoes our paste via the kernel line
|
||||
# discipline and moves the cursor off it on Enter, which is indistinguishable from
|
||||
# a real submit by cursor row alone. If a prompt box IS locatable and still carries
|
||||
# our tail, that is affirmative proof the message was not consumed. Absence of a
|
||||
# glyph is still never used for anything — that inference is the original E7 bug.
|
||||
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
|
||||
if [ -n "$promptline" ] && [ -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
|
||||
if [ "$saw_draft" = 1 ]; then
|
||||
if _draft_on_input; then
|
||||
status="draft"; continue # still on the input line => not submitted; flush + retry
|
||||
fi
|
||||
status="delivered"; break # left the input line => positively submitted
|
||||
fi
|
||||
# No confirmed baseline yet: try to (re)acquire it; never infer delivery from absence.
|
||||
if _draft_on_input; then saw_draft=1; status="draft"; continue; fi
|
||||
status="unconfirmed"; continue
|
||||
done
|
||||
|
||||
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Red-first regression test for E7 (#1017 task 2): the confirm-check must bind
|
||||
# "delivered" to WHETHER THE MESSAGE WAS SUBMITTED, not to which runtime's prompt
|
||||
# glyph is present. A pi seat renders a U+2500 rule input box with no ❯/^>/│ >
|
||||
# glyph; send-message.sh:118 locates the box only by glyph, so a genuinely
|
||||
# delivered message on a glyphless REPL falsely reports exit 2 "may be UNDELIVERED",
|
||||
# and the operator's rc=2-driven retry duplicates it.
|
||||
#
|
||||
# Parameterized on $SEND: RED against the shipping blob (B and D fail), GREEN
|
||||
# against a candidate patch. No pi; no fake HOME; hermetic throwaway socket.
|
||||
#
|
||||
# Submission counting is EXACT and terminal-echo-independent: the fixture message
|
||||
# is `echo <tok> >>SINK`; each real submission appends one line. wc -l SINK ==
|
||||
# number of times the REPL actually executed the send. This does not depend on how
|
||||
# many times the marker string is painted on screen.
|
||||
set -u
|
||||
SEND="${SEND:?set SEND=/path/to/send-message.sh}"
|
||||
SOCKET="glyphagnostic-$$"
|
||||
TMP="$(mktemp -d)"
|
||||
tmux() { command tmux -L "$SOCKET" "$@"; }
|
||||
cleanup() { command tmux -L "$SOCKET" kill-server 2>/dev/null; rm -rf "$TMP"; }
|
||||
trap cleanup EXIT
|
||||
pass=0; fail=0
|
||||
ok() { printf 'ok %s\n' "$1"; pass=$((pass+1)); }
|
||||
no() { printf 'FAIL %s -- %s\n' "$1" "$2"; fail=$((fail+1)); }
|
||||
|
||||
mk() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" "PS1='$2' exec bash --noprofile --norc -i"; sleep 0.5; }
|
||||
subs() { [ -f "$1" ] && wc -l <"$1" | tr -d ' ' || echo 0; } # exact submission count
|
||||
|
||||
echo "SEND=$SEND tmux $(command tmux -V | awk '{print $2}')"
|
||||
|
||||
# --- A (control): glyph box (❯) that submits => exit 0, exactly one submission.
|
||||
mk ctl '❯ '
|
||||
SINK="$TMP/sink.ctl"
|
||||
out=$("$SEND" -L "$SOCKET" -t ctl -m "echo x >>'$SINK'" 2>"$TMP/e.ctl"); rc=$?; sleep 0.4
|
||||
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
|
||||
ok "control: ❯-box submits => exit 0, exactly one submission"
|
||||
else no "control: ❯-box submits => exit 0, one submission" "rc=$rc subs=$(subs "$SINK") err=[$(cat "$TMP/e.ctl")]"; fi
|
||||
|
||||
# --- B (THE false-rc regression): glyphless U+2500 box that SUBMITS. Message lands
|
||||
# (subs==1) yet shipping reports exit 2. Must be exit 0.
|
||||
mk sub $'──────── \n'
|
||||
SINK="$TMP/sink.sub"
|
||||
out=$("$SEND" -L "$SOCKET" -t sub -m "echo x >>'$SINK'" 2>"$TMP/e.sub"); rc=$?; sleep 0.4
|
||||
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
|
||||
ok "glyphless: U+2500 box that submits => exit 0 (delivered, not 'UNDELIVERED')"
|
||||
else no "glyphless: U+2500 box that submits => exit 0" \
|
||||
"rc=$rc subs=$(subs "$SINK")(delivered=$([ "$(subs "$SINK")" -ge 1 ] && echo yes||echo no)) err=[$(cat "$TMP/e.sub")]"; fi
|
||||
|
||||
# --- D (duplicate arm): operator follows the rc=2 stderr and retries once. On the
|
||||
# glyphless box, shipping => two submissions (the reported duplicate). The
|
||||
# property: one logical send => exactly one submission. Same fix closes it.
|
||||
mk dup $'──────── \n'
|
||||
SINK="$TMP/sink.dup"
|
||||
tries=0
|
||||
for attempt in 1 2; do
|
||||
tries=$((tries+1))
|
||||
out=$("$SEND" -L "$SOCKET" -t dup -m "echo x >>'$SINK'" 2>/dev/null); rc=$?
|
||||
sleep 0.4
|
||||
[ "$rc" = 0 ] && break # operator stops retrying only when told delivered
|
||||
done
|
||||
if [ "$(subs "$SINK")" = 1 ]; then
|
||||
ok "duplicate: one logical send (rc-driven retry) => exactly one submission (tries=$tries)"
|
||||
else no "duplicate: one logical send => exactly one submission" "submissions=$(subs "$SINK") tries=$tries"; fi
|
||||
|
||||
# --- E (faithful hung managed TUI, NOT a cooked shell): raw/no-echo, paints nothing.
|
||||
# A cooked `sleep infinity` echoes the paste via the kernel line discipline and
|
||||
# false-passes a cursor-row fix that is correct on real seats (measured). So: raw.
|
||||
mk_rawstuck() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
|
||||
"bash --noprofile --norc -c 'stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity'"; sleep 0.5; }
|
||||
mk_rawstuck estuck
|
||||
SINK="$TMP/sink.estuck"
|
||||
out=$("$SEND" -L "$SOCKET" -t estuck -r 1 -m "this stuck draft was never submitted" 2>/dev/null); rc=$?
|
||||
sleep 0.3
|
||||
if [ "$rc" != 0 ] && [ "$(subs "$SINK")" = 0 ]; then
|
||||
ok "raw/no-echo stuck TUI (not submitted) => non-zero (no false delivered)"
|
||||
else no "raw stuck TUI must NOT report delivered" "rc=$rc subs=$(subs "$SINK")"; fi
|
||||
|
||||
# --- F (busy/queued branch, your BUSY-not-runtime finding): glyphless pane rendering the
|
||||
# queued banner, never consuming. QUEUED_RE :113 fires before the glyph grep => rc=0.
|
||||
mk_busy() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
|
||||
"bash --noprofile --norc -c 'printf \"Press up to edit queued messages\n\"; exec sleep infinity'"; sleep 0.5; }
|
||||
mk_busy ebusy
|
||||
SINK="$TMP/sink.ebusy"
|
||||
out=$("$SEND" -L "$SOCKET" -t ebusy -m "echo x >>'$SINK'" 2>/dev/null); rc=$?; sleep 0.3
|
||||
if [ "$rc" = 0 ]; then
|
||||
ok "busy/queued-banner glyphless => exit 0 (queued is delivery; runtime owns custody)"
|
||||
else no "busy/queued-banner must report delivered" "rc=$rc"; fi
|
||||
|
||||
# --- C (historical-bug guard): unresolvable target. No pane ever carried our draft
|
||||
# => must fail, never infer delivered from absence of a glyph/snippet.
|
||||
if out=$("$SEND" -L "$SOCKET" -t "nonexistent-$$" -m "echo x >>'$TMP/sink.wrong'" 2>/dev/null); then
|
||||
no "wrong-pane: unresolvable target must NOT report success" "expected non-zero, got 0"
|
||||
else ok "wrong-pane: unresolvable target => non-zero (no false delivered)"; fi
|
||||
|
||||
echo "---"; echo "pass=$pass fail=$fail"
|
||||
[ "$fail" = 0 ]
|
||||
@@ -4,10 +4,13 @@
|
||||
#
|
||||
# 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").
|
||||
# 2. DELIVERED — a pane with NO prompt glyph that DOES submit => exit 0. A pi
|
||||
# seat is this fixture (U+2500 rule, no glyph). Reshaped for
|
||||
# #1257; see the note at the fixture for why the old exit-2
|
||||
# assertion was wrong.
|
||||
# 2b. UNCONFIRMED— a glyphless pane that never submits (raw/no-echo hung TUI)
|
||||
# => must fail loud. This carries the historical
|
||||
# false-positive guard that fixture 2 used to be credited with.
|
||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||
# input line) => exit 2, stderr "unsubmitted draft".
|
||||
set -uo pipefail
|
||||
@@ -37,19 +40,44 @@ 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.
|
||||
# --- Fixture 2: NO prompt glyph, and the pane DOES submit (interactive bash).
|
||||
# RESHAPED 2026-08-16 (#1257), deliberately. This fixture previously asserted
|
||||
# exit 2 here and was labelled "false-positive FIXED". That assertion was wrong,
|
||||
# and locking it in is what kept E7 alive: the pane submits, so "delivered" is
|
||||
# the truth, and a pi seat — whose input box is a bare U+2500 rule with no glyph
|
||||
# — IS this fixture. Reporting exit 2 for it told operators a delivered message
|
||||
# may be undelivered, and the retry that advice invites is the duplicate.
|
||||
#
|
||||
# The guard this fixture was reaching for is real and is NOT dropped: "never
|
||||
# infer delivered from absence" is now enforced positively by fixture 2b below
|
||||
# (glyphless AND not submitting => must fail) and by fixture 3 (locatable box
|
||||
# still carrying our tail => draft). Absence alone decides nothing either way.
|
||||
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])"
|
||||
out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -qF "✓ delivered"; then
|
||||
ok "delivered: glyphless pane that submits => exit 0 (runtime-agnostic, E7 FIXED)"
|
||||
else
|
||||
no "delivered: glyphless pane that submits => exit 0" "rc=$rc out=[$out] err=[$(cat "$TMP/e2")]"
|
||||
fi
|
||||
|
||||
# --- Fixture 2b: NO prompt glyph AND never submits — a hung managed TUI holding the
|
||||
# terminal in raw/no-echo, which is what a stuck agent seat actually is (measured
|
||||
# on live pi: stty -echo -icanon). Nothing is echoed, nothing is consumed, so
|
||||
# there is no positive evidence of submission and the tool MUST fail loud. This
|
||||
# is the historical false-positive guard, kept as a positive test.
|
||||
tmux -L "$SOCKET" new-session -d -s rawstuck -c "$TMP" \
|
||||
'bash --noprofile --norc -c "stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity"'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=rawstuck" -r 1 -m "verdict fixture two-b never submitted" 2>"$TMP/e2b"); then
|
||||
no "unconfirmed: glyphless hung TUI must NOT report success" "expected non-zero, 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)"
|
||||
if [ "$rc" -ne 0 ] && grep -qF "could not confirm submission" "$TMP/e2b"; then
|
||||
ok "unconfirmed: glyphless hung TUI (raw/no-echo) => non-zero + 'could not confirm submission'"
|
||||
else
|
||||
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
|
||||
no "unconfirmed: glyphless hung TUI => non-zero + stderr" "rc=$rc err=[$(cat "$TMP/e2b")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user