Reconcile docs with reality; fix module graph, token compare, health #1
+119
-24
@@ -9,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -360,27 +361,87 @@ func (b *TmuxBackend) LaunchTransport(harness string) LaunchTransport {
|
||||
return LaunchInline
|
||||
}
|
||||
|
||||
// promptLine matches the harness input editor's own line. Its remainder is
|
||||
// what is still sitting unsubmitted, whether that is literal text or the
|
||||
// TUI's own "[Pasted text #1 +66 lines]" placeholder.
|
||||
// promptLine matches a harness input line. A queued or already-accepted
|
||||
// message renders with the same prefix, so a match alone proves nothing about
|
||||
// what is still unsubmitted; see inputState.
|
||||
var promptLine = regexp.MustCompile(`(?m)^[ \t]*[>❯][ \t]*(.*)$`)
|
||||
|
||||
// pendingInput returns whatever the input editor still holds.
|
||||
func pendingInput(pane string) string {
|
||||
m := promptLine.FindAllStringSubmatch(pane, -1)
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[len(m)-1][1])
|
||||
// separatorRow matches the rule Claude Code draws above and below its editor.
|
||||
var separatorRow = regexp.MustCompile(`^[\s─━┄┅┈┉-]+$`)
|
||||
|
||||
// InputState is what the interactive editor holds. Active distinguishes the
|
||||
// live editor from queued or already-submitted input: both render with the
|
||||
// same "❯" prefix, but only the live editor owns the pane cursor (F17,
|
||||
// found live during burn-in run 3).
|
||||
type InputState struct {
|
||||
Text string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// ConfirmLaunch waits for observable proof that the harness accepted the
|
||||
// prompt. An input editor that still holds the submitted text is definite
|
||||
// failure, which is the whole point: TaskLaunchAcknowledged previously meant
|
||||
// "Prompt returned nil" and so reported a launch that never happened.
|
||||
// sameInput compares editor content to what was submitted. Soft wrapping may
|
||||
// break the text at any column and re-indent the continuation, so the
|
||||
// comparison ignores whitespace entirely.
|
||||
func sameInput(a, b string) bool {
|
||||
strip := func(s string) string { return strings.Join(strings.Fields(s), "") }
|
||||
return strip(a) == strip(b)
|
||||
}
|
||||
|
||||
// inputState reads the editor that owns the cursor. Scanning every line
|
||||
// beginning with "❯" cannot tell an unsubmitted prompt from one the
|
||||
// harness already queued, which is why this asks tmux where the cursor is.
|
||||
func (b *TmuxBackend) inputState(ctx context.Context, s Session) (InputState, error) {
|
||||
out, err := b.command(ctx, "display-message", "-p", "-t", tmuxTarget(s.PaneID), "#{cursor_y}")
|
||||
if err != nil {
|
||||
return InputState{}, err
|
||||
}
|
||||
row, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
||||
if err != nil {
|
||||
return InputState{}, fmt.Errorf("tmux backend: cursor row %q: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
// Screen rows, unjoined: -J merges wrapped rows and would invalidate the
|
||||
// cursor row index.
|
||||
raw, err := b.command(ctx, "capture-pane", "-p", "-t", tmuxTarget(s.PaneID))
|
||||
if err != nil {
|
||||
return InputState{}, err
|
||||
}
|
||||
rows := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
|
||||
if row < 0 || row >= len(rows) {
|
||||
return InputState{}, nil
|
||||
}
|
||||
start := -1
|
||||
for i := row; i >= 0; i-- {
|
||||
if promptLine.MatchString(rows[i]) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
if separatorRow.MatchString(rows[i]) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return InputState{}, nil
|
||||
}
|
||||
parts := []string{strings.TrimSpace(promptLine.FindStringSubmatch(rows[start])[1])}
|
||||
for i := start + 1; i <= row; i++ {
|
||||
parts = append(parts, strings.TrimSpace(rows[i]))
|
||||
}
|
||||
return InputState{Text: strings.TrimSpace(strings.Join(parts, " ")), Active: true}, nil
|
||||
}
|
||||
|
||||
// launchResubmitLimit bounds the intervention independently of the observation
|
||||
// deadline. A slow TUI must not receive a fortieth Enter after it accepted the
|
||||
// first: three exact-editor resubmits, then observation only.
|
||||
const launchResubmitLimit = 3
|
||||
|
||||
// ConfirmLaunch drives the submit to a decision instead of assuming one Enter
|
||||
// landed. Burn-in run 3 proved the submit is not deterministic: the text
|
||||
// reached the editor on all three attempts and the following Enter never took
|
||||
// effect. So this resends Enter while the live editor still holds exactly what
|
||||
// was submitted, up to launchResubmitLimit times, then observes until the
|
||||
// deadline.
|
||||
//
|
||||
// This is not a timing workaround for the submit itself. The submit is
|
||||
// deterministic; this waits for the harness to visibly react to it.
|
||||
// The returned evidence records how many submits it took, which is the only
|
||||
// way to tell a harness that needs a second Enter from one that needed none.
|
||||
func (b *TmuxBackend) ConfirmLaunch(ctx context.Context, s Session, submitted string) (string, error) {
|
||||
timeout, poll := b.LaunchConfirmTimeout, b.LaunchConfirmPoll
|
||||
if timeout <= 0 {
|
||||
@@ -389,14 +450,42 @@ func (b *TmuxBackend) ConfirmLaunch(ctx context.Context, s Session, submitted st
|
||||
if poll <= 0 {
|
||||
poll = 250 * time.Millisecond
|
||||
}
|
||||
deadline := b.now().Add(timeout)
|
||||
var last string
|
||||
first := b.now()
|
||||
deadline := first.Add(timeout)
|
||||
attempts, resubmits := 1, 0
|
||||
var lastResubmit time.Time
|
||||
evidence := func(kind string) string {
|
||||
return fmt.Sprintf("confirmation=%s submit_attempts=%d first_submit_at=%s confirmed_at=%s",
|
||||
kind, attempts, first.UTC().Format(time.RFC3339Nano), b.now().UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
var last InputState
|
||||
for {
|
||||
pane, err := b.PaneCapture(ctx, s, "recent")
|
||||
state, err := b.inputState(ctx, s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if last = pendingInput(pane); last == "" {
|
||||
last = state
|
||||
switch {
|
||||
case state.Active && sameInput(state.Text, submitted):
|
||||
// Exactly what was submitted still owns the cursor, so the submit
|
||||
// did not take. Resend Enter, spaced so a TUI that accepted the
|
||||
// previous one cannot receive another inside its own redraw.
|
||||
if resubmits < launchResubmitLimit && (lastResubmit.IsZero() || b.now().Sub(lastResubmit) >= 2*poll) {
|
||||
if err := b.SendKeys(ctx, s, []string{"Enter"}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
resubmits++
|
||||
attempts++
|
||||
lastResubmit = b.now()
|
||||
}
|
||||
case state.Active && queuedInput(state.Text):
|
||||
// The harness accepted the instruction and parked it behind the
|
||||
// current turn. Queued is submitted.
|
||||
return evidence("queued"), nil
|
||||
case state.Active && state.Text != "":
|
||||
// Something else is in the editor, a paste placeholder for
|
||||
// instance. Not proof of acceptance, so keep observing.
|
||||
default:
|
||||
status, statusErr := b.AgentStatus(ctx, s)
|
||||
if statusErr != nil {
|
||||
return "", statusErr
|
||||
@@ -405,18 +494,18 @@ func (b *TmuxBackend) ConfirmLaunch(ctx context.Context, s Session, submitted st
|
||||
case "exited":
|
||||
return "", fmt.Errorf("%w: pane %s exited before the harness reacted", ErrPromptNotSubmitted, s.PaneID)
|
||||
case "busy":
|
||||
return "input editor cleared, agent busy", nil
|
||||
return evidence("busy"), nil
|
||||
case "blocked":
|
||||
// A permission dialog is the harness acting on the
|
||||
// instruction, so submission is proven even though the
|
||||
// session now needs an operator.
|
||||
return "input editor cleared, agent awaiting approval", nil
|
||||
return evidence("blocked"), nil
|
||||
default:
|
||||
return "input editor cleared", nil
|
||||
return evidence("editor_cleared"), nil
|
||||
}
|
||||
}
|
||||
if !b.now().Before(deadline) {
|
||||
return "", fmt.Errorf("%w: pane %s still holds %q after %s", ErrPromptNotSubmitted, s.PaneID, last, timeout)
|
||||
return "", fmt.Errorf("%w: pane %s still holds %q after %s and %d submits", ErrPromptNotSubmitted, s.PaneID, last.Text, timeout, attempts)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -425,3 +514,9 @@ func (b *TmuxBackend) ConfirmLaunch(ctx context.Context, s Session, submitted st
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// queuedInput recognizes the editor Claude Code shows once it has taken a
|
||||
// prompt and parked it behind the running turn.
|
||||
func queuedInput(text string) bool {
|
||||
return strings.Contains(strings.ToLower(text), "queued message")
|
||||
}
|
||||
|
||||
+169
-13
@@ -140,22 +140,173 @@ func TestLaunchTransportIsHarnessSpecific(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The exact F15 signature: the TUI coalesced the launch text into a paste and
|
||||
// absorbed the Enter, leaving the instruction in the input editor.
|
||||
func TestPendingInputSeesAnUnsubmittedPaste(t *testing.T) {
|
||||
stalled := " b141e4f6 - none | 0/1.0m ctx\n❯ [Pasted text #1 +66 lines]\n────────────\n"
|
||||
if got := pendingInput(stalled); got != "[Pasted text #1 +66 lines]" {
|
||||
t.Fatalf("pendingInput=%q, want the unsubmitted paste", got)
|
||||
// F17, found live during burn-in run 3: a queued or already-accepted prompt
|
||||
// renders with the same "❯" prefix as an unsubmitted one, so only cursor
|
||||
// ownership can tell them apart.
|
||||
func TestInputStateReadsTheEditorOwningTheCursor(t *testing.T) {
|
||||
wrapped := "\u2500\u2500\u2500\u2500\n\u276f @.orchestra/launch.md is your complete Orchestra launch instruction. Read\n .orchestra/launch.md now and follow it.\n\u2500\u2500\u2500\u2500\n"
|
||||
b := paneTmux(t, wrapped, 2)
|
||||
state, err := b.inputState(context.Background(), Session{PaneID: "s:1.0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := pendingInput("esc to interrupt\n❯ \n"); got != "" {
|
||||
t.Fatalf("pendingInput=%q, want empty for a cleared editor", got)
|
||||
if !state.Active || !sameInput(state.Text, LaunchReference) {
|
||||
t.Fatalf("state=%+v, want the wrapped launch reference in the active editor", state)
|
||||
}
|
||||
if got := pendingInput("❯ @.orchestra/launch.md is your complete Orchestra launch instruction."); got == "" {
|
||||
t.Fatal("a one-line reference still sitting in the editor must count as pending")
|
||||
|
||||
// The same text, but the cursor sits in the empty editor below it: the
|
||||
// harness took the prompt and queued it.
|
||||
queued := " \u276f @.orchestra/launch.md is your complete Orchestra launch instruction. Read\n .orchestra/launch.md now and follow it.\n\u2500\u2500\u2500\u2500\n\u276f Press up to edit queued messages\n"
|
||||
b = paneTmux(t, queued, 3)
|
||||
state, err = b.inputState(context.Background(), Session{PaneID: "s:1.0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := pendingInput("no editor line here"); got != "" {
|
||||
t.Fatalf("pendingInput=%q, want empty when no editor line is visible", got)
|
||||
if !state.Active || sameInput(state.Text, LaunchReference) {
|
||||
t.Fatalf("state=%+v, want the queued marker rather than the launch text", state)
|
||||
}
|
||||
if !queuedInput(state.Text) {
|
||||
t.Fatalf("state=%+v, want queued input recognized", state)
|
||||
}
|
||||
}
|
||||
|
||||
// Run 3 proved one Enter is not enough. A launch still owning the cursor must
|
||||
// be resubmitted, and the evidence must say how many submits it took.
|
||||
func TestConfirmLaunchResubmitsUntilTheEditorClears(t *testing.T) {
|
||||
b, keys := submitTmux(t, 2)
|
||||
evidence, err := b.ConfirmLaunch(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference)
|
||||
if err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
if !strings.Contains(evidence, "submit_attempts=2") {
|
||||
t.Fatalf("evidence=%q, want two submits recorded", evidence)
|
||||
}
|
||||
if got := enterCount(t, keys); got != 1 {
|
||||
t.Fatalf("resent Enter %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The intervention is bounded independently of the observation deadline: a TUI
|
||||
// that already accepted the prompt must not receive a fortieth Enter.
|
||||
func TestConfirmLaunchBoundsResubmission(t *testing.T) {
|
||||
b, keys := submitTmux(t, 99)
|
||||
// Generous deadline on purpose: the cap must be what stops the resends.
|
||||
b.LaunchConfirmTimeout = 3 * time.Second
|
||||
if _, err := b.ConfirmLaunch(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference); !errors.Is(err, ErrPromptNotSubmitted) {
|
||||
t.Fatalf("err=%v, want ErrPromptNotSubmitted", err)
|
||||
}
|
||||
if got := enterCount(t, keys); got != launchResubmitLimit {
|
||||
t.Fatalf("resent Enter %d times, want the %d-resubmit cap", got, launchResubmitLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// Queued is submitted. Treating it as a stalled editor would kill a pane whose
|
||||
// harness had already accepted the instruction.
|
||||
func TestConfirmLaunchAcceptsQueuedInput(t *testing.T) {
|
||||
pane := "\u2500\u2500\u2500\u2500\n\u276f Press up to edit queued messages\n"
|
||||
b := paneTmux(t, pane, 1)
|
||||
evidence, err := b.ConfirmLaunch(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference)
|
||||
if err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
if !strings.Contains(evidence, "confirmation=queued") {
|
||||
t.Fatalf("evidence=%q, want queued input confirmed", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
// paneTmux serves one fixed pane with the cursor on the given row.
|
||||
func paneTmux(t *testing.T, pane string, cursor int) *TmuxBackend {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
paneFile := filepath.Join(dir, "pane")
|
||||
if err := os.WriteFile(paneFile, []byte(pane), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(dir, "tmux")
|
||||
script := `#!/bin/sh
|
||||
cmd=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
capture-pane|has-session|display-message|send-keys) cmd=$a; break;;
|
||||
esac
|
||||
done
|
||||
case "$cmd" in
|
||||
has-session) exit 0 ;;
|
||||
capture-pane) cat ` + paneFile + ` ; exit 0 ;;
|
||||
display-message)
|
||||
case "$*" in
|
||||
*cursor_y*) printf '` + fmt.Sprint(cursor) + `\n' ;;
|
||||
*) printf '0\tclaude\n' ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &TmuxBackend{Binary: bin, LaunchConfirmTimeout: time.Second, LaunchConfirmPoll: 5 * time.Millisecond}
|
||||
}
|
||||
|
||||
// submitTmux holds the launch reference in the active editor until the given
|
||||
// number of submits has been received, then clears it. The first submit is the
|
||||
// one Prompt already sent, so submitOn=2 means one resend is required.
|
||||
func submitTmux(t *testing.T, submitOn int) (*TmuxBackend, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
keys := filepath.Join(dir, "keys")
|
||||
bin := filepath.Join(dir, "tmux")
|
||||
script := `#!/bin/sh
|
||||
dir=` + dir + `
|
||||
cmd=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
capture-pane|has-session|display-message|send-keys) cmd=$a; break;;
|
||||
esac
|
||||
done
|
||||
submits=$(cat $dir/submits 2>/dev/null || echo 1)
|
||||
case "$cmd" in
|
||||
has-session) exit 0 ;;
|
||||
send-keys)
|
||||
case "$*" in
|
||||
*Enter*)
|
||||
echo Enter >> ` + keys + `
|
||||
echo $((submits+1)) > $dir/submits ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
capture-pane)
|
||||
if [ "$submits" -ge "` + fmt.Sprint(submitOn) + `" ]; then
|
||||
printf 'esc to interrupt\n\xe2\x9d\xaf \n'
|
||||
else
|
||||
printf '\xe2\x94\x80\x0a\xe2\x9d\xaf ` + LaunchReference + `\n'
|
||||
fi
|
||||
exit 0 ;;
|
||||
display-message)
|
||||
case "$*" in
|
||||
*cursor_y*)
|
||||
if [ "$submits" -ge "` + fmt.Sprint(submitOn) + `" ]; then printf '1\n'; else printf '1\n'; fi ;;
|
||||
*) printf '0\tclaude\n' ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
esac
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &TmuxBackend{Binary: bin, LaunchConfirmTimeout: time.Second, LaunchConfirmPoll: 5 * time.Millisecond}, keys
|
||||
}
|
||||
|
||||
func enterCount(t *testing.T, path string) int {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return 0
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return len(strings.Fields(string(b)))
|
||||
}
|
||||
|
||||
// fakeTmux scripts capture-pane so confirmation can be driven without waiting
|
||||
@@ -179,7 +330,12 @@ for a in "$@"; do
|
||||
done
|
||||
case "$cmd" in
|
||||
has-session) exit 0 ;;
|
||||
display-message) printf '0\tclaude\n'; exit 0 ;;
|
||||
display-message)
|
||||
case "$*" in
|
||||
*cursor_y*) printf '0\n' ;;
|
||||
*) printf '0\tclaude\n' ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
capture-pane)
|
||||
if [ "` + fail + `" = "1" ]; then echo "no server running" >&2; exit 1; fi
|
||||
n=$(cat $state 2>/dev/null || echo 0); n=$((n+1)); echo $n > $state
|
||||
|
||||
Reference in New Issue
Block a user