fix(agent): resolve subturn deadlocks, panics and context retry state

This commit addresses several critical concurrency and state management bugs within the SubTurn execution and delivery logic.

1. Fix Goroutine Leak & Deadlock in deliverSubTurnResult:
   - Replaced non-blocking select with a safe blocking select that listens to `resultChan` and a new `<-parentTS.Finished()` channel.
   - This ensures results are not arbitrarily dropped when the channel is full (preventing orphaned valid results), while also guaranteeing the child goroutine safely unblocks and exits if the parent finishes execution early.

2. Prevent "Send on Closed Channel" Fatal Panics:
   - Removed `close(pendingResults)` and `drainPendingResults` from `turnState.Finish()`.
   - The pendingResults channel is now naturally garbage collected, completely eliminating the race condition panic when a child attempts delivery at the exact moment the parent finishes.
   - Added a `defer recover()` failsafe inside deliverSubTurnResult to gracefully emit Orphan events in extreme edge cases.

3. Fix Truncation Recovery Prompt Drop:
   - Fixed the runTurn truncation retry logic by introducing an explicit `promptAlreadyAdded` boolean.
   - Ensures that the dynamically generated `recoveryPrompt` is correctly injected into the LLM history sequence on subsequent iterations, adhering to API roles without duplicating arrays.

4. Test Suite Stabilization:
   - Fixed TestDeliverSubTurnResultNoDeadlock to accurately wait for deterministic deliveries instead of racing timeouts.
   - Replaced defunct closed-channel tests with TestFinishedChannelClosedState matching the new Finished() mechanism.
   - Fixed the Finish(true) parameter in TestGrandchildAbort_CascadingCancellation to correctly validate Context cascade behavior.
   - All tests now pass cleanly without hanging or emitting false positives.
This commit is contained in:
Administrator 2026-03-18 13:10:36 +08:00
parent c7ea018a73
commit e20ff43f8b
3 changed files with 94 additions and 190 deletions

View file

