Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 60 additions & 10 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2681,12 +2681,33 @@ EOF
# re-invokes this function from scratch with the same text after seeing
# an error, which is a human/escalation decision, not an automatic
# retry).
# Echoes empty|pending|unknown|send-failed, a subset of the proof-carrying
# submit vocabulary. Empty means confirmed submitted for every backend; how
# each backend confirms it is an internal decision, and herdr's is no longer
# literally "the composer read empty".
# Echoes empty|pending|queued-unsubmitted|unknown|send-failed, a subset of the
# proof-carrying submit vocabulary. Empty means confirmed submitted for every
# backend; how each backend confirms it is an internal decision, and herdr's is
# no longer literally "the composer read empty".
#
# Queued-unsubmitted (parked lane): when a harness accepts typed text into a
# numbered composer queue (`#N [fm-from-firstmate] ...`) without starting a turn,
# the composer row can read empty (and a pre-existing working agent_status can
# never prove this Enter landed). After any candidate success (idle-baseline
# busy, or non-idle composer empty), a pane scan for that positive queue marker
# keeps retrying Enter only; exhaustion reports `queued-unsubmitted` so
# fm-send exits non-zero instead of claiming delivery.
fm_backend_herdr_queued_unsubmitted_state() { # <target> -> queued|clear|unknown
local cap
# Generous enough to cover queue rows sitting just above the composer.
cap=$(fm_backend_herdr_capture "$1" "${FM_BACKEND_HERDR_COMPOSER_LINES:-20}" 2>/dev/null) \
|| { printf 'unknown'; return 0; }
if printf '%s\n' "$cap" | fm_composer_has_queued_unsubmitted; then
printf 'queued'
else
printf 'clear'
fi
}

fm_backend_herdr_send_text_submit() { # <target> <text> <retries> <enter-sleep> <settle>
local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 i=0 verdict baseline confirm_sleep
local queue_state candidate_empty=0
fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; }
fm_backend_herdr_send_literal "$target" "$text" || { printf 'send-failed'; return 0; }
sleep "$settle"
Expand All @@ -2698,17 +2719,46 @@ fm_backend_herdr_send_text_submit() { # <target> <text> <retries> <enter-sleep>
if [ "$baseline" = idle ]; then
verdict=$(fm_backend_herdr_wait_for_working "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE" \
"$confirm_sleep" "$FM_BACKEND_HERDR_SUBMIT_POLLS")
case "$verdict" in
busy) verdict=empty ;;
idle) verdict=pending ;;
unknown) printf 'unknown'; return 0 ;;
esac
else
sleep "$sleep_s"
verdict=$(fm_backend_herdr_composer_state "$target")
case "$verdict" in
empty) verdict=empty ;;
unknown) printf 'unknown'; return 0 ;;
*) verdict=pending ;;
esac
fi
candidate_empty=0
if [ "$verdict" = empty ]; then
candidate_empty=1
queue_state=$(fm_backend_herdr_queued_unsubmitted_state "$target")
case "$queue_state" in
queued) verdict=pending ;;
clear) printf 'empty'; return 0 ;;
*) printf 'unknown'; return 0 ;;
esac
fi
case "$verdict" in
busy) printf 'empty'; return 0 ;;
empty) printf 'empty'; return 0 ;;
unknown) printf 'unknown'; return 0 ;;
esac
i=$((i + 1))
[ "$i" -lt "$retries" ] || { printf 'pending'; return 0; }
[ "$i" -lt "$retries" ] || {
queue_state=$(fm_backend_herdr_queued_unsubmitted_state "$target")
case "$queue_state" in
queued) printf 'queued-unsubmitted' ;;
clear)
if [ "$candidate_empty" = 1 ]; then
printf 'empty'
else
printf 'pending'
fi
;;
*) printf 'unknown' ;;
esac
return 0
}
done
}

Expand Down
21 changes: 21 additions & 0 deletions bin/fm-composer-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,27 @@ fm_composer_idle_matches() {
esac
}

