Skip to content

Commit 6d6e9dc

Browse files
fix: Progressive timeout/cancel can keep the agent running for 5 extra minutes
1 parent 50e59be commit 6d6e9dc

2 files changed

Lines changed: 103 additions & 9 deletions

File tree

cmd/progressive.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -530,23 +530,31 @@ func progressiveFinalizeReserve(total time.Duration) time.Duration {
530530
return reserve
531531
}
532532

533-
// progressiveFinalizationContext returns a detached, time-boxed context for the
534-
// finalization pass plus its CancelFunc, which the caller must invoke (via defer)
535-
// to release resources. It returns (nil, nil) when there is no grace budget.
533+
// progressiveFinalizationContext returns a time-boxed context for the finalization
534+
// pass plus its CancelFunc, which the caller must invoke (via defer) to release
535+
// resources. Natural completion gets a detached grace window so the model can add
536+
// final polish; timeout/cancel exits stay attached to the parent context and are
537+
// capped to the reserved budget. It returns (nil, nil) when finalization should
538+
// be skipped.
536539
func progressiveFinalizationContext(parent context.Context, reserve time.Duration, exitReason string) (context.Context, context.CancelFunc) {
537-
grace := reserve
538-
if grace < progressiveDefaultFinalizeGrace {
539-
grace = progressiveDefaultFinalizeGrace
540-
}
541540
if exitReason == exitReasonNatural {
541+
grace := reserve
542+
if grace < progressiveDefaultFinalizeGrace {
543+
grace = progressiveDefaultFinalizeGrace
544+
}
542545
if grace < progressiveMinFinalizeBudget {
543546
grace = progressiveMinFinalizeBudget
544547
}
548+
if grace <= 0 {
549+
return nil, nil
550+
}
551+
return context.WithTimeout(context.WithoutCancel(parent), grace)
545552
}
546-
if grace <= 0 {
553+
554+
if reserve <= 0 || parent.Err() != nil {
547555
return nil, nil
548556
}
549-
return context.WithTimeout(context.WithoutCancel(parent), grace)
557+
return context.WithTimeout(parent, reserve)
550558
}
551559

552560
func buildProgressiveFinalizePrompt(latest *progressCommit) string {

cmd/progressive_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,92 @@ func TestRunProgressiveSessionFinalizesOnNaturalCompletion(t *testing.T) {
151151
}
152152
}
153153

154+
func TestRunProgressiveSessionTimeoutDoesNotStartDetachedFinalizationAfterDeadline(t *testing.T) {
155+
provider := llm.NewMockProvider("mock")
156+
provider.AddTurn(llm.MockTurn{Delay: 50 * time.Millisecond, Text: "too slow"})
157+
provider.AddTextResponse("finalization should not run")
158+
159+
engine := llm.NewEngine(provider, nil)
160+
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
161+
defer cancel()
162+
163+
result, err := runProgressiveSession(ctx, engine, llm.Request{
164+
Messages: []llm.Message{llm.UserText("Investigate X")},
165+
MaxTurns: 2,
166+
}, progressiveRunOptions{StopWhen: progressiveStopWhenTimeout})
167+
if err != nil {
168+
t.Fatalf("runProgressiveSession error = %v", err)
169+
}
170+
if result.ExitReason != exitReasonTimeout {
171+
t.Fatalf("exit reason = %q, want %q", result.ExitReason, exitReasonTimeout)
172+
}
173+
if len(provider.Requests) != 1 {
174+
t.Fatalf("provider saw %d requests, want timeout to skip detached finalization pass", len(provider.Requests))
175+
}
176+
}
177+
178+
func TestProgressiveFinalizationContextNaturalCompletionDetachesFromParent(t *testing.T) {
179+
parent, cancelParent := context.WithCancel(context.Background())
180+
finalizeCtx, finalizeCancel := progressiveFinalizationContext(parent, time.Second, exitReasonNatural)
181+
if finalizeCtx == nil {
182+
t.Fatal("expected finalization context")
183+
}
184+
defer finalizeCancel()
185+
186+
deadline, ok := finalizeCtx.Deadline()
187+
if !ok {
188+
t.Fatal("expected deadline on finalization context")
189+
}
190+
if remaining := time.Until(deadline); remaining < progressiveDefaultFinalizeGrace-time.Second {
191+
t.Fatalf("natural finalization remaining = %v, want about %v", remaining, progressiveDefaultFinalizeGrace)
192+
}
193+
194+
cancelParent()
195+
select {
196+
case <-finalizeCtx.Done():
197+
t.Fatal("natural finalization context should not be canceled with parent")
198+
default:
199+
}
200+
}
201+
202+
func TestProgressiveFinalizationContextTimeoutUsesReserveAndParentCancellation(t *testing.T) {
203+
parent, cancelParent := context.WithCancel(context.Background())
204+
defer cancelParent()
205+
206+
reserve := 50 * time.Millisecond
207+
finalizeCtx, finalizeCancel := progressiveFinalizationContext(parent, reserve, exitReasonTimeout)
208+
if finalizeCtx == nil {
209+
t.Fatal("expected finalization context")
210+
}
211+
defer finalizeCancel()
212+
213+
deadline, ok := finalizeCtx.Deadline()
214+
if !ok {
215+
t.Fatal("expected deadline on finalization context")
216+
}
217+
remaining := time.Until(deadline)
218+
if remaining <= 0 || remaining > time.Second {
219+
t.Fatalf("timeout finalization remaining = %v, want reserve-sized budget without 5m grace", remaining)
220+
}
221+
222+
cancelParent()
223+
select {
224+
case <-finalizeCtx.Done():
225+
case <-time.After(250 * time.Millisecond):
226+
t.Fatal("timeout finalization context did not stop when parent was canceled")
227+
}
228+
}
229+
230+
func TestProgressiveFinalizationContextCancelledExitSkipsWhenParentAlreadyCancelled(t *testing.T) {
231+
parent, cancelParent := context.WithCancel(context.Background())
232+
cancelParent()
233+
234+
finalizeCtx, finalizeCancel := progressiveFinalizationContext(parent, time.Second, exitReasonCancelled)
235+
if finalizeCtx != nil || finalizeCancel != nil {
236+
t.Fatal("expected cancelled exit to skip finalization when parent is already canceled")
237+
}
238+
}
239+
154240
func TestRunProgressivePassDoesNotDuplicateProducedAssistantWhenResponseCallbackFails(t *testing.T) {
155241
provider := llm.NewMockProvider("mock").WithCapabilities(llm.Capabilities{ToolCalls: true})
156242
provider.AddToolCall("call-1", "progressive_test_tool", map[string]any{})

0 commit comments

Comments
 (0)