@ -344,7 +344,24 @@ func spawnSubTurn(ctx context.Context, al *AgentLoop, parentTS *turnState, cfg S
// - SubTurnResultDeliveredEvent: successful delivery to channel // - SubTurnResultDeliveredEvent: successful delivery to channel
// - SubTurnOrphanResultEvent: delivery failed (parent finished or channel full) // - SubTurnOrphanResultEvent: delivery failed (parent finished or channel full)
func deliverSubTurnResult(parentTS *turnState, childID string, result *tools.ToolResult) { func deliverSubTurnResult(parentTS *turnState, childID string, result *tools.ToolResult) {
// Check parent state under lock, but don't hold lock while sending to channel // Let GC clean up the pendingResults channel; parent Finish will no longer close it.
// We use defer/recover to catch any unlikely channel panics if it were ever closed.
defer func() {
if r := recover(); r != nil {
logger.WarnCF("subturn", "recovered panic sending to pendingResults", map[string]any{
"parent_id": parentTS.turnID,
"child_id": childID,
"recover": r,
})
if result != nil {
MockEventBus.Emit(SubTurnOrphanResultEvent{
ParentID: parentTS.turnID,
ChildID: childID,
Result: result,
})
}
}
}()
parentTS.mu.Lock() parentTS.mu.Lock()
isFinished := parentTS.isFinished isFinished := parentTS.isFinished
resultChan := parentTS.pendingResults resultChan := parentTS.pendingResults
@ -363,8 +380,9 @@ func deliverSubTurnResult(parentTS *turnState, childID string, result *tools.Too
} }
// Parent Turn is still running → attempt to deliver result // Parent Turn is still running → attempt to deliver result
// Note: There's still a small race window between the isFinished check above and the send below, // We use a select statement with parentTS.Finished() to ensure that if the
// but this is acceptable - worst case the result becomes an orphan, which is handled gracefully. // parent turn finishes while we are waiting to send the result (e.g. channel
// is full), we don't leak this goroutine by blocking forever.
select { select {
case resultChan <- result: case resultChan <- result:
// Successfully delivered // Successfully delivered
@ -373,9 +391,10 @@ func deliverSubTurnResult(parentTS *turnState, childID string, result *tools.Too
ChildID: childID, ChildID: childID,
Result: result, Result: result,
}) })
default: case <-parentTS.Finished():
// Channel is full - treat as orphan result // Parent finished while we were waiting to deliver.
logger.WarnCF("subturn", "pendingResults channel full", map[string]any{ // The result cannot be delivered to the LLM, so it becomes an orphan.
logger.WarnCF("subturn", "parent finished before result could be delivered", map[string]any{
"parent_id": parentTS.turnID, "parent_id": parentTS.turnID,
"child_id": childID, "child_id": childID,
}) })
@ -474,6 +493,7 @@ func runTurn(ctx context.Context, al *AgentLoop, ts *turnState, cfg SubTurnConfi
truncationRetryCount := 0 truncationRetryCount := 0
contextRetryCount := 0 contextRetryCount := 0
currentPrompt := cfg.SystemPrompt currentPrompt := cfg.SystemPrompt
promptAlreadyAdded := false
for { for {
// Soft context limit: check and truncate before LLM call // Soft context limit: check and truncate before LLM call
@ -512,9 +532,13 @@ func runTurn(ctx context.Context, al *AgentLoop, ts *turnState, cfg SubTurnConfi
DefaultResponse: "", DefaultResponse: "",
EnableSummary: false, EnableSummary: false,
SendResponse: false, SendResponse: false,
SkipAddUserMessage: contextRetryCount > 0, SkipAddUserMessage: promptAlreadyAdded,
}) })
// Mark the prompt as added so subsequent truncation retries
// won't duplicate it in the history.
promptAlreadyAdded = true
// 1. Handle context length errors // 1. Handle context length errors
if err != nil && isContextLengthError(err) { if err != nil && isContextLengthError(err) {
if contextRetryCount >= maxContextRetries { if contextRetryCount >= maxContextRetries {
@ -562,6 +586,7 @@ func runTurn(ctx context.Context, al *AgentLoop, ts *turnState, cfg SubTurnConfi
// Inject recovery prompt - it will be added by runAgentLoop on next iteration // Inject recovery prompt - it will be added by runAgentLoop on next iteration
recoveryPrompt := "Your previous response was truncated due to length. Please provide a shorter, complete response that finishes your thought." recoveryPrompt := "Your previous response was truncated due to length. Please provide a shorter, complete response that finishes your thought."
currentPrompt = recoveryPrompt currentPrompt = recoveryPrompt
promptAlreadyAdded = false // We need this new recovery prompt to be added
truncationRetryCount++ truncationRetryCount++
continue // Retry with recovery prompt continue // Retry with recovery prompt

View file

@ -632,11 +632,12 @@ func TestDeliverSubTurnResultNoDeadlock(t *testing.T) {
} }
// Concurrently read from the channel to prevent blocking // Concurrently read from the channel to prevent blocking
// and to actually retrieve the matched number of results
go func() { go func() {
for i := 0; i < numChildren; i++ { for i := 0; i < numChildren; i++ {
select { select {
case <-parent.pendingResults: case <-parent.pendingResults:
case <-time.After(2 * time.Second): case <-time.After(5 * time.Second):
t.Error("timeout waiting for result") t.Error("timeout waiting for result")
return return
} }
@ -714,48 +715,48 @@ func TestHardAbortOrderOfOperations(t *testing.T) {
} }
} }
// TestFinishClosesChannel verifies that Finish() closes the pendingResults channel // TestFinishedChannelClosedState verifies that Finish() closes the Finished() channel
// and that deliverSubTurnResult handles closed channels gracefully. // so that child turns can safely abort waiting.
func TestFinishClosesChannel(t *testing.T) { func TestFinishedChannelClosedState(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
ts := &turnState{ ts := &turnState{
ctx: ctx, ctx: ctx,
cancelFunc: cancel, cancelFunc: cancel,
turnID: "test-finish-channel", turnID: "test-finished-channel",
depth: 0, depth: 0,
pendingResults: make(chan *tools.ToolResult, 2), pendingResults: make(chan *tools.ToolResult, 2),
isFinished: false, isFinished: false,
} }
// Verify channel is open initially // Verify Finished channel is blocking initially
select { select {
case ts.pendingResults <- &tools.ToolResult{ForLLM: "test"}: case <-ts.Finished():
// Good - channel is open t.Fatal("finished channel should block initially")
// Drain the message we just sent
<-ts.pendingResults
default: default:
t.Fatal("channel should be open initially") // Good
} }
// Call Finish() with graceful finish // Call Finish() with graceful finish
ts.Finish(false) ts.Finish(false)
// Verify channel is closed // Verify Finished channel is closed
_, ok := <-ts.pendingResults select {
case _, ok := <-ts.Finished():
if ok { if ok {
t.Error("expected channel to be closed after Finish()") t.Error("expected Finished() channel to be closed after Finish()")
}
default:
t.Fatal("expected <-ts.Finished() to not block")
} }
// Verify Finish() is idempotent (can be called multiple times) // Verify Finish() is idempotent
ts.Finish(false) // Should not panic ts.Finish(false) // Should not panic
// Verify deliverSubTurnResult doesn't panic when sending to closed channel // Verify deliverSubTurnResult correctly uses Finished() channel and treats as orphan
result := &tools.ToolResult{ForLLM: "late result"} result := &tools.ToolResult{ForLLM: "late result"}
deliverSubTurnResult(ts, "child-1", result) // Will emit orphan due to <-ts.Finished() case
// This should not panic - it should recover and emit OrphanResultEvent
deliverSubTurnResult(ts, "child-1", result)
} }
// TestFinalPollCapturesLateResults verifies that the final poll before Finish() // TestFinalPollCapturesLateResults verifies that the final poll before Finish()
@ -1159,14 +1160,14 @@ func TestFinish_ConcurrentCalls(t *testing.T) {
wg.Wait() wg.Wait()
// Verify the channel is closed // Verify the Finished() channel is closed
select { select {
case _, ok := <-parentTS.pendingResults: case _, ok := <-parentTS.Finished():
if ok { if ok {
t.Error("Expected channel to be closed") t.Error("Expected Finished() channel to be closed")
} }
default: default:
t.Error("Expected channel to be closed and readable") t.Error("Expected Finished() channel to be closed and readable without blocking")
} }
// Verify isFinished is set // Verify isFinished is set
@ -1413,73 +1414,7 @@ func TestContextWrapping_SingleLayer(t *testing.T) {
t.Log("Context wrapping test passed - no redundant layers detected") t.Log("Context wrapping test passed - no redundant layers detected")
} }
// TestFinish_DrainsChannel verifies that Finish() drains remaining results
// from the pendingResults channel and emits them as orphan events.
func TestFinish_DrainsChannel(t *testing.T) {
// Save original MockEventBus.Emit
originalEmit := MockEventBus.Emit
defer func() {
MockEventBus.Emit = originalEmit
}()
// Collect orphan events
var mu sync.Mutex
var orphanEvents []SubTurnOrphanResultEvent
MockEventBus.Emit = func(e any) {
mu.Lock()
defer mu.Unlock()
if orphan, ok := e.(SubTurnOrphanResultEvent); ok {
orphanEvents = append(orphanEvents, orphan)
}
}
ctx := context.Background()
parentTS := &turnState{
ctx: ctx,
turnID: "parent-drain-test",
depth: 0,
pendingResults: make(chan *tools.ToolResult, 16),
concurrencySem: make(chan struct{}, maxConcurrentSubTurns),
}
parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx)
// Add some results to the channel before calling Finish()
const numResults = 5
for i := 0; i < numResults; i++ {
parentTS.pendingResults <- &tools.ToolResult{
ForLLM: fmt.Sprintf("result-%d", i),
}
}
// Verify results are in the channel
if len(parentTS.pendingResults) != numResults {
t.Errorf("Expected %d results in channel, got %d", numResults, len(parentTS.pendingResults))
}
// Call Finish() - it should drain the channel
parentTS.Finish(false)
// Verify all results were drained and emitted as orphan events
mu.Lock()
drainedCount := len(orphanEvents)
mu.Unlock()
if drainedCount != numResults {
t.Errorf("Expected %d orphan events from drain, got %d", numResults, drainedCount)
}
// Verify the channel is closed and empty
select {
case _, ok := <-parentTS.pendingResults:
if ok {
t.Error("Expected channel to be closed")
}
default:
t.Error("Expected channel to be closed and readable")
}
t.Logf("Successfully drained %d results from channel", drainedCount)
}
// TestSyncSubTurn_NoChannelDelivery verifies that synchronous sub-turns // TestSyncSubTurn_NoChannelDelivery verifies that synchronous sub-turns
// do NOT deliver results to the pendingResults channel (only return directly). // do NOT deliver results to the pendingResults channel (only return directly).
@ -1591,72 +1526,7 @@ func TestAsyncSubTurn_ChannelDelivery(t *testing.T) {
} }
} }
// TestChannelFull_OrphanResults verifies behavior when the pendingResults channel
// is full (16+ async results). Results that cannot be delivered should become orphans.
func TestChannelFull_OrphanResults(t *testing.T) {
// Save original MockEventBus.Emit
originalEmit := MockEventBus.Emit
defer func() {
MockEventBus.Emit = originalEmit
}()
// Collect events
var mu sync.Mutex
var deliveredCount, orphanCount int
MockEventBus.Emit = func(e any) {
mu.Lock()
defer mu.Unlock()
switch e.(type) {
case SubTurnResultDeliveredEvent:
deliveredCount++
case SubTurnOrphanResultEvent:
orphanCount++
}
}
ctx := context.Background()
parentTS := &turnState{
ctx: ctx,
turnID: "parent-full-channel",
depth: 0,
pendingResults: make(chan *tools.ToolResult, 16),
concurrencySem: make(chan struct{}, maxConcurrentSubTurns),
}
parentTS.ctx, parentTS.cancelFunc = context.WithCancel(ctx)
defer parentTS.Finish(false)
// Send more results than the channel capacity (16)
const numResults = 25
for i := 0; i < numResults; i++ {
result := &tools.ToolResult{
ForLLM: fmt.Sprintf("result-%d", i),
}
deliverSubTurnResult(parentTS, fmt.Sprintf("child-%d", i), result)
}
// Get final counts
mu.Lock()
finalDelivered := deliveredCount
finalOrphan := orphanCount
mu.Unlock()
t.Logf("Delivered: %d, Orphan: %d, Total: %d", finalDelivered, finalOrphan, finalDelivered+finalOrphan)
// Should have delivered exactly 16 (channel capacity)
if finalDelivered != 16 {
t.Errorf("Expected 16 delivered results (channel capacity), got %d", finalDelivered)
}
// Should have 9 orphan results (25 - 16)
if finalOrphan != 9 {
t.Errorf("Expected 9 orphan results, got %d", finalOrphan)
}
// Total should equal numResults
if finalDelivered+finalOrphan != numResults {
t.Errorf("Expected %d total events, got %d", numResults, finalDelivered+finalOrphan)
}
}
// TestGrandchildAbort_CascadingCancellation verifies that when a grandparent turn // TestGrandchildAbort_CascadingCancellation verifies that when a grandparent turn
// is hard aborted, the cancellation cascades down to grandchild turns. // is hard aborted, the cancellation cascades down to grandchild turns.
@ -1720,7 +1590,7 @@ func TestGrandchildAbort_CascadingCancellation(t *testing.T) {
} }
// Hard abort the grandparent // Hard abort the grandparent
grandparentTS.Finish(false) grandparentTS.Finish(true)
// Wait a bit for cancellation to propagate // Wait a bit for cancellation to propagate
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)