# fm_composer_has_queued_unsubmitted: positive proof that a typed firstmate
# steer is still sitting in a harness's numbered composer queue rather than
# having been submitted. Reads plain pane text on stdin.
#
# Observed shape (grok on herdr, parked lane holding a foregrounded wait):
# messages land as numbered pending items above an empty composer, e.g.
# #1 [fm-from-firstmate]<U+2063>corr=... <text>
# and stay there until a later Enter actually submits them. The composer row
# itself can read empty while the queue still holds the instruction, so a
# submit path that only checks the composer (or a pre-existing busy agent
# status) would falsely report delivery. Matching this shape is fail-closed
# only on positive proof: absence of the pattern is never treated as delivery
# by this helper alone; callers own the empty/pending/unknown contract.
# Returns 0 when at least one matching item is present, 1 otherwise.
fm_composer_has_queued_unsubmitted() {
# Looser than a full-line anchor so a bordered queue row (│ #1 [fm-from-...] │)
# still matches. The [fm-from-firstmate] label is the live charter marker and
# is distinctive enough not to collide with ordinary transcript text.
grep -qE '#[0-9]+[[:space:]]+\[fm-from-firstmate\]'
}

fm_composer_classify_content() { # <bordered> <content> [idle_re] [idle_case] [plain_content]
local bordered=$1 content=$2 idle_re=${3:-} idle_case=${4:-sensitive} plain_content
plain_content=${5:-$content}
Expand Down
14 changes: 13 additions & 1 deletion bin/fm-send.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
# retried (Enter only, never retyped) until the target backend confirms a
# submit or reports an inconclusive send. If a swallowed Enter is positively
# confirmed, fm-send exits NON-ZERO so the caller knows the steer did not land
# instead of silently leaving an unsubmitted instruction.
# instead of silently leaving an unsubmitted instruction. The same loud refusal
# applies when the steer lands as a numbered parked-lane queue item
# (`#N [fm-from-firstmate] ...`) that never leaves the composer queue: the
# submit path retries Enter within the budget and exits non-zero with a
# `queued-unsubmitted` verdict if the item is still present - never exit 0 for
# a positively queued-unsubmitted message.
# Submission dispatches through the target's recorded backend; the tmux adapter
# shares its composer/submit core with the away-mode daemon via bin/fm-tmux-lib.sh.
# Tune with FM_SEND_RETRIES (default 3) / FM_SEND_SLEEP (0.4).
Expand Down Expand Up @@ -308,6 +313,13 @@ else
echo "error: text not sent to $T ($TARGET_BACKEND send failed; tried $RESOLUTION_TRIED)" >&2
exit 1
;;
queued-unsubmitted)
if [ "$PENDING_REPLY_CREATED" = 1 ] && [ -n "$PENDING_REPLY_CORR" ]; then
fm_pending_reply_discard_undelivered "$STATE" "$PENDING_REPLY_CORR" || true
fi
echo "error: text not submitted to $T (queued-unsubmitted: numbered pending item still in the composer queue; tried $RESOLUTION_TRIED)" >&2
exit 1
;;
*)
if [ "$PENDING_REPLY_CREATED" = 1 ] && [ -n "$PENDING_REPLY_CORR" ]; then
fm_pending_reply_discard_undelivered "$STATE" "$PENDING_REPLY_CORR" || true
Expand Down
61 changes: 58 additions & 3 deletions bin/fm-tmux-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@
# as a known gap in `docs/herdr-backend.md` rather than patched here, so the
# tmux adapter does not paper over a herdr-specific shape.
#
# Queued-unsubmitted (parked lane / numbered composer queue): a different
# failure shape from the opencode busy-queue exception. Some harnesses (observed
# on grok) accept typed text into a numbered pending queue (`#N [fm-from-firstmate]
# ...`) above an empty composer without submitting it. The composer row can
# therefore read empty while the steer is still undelivered. After each Enter
# that would otherwise report empty, the submit core scans the pane for that
# positive queue marker via fm_composer_has_queued_unsubmitted; a match keeps
# retrying Enter only, and exhaustion reports `queued-unsubmitted` rather than
# empty. This never converts the opencode busy-queue case (text still pending
# inside the composer box, no numbered queue marker).
#
# Overrides: FM_COMPOSER_IDLE_RE matches an empty composer after ghost and
# structural border stripping. FM_BUSY_REGEX overrides the rendered busy-footer
# matching used here.
Expand Down Expand Up @@ -388,6 +399,18 @@ fm_pane_is_busy() { # <target> [harness]
| fm_busy_lines_match "$harness"
}

