Observe claimed prepared executions
This commit is contained in:
@@ -18,6 +18,11 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.RunRequest{}, err
|
return domain.RunRequest{}, err
|
||||||
}
|
}
|
||||||
|
var observer domain.ExecutionObserver
|
||||||
|
if req.Observer != nil {
|
||||||
|
callback := req.Observer
|
||||||
|
observer = func(event domain.ExecutionEvent) { callback(fromDomainExecutionEvent(event)) }
|
||||||
|
}
|
||||||
return domain.RunRequest{
|
return domain.RunRequest{
|
||||||
PromptID: req.PromptID,
|
PromptID: req.PromptID,
|
||||||
PromptVersion: req.PromptVersion,
|
PromptVersion: req.PromptVersion,
|
||||||
@@ -30,6 +35,7 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
|||||||
Validation: toDomainOutputContractPtr(req.Validation),
|
Validation: toDomainOutputContractPtr(req.Validation),
|
||||||
AppendedMessages: appendedMessages,
|
AppendedMessages: appendedMessages,
|
||||||
TransientRetries: copyIntPtr(req.TransientRetries),
|
TransientRetries: copyIntPtr(req.TransientRetries),
|
||||||
|
Observer: observer,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ handling policy. The
|
|||||||
[engine operation GoDoc](../../engine.go) own exact lifecycle, engine-binding,
|
[engine operation GoDoc](../../engine.go) own exact lifecycle, engine-binding,
|
||||||
credential, cancellation, timing, and error semantics.
|
credential, cancellation, timing, and error semantics.
|
||||||
|
|
||||||
|
If the request supplied `Observer`, successful preparation retains that
|
||||||
|
function for `RunPrepared`; preparation itself emits nothing. Observation
|
||||||
|
starts only when the owning engine successfully claims the handle. Reused,
|
||||||
|
discarded, foreign, and invalid handles emit nothing. `Discard` clears the
|
||||||
|
retained callback without invoking it. The function's closure is retained by
|
||||||
|
identity, so synchronize any shared state it uses. Prepared start events can
|
||||||
|
include identities already resolved in the frozen snapshot.
|
||||||
|
|
||||||
## Execute And Validate
|
## Execute And Validate
|
||||||
|
|
||||||
[`Engine.Run`](../../engine.go) performs the same preparation, invokes the
|
[`Engine.Run`](../../engine.go) performs the same preparation, invokes the
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ Its event values and callback type live in `internal/domain` so internal
|
|||||||
requests can carry them without depending on the recorder package. The
|
requests can carry them without depending on the recorder package. The
|
||||||
recorder currently supplies run IDs to the runner and receives attempt and
|
recorder currently supplies run IDs to the runner and receives attempt and
|
||||||
retry-delay measurements from the generation coordinator. Run lifecycle
|
retry-delay measurements from the generation coordinator. Run lifecycle
|
||||||
instrumentation from ordinary `Run` at the facade and runner boundaries.
|
instrumentation for ordinary `Run` runs at the facade and runner boundaries.
|
||||||
|
Prepared execution starts only after its private payload is atomically claimed;
|
||||||
|
the facade receives that invocation's recorder privately and emits the terminal
|
||||||
|
event after public error mapping. Failed claims and preparation emit nothing.
|
||||||
|
|
||||||
A recorder keeps only counters, resolved identity fields, and the selected
|
A recorder keeps only counters, resolved identity fields, and the selected
|
||||||
callback. It has no global history, queue, goroutine, or mutex held across a
|
callback. It has no global history, queue, goroutine, or mutex held across a
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ through context. Prompt and profile identities are enriched only after their
|
|||||||
respective resolutions succeed, before admission. The facade starts observation
|
respective resolutions succeed, before admission. The facade starts observation
|
||||||
before request conversion and emits its terminal event after public error
|
before request conversion and emits its terminal event after public error
|
||||||
mapping and after the runner's deferred admission release. Preparation and
|
mapping and after the runner's deferred admission release. Preparation and
|
||||||
prepared execution have no observation delivery yet.
|
prepared execution follows the same terminal mapping after a successful claim;
|
||||||
|
invalid or discarded handles do not acquire an observer.
|
||||||
|
|
||||||
Validation preparation and execution honor cancellation at every
|
Validation preparation and execution honor cancellation at every
|
||||||
Promptkit-controlled boundary and do not publish a partial plan or result.
|
Promptkit-controlled boundary and do not publish a partial plan or result.
|
||||||
|
|||||||
@@ -407,6 +407,8 @@ guidance for ordinary observations only; run root and internal race tests; commi
|
|||||||
|
|
||||||
## Stage 9 — Observe Prepared Execution Without Weakening Its Lifecycle
|
## Stage 9 — Observe Prepared Execution Without Weakening Its Lifecycle
|
||||||
|
|
||||||
|
**Completed:** 2026-09-23.
|
||||||
|
|
||||||
Retain the adapted observer with the effective retries in the private prepared
|
Retain the adapted observer with the effective retries in the private prepared
|
||||||
payload. Claim atomically before starting observation, release the claim mutex,
|
payload. Claim atomically before starting observation, release the claim mutex,
|
||||||
install payload cleanup, then emit started. Preparation emits nothing; successful
|
install payload cleanup, then emit started. Preparation emits nothing; successful
|
||||||
|
|||||||
28
engine.go
28
engine.go
@@ -473,7 +473,8 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
|
|||||||
// PrepareExecution returns the same error categories as [Engine.Prepare] and
|
// PrepareExecution returns the same error categories as [Engine.Prepare] and
|
||||||
// returns no handle on error. A nil Engine returns an error matching
|
// returns no handle on error. A nil Engine returns an error matching
|
||||||
// ErrInvalidConfig. The effective transient retry budget is frozen privately
|
// ErrInvalidConfig. The effective transient retry budget is frozen privately
|
||||||
// for RunPrepared and is absent from Details.
|
// for RunPrepared and is absent from Details. A supplied Observer is retained
|
||||||
|
// privately for RunPrepared; preparation itself emits no events.
|
||||||
func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, error) {
|
func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, error) {
|
||||||
if e == nil || e.internal == nil {
|
if e == nil || e.internal == nil {
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
@@ -590,7 +591,12 @@ func (e *Engine) run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
// content-validation rejection, including repair exhaustion, is returned in
|
// content-validation rejection, including repair exhaustion, is returned in
|
||||||
// RunResult, not as an operational
|
// RunResult, not as an operational
|
||||||
// error. RunPrepared uses the retry choice frozen during PrepareExecution. An
|
// error. RunPrepared uses the retry choice frozen during PrepareExecution. An
|
||||||
// operational error returns no partial RunResult.
|
// operational error returns no partial RunResult. A retained Observer begins
|
||||||
|
// only after a successful atomic claim, with identities from the frozen
|
||||||
|
// snapshot, and receives a terminal event on normal return after public error
|
||||||
|
// mapping. Invalid, foreign, discarded, or reused handles emit no events.
|
||||||
|
// Callbacks run outside the handle and capacity locks; panics propagate and
|
||||||
|
// do not promise a terminal event.
|
||||||
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
|
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
|
||||||
if e == nil || e.internal == nil {
|
if e == nil || e.internal == nil {
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
@@ -600,9 +606,21 @@ func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (
|
|||||||
if prepared != nil {
|
if prepared != nil {
|
||||||
internal = prepared.internal
|
internal = prepared.internal
|
||||||
}
|
}
|
||||||
result, err := e.internal.RunPrepared(ctx, internal)
|
var recorder *observation.Recorder
|
||||||
|
result, err := e.internal.RunPrepared(observation.WithClaimSink(ctx, &recorder), internal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapPublicError(err)
|
mapped := mapPublicError(err)
|
||||||
|
if recorder != nil {
|
||||||
|
terminal := observedFinish(nil, mapped)
|
||||||
|
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure,
|
||||||
|
StatusCode: terminal.StatusCode, RetriesExhausted: terminal.RetriesExhausted})
|
||||||
|
}
|
||||||
|
return nil, mapped
|
||||||
}
|
}
|
||||||
return fromDomainRunResult(result), nil
|
publicResult := fromDomainRunResult(result)
|
||||||
|
if recorder != nil {
|
||||||
|
terminal := observedFinish(publicResult, nil)
|
||||||
|
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome})
|
||||||
|
}
|
||||||
|
return publicResult, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type RunRequest struct {
|
|||||||
Validation *OutputContract
|
Validation *OutputContract
|
||||||
AppendedMessages []RenderedMessage
|
AppendedMessages []RenderedMessage
|
||||||
TransientRetries *int
|
TransientRetries *int
|
||||||
|
Observer ExecutionObserver `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunResult represents the complete result of a prompt execution run.
|
// RunResult represents the complete result of a prompt execution run.
|
||||||
|
|||||||
@@ -202,6 +202,21 @@ func FromContext(ctx context.Context) *Recorder {
|
|||||||
return recorder
|
return recorder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type claimSinkKey struct{}
|
||||||
|
|
||||||
|
// WithClaimSink lets the facade learn which invocation actually claimed a
|
||||||
|
// prepared handle, so it can emit the terminal event after public mapping.
|
||||||
|
func WithClaimSink(ctx context.Context, sink **Recorder) context.Context {
|
||||||
|
return context.WithValue(ctx, claimSinkKey{}, sink)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterClaim records an invocation after its prepared handle is claimed.
|
||||||
|
func RegisterClaim(ctx context.Context, recorder *Recorder) {
|
||||||
|
if sink, ok := ctx.Value(claimSinkKey{}).(**Recorder); ok && sink != nil {
|
||||||
|
*sink = recorder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type usageField struct {
|
type usageField struct {
|
||||||
value int
|
value int
|
||||||
known, complete bool
|
known, complete bool
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type preparedExecutionPayload struct {
|
|||||||
validation validate.PreparedValidation
|
validation validate.PreparedValidation
|
||||||
directKey string
|
directKey string
|
||||||
transientRetries uint64
|
transientRetries uint64
|
||||||
|
observer domain.ExecutionObserver
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrepareExecution completes preparation without generation or admission and
|
// PrepareExecution completes preparation without generation or admission and
|
||||||
@@ -68,6 +69,7 @@ func (r *Runner) PrepareExecution(ctx context.Context, req domain.RunRequest) (*
|
|||||||
validation: operation.validation,
|
validation: operation.validation,
|
||||||
directKey: state.effectiveModel.APIKey,
|
directKey: state.effectiveModel.APIKey,
|
||||||
transientRetries: state.transientRetries,
|
transientRetries: state.transientRetries,
|
||||||
|
observer: req.Observer,
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -115,9 +117,28 @@ func (r *Runner) RunPrepared(ctx context.Context, prepared *PreparedExecution) (
|
|||||||
}
|
}
|
||||||
defer payload.clear()
|
defer payload.clear()
|
||||||
|
|
||||||
runID, err := observation.NewRunID()
|
var recorder *observation.Recorder
|
||||||
if err != nil {
|
var runID string
|
||||||
return nil, fmt.Errorf("failed to create run id: %w", err)
|
if payload.observer != nil {
|
||||||
|
var idErr error
|
||||||
|
recorder, idErr = observation.New(payload.observer)
|
||||||
|
observation.RegisterClaim(ctx, recorder)
|
||||||
|
ctx = observation.WithRecorder(ctx, recorder)
|
||||||
|
selected := payload.prepared
|
||||||
|
recorder.UpdateSelection(observation.Selection{PromptID: selected.PromptID,
|
||||||
|
PromptVersion: selected.PromptVersion, ProfileID: selected.SelectedProfileID,
|
||||||
|
BackendID: selected.SelectedBackendID, Model: selected.EffectiveModelParams.Model})
|
||||||
|
recorder.Start()
|
||||||
|
if idErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create run id: %w", idErr)
|
||||||
|
}
|
||||||
|
runID = recorder.RunID()
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
runID, err = observation.NewRunID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create run id: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
start := time.Now().UTC()
|
start := time.Now().UTC()
|
||||||
|
|
||||||
@@ -173,6 +194,7 @@ func (p *preparedExecutionPayload) clear() {
|
|||||||
p.validation = nil
|
p.validation = nil
|
||||||
p.directKey = ""
|
p.directKey = ""
|
||||||
p.transientRetries = 0
|
p.transientRetries = 0
|
||||||
|
p.observer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type noOpPreparedValidation struct {
|
type noOpPreparedValidation struct {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ const preparedExecutionString = "promptkit.PreparedExecution{opaque}"
|
|||||||
// PreparedExecution contains no supported serializable state and cannot be
|
// PreparedExecution contains no supported serializable state and cannot be
|
||||||
// used as a restartable job. Copying the value preserves the same shared
|
// used as a restartable job. Copying the value preserves the same shared
|
||||||
// lifecycle; it does not create another execution attempt.
|
// lifecycle; it does not create another execution attempt.
|
||||||
|
// A successful PrepareExecution may retain an Observer function in this
|
||||||
|
// handle's private payload. The callback and other private execution settings
|
||||||
|
// are cleared on claim completion or Discard. Details never exposes them.
|
||||||
type PreparedExecution struct {
|
type PreparedExecution struct {
|
||||||
internal *usecase.PreparedExecution
|
internal *usecase.PreparedExecution
|
||||||
}
|
}
|
||||||
|
|||||||
186
prepared_observation_contract_test.go
Normal file
186
prepared_observation_contract_test.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreparedObservationStartsOnlyAfterClaimAndKeepsObserver(t *testing.T) {
|
||||||
|
engine := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "hello")
|
||||||
|
var events []promptkit.ExecutionEvent
|
||||||
|
request := promptkit.RunRequest{PromptID: "prepared", Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }}
|
||||||
|
prepared, err := engine.PrepareExecution(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request.Observer = func(promptkit.ExecutionEvent) { t.Error("later observer was invoked") }
|
||||||
|
if len(events) != 0 {
|
||||||
|
t.Fatalf("preparation emitted events: %+v", events)
|
||||||
|
}
|
||||||
|
result, err := engine.RunPrepared(context.Background(), prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(events) != 3 || events[0].Kind != promptkit.ExecutionStarted || events[1].Kind != promptkit.ExecutionAttempt || events[2].Kind != promptkit.ExecutionFinished {
|
||||||
|
t.Fatalf("events=%+v", events)
|
||||||
|
}
|
||||||
|
if events[0].PromptID != "prepared" || events[0].ProfileID != "profile" || events[0].Model != "model" || events[0].RunID != result.RunID || events[2].RunID != result.RunID || events[2].Attempts != 1 {
|
||||||
|
t.Fatalf("result=%+v events=%+v", result, events)
|
||||||
|
}
|
||||||
|
if again, err := engine.RunPrepared(context.Background(), prepared); again != nil || !errors.Is(err, promptkit.ErrInvalidRequest) || len(events) != 3 {
|
||||||
|
t.Fatalf("reused result=%+v error=%v events=%+v", again, err, events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedObservationInvalidForeignAndDiscardedHandlesAreSilent(t *testing.T) {
|
||||||
|
engine := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "hello")
|
||||||
|
foreign := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "hello")
|
||||||
|
var events []promptkit.ExecutionEvent
|
||||||
|
request := promptkit.RunRequest{PromptID: "prepared", Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }}
|
||||||
|
prepared, err := engine.PrepareExecution(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result, err := foreign.RunPrepared(context.Background(), prepared); result != nil || !errors.Is(err, promptkit.ErrInvalidRequest) || len(events) != 0 {
|
||||||
|
t.Fatalf("foreign result=%+v error=%v events=%+v", result, err, events)
|
||||||
|
}
|
||||||
|
prepared.Discard()
|
||||||
|
if result, err := engine.RunPrepared(context.Background(), prepared); result != nil || !errors.Is(err, promptkit.ErrInvalidRequest) || len(events) != 0 {
|
||||||
|
t.Fatalf("discarded result=%+v error=%v events=%+v", result, err, events)
|
||||||
|
}
|
||||||
|
if result, err := engine.RunPrepared(context.Background(), nil); result != nil || !errors.Is(err, promptkit.ErrInvalidRequest) || len(events) != 0 {
|
||||||
|
t.Fatalf("nil result=%+v error=%v events=%+v", result, err, events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedObservationCountsCompletedUsageBeforeRepairFailure(t *testing.T) {
|
||||||
|
client := &preparedRecordingClient{responses: []*promptkit.GenerateResponse{{Content: "invalid json", Usage: promptkit.TokenUsage{PromptTokens: 4}}, nil}}
|
||||||
|
engine := newPreparedContractEngine(t, client, "hello")
|
||||||
|
var events []promptkit.ExecutionEvent
|
||||||
|
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared", Validation: &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSON, RepairAttempts: 1}, Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := engine.RunPrepared(context.Background(), prepared)
|
||||||
|
if result != nil || !errors.Is(err, promptkit.ErrLLMGenerate) || len(events) != 4 {
|
||||||
|
t.Fatalf("result=%+v error=%v events=%+v", result, err, events)
|
||||||
|
}
|
||||||
|
terminal := events[3]
|
||||||
|
if terminal.Outcome != promptkit.ExecutionOutcomeError || terminal.Failure != promptkit.ExecutionFailureMalformedResponse || terminal.Attempts != 2 || terminal.RepairCalls != 1 || terminal.Usage.PromptTokens.Value != 4 || terminal.Usage.PromptTokens.Complete {
|
||||||
|
t.Fatalf("terminal=%+v", terminal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedObservationClaimRaceAndPanicCleanup(t *testing.T) {
|
||||||
|
engine := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "hello")
|
||||||
|
var mu sync.Mutex
|
||||||
|
var events []promptkit.ExecutionEvent
|
||||||
|
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared", Observer: func(event promptkit.ExecutionEvent) { mu.Lock(); events = append(events, event); mu.Unlock() }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var successes, invalid int
|
||||||
|
var countMu sync.Mutex
|
||||||
|
for range 8 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := engine.RunPrepared(context.Background(), prepared)
|
||||||
|
countMu.Lock()
|
||||||
|
defer countMu.Unlock()
|
||||||
|
if err == nil {
|
||||||
|
successes++
|
||||||
|
} else if errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
invalid++
|
||||||
|
} else {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if successes != 1 || invalid != 7 || len(events) != 3 {
|
||||||
|
t.Fatalf("successes=%d invalid=%d events=%+v", successes, invalid, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
panicking, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared", Observer: func(event promptkit.ExecutionEvent) {
|
||||||
|
if event.Kind == promptkit.ExecutionStarted {
|
||||||
|
panic("callback")
|
||||||
|
}
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Error("expected panic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, _ = engine.RunPrepared(context.Background(), panicking)
|
||||||
|
}()
|
||||||
|
if result, err := engine.RunPrepared(context.Background(), panicking); result != nil || !errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatalf("reused after panic: result=%+v error=%v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedObservationFinishesAfterCredentialAndCapacityFailures(t *testing.T) {
|
||||||
|
const envName = "PROMPTKIT_PREPARED_OBSERVATION_KEY"
|
||||||
|
t.Setenv(envName, "available")
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{},
|
||||||
|
promptkit.WithPromptFS(contractPromptFS("prepared", "profile", "hello"), "."),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{ID: "profile", Endpoint: "https://example.invalid/v1", Model: "model", APIKeyRequired: true}),
|
||||||
|
promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var events []promptkit.ExecutionEvent
|
||||||
|
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared", Execution: &promptkit.ExecutionTargetOverride{APIKeyEnv: envName}, Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(envName, "")
|
||||||
|
result, err := engine.RunPrepared(context.Background(), prepared)
|
||||||
|
if result != nil || !errors.Is(err, promptkit.ErrInvalidRequest) || len(events) != 2 || events[1].Failure != promptkit.ExecutionFailureInvalidRequest || events[1].Attempts != 0 {
|
||||||
|
t.Fatalf("credential result=%+v error=%v events=%+v", result, err, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
client := newCapacityGateClient(release, 2)
|
||||||
|
limited := newBackendCapacityEngine(t, client, 1, capacityInt(0), nil)
|
||||||
|
first := make(chan capacityRunResult, 1)
|
||||||
|
go runCapacityRequest(limited, context.Background(), promptkit.RunRequest{PromptID: "prompt"}, first)
|
||||||
|
awaitCapacityRequest(t, client.started)
|
||||||
|
events = nil
|
||||||
|
prepared, err = limited.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt", Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err = limited.RunPrepared(context.Background(), prepared)
|
||||||
|
close(release)
|
||||||
|
if firstResult := awaitCapacityRun(t, first); firstResult.err != nil {
|
||||||
|
t.Fatalf("first: %v", firstResult.err)
|
||||||
|
}
|
||||||
|
if result != nil || !errors.Is(err, promptkit.ErrCapacityExceeded) || len(events) != 2 || events[1].Failure != promptkit.ExecutionFailureCapacity || events[1].Attempts != 0 {
|
||||||
|
t.Fatalf("capacity result=%+v error=%v events=%+v", result, err, events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedObservationCanceledClaimStillFinishes(t *testing.T) {
|
||||||
|
engine := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "hello")
|
||||||
|
var events []promptkit.ExecutionEvent
|
||||||
|
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared", Observer: func(event promptkit.ExecutionEvent) { events = append(events, event) }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
result, err := engine.RunPrepared(ctx, prepared)
|
||||||
|
if result != nil || !errors.Is(err, context.Canceled) || len(events) != 3 || events[1].Dispatched || events[2].Failure != promptkit.ExecutionFailureCanceled || events[2].Attempts != 0 {
|
||||||
|
t.Fatalf("result=%+v error=%v events=%+v", result, err, events)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
types.go
5
types.go
@@ -143,8 +143,11 @@ type RunRequest struct {
|
|||||||
// Negative values are invalid for Prepare, PrepareExecution, and Run.
|
// Negative values are invalid for Prepare, PrepareExecution, and Run.
|
||||||
// Injected LLMClient implementations remain single-dispatch.
|
// Injected LLMClient implementations remain single-dispatch.
|
||||||
TransientRetries *int
|
TransientRetries *int
|
||||||
// Observer receives metadata about this Run invocation synchronously. It is
|
// Observer receives metadata about an execution invocation synchronously. It is
|
||||||
// excluded from JSON and is not invoked by Prepare or PrepareExecution.
|
// excluded from JSON and is not invoked by Prepare or PrepareExecution.
|
||||||
|
// Successful PrepareExecution retains the function for its RunPrepared call;
|
||||||
|
// Discard or a completed claim clears that reference. The closure itself is
|
||||||
|
// not copied, so callers must synchronize its shared state.
|
||||||
Observer ExecutionObserver `json:"-"`
|
Observer ExecutionObserver `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user