From 92adf0477d15e260d50946d7e3a5d2fbd8b91760 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 28 Aug 2026 01:09:00 +0400 Subject: [PATCH] Recognise an empty tmux server's answer for a missing session A running but empty server answers "no current target". hasSession only knew "can't find session", "no server running" and "no sessions", so it returned that as a real error. Kill then failed for a pane that was already gone, the quarantine never cleared, and the worker's only session slot stayed pinned. This is a side effect of dea56e4. Before the runtime became its own unit the server exited with its last session and answered "no server running", which hasSession already handled, so this reply had never been produced. AgentStatus shares the same helper and now reports "exited" instead of erroring. Found live: the F28 disposable task's session survived its own block on 93338b7, quarantined and unreapable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011xsXyr5J1RACo71YeKG3Pu --- internal/herdr/tmux.go | 10 +++++++++- internal/herdr/tmux_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) 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) + } + } +}