diff --git a/internal/herdr/tmux.go b/internal/herdr/tmux.go index 63b6506..b086aa3 100644 --- a/internal/herdr/tmux.go +++ b/internal/herdr/tmux.go @@ -133,7 +133,15 @@ func (b *TmuxBackend) hasSession(ctx context.Context, session string) (bool, err return true, nil } message := strings.ToLower(string(out) + " " + err.Error()) - if strings.Contains(message, "can't find session") || strings.Contains(message, "no server running") || strings.Contains(message, "no sessions") || (strings.Contains(message, "error connecting to") && strings.Contains(message, "no such file")) { + // "no current target" is what a *running but empty* server answers: the + // target does not resolve and there is no current session to fall back to. + // It only started appearing once the runtime became its own unit running + // `tmux -D`, which keeps the server alive past its last pane (F32). Before + // that the server exited with its last session and answered "no server + // running", so this branch was never reached. Without it Kill returns an + // error for a pane that is already gone, quarantine never clears, and the + // worker's only session slot stays pinned. + if strings.Contains(message, "can't find session") || strings.Contains(message, "no server running") || strings.Contains(message, "no sessions") || strings.Contains(message, "no current target") || (strings.Contains(message, "error connecting to") && strings.Contains(message, "no such file")) { return false, nil } return false, err diff --git a/internal/herdr/tmux_test.go b/internal/herdr/tmux_test.go index ee45660..b358439 100644 --- a/internal/herdr/tmux_test.go +++ b/internal/herdr/tmux_test.go @@ -421,3 +421,27 @@ func TestPaneProgressIgnoresInputLines(t *testing.T) { t.Fatal("real harness output left the progress digest unchanged") } } + +// TestMissingSessionIsRecognisedOnAnEmptyRunningServer guards F32. A running +// but empty tmux server answers "no current target", not "no server running". +// The runtime only stays alive past its last pane since it became its own unit +// running `tmux -D`, so this reply had never been seen before. Treating it as +// a real error made Kill fail for a pane that was already gone, which left the +// quarantine set and pinned the worker's only session slot. +func TestMissingSessionIsRecognisedOnAnEmptyRunningServer(t *testing.T) { + for _, message := range []string{"no current target", "can't find session: x", "no server running"} { + bin := filepath.Join(t.TempDir(), "tmux") + script := "#!/bin/sh\necho \"" + message + "\" >&2\nexit 1\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + b := &TmuxBackend{Binary: bin} + if err := b.Kill(context.Background(), Session{PaneID: "s:1.0"}); err != nil { + t.Fatalf("%q: killing an already-gone session failed: %v", message, err) + } + status, err := b.AgentStatus(context.Background(), Session{PaneID: "s:1.0"}) + if err != nil || status != "exited" { + t.Fatalf("%q: status=%q err=%v, want exited", message, status, err) + } + } +}