Skip to content

Commit 195c22a

Browse files
committed
Fix terminal session reattach reliability and legacy tmux discovery
1 parent c751866 commit 195c22a

3 files changed

Lines changed: 194 additions & 0 deletions

File tree

internal/app/app_tmux_activity.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,17 @@ func (a *App) handleTmuxAvailableResult(msg tmuxAvailableResult) []tea.Cmd {
194194
return []tea.Cmd{a.toast.ShowError("tmux not installed. " + msg.installHint)}
195195
}
196196
cmds := []tea.Cmd{a.scanTmuxActivityNow()}
197+
if a.activeWorkspace != nil {
198+
if discoverCmd := a.discoverWorkspaceTabsFromTmux(a.activeWorkspace); discoverCmd != nil {
199+
cmds = append(cmds, discoverCmd)
200+
}
201+
if discoverTermCmd := a.discoverSidebarTerminalsFromTmux(a.activeWorkspace); discoverTermCmd != nil {
202+
cmds = append(cmds, discoverTermCmd)
203+
}
204+
if syncCmd := a.syncWorkspaceTabsFromTmux(a.activeWorkspace); syncCmd != nil {
205+
cmds = append(cmds, syncCmd)
206+
}
207+
}
197208
if a.tmuxService != nil {
198209
cmds = append(cmds, func() tea.Msg {
199210
_ = a.tmuxService.SetMonitorActivityOn(a.tmuxOptions)

internal/app/app_tmux_discover.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/andyrewlee/amux/internal/data"
1111
"github.com/andyrewlee/amux/internal/logging"
12+
"github.com/andyrewlee/amux/internal/tmux"
1213
"github.com/andyrewlee/amux/internal/ui/sidebar"
1314
)
1415

@@ -144,11 +145,13 @@ func (a *App) discoverSidebarTerminalsFromTmux(ws *data.Workspace) tea.Cmd {
144145
logging.Warn("tmux sidebar discovery failed: %v", err)
145146
return tmuxSidebarDiscoverResult{WorkspaceID: wsID}
146147
}
148+
seen := make(map[string]struct{}, len(rows))
147149
sessions := make([]sidebarSessionInfo, 0, len(rows))
148150
for _, row := range rows {
149151
if row.Name == "" {
150152
continue
151153
}
154+
seen[row.Name] = struct{}{}
152155
state, err := svc.SessionStateFor(row.Name, opts)
153156
if err != nil || !state.Exists || !state.HasLivePane {
154157
continue
@@ -175,6 +178,9 @@ func (a *App) discoverSidebarTerminalsFromTmux(ws *data.Workspace) tea.Cmd {
175178
hasClients: attached,
176179
})
177180
}
181+
if fallback := a.discoverLegacySidebarSessions(wsID, opts, svc, seen); len(fallback) > 0 {
182+
sessions = append(sessions, fallback...)
183+
}
178184
if len(sessions) == 0 {
179185
return tmuxSidebarDiscoverResult{WorkspaceID: wsID}
180186
}
@@ -183,6 +189,91 @@ func (a *App) discoverSidebarTerminalsFromTmux(ws *data.Workspace) tea.Cmd {
183189
}
184190
}
185191

192+
// discoverLegacySidebarSessions finds likely terminal sessions created by older
193+
// builds that are missing @amux tags and retags them.
194+
func (a *App) discoverLegacySidebarSessions(wsID string, opts tmux.Options, svc *tmuxService, seen map[string]struct{}) []sidebarSessionInfo {
195+
if wsID == "" || svc == nil {
196+
return nil
197+
}
198+
names, err := tmux.ListSessions(opts)
199+
if err != nil {
200+
logging.Warn("tmux sidebar fallback discovery failed: %v", err)
201+
return nil
202+
}
203+
sessionPrefix := tmux.SessionName("amux", wsID) + "-"
204+
out := make([]sidebarSessionInfo, 0, len(names))
205+
for _, raw := range names {
206+
name := strings.TrimSpace(raw)
207+
if name == "" {
208+
continue
209+
}
210+
if _, ok := seen[name]; ok {
211+
continue
212+
}
213+
if !strings.HasPrefix(name, sessionPrefix) {
214+
continue
215+
}
216+
tabID := strings.TrimPrefix(name, sessionPrefix)
217+
if !strings.HasPrefix(tabID, "term-tab-") {
218+
continue
219+
}
220+
state, err := svc.SessionStateFor(name, opts)
221+
if err != nil || !state.Exists || !state.HasLivePane {
222+
continue
223+
}
224+
// Assume clients exist on error to avoid detaching other sessions.
225+
attached := true
226+
if value, err := svc.SessionHasClients(name, opts); err == nil {
227+
attached = value
228+
}
229+
createdAt, _ := svc.SessionCreatedAt(name, opts)
230+
if err := a.retagSidebarTerminalSession(name, wsID, tabID, createdAt, opts); err != nil {
231+
logging.Warn("tmux sidebar fallback retag failed for %s: %v", name, err)
232+
}
233+
out = append(out, sidebarSessionInfo{
234+
name: name,
235+
instanceID: a.instanceID,
236+
createdAt: createdAt,
237+
hasClients: attached,
238+
})
239+
}
240+
return out
241+
}
242+
243+
func (a *App) retagSidebarTerminalSession(sessionName, wsID, tabID string, createdAt int64, opts tmux.Options) error {
244+
values := []struct {
245+
key string
246+
val string
247+
}{
248+
{key: "@amux", val: "1"},
249+
{key: "@amux_workspace", val: wsID},
250+
{key: "@amux_tab", val: tabID},
251+
{key: "@amux_type", val: "terminal"},
252+
{key: "@amux_assistant", val: "terminal"},
253+
}
254+
if createdAt > 0 {
255+
values = append(values, struct {
256+
key string
257+
val string
258+
}{key: "@amux_created_at", val: strconv.FormatInt(createdAt, 10)})
259+
}
260+
if strings.TrimSpace(a.instanceID) != "" {
261+
values = append(values, struct {
262+
key string
263+
val string
264+
}{key: "@amux_instance", val: strings.TrimSpace(a.instanceID)})
265+
}
266+
for _, item := range values {
267+
if strings.TrimSpace(item.val) == "" {
268+
continue
269+
}
270+
if err := tmux.SetSessionTagValue(sessionName, item.key, item.val, opts); err != nil {
271+
return err
272+
}
273+
}
274+
return nil
275+
}
276+
186277
func buildSidebarSessionAttachInfos(sessions []sidebarSessionInfo) []sidebar.SessionAttachInfo {
187278
sorted := make([]sidebarSessionInfo, len(sessions))
188279
copy(sorted, sessions)

internal/ui/sidebar/terminal_pty_attach.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"os"
7+
"strings"
78
"time"
89

910
tea "charm.land/bubbletea/v2"
@@ -53,6 +54,10 @@ func (m *TerminalModel) createTerminalTab(ws *data.Workspace) tea.Cmd {
5354
if err != nil {
5455
return SidebarTerminalCreateFailed{WorkspaceID: wsID, Err: err}
5556
}
57+
if err := verifyTerminalSessionTags(sessionName, tags, opts); err != nil {
58+
_ = term.Close()
59+
return SidebarTerminalCreateFailed{WorkspaceID: wsID, Err: err}
60+
}
5661

5762
return SidebarTerminalCreated{
5863
WorkspaceID: wsID,
@@ -186,6 +191,15 @@ func (m *TerminalModel) attachToSession(ws *data.Workspace, tabID TerminalTabID,
186191
Action: action,
187192
}
188193
}
194+
if err := verifyTerminalSessionTags(sessionName, tags, opts); err != nil {
195+
_ = term.Close()
196+
return SidebarTerminalReattachFailed{
197+
WorkspaceID: wsID,
198+
TabID: tabID,
199+
Err: err,
200+
Action: action,
201+
}
202+
}
189203
scrollback, _ := tmux.CapturePane(sessionName, opts)
190204
return SidebarTerminalReattachResult{
191205
WorkspaceID: wsID,
@@ -196,3 +210,81 @@ func (m *TerminalModel) attachToSession(ws *data.Workspace, tabID TerminalTabID,
196210
}
197211
}
198212
}
213+
214+
func verifyTerminalSessionTags(sessionName string, tags tmux.SessionTags, opts tmux.Options) error {
215+
const (
216+
verifyTimeout = 2 * time.Second
217+
verifyInterval = 40 * time.Millisecond
218+
)
219+
deadline := time.Now().Add(verifyTimeout)
220+
var lastErr error
221+
for {
222+
lastErr = verifyTerminalSessionTagsOnce(sessionName, tags, opts)
223+
if lastErr == nil {
224+
return nil
225+
}
226+
if time.Now().After(deadline) {
227+
return lastErr
228+
}
229+
time.Sleep(verifyInterval)
230+
}
231+
}
232+
233+
func verifyTerminalSessionTagsOnce(sessionName string, tags tmux.SessionTags, opts tmux.Options) error {
234+
if strings.TrimSpace(sessionName) == "" {
235+
return errors.New("missing tmux session name")
236+
}
237+
checks := []struct {
238+
key string
239+
want string
240+
}{
241+
{key: "@amux", want: "1"},
242+
}
243+
if strings.TrimSpace(tags.WorkspaceID) != "" {
244+
checks = append(checks, struct {
245+
key string
246+
want string
247+
}{key: "@amux_workspace", want: strings.TrimSpace(tags.WorkspaceID)})
248+
}
249+
if strings.TrimSpace(tags.TabID) != "" {
250+
checks = append(checks, struct {
251+
key string
252+
want string
253+
}{key: "@amux_tab", want: strings.TrimSpace(tags.TabID)})
254+
}
255+
if strings.TrimSpace(tags.Type) != "" {
256+
checks = append(checks, struct {
257+
key string
258+
want string
259+
}{key: "@amux_type", want: strings.TrimSpace(tags.Type)})
260+
}
261+
if strings.TrimSpace(tags.Assistant) != "" {
262+
checks = append(checks, struct {
263+
key string
264+
want string
265+
}{key: "@amux_assistant", want: strings.TrimSpace(tags.Assistant)})
266+
}
267+
if tags.CreatedAt > 0 {
268+
checks = append(checks, struct {
269+
key string
270+
want string
271+
}{key: "@amux_created_at", want: fmt.Sprintf("%d", tags.CreatedAt)})
272+
}
273+
if strings.TrimSpace(tags.InstanceID) != "" {
274+
checks = append(checks, struct {
275+
key string
276+
want string
277+
}{key: "@amux_instance", want: strings.TrimSpace(tags.InstanceID)})
278+
}
279+
for _, check := range checks {
280+
got, err := tmux.SessionTagValue(sessionName, check.key, opts)
281+
if err != nil {
282+
return fmt.Errorf("failed to verify tmux tag %s: %w", check.key, err)
283+
}
284+
got = strings.TrimSpace(got)
285+
if got != check.want {
286+
return fmt.Errorf("tmux tag mismatch for %s: expected %q, got %q", check.key, check.want, got)
287+
}
288+
}
289+
return nil
290+
}

0 commit comments

Comments
 (0)