From cb62378a2bd7cc9a715fc48eab2e584544d57cc5 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 18:32:21 +0100 Subject: [PATCH] =?UTF-8?q?fix(mcp/agentic=5Fwatch):=20default=2030m=20?= =?UTF-8?q?=E2=86=92=2060s=20+=20per-call=20cap=20at=2030m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit defaultWatchTimeout reduced to 60s; new maxWatchTimeout 30m caps any per-call WatchInput.Timeout. resolveWatchTimeout() honours the request value (in seconds), clamps above-cap, and treats <=0 as default. Defends against connection-pool drain from idle long-poll subscribers whether internal-only today or future external-facing. Tests: default = 60s, 10s honoured, 10h clamps to 30m, 0 falls back. Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=1005 --- pkg/mcp/agentic/watch.go | 21 ++++++++++++++++----- pkg/mcp/agentic/watch_test.go | 30 +++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/pkg/mcp/agentic/watch.go b/pkg/mcp/agentic/watch.go index 802f55a..9f6863b 100644 --- a/pkg/mcp/agentic/watch.go +++ b/pkg/mcp/agentic/watch.go @@ -14,7 +14,8 @@ import ( const ( defaultWatchPollInterval = 5 * time.Second - defaultWatchTimeout = 30 * time.Minute + defaultWatchTimeout = 60 * time.Second + maxWatchTimeout = 30 * time.Minute ) // WatchInput is the input for agentic_watch. @@ -57,10 +58,7 @@ func (s *PrepSubsystem) watch(ctx context.Context, req *mcp.CallToolRequest, inp pollInterval = defaultWatchPollInterval } - timeout := time.Duration(input.Timeout) * time.Second - if timeout <= 0 { - timeout = defaultWatchTimeout - } + timeout := resolveWatchTimeout(input) start := time.Now() deadline := start.Add(timeout) @@ -166,6 +164,19 @@ func (s *PrepSubsystem) watch(ctx context.Context, req *mcp.CallToolRequest, inp }, nil } +func resolveWatchTimeout(input WatchInput) time.Duration { + if input.Timeout <= 0 { + return defaultWatchTimeout + } + + maxSeconds := int(maxWatchTimeout / time.Second) + if input.Timeout > maxSeconds { + return maxWatchTimeout + } + + return time.Duration(input.Timeout) * time.Second +} + func (s *PrepSubsystem) findActiveWorkspaces() []string { wsDirs := s.listWorkspaceDirs() if len(wsDirs) == 0 { diff --git a/pkg/mcp/agentic/watch_test.go b/pkg/mcp/agentic/watch_test.go index 6af8bb3..bf8aee0 100644 --- a/pkg/mcp/agentic/watch_test.go +++ b/pkg/mcp/agentic/watch_test.go @@ -7,11 +7,35 @@ import ( "time" ) -func TestWatchDefaults_Good_RFCThirtyMinuteTimeout(t *testing.T) { - if defaultWatchTimeout != 30*time.Minute { - t.Fatalf("expected default watch timeout to be 30m, got %s", defaultWatchTimeout) +func TestWatchDefaults_Good_RFCOneMinuteTimeout(t *testing.T) { + if defaultWatchTimeout != 60*time.Second { + t.Fatalf("expected default watch timeout to be 60s, got %s", defaultWatchTimeout) } if defaultWatchPollInterval != 5*time.Second { t.Fatalf("expected default poll interval to be 5s, got %s", defaultWatchPollInterval) } + if maxWatchTimeout != 30*time.Minute { + t.Fatalf("expected max watch timeout to be 30m, got %s", maxWatchTimeout) + } +} + +func TestResolveWatchTimeout_Good_HonorsInputTimeout(t *testing.T) { + got := resolveWatchTimeout(WatchInput{Timeout: 10}) + if got != 10*time.Second { + t.Fatalf("expected input timeout to be honored as 10s, got %s", got) + } +} + +func TestResolveWatchTimeout_Good_ClampsInputTimeout(t *testing.T) { + got := resolveWatchTimeout(WatchInput{Timeout: int((10 * time.Hour) / time.Second)}) + if got != 30*time.Minute { + t.Fatalf("expected input timeout to clamp to 30m, got %s", got) + } +} + +func TestResolveWatchTimeout_Good_ZeroUsesDefault(t *testing.T) { + got := resolveWatchTimeout(WatchInput{Timeout: 0}) + if got != defaultWatchTimeout { + t.Fatalf("expected zero timeout to use default %s, got %s", defaultWatchTimeout, got) + } }