Detach prepared execution ownership from runners

This commit is contained in:
2026-11-05 13:52:36 +00:00
parent 7d500ee62f
commit 2f0f5fa607
5 changed files with 23 additions and 4 deletions

View File

@@ -266,6 +266,10 @@ same-engine views, one-shot/concurrent claim/discard and details after use.
Session Unicode/trim/content/wire/hash tests stay unchanged in meaning. Reference Session Unicode/trim/content/wire/hash tests stay unchanged in meaning. Reference
analysis, not a GC-timing assertion, establishes that the token retains no runner. analysis, not a GC-timing assertion, establishes that the token retains no runner.
Completion: prepared handles retain only a private nonzero-sized runner identity
token, including after use or discard; accepted session strings own their
storage. Focused prepared/session race tests pass on Go 1.27.3.
## Stage 10: Declare Validation Preparation As The Internal Dependency ## Stage 10: Declare Validation Preparation As The Internal Dependency
**Catalog:** 21, interface part. **Files:** validator constructors, engine Options, **Catalog:** 21, interface part. **Files:** validator constructors, engine Options,

View File

@@ -18,5 +18,5 @@ func NormalizeSessionID(raw string) (string, error) {
if length := utf8.RuneCountInString(normalized); length > SessionIDMaxLength { if length := utf8.RuneCountInString(normalized); length > SessionIDMaxLength {
return "", fmt.Errorf("session_id length %d exceeds maximum %d", length, SessionIDMaxLength) return "", fmt.Errorf("session_id length %d exceeds maximum %d", length, SessionIDMaxLength)
} }
return normalized, nil return strings.Clone(normalized), nil
} }

View File

@@ -22,7 +22,7 @@ const (
// PreparedExecution owns one frozen, single-use runner execution. // PreparedExecution owns one frozen, single-use runner execution.
type PreparedExecution struct { type PreparedExecution struct {
owner *Runner owner *runnerIdentity
mu sync.Mutex mu sync.Mutex
state preparedExecutionState state preparedExecutionState
details *domain.PreparedRun details *domain.PreparedRun
@@ -63,7 +63,7 @@ func (r *Runner) PrepareExecution(ctx context.Context, req domain.RunRequest) (*
} }
return &PreparedExecution{ return &PreparedExecution{
owner: r, owner: r.identity,
state: preparedExecutionReady, state: preparedExecutionReady,
details: details, details: details,
payload: &preparedExecutionPayload{ payload: &preparedExecutionPayload{
@@ -170,7 +170,7 @@ func (r *Runner) RunPrepared(ctx context.Context, prepared *PreparedExecution) (
} }
func (p *PreparedExecution) claim(owner *Runner) (*preparedExecutionPayload, error) { func (p *PreparedExecution) claim(owner *Runner) (*preparedExecutionPayload, error) {
if p == nil || owner == nil || p.owner != owner { if p == nil || owner == nil || owner.identity == nil || p.owner != owner.identity {
return nil, fmt.Errorf("%w: prepared execution does not belong to this runner", ErrInvalidRequest) return nil, fmt.Errorf("%w: prepared execution does not belong to this runner", ErrInvalidRequest)
} }

View File

@@ -600,6 +600,9 @@ func TestRunnerPreparedExecutionOwnershipUseAndDiscard(t *testing.T) {
} }
owner := newRunner() owner := newRunner()
if other := newRunner(); owner.identity == nil || owner.identity == other.identity {
t.Fatal("constructed runners must have independent identity tokens")
}
prepared, err := owner.PrepareExecution(context.Background(), request) prepared, err := owner.PrepareExecution(context.Background(), request)
if err != nil { if err != nil {
t.Fatalf("prepare execution: %v", err) t.Fatalf("prepare execution: %v", err)
@@ -616,6 +619,9 @@ func TestRunnerPreparedExecutionOwnershipUseAndDiscard(t *testing.T) {
if prepared.Details() == nil { if prepared.Details() == nil {
t.Fatal("details unavailable after execution") t.Fatal("details unavailable after execution")
} }
if prepared.owner != owner.identity || prepared.payload != nil {
t.Fatal("claimed handle retained payload or lost owner identity")
}
discarded, err := owner.PrepareExecution(context.Background(), request) discarded, err := owner.PrepareExecution(context.Background(), request)
if err != nil { if err != nil {
@@ -623,6 +629,9 @@ func TestRunnerPreparedExecutionOwnershipUseAndDiscard(t *testing.T) {
} }
discarded.Discard() discarded.Discard()
discarded.Discard() discarded.Discard()
if discarded.owner != owner.identity || discarded.payload != nil {
t.Fatal("discarded handle retained payload or lost owner identity")
}
if _, err := owner.RunPrepared(context.Background(), discarded); !errors.Is(err, ErrInvalidRequest) { if _, err := owner.RunPrepared(context.Background(), discarded); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("discarded execution error=%v, want ErrInvalidRequest", err) t.Fatalf("discarded execution error=%v, want ErrInvalidRequest", err)
} }

View File

@@ -43,6 +43,7 @@ var (
// Runner executes the Promptkit core use case. // Runner executes the Promptkit core use case.
type Runner struct { type Runner struct {
identity *runnerIdentity
promptDefs promptdef.Repository promptDefs promptdef.Repository
profiles profile.Repository profiles profile.Repository
artifacts artifact.Reader artifacts artifact.Reader
@@ -54,6 +55,10 @@ type Runner struct {
defaultTransientRetries uint64 defaultTransientRetries uint64
} }
// A non-zero-sized token gives each constructed runner a distinct identity
// without retaining its collaborators in prepared handles.
type runnerIdentity struct{ marker byte }
// RunAdmitter reserves capacity for one resolved backend run. // RunAdmitter reserves capacity for one resolved backend run.
type RunAdmitter interface { type RunAdmitter interface {
Admit(context.Context, string) (func(), error) Admit(context.Context, string) (func(), error)
@@ -113,6 +118,7 @@ func NewRunnerWithRepairer(
retries = defaultTransientRetries[0] retries = defaultTransientRetries[0]
} }
return &Runner{ return &Runner{
identity: &runnerIdentity{},
promptDefs: promptDefs, promptDefs: promptDefs,
profiles: profiles, profiles: profiles,
artifacts: artifacts, artifacts: artifacts,