detached 7018e79

fix(agent): bound startup alert buffering

commit
7018e79e81e603e454956487acc52a37a62d63b1
author
brice · · 2026-08-04 08:59 UTC
parent
149c6c3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +39 -6
M packages/agent/cli.go +6 -1
@@ -32,6 +32,9 @@ import (
3232 // manager call back into the Interactive instance built later in
3333 // runInteractive. Alerts are buffered until the instance is attached so
3434 // an extension that signals attention during startup is not silently lost.
35+// Keep the startup buffer bounded because extensions can flood their host.
36+const maxBufferedInteractiveAlerts = 64
37+
3538 type interactiveExtHooks struct {
3639 mu sync.Mutex
3740 interactive *modes.Interactive
@@ -77,7 +80,9 @@ func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest)
7780 }
7881 h.mu.Lock()
7982 if h.interactive == nil {
80- h.pendingAlerts = append(h.pendingAlerts, interactiveAlert{extName: extName, alert: alert})
83+ if len(h.pendingAlerts) < maxBufferedInteractiveAlerts {
84+ h.pendingAlerts = append(h.pendingAlerts, interactiveAlert{extName: extName, alert: alert})
85+ }
8186 h.mu.Unlock()
8287 return
8388 }
@@ -32,6 +32,9 @@ import (
32 // manager call back into the Interactive instance built later in32 // manager call back into the Interactive instance built later in
33 // runInteractive. Alerts are buffered until the instance is attached so33 // runInteractive. Alerts are buffered until the instance is attached so
34 // an extension that signals attention during startup is not silently lost.34 // an extension that signals attention during startup is not silently lost.
35+// Keep the startup buffer bounded because extensions can flood their host.
36+const maxBufferedInteractiveAlerts = 64
37+
35 type interactiveExtHooks struct {38 type interactiveExtHooks struct {
36 mu sync.Mutex39 mu sync.Mutex
37 interactive *modes.Interactive40 interactive *modes.Interactive
@@ -77,7 +80,9 @@ func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest)
77 }80 }
78 h.mu.Lock()81 h.mu.Lock()
79 if h.interactive == nil {82 if h.interactive == nil {
80- h.pendingAlerts = append(h.pendingAlerts, interactiveAlert{extName: extName, alert: alert})83+ if len(h.pendingAlerts) < maxBufferedInteractiveAlerts {
84+ h.pendingAlerts = append(h.pendingAlerts, interactiveAlert{extName: extName, alert: alert})
85+ }
81 h.mu.Unlock()86 h.mu.Unlock()
82 return87 return
83 }88 }
M packages/agent/cli_alert_test.go +5 -2
@@ -10,12 +10,15 @@ import (
1010 func TestInteractiveExtHooksBufferStartupAlerts(t *testing.T) {
1111 hooks := &interactiveExtHooks{}
1212 hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
13+ for n := 0; n < maxBufferedInteractiveAlerts+8; n++ {
14+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "flood"})
15+ }
1316
1417 hooks.mu.Lock()
1518 pending := len(hooks.pendingAlerts)
1619 hooks.mu.Unlock()
17- if pending != 1 {
18- t.Fatalf("pending startup alerts = %d, want 1", pending)
20+ if pending != maxBufferedInteractiveAlerts {
21+ t.Fatalf("pending startup alerts = %d, want bounded capacity %d", pending, maxBufferedInteractiveAlerts)
1922 }
2023
2124 iv := &modes.Interactive{}
@@ -10,12 +10,15 @@ import (
10 func TestInteractiveExtHooksBufferStartupAlerts(t *testing.T) {10 func TestInteractiveExtHooksBufferStartupAlerts(t *testing.T) {
11 hooks := &interactiveExtHooks{}11 hooks := &interactiveExtHooks{}
12 hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})12 hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "question_ready"})
13+ for n := 0; n < maxBufferedInteractiveAlerts+8; n++ {
14+ hooks.Alert("question-ext", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "flood"})
15+ }
13 16
14 hooks.mu.Lock()17 hooks.mu.Lock()
15 pending := len(hooks.pendingAlerts)18 pending := len(hooks.pendingAlerts)
16 hooks.mu.Unlock()19 hooks.mu.Unlock()
17- if pending != 1 {20+ if pending != maxBufferedInteractiveAlerts {
18- t.Fatalf("pending startup alerts = %d, want 1", pending)21+ t.Fatalf("pending startup alerts = %d, want bounded capacity %d", pending, maxBufferedInteractiveAlerts)
19 }22 }
20 23
21 iv := &modes.Interactive{}24 iv := &modes.Interactive{}
M packages/agent/ext/ext_test.go +13 -2
@@ -222,7 +222,19 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
222222
223223 func TestAlertEmitsStructuredFrame(t *testing.T) {
224224 h := newHarness("alert-ext")
225- go h.ext.Run()
225+ runDone := make(chan error, 1)
226+ go func() { runDone <- h.ext.Run() }()
227+ t.Cleanup(func() {
228+ _ = h.hostW.Close()
229+ select {
230+ case err := <-runDone:
231+ if err != nil {
232+ t.Errorf("extension Run: %v", err)
233+ }
234+ case <-time.After(2 * time.Second):
235+ t.Error("extension Run did not exit after closing host input")
236+ }
237+ })
226238 h.handshake(t)
227239
228240 go h.ext.Alert(AlertRequest{Kind: AlertKindBell, Reason: "question_ready"})
@@ -235,7 +247,6 @@ func TestAlertEmitsStructuredFrame(t *testing.T) {
235247 if alert.Kind != AlertKindBell || alert.Reason != "question_ready" {
236248 t.Fatalf("alert = %+v, want bell/question_ready", alert)
237249 }
238- h.hostW.Close()
239250 }
240251
241252 func TestToolConfirmationRequestedEvent(t *testing.T) {
@@ -222,7 +222,19 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
222 222
223 func TestAlertEmitsStructuredFrame(t *testing.T) {223 func TestAlertEmitsStructuredFrame(t *testing.T) {
224 h := newHarness("alert-ext")224 h := newHarness("alert-ext")
225- go h.ext.Run()225+ runDone := make(chan error, 1)
226+ go func() { runDone <- h.ext.Run() }()
227+ t.Cleanup(func() {
228+ _ = h.hostW.Close()
229+ select {
230+ case err := <-runDone:
231+ if err != nil {
232+ t.Errorf("extension Run: %v", err)
233+ }
234+ case <-time.After(2 * time.Second):
235+ t.Error("extension Run did not exit after closing host input")
236+ }
237+ })
226 h.handshake(t)238 h.handshake(t)
227 239
228 go h.ext.Alert(AlertRequest{Kind: AlertKindBell, Reason: "question_ready"})240 go h.ext.Alert(AlertRequest{Kind: AlertKindBell, Reason: "question_ready"})
@@ -235,7 +247,6 @@ func TestAlertEmitsStructuredFrame(t *testing.T) {
235 if alert.Kind != AlertKindBell || alert.Reason != "question_ready" {247 if alert.Kind != AlertKindBell || alert.Reason != "question_ready" {
236 t.Fatalf("alert = %+v, want bell/question_ready", alert)248 t.Fatalf("alert = %+v, want bell/question_ready", alert)
237 }249 }
238- h.hostW.Close()
239 }250 }
240 251
241 func TestToolConfirmationRequestedEvent(t *testing.T) {252 func TestToolConfirmationRequestedEvent(t *testing.T) {
M packages/agent/modes/alert_test.go +10 -0
@@ -89,27 +89,37 @@ func TestCancelledMainTurnDoesNotBell(t *testing.T) {
8989 i.startTurn(context.Background(), "question")
9090
9191 deadline := time.Now().Add(time.Second)
92+ started := false
9293 for time.Now().Before(deadline) {
9394 i.mu.Lock()
9495 busy := i.busy
9596 i.mu.Unlock()
9697 if busy {
98+ started = true
9799 break
98100 }
99101 time.Sleep(time.Millisecond)
100102 }
103+ if !started {
104+ t.Fatal("turn did not enter busy state")
105+ }
101106 i.CancelTurn()
102107
103108 deadline = time.Now().Add(time.Second)
109+ stopped := false
104110 for time.Now().Before(deadline) {
105111 i.mu.Lock()
106112 busy := i.busy
107113 i.mu.Unlock()
108114 if !busy {
115+ stopped = true
109116 break
110117 }
111118 time.Sleep(time.Millisecond)
112119 }
120+ if !stopped {
121+ t.Fatal("turn did not finish after cancellation")
122+ }
113123 if got := term.String(); got != "" {
114124 t.Fatalf("cancelled main turn alert output = %q, want empty", got)
115125 }
@@ -89,27 +89,37 @@ func TestCancelledMainTurnDoesNotBell(t *testing.T) {
89 i.startTurn(context.Background(), "question")89 i.startTurn(context.Background(), "question")
90 90
91 deadline := time.Now().Add(time.Second)91 deadline := time.Now().Add(time.Second)
92+ started := false
92 for time.Now().Before(deadline) {93 for time.Now().Before(deadline) {
93 i.mu.Lock()94 i.mu.Lock()
94 busy := i.busy95 busy := i.busy
95 i.mu.Unlock()96 i.mu.Unlock()
96 if busy {97 if busy {
98+ started = true
97 break99 break
98 }100 }
99 time.Sleep(time.Millisecond)101 time.Sleep(time.Millisecond)
100 }102 }
103+ if !started {
104+ t.Fatal("turn did not enter busy state")
105+ }
101 i.CancelTurn()106 i.CancelTurn()
102 107
103 deadline = time.Now().Add(time.Second)108 deadline = time.Now().Add(time.Second)
109+ stopped := false
104 for time.Now().Before(deadline) {110 for time.Now().Before(deadline) {
105 i.mu.Lock()111 i.mu.Lock()
106 busy := i.busy112 busy := i.busy
107 i.mu.Unlock()113 i.mu.Unlock()
108 if !busy {114 if !busy {
115+ stopped = true
109 break116 break
110 }117 }
111 time.Sleep(time.Millisecond)118 time.Sleep(time.Millisecond)
112 }119 }
120+ if !stopped {
121+ t.Fatal("turn did not finish after cancellation")
122+ }
113 if got := term.String(); got != "" {123 if got := term.String(); got != "" {
114 t.Fatalf("cancelled main turn alert output = %q, want empty", got)124 t.Fatalf("cancelled main turn alert output = %q, want empty", got)
115 }125 }
M packages/agent/rpc.go +5 -1
@@ -171,6 +171,8 @@ func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}
171171 func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}
172172 func (h *rpcExtHooks) ClosePanel(string, string) {}
173173
174+const maxPendingRPCExtEvents = 64
175+
174176 type rpcServer struct {
175177 ctx context.Context
176178 args Args
@@ -486,7 +488,9 @@ func (s *rpcServer) writeExtensionEvent(payload map[string]any) {
486488 s.writeMu.Lock()
487489 defer s.writeMu.Unlock()
488490 if !s.started || !s.authed {
489- s.pendingExtEvents = append(s.pendingExtEvents, b)
491+ if len(s.pendingExtEvents) < maxPendingRPCExtEvents {
492+ s.pendingExtEvents = append(s.pendingExtEvents, b)
493+ }
490494 return
491495 }
492496 s.writeLocked(b)
@@ -171,6 +171,8 @@ func (h *rpcExtHooks) OpenPanel(string, extproto.PanelSpec) {}
171 func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}171 func (h *rpcExtHooks) UpdatePanel(string, string, string, []string, string) {}
172 func (h *rpcExtHooks) ClosePanel(string, string) {}172 func (h *rpcExtHooks) ClosePanel(string, string) {}
173 173
174+const maxPendingRPCExtEvents = 64
175+
174 type rpcServer struct {176 type rpcServer struct {
175 ctx context.Context177 ctx context.Context
176 args Args178 args Args
@@ -486,7 +488,9 @@ func (s *rpcServer) writeExtensionEvent(payload map[string]any) {
486 s.writeMu.Lock()488 s.writeMu.Lock()
487 defer s.writeMu.Unlock()489 defer s.writeMu.Unlock()
488 if !s.started || !s.authed {490 if !s.started || !s.authed {
489- s.pendingExtEvents = append(s.pendingExtEvents, b)491+ if len(s.pendingExtEvents) < maxPendingRPCExtEvents {
492+ s.pendingExtEvents = append(s.pendingExtEvents, b)
493+ }
490 return494 return
491 }495 }
492 s.writeLocked(b)496 s.writeLocked(b)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close