fix(mcp/agentic_watch): default 30m → 60s + per-call cap at 30m

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 <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=1005
This commit is contained in:
Snider 2026-04-25 18:32:21 +01:00
parent 1e3a5996fa
commit cb62378a2b
2 changed files with 43 additions and 8 deletions

View file

@ -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 {

View file

@ -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)
}
}