# fm_tmux_queued_unsubmitted_state: classify a numbered firstmate queue scan.
fm_tmux_queued_unsubmitted_state() { # <target> -> queued|clear|unknown
local pane
pane=$(tmux capture-pane -p -t "$1" -S -40 2>/dev/null) \
|| { printf 'unknown'; return 0; }
if printf '%s\n' "$pane" | fm_composer_has_queued_unsubmitted; then
printf 'queued'
else
printf 'clear'
fi
}

# fm_tmux_submit_core: type <text> into <target> ONCE, then submit with Enter,
# verifying the composer cleared. Retries Enter ONLY — never retypes, because a
# swallowed Enter leaves our text in the composer and retyping would duplicate
Expand All @@ -401,24 +424,56 @@ fm_pane_is_busy() { # <target> [harness]
# `empty` so the caller does not re-send), while an idle pane keeps `pending` as
# a genuine swallow. Pending-unproven receives the same Enter retry budget but
# never reaches this exception.
# Queued-unsubmitted: a numbered `#N [fm-from-firstmate]` item still visible
# after Enter means the steer landed in a parked queue, not that it was
# delivered. That positive proof overrides an empty composer row, keeps
# retrying Enter only, and exhausts as `queued-unsubmitted` (never as empty).
fm_tmux_submit_enter_core() { # <target> <retries> <enter-sleep>
local target=$1 retries=$2 sleep_s=$3 i=0 state
local target=$1 retries=$2 sleep_s=$3 i=0 state queue_state candidate_empty=0
while :; do
tmux send-keys -t "$target" Enter 2>/dev/null || true
sleep "$sleep_s"
state=$(fm_tmux_composer_state "$target")
candidate_empty=0
case "$state" in
pending|pending-unproven) ;;
*) printf '%s' "$state"; return 0 ;;
empty)
candidate_empty=1
queue_state=$(fm_tmux_queued_unsubmitted_state "$target")
case "$queue_state" in
queued) state=pending ;;
clear) printf 'empty'; return 0 ;;
*) printf 'unknown'; return 0 ;;
esac
;;
*)
queue_state=$(fm_tmux_queued_unsubmitted_state "$target")
case "$queue_state" in
queued) state=pending ;;
clear) printf '%s' "$state"; return 0 ;;
*) printf 'unknown'; return 0 ;;
esac
;;
esac
i=$((i + 1))
[ "$i" -lt "$retries" ] || break
done
# Retries exhausted. A still-visible numbered queue is a loud undelivered
# state and must never be converted to empty by the busy-queue exception.
queue_state=$(fm_tmux_queued_unsubmitted_state "$target")
case "$queue_state" in
queued) printf 'queued-unsubmitted'; return 0 ;;
unknown) printf 'unknown'; return 0 ;;
esac
if [ "$candidate_empty" = 1 ]; then
printf 'empty'
return 0
fi
if [ "$state" != pending ]; then
printf '%s' "$state"
return 0
fi
# Retries exhausted, composer still shows proven pending.
# Retries exhausted, composer still shows proven pending (no numbered queue).
# If the pane is busy (agent mid-turn), the harness accepted the Enter
# and queued the message for processing when the current turn ends.
# Treat it as submitted so the caller does not re-send.
Expand Down
3 changes: 3 additions & 0 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ Text is typed once; only Enter is retried.

