Skip to content

Commit f9f85a8

Browse files
committed
auto-reconnect attach stream with Reconnecting... UX
1 parent 5a19eaf commit f9f85a8

2 files changed

Lines changed: 446 additions & 39 deletions

File tree

commands/agents.go

Lines changed: 80 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ Use `+"`"+`--name`+"`"+` to name the session (this sets the manifest's `+"`"+`me
233233

234234
cmdAttach := CmdBuilder(cmd, RunAgentsAttach, "attach <session>",
235235
"Attach to an agent session",
236-
`Opens an interactive line-mode TUI on an existing session. Streams events from the server and accepts typed input. A dropped SSE connection is reconnected automatically and the server replays any events missed during the gap.
236+
`Opens an interactive line-mode TUI on an existing session. Streams events from the server and accepts typed input. If the SSE connection drops, doctl shows Reconnecting... and retries automatically (5 attempts with backoff). If reconnection fails, it prints an error and stops the stream.
237237
238238
When a HITL approval is pending, the prompt switches to a compact approve/reject/defer menu showing the command awaiting approval. In an interactive terminal you can move the highlight with the arrow keys and press Enter, or resolve directly with a single keystroke -- no Enter required: `+"`"+`y`+"`"+`/`+"`"+`a`+"`"+` approves, `+"`"+`n`+"`"+`/`+"`"+`r`+"`"+` rejects, `+"`"+`d`+"`"+` defers. Piped input (CI / scripts) must send the letter word (`+"`"+`yes`+"`"+`/`+"`"+`no`+"`"+`/`+"`"+`defer`+"`"+`) followed by a newline. The explicit `+"`"+`/a <request-id>`+"`"+`, `+"`"+`/r <request-id>`+"`"+`, `+"`"+`/d <request-id>`+"`"+` slash commands still work; type `+"`"+`/help`+"`"+` to see them. Ctrl-D detaches without destroying the session.`,
239239
Writer, aliasOpt("chat"))
@@ -878,7 +878,7 @@ func runAttach(c *CmdConfig, svc do.HostedAgentsService, sessionID string, in io
878878
if f, ok := in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
879879
return attachLoopTTY(c, svc, sessionID, f, state)
880880
}
881-
return attachLoop(c, svc, sessionID, in, state.pending)
881+
return attachLoop(c, svc, sessionID, in, state)
882882
}
883883

884884
// eventCursor holds the EventID of the latest event rendered. The stream
@@ -904,13 +904,27 @@ func (c *eventCursor) get() string {
904904
return c.id
905905
}
906906

907-
// Backoff schedule for reconnects. Caps at maxReconnectBackoff and retries
908-
// indefinitely; users break out via Ctrl-D (which cancels ctx) or Ctrl-C.
907+
// Backoff schedule for auto reconnects between attempts. maxAutoReconnectAttempts
908+
// bounds CONSECUTIVE failed reconnects, not the lifetime total: a connection
909+
// that stays healthy resets the budget (see healthyStreamDuration).
909910
const (
910-
initialReconnectBackoff = 1 * time.Second
911-
maxReconnectBackoff = 30 * time.Second
911+
maxAutoReconnectAttempts = 5
912+
initialReconnectBackoff = 1 * time.Second
913+
maxReconnectBackoff = 30 * time.Second
914+
msgReconnecting = "Reconnecting..."
915+
msgReconnectFailed = "Failed to reconnect to agent activity stream."
912916
)
913917

918+
// healthyStreamDuration is how long a stream must stay connected before a
919+
// mid-stream drop is treated as a normal idle timeout (which resets the
920+
// reconnect budget) rather than a failing connection. This lets a long, quiet
921+
// attach survive an unbounded number of server idle drops while still giving up
922+
// on a session that keeps dropping immediately. Overridable in tests.
923+
var healthyStreamDuration = 30 * time.Second
924+
925+
// streamClock returns the current time; overridable in tests.
926+
var streamClock = time.Now
927+
914928
// thinkingState shows a spinner between RunStarted and the first real
915929
// output. Animates above the prompt when out is a *promptDisplay; falls back
916930
// to a one-shot "(thinking...)" print otherwise (pipes, line-mode).
@@ -995,8 +1009,11 @@ func (s *thinkingState) animate(ctx context.Context, d *promptDisplay, done chan
9951009
}
9961010

9971011
// streamWithReconnect drains the SSE iterator and reconnects on transient
998-
// errors with bounded backoff, replaying from cursor.get(). Returns when ctx
999-
// is cancelled or the server cleanly ends the stream.
1012+
// errors. It shows Reconnecting... before each retry and gives up (printing
1013+
// msgReconnectFailed) only after maxAutoReconnectAttempts CONSECUTIVE failures.
1014+
// A connection that stays up for at least healthyStreamDuration before dropping
1015+
// is treated as a normal server idle timeout and resets the failure budget, so
1016+
// a long, quiet attach can recover from an unbounded number of idle drops.
10001017
func streamWithReconnect(
10011018
ctx context.Context,
10021019
svc do.HostedAgentsService,
@@ -1006,66 +1023,95 @@ func streamWithReconnect(
10061023
cursor *eventCursor,
10071024
thinking *thinkingState,
10081025
) {
1009-
backoff := initialReconnectBackoff
1010-
attempt := 0
1011-
// Persisted across reconnects so a cursor-replayed segment is still
1012-
// recognised as a repeat.
10131026
dedup := &tokenDeduper{}
1027+
backoff := initialReconnectBackoff
1028+
failures := 0
1029+
reconnecting := false
10141030

10151031
for {
10161032
if ctx.Err() != nil {
10171033
return
10181034
}
10191035

1036+
// Show the reconnect notice on every attempt after the first, whether
1037+
// this is a retry after a failed connect or a fresh reconnect after a
1038+
// healthy idle drop. The failure budget below governs when we give up;
1039+
// it must not gate this message, since a healthy drop resets the budget
1040+
// to zero and would otherwise silently suppress the notice.
1041+
if reconnecting {
1042+
fmt.Fprintf(out, "\n%s\n", msgReconnecting)
1043+
}
1044+
reconnecting = true
1045+
10201046
opt := &godo.HostedAgentSessionStreamOptions{ReplayFrom: cursor.get()}
10211047
stream, err := svc.StreamSession(ctx, sessionID, opt)
10221048
if err != nil {
1023-
if ctx.Err() != nil {
1024-
return
1025-
}
10261049
thinking.stop()
10271050
if msg, terminal := classifyStreamError(err); terminal {
10281051
fmt.Fprintln(out, msg)
10291052
return
10301053
}
1031-
attempt++
1032-
fmt.Fprintf(out, "\n(reconnect attempt %d failed: %v; retrying in %s)\n", attempt, err, backoff)
1033-
if !sleepCtx(ctx, backoff) {
1054+
failures++
1055+
if failures >= maxAutoReconnectAttempts {
1056+
fmt.Fprintf(out, "\n%s\n", msgReconnectFailed)
1057+
return
1058+
}
1059+
if !reconnectSleepFn(ctx, backoff) {
10341060
return
10351061
}
10361062
backoff = nextBackoff(backoff)
10371063
continue
10381064
}
10391065

1040-
if attempt > 0 {
1041-
fmt.Fprintln(out, "(reconnected)")
1042-
}
1043-
attempt = 0
1044-
backoff = initialReconnectBackoff
1045-
1066+
connectedAt := streamClock()
10461067
drainStream(stream, out, pending, cursor, thinking, dedup)
10471068
streamErr := stream.Err()
10481069
stream.Close()
10491070

10501071
if ctx.Err() != nil {
10511072
return
10521073
}
1053-
if streamErr == nil {
1054-
return
1055-
}
1074+
10561075
thinking.stop()
1057-
if msg, terminal := classifyStreamError(streamErr); terminal {
1058-
fmt.Fprintln(out, msg)
1076+
1077+
// An interactive attach ends only when the user detaches (ctx cancel,
1078+
// handled above) or the session is gone (a terminal error). Any other
1079+
// stream end is an unexpected drop we reconnect from — including a clean
1080+
// EOF, which is how a server idle-timeout close looks (err == nil). A
1081+
// genuinely finished session surfaces as a terminal error (404) on the
1082+
// next connect, which stops the loop below.
1083+
if streamErr != nil {
1084+
if msg, terminal := classifyStreamError(streamErr); terminal {
1085+
fmt.Fprintln(out, msg)
1086+
return
1087+
}
1088+
}
1089+
1090+
// A drop after a healthy, long-lived connection is a normal idle
1091+
// timeout, not a failing session: reset the budget and backoff so the
1092+
// attach keeps recovering. Only rapid, back-to-back drops accumulate
1093+
// toward the give-up limit.
1094+
if streamClock().Sub(connectedAt) >= healthyStreamDuration {
1095+
failures = 0
1096+
backoff = initialReconnectBackoff
1097+
} else {
1098+
failures++
1099+
}
1100+
if failures >= maxAutoReconnectAttempts {
1101+
fmt.Fprintf(out, "\n%s\n", msgReconnectFailed)
10591102
return
10601103
}
1061-
fmt.Fprintf(out, "\n(stream dropped: %v; reconnecting in %s)\n", streamErr, backoff)
1062-
if !sleepCtx(ctx, backoff) {
1104+
if !reconnectSleepFn(ctx, backoff) {
10631105
return
10641106
}
10651107
backoff = nextBackoff(backoff)
10661108
}
10671109
}
10681110

1111+
// reconnectSleepFn is the backoff wait between reconnect attempts. Tests may
1112+
// replace it to avoid real-time delays.
1113+
var reconnectSleepFn = sleepCtx
1114+
10691115
// classifyStreamError returns (user-facing message, terminal). Terminal
10701116
// errors stop the reconnect loop (auth, missing session, V0 single-connection
10711117
// rejection); status codes follow harness-api's apierr convention.
@@ -1367,7 +1413,8 @@ func (p *pendingHITL) reset() int {
13671413
return n
13681414
}
13691415

1370-
func attachLoop(c *CmdConfig, svc do.HostedAgentsService, sessionID string, in io.Reader, pending *pendingHITL) error {
1416+
func attachLoop(c *CmdConfig, svc do.HostedAgentsService, sessionID string, in io.Reader, state *attachState) error {
1417+
pending := state.pending
13711418
reader := bufio.NewReader(in)
13721419
for {
13731420
fmt.Fprint(c.Out, "\n", attachPrompt(pending))
@@ -1659,7 +1706,7 @@ func attachLoopTTY(c *CmdConfig, svc do.HostedAgentsService, sessionID string, f
16591706
oldState, err := term.MakeRaw(fd)
16601707
if err != nil {
16611708
// Raw mode unavailable; fall back to bufio line mode.
1662-
return attachLoop(c, svc, sessionID, f, state.pending)
1709+
return attachLoop(c, svc, sessionID, f, state)
16631710
}
16641711
defer term.Restore(fd, oldState)
16651712

0 commit comments

Comments
 (0)