From 0b45c66128883de71d845cc675a2b96c897e2801 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 8 Apr 2026 12:11:34 -0700 Subject: [PATCH 1/4] Add timeout to explain summary generation Entire-Checkpoint: c8bb257f3d46 --- cmd/entire/cli/explain.go | 5 ++- cmd/entire/cli/summarize/claude.go | 7 +++ .../cli/summarize/generator_config_test.go | 43 +++++++++++++++++++ cmd/entire/cli/summarize/summarize.go | 12 +++++- 4 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 cmd/entire/cli/summarize/generator_config_test.go diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 022bb482b..e0fa97783 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -451,7 +451,7 @@ func readV2ContentFromMain(ctx context.Context, v2Reader *checkpoint.V2GitStore, // generateCheckpointSummary generates an AI summary for a checkpoint and persists it. // The summary is generated from the scoped transcript (only this checkpoint's portion), // not the entire session transcript. -func generateCheckpointSummary(ctx context.Context, w, _ io.Writer, v1Store *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, checkpointID id.CheckpointID, cpSummary *checkpoint.CheckpointSummary, content *checkpoint.SessionContent, force bool) error { +func generateCheckpointSummary(ctx context.Context, w, errW io.Writer, v1Store *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, checkpointID id.CheckpointID, cpSummary *checkpoint.CheckpointSummary, content *checkpoint.SessionContent, force bool) error { // Check if summary already exists if content.Metadata.Summary != nil && !force { return fmt.Errorf("checkpoint %s already has a summary (use --force to regenerate)", checkpointID) @@ -470,6 +470,9 @@ func generateCheckpointSummary(ctx context.Context, w, _ io.Writer, v1Store *che // Generate summary using shared helper logging.Info(ctx, "generating checkpoint summary") + if errW != nil { + fmt.Fprintln(errW, "Generating checkpoint summary...") + } summary, err := summarize.GenerateFromTranscript(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent, nil) if err != nil { diff --git a/cmd/entire/cli/summarize/claude.go b/cmd/entire/cli/summarize/claude.go index 7b2e6dd3e..67b3141cc 100644 --- a/cmd/entire/cli/summarize/claude.go +++ b/cmd/entire/cli/summarize/claude.go @@ -118,6 +118,13 @@ func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoin err := cmd.Run() if err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil, context.DeadlineExceeded + } + if errors.Is(ctx.Err(), context.Canceled) { + return nil, context.Canceled + } + // Check if the command was not found var execErr *exec.Error if errors.As(err, &execErr) { diff --git a/cmd/entire/cli/summarize/generator_config_test.go b/cmd/entire/cli/summarize/generator_config_test.go new file mode 100644 index 000000000..0caa5cbb5 --- /dev/null +++ b/cmd/entire/cli/summarize/generator_config_test.go @@ -0,0 +1,43 @@ +package summarize + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" +) + +type blockingGenerator struct { + wait <-chan struct{} +} + +func (g *blockingGenerator) Generate(ctx context.Context, _ Input) (*checkpoint.Summary, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-g.wait: + return nil, nil + } +} + +func TestGenerateFromTranscript_Timeout(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + transcript := []byte(`{"type":"user","message":{"content":"Hello"}}`) + start := time.Now() + _, err := GenerateFromTranscript(ctx, transcript, nil, "", &blockingGenerator{wait: make(chan struct{})}) + if err == nil { + t.Fatal("expected timeout error") + } + if !strings.Contains(err.Error(), "timed out") { + t.Fatalf("unexpected error: %v", err) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("timeout took too long: %s", elapsed) + } +} diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index f6d1ae357..6055d64fc 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" @@ -47,14 +48,21 @@ func GenerateFromTranscript(ctx context.Context, transcriptBytes []byte, filesTo FilesTouched: filesTouched, } - // Use default generator if none provided + const defaultSummaryGenerationTimeout = 30 * time.Second + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, defaultSummaryGenerationTimeout) + defer cancel() + if generator == nil { generator = &ClaudeGenerator{} } summary, err := generator.Generate(ctx, input) if err != nil { - return nil, fmt.Errorf("failed to generate summary: %w", err) + if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { + return nil, fmt.Errorf("summary generation timed out after %s", defaultSummaryGenerationTimeout.Round(time.Second)) + } + return nil, err } return summary, nil From 44d8c8ac1b8cf11c0ebdcd8303da8f42dc260aa8 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 8 Apr 2026 12:38:04 -0700 Subject: [PATCH 2/4] Scope summary timeout to explain command Entire-Checkpoint: 512e7cbbecb7 --- cmd/entire/cli/explain.go | 43 ++++++++- cmd/entire/cli/explain_test.go | 90 +++++++++++++++++++ .../cli/summarize/generator_config_test.go | 43 --------- cmd/entire/cli/summarize/summarize.go | 11 +-- 4 files changed, 133 insertions(+), 54 deletions(-) delete mode 100644 cmd/entire/cli/summarize/generator_config_test.go diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index e0fa97783..160c205fe 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -36,6 +36,12 @@ import ( "golang.org/x/term" ) +const defaultCheckpointSummaryTimeout = 30 * time.Second + +var checkpointSummaryTimeout = defaultCheckpointSummaryTimeout + +var generateTranscriptSummary = summarize.GenerateFromTranscript + // interaction holds a single prompt and its responses for display. type interaction struct { Prompt string @@ -474,7 +480,7 @@ func generateCheckpointSummary(ctx context.Context, w, errW io.Writer, v1Store * fmt.Fprintln(errW, "Generating checkpoint summary...") } - summary, err := summarize.GenerateFromTranscript(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent, nil) + summary, err := generateCheckpointAISummary(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent) if err != nil { return fmt.Errorf("failed to generate summary: %w", err) } @@ -509,6 +515,41 @@ func generateCheckpointSummary(ctx context.Context, w, errW io.Writer, v1Store * return nil } +func generateCheckpointAISummary(ctx context.Context, scopedTranscript []byte, filesTouched []string, agentType types.AgentType) (*checkpoint.Summary, error) { + timeoutCtx := ctx + cancel := func() {} + timeoutDuration := checkpointSummaryTimeout + + if deadline, ok := ctx.Deadline(); ok { + timeoutDuration = time.Until(deadline) + } else { + var cancelFunc context.CancelFunc + timeoutCtx, cancelFunc = context.WithTimeout(ctx, checkpointSummaryTimeout) + cancel = cancelFunc + } + defer cancel() + + summary, err := generateTranscriptSummary(timeoutCtx, scopedTranscript, filesTouched, agentType, nil) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { + return nil, fmt.Errorf("summary generation timed out after %s: %w", formatSummaryTimeout(timeoutDuration), context.DeadlineExceeded) + } + return nil, err + } + + return summary, nil +} + +func formatSummaryTimeout(d time.Duration) string { + if d < 0 { + d = 0 + } + if d < time.Second { + return d.Round(10 * time.Millisecond).String() + } + return d.Round(time.Second).String() +} + // explainTemporaryCheckpoint finds and formats a temporary checkpoint by shadow commit hash prefix. // Returns the formatted output and whether the checkpoint was found. // Searches ALL shadow branches, not just the one for current HEAD, to find checkpoints diff --git a/cmd/entire/cli/explain_test.go b/cmd/entire/cli/explain_test.go index 4ef140524..9a4a3ab2b 100644 --- a/cmd/entire/cli/explain_test.go +++ b/cmd/entire/cli/explain_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "errors" "os" "os/exec" "path/filepath" @@ -11,10 +12,12 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/summarize" "github.com/entireio/cli/cmd/entire/cli/testutil" "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/entireio/cli/cmd/entire/cli/transcript" @@ -99,6 +102,93 @@ func TestExplainCmd_RejectsPositionalArgs(t *testing.T) { } } +func TestGenerateCheckpointAISummary_AddsDefaultTimeoutWithoutParentDeadline(t *testing.T) { + tmpTimeout := checkpointSummaryTimeout + tmpGenerator := generateTranscriptSummary + t.Cleanup(func() { + checkpointSummaryTimeout = tmpTimeout + generateTranscriptSummary = tmpGenerator + }) + + checkpointSummaryTimeout = 50 * time.Millisecond + + var gotDeadline time.Time + generateTranscriptSummary = func( + ctx context.Context, + _ []byte, + _ []string, + _ types.AgentType, + _ summarize.Generator, + ) (*checkpoint.Summary, error) { + deadline, ok := ctx.Deadline() + if !ok { + return nil, errors.New("expected deadline on summary context") + } + gotDeadline = deadline + return &checkpoint.Summary{Intent: "intent", Outcome: "outcome"}, nil + } + + start := time.Now() + summary, err := generateCheckpointAISummary(context.Background(), []byte("transcript"), nil, agent.AgentTypeClaudeCode) + if err != nil { + t.Fatalf("generateCheckpointAISummary() error = %v", err) + } + if summary == nil { + t.Fatal("expected summary") + } + if gotDeadline.IsZero() { + t.Fatal("expected deadline to be set") + } + if remaining := gotDeadline.Sub(start); remaining < 30*time.Millisecond || remaining > 200*time.Millisecond { + t.Fatalf("deadline offset = %s, want around %s", remaining, checkpointSummaryTimeout) + } +} + +func TestGenerateCheckpointAISummary_UsesParentDeadlineAndWrapsSentinel(t *testing.T) { + tmpTimeout := checkpointSummaryTimeout + tmpGenerator := generateTranscriptSummary + t.Cleanup(func() { + checkpointSummaryTimeout = tmpTimeout + generateTranscriptSummary = tmpGenerator + }) + + checkpointSummaryTimeout = 30 * time.Second + + parentCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + parentDeadline, _ := parentCtx.Deadline() + + var gotDeadline time.Time + generateTranscriptSummary = func( + ctx context.Context, + _ []byte, + _ []string, + _ types.AgentType, + _ summarize.Generator, + ) (*checkpoint.Summary, error) { + gotDeadline, _ = ctx.Deadline() + <-ctx.Done() + return nil, ctx.Err() + } + + _, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode) + if err == nil { + t.Fatal("expected timeout error") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected DeadlineExceeded, got %v", err) + } + if gotDeadline.IsZero() { + t.Fatal("expected deadline to be captured") + } + if delta := gotDeadline.Sub(parentDeadline); delta < -5*time.Millisecond || delta > 5*time.Millisecond { + t.Fatalf("deadline delta = %s, want near 0", delta) + } + if strings.Contains(err.Error(), "30s") { + t.Fatalf("timeout error should not report default timeout when parent deadline fired: %v", err) + } +} + func TestExplainCommit_NotFound(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) diff --git a/cmd/entire/cli/summarize/generator_config_test.go b/cmd/entire/cli/summarize/generator_config_test.go deleted file mode 100644 index 0caa5cbb5..000000000 --- a/cmd/entire/cli/summarize/generator_config_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package summarize - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/entireio/cli/cmd/entire/cli/checkpoint" -) - -type blockingGenerator struct { - wait <-chan struct{} -} - -func (g *blockingGenerator) Generate(ctx context.Context, _ Input) (*checkpoint.Summary, error) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-g.wait: - return nil, nil - } -} - -func TestGenerateFromTranscript_Timeout(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) - defer cancel() - - transcript := []byte(`{"type":"user","message":{"content":"Hello"}}`) - start := time.Now() - _, err := GenerateFromTranscript(ctx, transcript, nil, "", &blockingGenerator{wait: make(chan struct{})}) - if err == nil { - t.Fatal("expected timeout error") - } - if !strings.Contains(err.Error(), "timed out") { - t.Fatalf("unexpected error: %v", err) - } - if elapsed := time.Since(start); elapsed > time.Second { - t.Fatalf("timeout took too long: %s", elapsed) - } -} diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index 6055d64fc..539d42a0a 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "strings" - "time" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" @@ -48,21 +47,13 @@ func GenerateFromTranscript(ctx context.Context, transcriptBytes []byte, filesTo FilesTouched: filesTouched, } - const defaultSummaryGenerationTimeout = 30 * time.Second - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, defaultSummaryGenerationTimeout) - defer cancel() - if generator == nil { generator = &ClaudeGenerator{} } summary, err := generator.Generate(ctx, input) if err != nil { - if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) { - return nil, fmt.Errorf("summary generation timed out after %s", defaultSummaryGenerationTimeout.Round(time.Second)) - } - return nil, err + return nil, fmt.Errorf("failed to generate summary: %w", err) } return summary, nil From 48b5d03e1024647072e91f2ca51b65de799b04b7 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 8 Apr 2026 14:23:34 -0700 Subject: [PATCH 3/4] Handle canceled explain summary generation --- cmd/entire/cli/explain.go | 3 +++ cmd/entire/cli/explain_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 160c205fe..44282f6ca 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -531,6 +531,9 @@ func generateCheckpointAISummary(ctx context.Context, scopedTranscript []byte, f summary, err := generateTranscriptSummary(timeoutCtx, scopedTranscript, filesTouched, agentType, nil) if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(timeoutCtx.Err(), context.Canceled) { + return nil, fmt.Errorf("summary generation canceled: %w", context.Canceled) + } if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { return nil, fmt.Errorf("summary generation timed out after %s: %w", formatSummaryTimeout(timeoutDuration), context.DeadlineExceeded) } diff --git a/cmd/entire/cli/explain_test.go b/cmd/entire/cli/explain_test.go index 9a4a3ab2b..23509eab7 100644 --- a/cmd/entire/cli/explain_test.go +++ b/cmd/entire/cli/explain_test.go @@ -189,6 +189,40 @@ func TestGenerateCheckpointAISummary_UsesParentDeadlineAndWrapsSentinel(t *testi } } +func TestGenerateCheckpointAISummary_UsesCancellationSentinel(t *testing.T) { + tmpTimeout := checkpointSummaryTimeout + tmpGenerator := generateTranscriptSummary + t.Cleanup(func() { + checkpointSummaryTimeout = tmpTimeout + generateTranscriptSummary = tmpGenerator + }) + + parentCtx, cancel := context.WithCancel(context.Background()) + + generateTranscriptSummary = func( + ctx context.Context, + _ []byte, + _ []string, + _ types.AgentType, + _ summarize.Generator, + ) (*checkpoint.Summary, error) { + cancel() + <-ctx.Done() + return nil, ctx.Err() + } + + _, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode) + if err == nil { + t.Fatal("expected cancellation error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected Canceled, got %v", err) + } + if !strings.Contains(err.Error(), "canceled") { + t.Fatalf("expected cancellation message, got %v", err) + } +} + func TestExplainCommit_NotFound(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) From 5c18bb600da0dfd6a5ecb59fe12b235aed730b0d Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 8 Apr 2026 14:37:07 -0700 Subject: [PATCH 4/4] Clamp explain summary timeout to 30s --- cmd/entire/cli/explain.go | 10 ++------ cmd/entire/cli/explain_test.go | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 44282f6ca..c4e602588 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -516,16 +516,10 @@ func generateCheckpointSummary(ctx context.Context, w, errW io.Writer, v1Store * } func generateCheckpointAISummary(ctx context.Context, scopedTranscript []byte, filesTouched []string, agentType types.AgentType) (*checkpoint.Summary, error) { - timeoutCtx := ctx - cancel := func() {} + timeoutCtx, cancel := context.WithTimeout(ctx, checkpointSummaryTimeout) timeoutDuration := checkpointSummaryTimeout - - if deadline, ok := ctx.Deadline(); ok { + if deadline, ok := timeoutCtx.Deadline(); ok { timeoutDuration = time.Until(deadline) - } else { - var cancelFunc context.CancelFunc - timeoutCtx, cancelFunc = context.WithTimeout(ctx, checkpointSummaryTimeout) - cancel = cancelFunc } defer cancel() diff --git a/cmd/entire/cli/explain_test.go b/cmd/entire/cli/explain_test.go index 23509eab7..cc1feed91 100644 --- a/cmd/entire/cli/explain_test.go +++ b/cmd/entire/cli/explain_test.go @@ -189,6 +189,51 @@ func TestGenerateCheckpointAISummary_UsesParentDeadlineAndWrapsSentinel(t *testi } } +func TestGenerateCheckpointAISummary_ClampsLongParentDeadlineToDefaultTimeout(t *testing.T) { + tmpTimeout := checkpointSummaryTimeout + tmpGenerator := generateTranscriptSummary + t.Cleanup(func() { + checkpointSummaryTimeout = tmpTimeout + generateTranscriptSummary = tmpGenerator + }) + + checkpointSummaryTimeout = 50 * time.Millisecond + + parentCtx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + var gotDeadline time.Time + generateTranscriptSummary = func( + ctx context.Context, + _ []byte, + _ []string, + _ types.AgentType, + _ summarize.Generator, + ) (*checkpoint.Summary, error) { + deadline, ok := ctx.Deadline() + if !ok { + return nil, errors.New("expected deadline on summary context") + } + gotDeadline = deadline + return &checkpoint.Summary{Intent: "intent", Outcome: "outcome"}, nil + } + + start := time.Now() + summary, err := generateCheckpointAISummary(parentCtx, []byte("transcript"), nil, agent.AgentTypeClaudeCode) + if err != nil { + t.Fatalf("generateCheckpointAISummary() error = %v", err) + } + if summary == nil { + t.Fatal("expected summary") + } + if gotDeadline.IsZero() { + t.Fatal("expected deadline to be set") + } + if remaining := gotDeadline.Sub(start); remaining < 30*time.Millisecond || remaining > 200*time.Millisecond { + t.Fatalf("deadline offset = %s, want around %s", remaining, checkpointSummaryTimeout) + } +} + func TestGenerateCheckpointAISummary_UsesCancellationSentinel(t *testing.T) { tmpTimeout := checkpointSummaryTimeout tmpGenerator := generateTranscriptSummary