On an idle or done native baseline, submit confirmation waits for `working` or `blocked` across a bounded polling window.
On an already active or unreadable baseline, it falls back to conservative composer clearance.
A would-be success on either path is also checked for a numbered `#N [fm-from-firstmate]` item still in the pane's composer queue.
That queued-unsubmitted proof keeps the Enter-only retry active and, on exhaustion, makes `fm-send.sh` exit non-zero with a named `queued-unsubmitted` diagnostic.
A failed queue read reports unknown rather than assuming delivery.
A fully unreadable target stops retrying and reports unknown.
The poll density bounds the residual possibility of an extremely fast complete turn; a missed transition can cause only a redundant Enter on an empty composer, never duplicate message text.

Expand Down
9 changes: 5 additions & 4 deletions docs/tmux-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,17 @@ The submit acknowledgement and away-mode supervisor-pane busy guard below still
The supervisor guard selects only the detected primary harness's signature rather than a global union of vendor patterns.

`bin/fm-tmux-lib.sh` owns exact type-and-submit mechanics.
It types a message once and retries Enter only until the composer clears.
Only a proven empty composer is a positive delivery acknowledgement.
It types a message once and retries Enter only until the backend has a positive delivery acknowledgement.
A proven empty composer is not enough while a numbered `#N [fm-from-firstmate]` item remains in the pane's composer queue.
That queued-unsubmitted proof keeps the Enter-only retry active and, on exhaustion, makes `fm-send.sh` exit non-zero with a named `queued-unsubmitted` diagnostic.
Text left in established structure remains `pending`, text in ambiguous structure remains unproven, and unreadable or unsafe state remains unknown.
`fm-send.sh` reports every unconfirmed verdict as a failure instead of retyping or assuming delivery.

OpenCode 1.18.4 has one busy-queue exception.
While OpenCode is mid-turn, Enter queues the message but leaves its text visible until the turn completes.
After the normal retry budget, only structurally proven pending text in a provably busy pane is accepted as queued, while an idle pane remains `pending` as a genuine swallowed Enter.
Ambiguous pending text never receives the busy-queue conversion.
`tests/fm-tmux-submit-busy.test.sh` covers busy and idle panes with proven, ambiguous, and cleared composers.
Ambiguous pending text never receives the busy-queue conversion, and a numbered queued-unsubmitted item always overrides it.
`tests/fm-tmux-submit-busy.test.sh` covers busy and idle panes with proven, ambiguous, cleared, and numbered queued-unsubmitted states.

## Limits and regression entry points

Expand Down
6 changes: 4 additions & 2 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ tests/fm-kimi-harness.test.sh
tests/fm-tmux-submit-busy.test.sh
```

Expected structural matrix: real text on any content row is pending; all-empty complete boxes are empty; unreadable, incomplete, or unsafe boxes are unknown; and non-bordered panes retain cursor-row compatibility.
Expected submit matrix: proven pending plus busy is accepted as queued; proven pending plus idle remains pending; ambiguous pending is never converted by the busy exception; and only a proven empty composer succeeds directly.
The [tmux backend guide](../tmux-backend.md#current-behavior-and-safety) owns the current structural and submit semantics.
The suite exercises empty, pending, ambiguous, busy, idle, numbered queue retry, queue-clear, retry-exhaustion, and unreadable queue cases against that contract.

### Cleanup endpoint identity

Expand Down Expand Up @@ -350,6 +350,8 @@ Real captures verified these active distinctions:
- A bare shell prompt has no safe agent-composer container and is unknown.

`tests/fm-composer-ghost.test.sh`, `tests/fm-composer-lib.test.sh`, and the Herdr composer cases pin the exact captured ANSI bytes.
The [Herdr backend guide](../herdr-backend.md#current-transport-behavior) owns the current submit semantics.
`tests/fm-backend-herdr.test.sh` and `tests/fm-send-strict.test.sh` exercise numbered queue retry, queue-clear, retry-exhaustion, unreadable queue, and the caller-facing failure diagnostic against that contract.
The U+2063 operational and routed-request separators were exercised through a real Pi-on-Herdr path; the byte-exact active regression is:

```sh
Expand Down
Loading
Loading