Preserve comparison failures during cancellation

This commit is contained in:
2026-08-13 03:26:11 +00:00
parent 79cba800ee
commit 5e492cf1fb
10 changed files with 167 additions and 15 deletions

View File

@@ -47,7 +47,8 @@ stop its peers, while caller cancellation applies to every in-flight execution.
Weatherreporter starts selected profile executions concurrently and does not Weatherreporter starts selected profile executions concurrently and does not
add an application-level concurrency limit. Promptkit owns backend capacity and add an application-level concurrency limit. Promptkit owns backend capacity and
any profile or backend concurrency policy. The durable comparison output and any profile or backend concurrency policy. A shared Weatherreporter executor
must safely accept those concurrent `Execute` calls. The durable comparison output and
its compatibility rules are defined by the its compatibility rules are defined by the
[comparison bundle contract](comparison-bundle.md); the user-facing command [comparison bundle contract](comparison-bundle.md); the user-facing command
contract is in the [CLI reference](../cli.md). contract is in the [CLI reference](../cli.md).

View File

@@ -45,8 +45,10 @@ and hash. Artifact paths are added only after publication commits.
The comparison execution core starts each inspected profile independently, The comparison execution core starts each inspected profile independently,
keeps results in selection order, and waits for all started work. Every profile keeps results in selection order, and waits for all started work. Every profile
reconciles its callback and completion provenance before its JSON can be reconciles its callback and completion provenance before its JSON can be
rendered. Independent profile failures are recorded and do not stop peers. rendered. Independent profile failures are recorded and do not stop peers; a
Context cancellation marks unfinished work and prevents publication. Details of completed profile failure remains recorded if cancellation happens later.
Context cancellation marks only unfinished or cancellation-terminated work and
prevents publication. Details of
prepared values, execution and debugging, and publication are documented in [prepared report prepared values, execution and debugging, and publication are documented in [prepared report
internals](prepared-report.md), [comparison execution internals](prepared-report.md), [comparison execution
internals](comparison-execution.md), and [comparison publication internals](comparison-execution.md), and [comparison publication

View File

@@ -9,9 +9,12 @@ selection order even though execution completes in an arbitrary order.
Every profile uses the exact inspected prompt identity and a private copy of Every profile uses the exact inspected prompt identity and a private copy of
the same prepared data package. Provider, generated-text validation, rendering, the same prepared data package. Provider, generated-text validation, rendering,
or debug-write failure becomes that profile's safe failed outcome and does not or debug-write failure becomes that profile's safe failed outcome and does not
cancel its peers. The application deliberately imposes no additional semaphore: cancel its peers. The shared executor must support those concurrent `Execute`
Promptkit owns backend capacity. Cancellation or a deadline marks unfinished calls. The application deliberately imposes no additional semaphore: Promptkit
outcomes as skipped or failed, joins work, and prevents bundle publication. owns backend capacity. A profile failure completed before a later cancellation
remains its original safe outcome; cancellation or a deadline marks only
unfinished or cancellation-terminated outcomes as skipped or failed, joins work,
and prevents bundle publication.
When debugging is enabled, each execution receives a deterministic reference When debugging is enabled, each execution receives a deterministic reference
derived from the comparison identity, ordered profile position, and safe derived from the comparison identity, ordered profile position, and safe

View File

@@ -24,6 +24,7 @@ type Config struct {
} }
// Adapter owns one Promptkit engine and its opaque prepared execution handles. // Adapter owns one Promptkit engine and its opaque prepared execution handles.
// It supports concurrent Execute calls on the shared executor.
type Adapter struct { type Adapter struct {
engine *promptkit.Engine engine *promptkit.Engine
} }

View File

@@ -23,6 +23,7 @@ type fakeClient struct {
calls int calls int
requests []promptkit.GenerateRequest requests []promptkit.GenerateRequest
block bool block bool
started chan struct{}
} }
type recordingReader struct { type recordingReader struct {
@@ -45,9 +46,13 @@ func (client *fakeClient) Generate(ctx context.Context, request promptkit.Genera
client.calls++ client.calls++
client.requests = append(client.requests, request) client.requests = append(client.requests, request)
block := client.block block := client.block
started := client.started
response := client.response response := client.response
err := client.err err := client.err
client.mu.Unlock() client.mu.Unlock()
if started != nil {
started <- struct{}{}
}
if block { if block {
<-ctx.Done() <-ctx.Done()
return nil, ctx.Err() return nil, ctx.Err()
@@ -55,6 +60,36 @@ func (client *fakeClient) Generate(ctx context.Context, request promptkit.Genera
return response, err return response, err
} }
func TestExecuteSupportsConcurrentCalls(t *testing.T) {
client := &fakeClient{response: validResponse(), block: true, started: make(chan struct{}, 2)}
adapter := newTestAdapter(t, client)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
executionErrors := make(chan error, 2)
for range 2 {
go func() {
_, err := adapter.Execute(ctx, testExecuteRequest(), nil)
executionErrors <- err
}()
}
for range 2 {
select {
case <-client.started:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for concurrent Promptkit calls")
}
}
cancel()
for range 2 {
if err := <-executionErrors; promptexec.CategoryOf(err) != promptexec.Canceled {
t.Fatalf("Execute() error/category = %v/%q", err, promptexec.CategoryOf(err))
}
}
if client.callCount() != 2 {
t.Fatalf("provider calls = %d, want 2", client.callCount())
}
}
func (client *fakeClient) callCount() int { func (client *fakeClient) callCount() int {
client.mu.Lock() client.mu.Lock()
defer client.mu.Unlock() defer client.mu.Unlock()

View File

@@ -35,11 +35,21 @@ type comparisonProfileOutcome struct {
Markdown []byte Markdown []byte
LLMDebugPath string LLMDebugPath string
Error *comparison.SafeError Error *comparison.SafeError
canceled bool
} }
type comparisonProfileExecutionState uint8
const (
comparisonProfilePending comparisonProfileExecutionState = iota
comparisonProfileRunning
comparisonProfileComplete
)
func executeComparisonProfiles(ctx context.Context, req comparisonExecutionRequest) comparisonExecutionResult { func executeComparisonProfiles(ctx context.Context, req comparisonExecutionRequest) comparisonExecutionResult {
profiles := req.Inspection.Profiles profiles := req.Inspection.Profiles
result := comparisonExecutionResult{Outcomes: make([]comparisonProfileOutcome, len(profiles))} result := comparisonExecutionResult{Outcomes: make([]comparisonProfileOutcome, len(profiles))}
states := make([]comparisonProfileExecutionState, len(profiles))
for index, profile := range profiles { for index, profile := range profiles {
result.Outcomes[index] = comparisonProfileOutcome{ result.Outcomes[index] = comparisonProfileOutcome{
Position: index + 1, Position: index + 1,
@@ -54,21 +64,22 @@ func executeComparisonProfiles(ctx context.Context, req comparisonExecutionReque
for index, profile := range profiles { for index, profile := range profiles {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
result.Canceled = true result.Canceled = true
markUnstartedComparisonOutcomes(result.Outcomes[index:], err)
break break
} }
index, profile := index, profile index, profile := index, profile
states[index] = comparisonProfileRunning
waitGroup.Add(1) waitGroup.Add(1)
go func() { go func() {
defer waitGroup.Done() defer waitGroup.Done()
result.Outcomes[index] = executeComparisonProfile(ctx, req, index, profile) result.Outcomes[index] = executeComparisonProfile(ctx, req, index, profile)
states[index] = comparisonProfileComplete
}() }()
} }
waitGroup.Wait() waitGroup.Wait()
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
result.Canceled = true result.Canceled = true
for index := range result.Outcomes { for index := range result.Outcomes {
if result.Outcomes[index].Status != comparison.StatusSucceeded { if states[index] != comparisonProfileComplete || result.Outcomes[index].canceled {
markCanceledComparisonOutcome(&result.Outcomes[index], err) markCanceledComparisonOutcome(&result.Outcomes[index], err)
} }
} }
@@ -99,6 +110,7 @@ func executeComparisonProfile(ctx context.Context, req comparisonExecutionReques
outcome.ValidationStatus = execution.ValidationStatus outcome.ValidationStatus = execution.ValidationStatus
outcome.LLMDebugPath = execution.LLMDebugPath outcome.LLMDebugPath = execution.LLMDebugPath
if err != nil { if err != nil {
outcome.canceled = cancellationError(err)
safe := comparisonSafeExecutionError(err) safe := comparisonSafeExecutionError(err)
outcome.Error = &safe outcome.Error = &safe
return outcome return outcome
@@ -119,12 +131,6 @@ func comparisonDebugRunID(comparisonID string, position, profileCount int, profi
return fmt.Sprintf("%s_%0*d-%s", comparisonID, comparison.OrdinalWidth(profileCount), position, comparison.ProfileSlug(profileID)) return fmt.Sprintf("%s_%0*d-%s", comparisonID, comparison.OrdinalWidth(profileCount), position, comparison.ProfileSlug(profileID))
} }
func markUnstartedComparisonOutcomes(outcomes []comparisonProfileOutcome, err error) {
for index := range outcomes {
markCanceledComparisonOutcome(&outcomes[index], err)
}
}
func markCanceledComparisonOutcome(outcome *comparisonProfileOutcome, err error) { func markCanceledComparisonOutcome(outcome *comparisonProfileOutcome, err error) {
outcome.Status = comparison.StatusFailed outcome.Status = comparison.StatusFailed
outcome.ValidationStatus = promptexec.ValidationSkipped outcome.ValidationStatus = promptexec.ValidationSkipped
@@ -134,6 +140,12 @@ func markCanceledComparisonOutcome(outcome *comparisonProfileOutcome, err error)
outcome.Error = &safe outcome.Error = &safe
} }
func cancellationError(err error) bool {
category := promptexec.CategoryOf(err)
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
category == promptexec.Canceled || category == promptexec.DeadlineExceeded
}
func comparisonSafeExecutionError(err error) comparison.SafeError { func comparisonSafeExecutionError(err error) comparison.SafeError {
category := promptexec.CategoryOf(err) category := promptexec.CategoryOf(err)
if category == "" { if category == "" {

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -307,6 +308,57 @@ func TestCompareDetailedCancellationPreservesPublishedBundle(t *testing.T) {
} }
} }
func TestCompareDetailedPreservesCompletedProfileFailureWhenCanceled(t *testing.T) {
bundle := generationBundle(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
failureStarted := make(chan struct{})
var signalFailure sync.Once
executor := &generationExecutor{
validations: map[string]promptexec.ValidationStatus{"weather-light": promptexec.ValidationFailed},
waitForCancellation: map[string]bool{"weather-deep": true},
beforeExecute: func(request promptexec.ExecuteRequest) {
if request.ProfileID == "weather-light" {
signalFailure.Do(func() { close(failureStarted) })
}
},
}
results := make(chan struct {
result *ComparisonResult
err error
}, 1)
go func() {
result, err := CompareDetailed(ctx, ComparisonRequest{
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
})
results <- struct {
result *ComparisonResult
err error
}{result: result, err: err}
}()
select {
case <-failureStarted:
cancel()
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the completed profile failure")
}
completed := <-results
if !errors.Is(completed.err, context.Canceled) || completed.result == nil || completed.result.ManifestPath != "" || completed.result.DataPackagePath != "" || completed.result.Succeeded != 0 || completed.result.Failed != 2 {
t.Fatalf("CompareDetailed() result/error = %#v/%v", completed.result, completed.err)
}
failed, canceled := completed.result.Results[0], completed.result.Results[1]
if failed.Error == nil || failed.Error.Category != string(promptexec.ValidationRejected) || failed.ValidationStatus != promptexec.ValidationFailed || failed.ReportPath != "" {
t.Fatalf("completed failure = %#v", failed)
}
if canceled.Error == nil || canceled.Error.Category != string(promptexec.Canceled) || canceled.ValidationStatus != promptexec.ValidationSkipped || canceled.ReportPath != "" {
t.Fatalf("canceled profile = %#v", canceled)
}
}
func TestCompareDetailedLeavesExistingBundleWhenPublicationPreflightChanges(t *testing.T) { func TestCompareDetailedLeavesExistingBundleWhenPublicationPreflightChanges(t *testing.T) {
workingDir := t.TempDir() workingDir := t.TempDir()
target := filepath.Join(workingDir, "comparison-output") target := filepath.Join(workingDir, "comparison-output")

View File

@@ -66,7 +66,9 @@ type generationExecutor struct {
beforeExecute func(promptexec.ExecuteRequest) beforeExecute func(promptexec.ExecuteRequest)
cancelBeforeReturn context.CancelFunc cancelBeforeReturn context.CancelFunc
validation promptexec.ValidationStatus validation promptexec.ValidationStatus
validations map[string]promptexec.ValidationStatus
rawOutput []byte rawOutput []byte
waitForCancellation map[string]bool
failedPrompt string failedPrompt string
skipPreparation bool skipPreparation bool
preparationCalls int preparationCalls int
@@ -95,7 +97,7 @@ func (e *generationExecutor) InspectProfile(_ context.Context, id string) (promp
} }
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
} }
func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) { func (e *generationExecutor) Execute(ctx context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC) stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
generationExecutorMu.Lock() generationExecutorMu.Lock()
skipPreparation := e.skipPreparation skipPreparation := e.skipPreparation
@@ -124,7 +126,11 @@ func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRe
profileErr := e.executeErrors[req.ProfileID] profileErr := e.executeErrors[req.ProfileID]
executeErr := e.executeErr executeErr := e.executeErr
status := e.validation status := e.validation
if profileStatus, ok := e.validations[req.ProfileID]; ok {
status = profileStatus
}
rawOutput := append([]byte(nil), e.rawOutput...) rawOutput := append([]byte(nil), e.rawOutput...)
waitForCancellation := e.waitForCancellation[req.ProfileID]
failedPrompt := e.failedPrompt failedPrompt := e.failedPrompt
cancelBeforeReturn := e.cancelBeforeReturn cancelBeforeReturn := e.cancelBeforeReturn
complete := e.complete complete := e.complete
@@ -132,6 +138,10 @@ func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRe
if beforeExecute != nil { if beforeExecute != nil {
beforeExecute(req) beforeExecute(req)
} }
if waitForCancellation {
<-ctx.Done()
return nil, ctx.Err()
}
if profileErr != nil { if profileErr != nil {
return nil, profileErr return nil, profileErr
} }

View File

@@ -215,6 +215,40 @@ func TestCompareCommandWritesStructuredPartialFailure(t *testing.T) {
} }
} }
func TestCompareCommandPreservesMixedCancellationOutcomes(t *testing.T) {
workingDir := t.TempDir()
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n")
profileFailure := comparison.NewSafeError("validation_rejected", "validate prompt execution failed")
canceledProfile := comparison.NewSafeError("canceled", "profile execution canceled")
result := comparisonResult("/reports/comparison-daily", []app.ComparisonProfileResult{
{Position: 1, ProfileID: "weather-light", ModelName: "light", Status: comparison.StatusFailed, ValidationStatus: promptexec.ValidationFailed, Error: &profileFailure},
{Position: 2, ProfileID: "weather-deep", ModelName: "deep", Status: comparison.StatusFailed, ValidationStatus: promptexec.ValidationSkipped, Error: &canceledProfile},
})
runner := comparisonRunner(t, workingDir)
runner.compareDetailed = func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) {
return result, context.Canceled
}
var stdout, stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"compare", "daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--config", configPath}, &stdout, &stderr)
if !errors.Is(err, context.Canceled) || stderr.Len() != 0 {
t.Fatalf("Run() error/stderr = %v/%q", err, stderr.String())
}
var summary comparisonSummary
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
t.Fatalf("decode summary: %v\n%s", err, stdout.String())
}
if summary.Status != summaryStatusFailed || summary.Error == nil || summary.Error.Category != "canceled" || len(summary.Results) != 2 {
t.Fatalf("summary = %#v", summary)
}
if first := summary.Results[0]; first.Error == nil || first.Error.Category != "validation_rejected" || first.ValidationStatus != string(promptexec.ValidationFailed) {
t.Fatalf("completed profile summary = %#v", first)
}
if second := summary.Results[1]; second.Error == nil || second.Error.Category != "canceled" || second.ValidationStatus != string(promptexec.ValidationSkipped) {
t.Fatalf("canceled profile summary = %#v", second)
}
}
func TestCompareCommandReportsCommittedBundleWhenCleanupFails(t *testing.T) { func TestCompareCommandReportsCommittedBundleWhenCleanupFails(t *testing.T) {
workingDir := t.TempDir() workingDir := t.TempDir()
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n") configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n")

View File

@@ -20,6 +20,8 @@ const (
// error, Execute must not call the provider. Completed validation rejection is // error, Execute must not call the provider. Completed validation rejection is
// returned as an Execution with a failed Validation status; operational failures // returned as an Execution with a failed Validation status; operational failures
// return no Execution. Sensitive debug values are populated only when requested. // return no Execution. Sensitive debug values are populated only when requested.
// Comparison may invoke Execute concurrently on one shared Executor, so every
// implementation must support concurrent calls.
type Executor interface { type Executor interface {
InspectPrompt(context.Context, string, string) (PromptInspection, error) InspectPrompt(context.Context, string, string) (PromptInspection, error)
InspectProfile(context.Context, string) (ProfileInspection, error) InspectProfile(context.Context, string) (ProfileInspection, error)