Merge branch 'sipeed:main' into fix/inbound-dedup-messageid

This commit is contained in:
mosir 2026-02-28 15:05:41 +08:00 committed by GitHub
commit d0c508237b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 235 additions and 24 deletions

View file

@ -9,6 +9,7 @@ package agent
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
@ -574,11 +575,36 @@ func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, chan
return return
} }
al.bus.PublishOutbound(ctx, bus.OutboundMessage{ // Use a short timeout so the goroutine does not block indefinitely when
// the outbound bus is full. Reasoning output is best-effort; dropping it
// is acceptable to avoid goroutine accumulation.
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
defer pubCancel()
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channelName, Channel: channelName,
ChatID: channelID, ChatID: channelID,
Content: reasoningContent, Content: reasoningContent,
}); err != nil {
// Treat context.DeadlineExceeded / context.Canceled as expected
// (bus full under load, or parent canceled). Check the error
// itself rather than ctx.Err(), because pubCtx may time out
// (5 s) while the parent ctx is still active.
// Also treat ErrBusClosed as expected — it occurs during normal
// shutdown when the bus is closed before all goroutines finish.
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
errors.Is(err, bus.ErrBusClosed) {
logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{
"channel": channelName,
"error": err.Error(),
}) })
} else {
logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{
"channel": channelName,
"error": err.Error(),
})
}
}
} }
// runLLMIteration executes the LLM call loop with tool handling. // runLLMIteration executes the LLM call loop with tool handling.
@ -666,10 +692,35 @@ func (al *AgentLoop) runLLMIteration(
} }
errMsg := strings.ToLower(err.Error()) errMsg := strings.ToLower(err.Error())
isContextError := strings.Contains(errMsg, "token") ||
strings.Contains(errMsg, "context") || // Check if this is a network/HTTP timeout — not a context window error.
isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
strings.Contains(errMsg, "deadline exceeded") ||
strings.Contains(errMsg, "client.timeout") ||
strings.Contains(errMsg, "timed out") ||
strings.Contains(errMsg, "timeout exceeded")
// Detect real context window / token limit errors, excluding network timeouts.
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
strings.Contains(errMsg, "context window") ||
strings.Contains(errMsg, "maximum context length") ||
strings.Contains(errMsg, "token limit") ||
strings.Contains(errMsg, "too many tokens") ||
strings.Contains(errMsg, "max_tokens") ||
strings.Contains(errMsg, "invalidparameter") || strings.Contains(errMsg, "invalidparameter") ||
strings.Contains(errMsg, "length") strings.Contains(errMsg, "prompt is too long") ||
strings.Contains(errMsg, "request too large"))
if isTimeoutError && retry < maxRetries {
backoff := time.Duration(retry+1) * 5 * time.Second
logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
"error": err.Error(),
"retry": retry,
"backoff": backoff.String(),
})
time.Sleep(backoff)
continue
}
if isContextError && retry < maxRetries { if isContextError && retry < maxRetries {
logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{

View file

@ -797,4 +797,57 @@ func TestHandleReasoning(t *testing.T) {
t.Fatalf("expected no outbound message, got %+v", msg) t.Fatalf("expected no outbound message, got %+v", msg)
} }
}) })
t.Run("returns promptly when bus is full", func(t *testing.T) {
al, msgBus := newLoop(t)
// Fill the outbound bus buffer until a publish would block.
// Use a short timeout to detect when the buffer is full,
// rather than hardcoding the buffer size.
for i := 0; ; i++ {
fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{
Channel: "filler",
ChatID: "filler",
Content: fmt.Sprintf("filler-%d", i),
})
fillCancel()
if err != nil {
// Buffer is full (timed out trying to send).
break
}
}
// Use a short-deadline parent context to bound the test.
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
start := time.Now()
al.handleReasoning(ctx, "should timeout", "slack", "channel-full")
elapsed := time.Since(start)
// handleReasoning uses a 5s internal timeout, but the parent ctx
// expires in 500ms. It should return within ~500ms, not 5s.
if elapsed > 2*time.Second {
t.Fatalf("handleReasoning blocked too long (%v); expected prompt return", elapsed)
}
// Drain the bus and verify the reasoning message was NOT published
// (it should have been dropped due to timeout).
drainCtx, drainCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer drainCancel()
foundReasoning := false
for {
msg, ok := msgBus.SubscribeOutbound(drainCtx)
if !ok {
break
}
if msg.Content == "should timeout" {
foundReasoning = true
}
}
if foundReasoning {
t.Fatal("expected reasoning message to be dropped when bus is full, but it was published")
}
})
} }

View file

