Merge branch 'sipeed:main' into fix/inbound-dedup-messageid
This commit is contained in:
commit
d0c508237b
3 changed files with 235 additions and 24 deletions
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue