@@ -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
238238When 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).
909910const (
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.
10001017func 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