@ -15,6 +15,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/mdp/qrterminal/v3" "github.com/mdp/qrterminal/v3"
@ -56,6 +57,8 @@ type WhatsAppNativeChannel struct {
runCancel context.CancelFunc runCancel context.CancelFunc
reconnectMu sync.Mutex reconnectMu sync.Mutex
reconnecting bool reconnecting bool
stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls
wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect)
} }
// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.
@ -80,6 +83,14 @@ func NewWhatsAppNativeChannel(
func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath}) logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath})
// Reset lifecycle state from any previous Stop() so a restarted channel
// behaves correctly. Use reconnectMu to be consistent with eventHandler
// and Stop() which coordinate under the same lock.
c.reconnectMu.Lock()
c.stopping.Store(false)
c.reconnecting = false
c.reconnectMu.Unlock()
if err := os.MkdirAll(c.storePath, 0o700); err != nil { if err := os.MkdirAll(c.storePath, 0o700); err != nil {
return fmt.Errorf("create session store dir: %w", err) return fmt.Errorf("create session store dir: %w", err)
} }
@ -112,6 +123,12 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
} }
client := whatsmeow.NewClient(deviceStore, waLogger) client := whatsmeow.NewClient(deviceStore, waLogger)
// Create runCtx/runCancel BEFORE registering event handler and starting
// goroutines so that Stop() can cancel them at any time, including during
// the QR-login flow.
c.runCtx, c.runCancel = context.WithCancel(ctx)
client.AddEventHandler(c.eventHandler) client.AddEventHandler(c.eventHandler)
c.mu.Lock() c.mu.Lock()
@ -119,17 +136,55 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
c.client = client c.client = client
c.mu.Unlock() c.mu.Unlock()
if client.Store.ID == nil { // cleanupOnError clears struct references and releases resources when
qrChan, err := client.GetQRChannel(ctx) // Start() fails after fields are already assigned. This prevents
if err != nil { // Stop() from operating on stale references (double-close, disconnect
// of a partially-initialized client, or stray event handler callbacks).
startOK := false
defer func() {
if startOK {
return
}
c.runCancel()
client.Disconnect()
c.mu.Lock()
c.client = nil
c.container = nil
c.mu.Unlock()
_ = container.Close() _ = container.Close()
}()
if client.Store.ID == nil {
qrChan, err := client.GetQRChannel(c.runCtx)
if err != nil {
return fmt.Errorf("get QR channel: %w", err) return fmt.Errorf("get QR channel: %w", err)
} }
if err := client.Connect(); err != nil { if err := client.Connect(); err != nil {
_ = container.Close()
return fmt.Errorf("connect: %w", err) return fmt.Errorf("connect: %w", err)
} }
for evt := range qrChan { // Handle QR events in a background goroutine so Start() returns
// promptly. The goroutine is tracked via c.wg and respects
// c.runCtx for cancellation.
// Guard wg.Add with reconnectMu + stopping check (same protocol
// as eventHandler) so a concurrent Stop() cannot enter wg.Wait()
// while we call wg.Add(1).
c.reconnectMu.Lock()
if c.stopping.Load() {
c.reconnectMu.Unlock()
return fmt.Errorf("channel stopped during QR setup")
}
c.wg.Add(1)
c.reconnectMu.Unlock()
go func() {
defer c.wg.Done()
for {
select {
case <-c.runCtx.Done():
return
case evt, ok := <-qrChan:
if !ok {
return
}
if evt.Event == "code" { if evt.Event == "code" {
logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil) logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil)
qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{ qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{
@ -141,14 +196,15 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event}) logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event})
} }
} }
}
}()
} else { } else {
if err := client.Connect(); err != nil { if err := client.Connect(); err != nil {
_ = container.Close()
return fmt.Errorf("connect: %w", err) return fmt.Errorf("connect: %w", err)
} }
} }
c.runCtx, c.runCancel = context.WithCancel(ctx) startOK = true
c.SetRunning(true) c.SetRunning(true)
logger.InfoC("whatsapp", "WhatsApp native channel connected") logger.InfoC("whatsapp", "WhatsApp native channel connected")
return nil return nil
@ -156,19 +212,53 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {
logger.InfoC("whatsapp", "Stopping WhatsApp native channel") logger.InfoC("whatsapp", "Stopping WhatsApp native channel")
// Mark as stopping under reconnectMu so the flag is visible to
// eventHandler atomically with respect to its wg.Add(1) call.
// This closes the TOCTOU window where eventHandler could check
// stopping (false), then Stop sets it true + enters wg.Wait,
// then eventHandler calls wg.Add(1) — causing a panic.
c.reconnectMu.Lock()
c.stopping.Store(true)
c.reconnectMu.Unlock()
if c.runCancel != nil { if c.runCancel != nil {
c.runCancel() c.runCancel()
} }
// Disconnect the client first so any blocking Connect()/reconnect loops
// can be interrupted before we wait on the goroutines.
c.mu.Lock() c.mu.Lock()
client := c.client client := c.client
container := c.container container := c.container
c.client = nil
c.container = nil
c.mu.Unlock() c.mu.Unlock()
if client != nil { if client != nil {
client.Disconnect() client.Disconnect()
} }
// Wait for background goroutines (QR handler, reconnect) to finish in a
// context-aware way so Stop can be bounded by ctx.
done := make(chan struct{})
go func() {
c.wg.Wait()
close(done)
}()
select {
case <-done:
// All goroutines have finished.
case <-ctx.Done():
// Context canceled or timed out; log and proceed with best-effort cleanup.
logger.WarnC("whatsapp", fmt.Sprintf("Stop context canceled before all goroutines finished: %v", ctx.Err()))
}
// Now it is safe to clear and close resources.
c.mu.Lock()
c.client = nil
c.container = nil
c.mu.Unlock()
if container != nil { if container != nil {
_ = container.Close() _ = container.Close()
} }
@ -187,9 +277,20 @@ func (c *WhatsAppNativeChannel) eventHandler(evt any) {
c.reconnectMu.Unlock() c.reconnectMu.Unlock()
return return
} }
c.reconnecting = true // Check stopping while holding the lock so the check and wg.Add
// are atomic with respect to Stop() setting the flag + calling
// wg.Wait(). This prevents the TOCTOU race.
if c.stopping.Load() {
c.reconnectMu.Unlock() c.reconnectMu.Unlock()
go c.reconnectWithBackoff() return
}
c.reconnecting = true
c.wg.Add(1)
c.reconnectMu.Unlock()
go func() {
defer c.wg.Done()
c.reconnectWithBackoff()
}()
} }
} }
@ -313,6 +414,12 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
} }
// Detect unpaired state: the client is connected (to WhatsApp servers)
// but has not completed QR-login yet, so sending would fail.
if client.Store.ID == nil {
return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary)
}
to, err := parseJID(msg.ChatID) to, err := parseJID(msg.ChatID)
if err != nil { if err != nil {
return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)