View file

@ -45,6 +45,7 @@ type turnState struct {
isFinished bool // MUST be accessed under mu lock isFinished bool // MUST be accessed under mu lock
closeOnce sync.Once // Ensures pendingResults channel is closed exactly once closeOnce sync.Once // Ensures pendingResults channel is closed exactly once
concurrencySem chan struct{} // Limits concurrent child sub-turns concurrencySem chan struct{} // Limits concurrent child sub-turns
finishedChan chan struct{} // Lazily initialized, closed when turn finishes
// parentEnded signals that the parent turn has finished gracefully. // parentEnded signals that the parent turn has finished gracefully.
// Child SubTurns should check this via IsParentEnded() to decide whether // Child SubTurns should check this via IsParentEnded() to decide whether
@ -158,6 +159,21 @@ func (ts *turnState) GetLastFinishReason() string {
// IsParentEnded is a convenience method to check if parent ended. // IsParentEnded is a convenience method to check if parent ended.
// It returns the value of the parent's parentEnded atomic flag. // It returns the value of the parent's parentEnded atomic flag.
// Finished returns a channel that is closed when the turn finishes.
// This allows child turns to safely block on delivering results without leaking
// if the parent finishes before they can deliver.
func (ts *turnState) Finished() <-chan struct{} {
ts.mu.Lock()
defer ts.mu.Unlock()
if ts.finishedChan == nil {
ts.finishedChan = make(chan struct{})
if ts.isFinished {
close(ts.finishedChan)
}
}
return ts.finishedChan
}
// Finish marks the turn as finished. // Finish marks the turn as finished.
// //
// If isHardAbort is true (Hard Abort): // If isHardAbort is true (Hard Abort):
@ -170,12 +186,20 @@ func (ts *turnState) GetLastFinishReason() string {
// - Critical SubTurns continue running and deliver orphan results // - Critical SubTurns continue running and deliver orphan results
// - Non-Critical SubTurns exit gracefully without error // - Non-Critical SubTurns exit gracefully without error
// //
// In both cases, the pendingResults channel is closed to signal // In both cases, the pendingResults channel is NOT closed.
// that no more results will be delivered. // It is left open to be garbage collected when no longer used, avoiding
// "send on closed channel" panics from concurrently finishing async subturns.
func (ts *turnState) Finish(isHardAbort bool) { func (ts *turnState) Finish(isHardAbort bool) {
var fc chan struct{}
ts.mu.Lock() ts.mu.Lock()
if !ts.isFinished {
ts.isFinished = true ts.isFinished = true
resultChan := ts.pendingResults if ts.finishedChan == nil {
ts.finishedChan = make(chan struct{})
}
fc = ts.finishedChan
}
ts.mu.Unlock() ts.mu.Unlock()
if isHardAbort { if isHardAbort {
@ -188,30 +212,15 @@ func (ts *turnState) Finish(isHardAbort bool) {
ts.parentEnded.Store(true) ts.parentEnded.Store(true)
} }
// Use sync.Once to ensure the channel is closed exactly once, even if Finish() is called concurrently. // Safely close the finishedChan exactly once
// This prevents "close of closed channel" panics. if fc != nil {
ts.closeOnce.Do(func() { ts.closeOnce.Do(func() {
if resultChan != nil { close(fc)
close(resultChan)
// Drain any remaining results from the channel and emit them as orphan events.
// This prevents goroutine leaks and ensures all results are accounted for.
ts.drainPendingResults(resultChan)
}
}) })
} }
// drainPendingResults drains all remaining results from the closed channel // We no longer close(ts.pendingResults) here to avoid panicking any
// and emits them as orphan events. This must be called after the channel is closed. // concurrent deliverSubTurnResult calls. We rely on GC to clean up the channel.
func (ts *turnState) drainPendingResults(ch chan *tools.ToolResult) {
for result := range ch {
if result != nil {
MockEventBus.Emit(SubTurnOrphanResultEvent{
ParentID: ts.turnID,
ChildID: "unknown", // We don't know which child this came from
Result: result,
})
}
}
} }
// ====================== Ephemeral Session Store ====================== // ====================== Ephemeral Session Store ======================