Fix agent loop reload and panic cleanup stability
This commit is contained in:
parent
b7db059544
commit
3a68d26837
5 changed files with 343 additions and 35 deletions
|
|
@ -117,6 +117,7 @@ const (
|
||||||
handledToolResponseSummary = "Requested output delivered via tool attachment."
|
handledToolResponseSummary = "Requested output delivered via tool attachment."
|
||||||
sessionKeyAgentPrefix = "agent:"
|
sessionKeyAgentPrefix = "agent:"
|
||||||
pendingTurnPrefix = "pending-"
|
pendingTurnPrefix = "pending-"
|
||||||
|
providerReloadGracePeriod = 30 * time.Second
|
||||||
metadataKeyMessageKind = "message_kind"
|
metadataKeyMessageKind = "message_kind"
|
||||||
metadataKeyToolCalls = "tool_calls"
|
metadataKeyToolCalls = "tool_calls"
|
||||||
metadataKeyOutboundKind = "outbound_kind"
|
metadataKeyOutboundKind = "outbound_kind"
|
||||||
|
|
@ -208,6 +209,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
// slot. The goroutine is spawned immediately so the main loop keeps
|
// slot. The goroutine is spawned immediately so the main loop keeps
|
||||||
// draining the inbound channel. The goroutine blocks on the semaphore.
|
// draining the inbound channel. The goroutine blocks on the semaphore.
|
||||||
go func(m bus.InboundMessage) {
|
go func(m bus.InboundMessage) {
|
||||||
|
var releaseSession bool
|
||||||
// Acquire semaphore slot (blocks if at capacity)
|
// Acquire semaphore slot (blocks if at capacity)
|
||||||
select {
|
select {
|
||||||
case al.workerSem <- struct{}{}:
|
case al.workerSem <- struct{}{}:
|
||||||
|
|
@ -215,7 +217,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Context canceled while waiting for a slot — clean up the
|
// Context canceled while waiting for a slot — clean up the
|
||||||
// placeholder to prevent session-level deadlock.
|
// placeholder to prevent session-level deadlock.
|
||||||
al.activeTurnStates.Delete(sessionKey)
|
al.releaseSessionTurnState(sessionKey, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,16 +226,21 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
// completes normally, clearActiveTurn deletes the real turnState and
|
// completes normally, clearActiveTurn deletes the real turnState and
|
||||||
// this becomes a no-op (the key is already gone).
|
// this becomes a no-op (the key is already gone).
|
||||||
defer func() {
|
defer func() {
|
||||||
|
if releaseSession {
|
||||||
|
al.releaseSessionTurnState(sessionKey, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
if actual, ok := al.activeTurnStates.Load(sessionKey); ok {
|
if actual, ok := al.activeTurnStates.Load(sessionKey); ok {
|
||||||
if ts, ok := actual.(*turnState); ok && strings.HasPrefix(ts.turnID, pendingTurnPrefix) {
|
if ts, ok := actual.(*turnState); ok && strings.HasPrefix(ts.turnID, pendingTurnPrefix) {
|
||||||
// Placeholder still present — runTurn never replaced it.
|
// Placeholder still present — runTurn never replaced it.
|
||||||
al.activeTurnStates.Delete(sessionKey)
|
al.releaseSessionTurnState(sessionKey, ts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
releaseSession = true
|
||||||
logger.RecoverPanicNoExit(r)
|
logger.RecoverPanicNoExit(r)
|
||||||
logger.ErrorCF("agent", "Worker goroutine panicked",
|
logger.ErrorCF("agent", "Worker goroutine panicked",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
@ -251,7 +258,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if al.takePendingStop(sessionKey) {
|
if al.takePendingStop(sessionKey) {
|
||||||
al.activeTurnStates.Delete(sessionKey)
|
al.releaseSessionTurnState(sessionKey, nil)
|
||||||
target := &continuationTarget{
|
target := &continuationTarget{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Channel: m.Channel,
|
Channel: m.Channel,
|
||||||
|
|
@ -365,37 +372,23 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
||||||
return fmt.Errorf("config cannot be nil")
|
return fmt.Errorf("config cannot be nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new registry with updated config and provider
|
|
||||||
// Wrap in defer/recover to handle any panics gracefully
|
|
||||||
var registry *AgentRegistry
|
var registry *AgentRegistry
|
||||||
var panicErr error
|
func() {
|
||||||
done := make(chan struct{}, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
logger.RecoverPanicNoExit(r)
|
logger.RecoverPanicNoExit(r)
|
||||||
panicErr = fmt.Errorf("panic during registry creation: %v", r)
|
|
||||||
logger.ErrorCF("agent", "Panic during registry creation",
|
logger.ErrorCF("agent", "Panic during registry creation",
|
||||||
map[string]any{"panic": r})
|
map[string]any{"panic": r})
|
||||||
|
registry = nil
|
||||||
}
|
}
|
||||||
close(done)
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
registry = NewAgentRegistry(cfg, provider)
|
registry = NewAgentRegistry(cfg, provider)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Wait for completion or context cancellation
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
if registry == nil {
|
if registry == nil {
|
||||||
if panicErr != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return fmt.Errorf("registry creation failed: %w", panicErr)
|
return fmt.Errorf("context canceled during registry creation: %w", err)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("registry creation failed (nil result)")
|
return fmt.Errorf("registry creation failed")
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return fmt.Errorf("context canceled during registry creation: %w", ctx.Err())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check context again before proceeding
|
// Check context again before proceeding
|
||||||
|
|
@ -471,17 +464,7 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
||||||
// This prevents blocking readers while closing
|
// This prevents blocking readers while closing
|
||||||
if oldProvider, ok := extractProvider(oldRegistry); ok {
|
if oldProvider, ok := extractProvider(oldRegistry); ok {
|
||||||
if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
|
if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
|
||||||
// Give in-flight requests a moment to complete
|
al.closeReloadedProvider(ctx, stateful)
|
||||||
// Use a reasonable timeout that balances cleanup vs resource usage
|
|
||||||
select {
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
stateful.Close()
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Context canceled, close immediately but log warning
|
|
||||||
logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close",
|
|
||||||
map[string]any{"error": ctx.Err()})
|
|
||||||
stateful.Close()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -119,6 +120,31 @@ type recordingProvider struct {
|
||||||
lastModel string
|
lastModel string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type panicAfterStartProvider struct {
|
||||||
|
started chan struct{}
|
||||||
|
calls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panicAfterStartProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
options map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
p.calls.Add(1)
|
||||||
|
select {
|
||||||
|
case <-p.started:
|
||||||
|
default:
|
||||||
|
close(p.started)
|
||||||
|
}
|
||||||
|
panic("provider panic after turn registration")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panicAfterStartProvider) GetDefaultModel() string {
|
||||||
|
return "panic-after-start"
|
||||||
|
}
|
||||||
|
|
||||||
func (r *recordingProvider) Chat(
|
func (r *recordingProvider) Chat(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
messages []providers.Message,
|
messages []providers.Message,
|
||||||
|
|
@ -5710,3 +5736,83 @@ func (p *concurrentMockProvider) Chat(
|
||||||
func (p *concurrentMockProvider) GetDefaultModel() string {
|
func (p *concurrentMockProvider) GetDefaultModel() string {
|
||||||
return "test-model"
|
return "test-model"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunWorkerPanicReleasesSessionTurnState(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = t.TempDir()
|
||||||
|
cfg.Agents.Defaults.MaxParallelTurns = 1
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &panicAfterStartProvider{started: make(chan struct{})}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
defer al.Close()
|
||||||
|
|
||||||
|
runCtx, cancelRun := context.WithCancel(context.Background())
|
||||||
|
runDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
runDone <- al.Run(runCtx)
|
||||||
|
}()
|
||||||
|
defer func() {
|
||||||
|
cancelRun()
|
||||||
|
select {
|
||||||
|
case err := <-runDone:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for Run() to exit")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
msg := bus.InboundMessage{
|
||||||
|
Context: bus.InboundContext{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "panic-chat",
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderID: "user1",
|
||||||
|
},
|
||||||
|
Content: "trigger panic",
|
||||||
|
SessionKey: "panic-session",
|
||||||
|
}
|
||||||
|
route, _, err := al.resolveMessageRoute(msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveMessageRoute() error = %v", err)
|
||||||
|
}
|
||||||
|
scopeKey := resolveScopeKey(al.allocateRouteSession(route, msg).SessionKey, msg.SessionKey)
|
||||||
|
|
||||||
|
if err := msgBus.PublishInbound(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("PublishInbound(first) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-provider.started:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for first turn to start")
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
if al.getActiveTurnState(scopeKey) == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("session turn state remained stuck after worker panic")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := msgBus.PublishInbound(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("PublishInbound(second) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline = time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
if provider.calls.Load() >= 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("second message did not start a new turn after panic cleanup")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/commands"
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/session"
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
|
@ -584,6 +585,55 @@ func closeProviderIfStateful(provider providers.LLMProvider) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) waitForActiveRequests(ctx context.Context, timeout time.Duration) bool {
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
al.activeRequests.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if timeout <= 0 {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
case <-timer.C:
|
||||||
|
return false
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) closeReloadedProvider(ctx context.Context, provider providers.StatefulProvider) {
|
||||||
|
waitCtx := ctx
|
||||||
|
if waitCtx == nil {
|
||||||
|
waitCtx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
drained := al.waitForActiveRequests(waitCtx, providerReloadGracePeriod)
|
||||||
|
if !drained {
|
||||||
|
fields := map[string]any{"grace_period": providerReloadGracePeriod.String()}
|
||||||
|
if err := waitCtx.Err(); err != nil {
|
||||||
|
fields["error"] = err.Error()
|
||||||
|
logger.WarnCF("agent", "Provider reload interrupted while waiting for in-flight requests", fields)
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "Provider reload grace period expired with in-flight requests still running", fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func makePendingTurnID(sessionKey string, seq uint64) string {
|
func makePendingTurnID(sessionKey string, seq uint64) string {
|
||||||
return pendingTurnPrefix + sessionKey + "-" + fmt.Sprintf("%d", seq)
|
return pendingTurnPrefix + sessionKey + "-" + fmt.Sprintf("%d", seq)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -9,6 +10,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRuntimeEventLoggerFiltering(t *testing.T) {
|
func TestRuntimeEventLoggerFiltering(t *testing.T) {
|
||||||
|
|
@ -191,6 +193,163 @@ func TestReloadProviderAndConfigRefreshesRuntimeEventLogger(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type reloadBlockingProvider struct {
|
||||||
|
chatStarted chan struct{}
|
||||||
|
releaseChat chan struct{}
|
||||||
|
closeCalled chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *reloadBlockingProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
options map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
select {
|
||||||
|
case <-p.chatStarted:
|
||||||
|
default:
|
||||||
|
close(p.chatStarted)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-p.releaseChat:
|
||||||
|
return &providers.LLMResponse{Content: "done"}, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *reloadBlockingProvider) GetDefaultModel() string {
|
||||||
|
return "reload-blocking"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *reloadBlockingProvider) Close() {
|
||||||
|
select {
|
||||||
|
case <-p.closeCalled:
|
||||||
|
default:
|
||||||
|
close(p.closeCalled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadProviderAndConfigWaitsForInFlightRequestsBeforeClosingOldProvider(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = t.TempDir()
|
||||||
|
|
||||||
|
oldProvider := &reloadBlockingProvider{
|
||||||
|
chatStarted: make(chan struct{}),
|
||||||
|
releaseChat: make(chan struct{}),
|
||||||
|
closeCalled: make(chan struct{}),
|
||||||
|
}
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), oldProvider)
|
||||||
|
defer al.Close()
|
||||||
|
|
||||||
|
msg := testInboundMessage(bus.InboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
ChatID: "reload-chat",
|
||||||
|
SenderID: "user-1",
|
||||||
|
Content: "hold request open",
|
||||||
|
})
|
||||||
|
|
||||||
|
reqDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := al.processMessage(context.Background(), msg)
|
||||||
|
reqDone <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-oldProvider.chatStarted:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for in-flight provider request")
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
reloaded := config.DefaultConfig()
|
||||||
|
reloaded.Agents.Defaults.Workspace = cfg.Agents.Defaults.Workspace
|
||||||
|
reloadDone <- al.ReloadProviderAndConfig(context.Background(), &mockProvider{}, reloaded)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-oldProvider.closeCalled:
|
||||||
|
t.Fatal("old provider closed before in-flight request completed")
|
||||||
|
case err := <-reloadDone:
|
||||||
|
t.Fatalf("reload returned early: %v", err)
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
close(oldProvider.releaseChat)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-reqDone:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for in-flight request to complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-reloadDone:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReloadProviderAndConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for reload to finish")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-oldProvider.closeCalled:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for old provider close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitForActiveRequestsHonorsContextCancellation(t *testing.T) {
|
||||||
|
al := &AgentLoop{}
|
||||||
|
al.activeRequests.Add(1)
|
||||||
|
defer al.activeRequests.Done()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if al.waitForActiveRequests(ctx, time.Second) {
|
||||||
|
t.Fatal("waitForActiveRequests() = true, want false on canceled context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReloadProviderAndConfigReturnsCanceledErrorWhenRegistryCreationPanics(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.Workspace = t.TempDir()
|
||||||
|
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
|
||||||
|
defer al.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := al.ReloadProviderAndConfig(ctx, &panicProviderForReloadTest{}, cfg)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("ReloadProviderAndConfig() error = %v, want context canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type panicProviderForReloadTest struct{}
|
||||||
|
|
||||||
|
func (p *panicProviderForReloadTest) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
options map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
return &providers.LLMResponse{Content: "unused"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *panicProviderForReloadTest) GetDefaultModel() string {
|
||||||
|
panic("boom")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCloseRuntimeEventLoggerSubscriptionWaitsForDrain(t *testing.T) {
|
func TestCloseRuntimeEventLoggerSubscriptionWaitsForDrain(t *testing.T) {
|
||||||
eventBus := runtimeevents.NewBus()
|
eventBus := runtimeevents.NewBus()
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
|
||||||
|
|
@ -285,7 +285,17 @@ func (al *AgentLoop) registerActiveTurn(ts *turnState) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) clearActiveTurn(ts *turnState) {
|
func (al *AgentLoop) clearActiveTurn(ts *turnState) {
|
||||||
al.activeTurnStates.Delete(ts.sessionKey)
|
al.releaseSessionTurnState(ts.sessionKey, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) releaseSessionTurnState(sessionKey string, expected *turnState) {
|
||||||
|
if expected == nil {
|
||||||
|
al.activeTurnStates.Delete(sessionKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if actual, ok := al.activeTurnStates.Load(sessionKey); ok && actual == expected {
|
||||||
|
al.activeTurnStates.Delete(sessionKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState {
|
func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue