diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d3e2261..d19dc1d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 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 **Catalog:** 21, interface part. **Files:** validator constructors, engine Options, diff --git a/internal/domain/session.go b/internal/domain/session.go index 435db87..2330f53 100644 --- a/internal/domain/session.go +++ b/internal/domain/session.go @@ -18,5 +18,5 @@ func NormalizeSessionID(raw string) (string, error) { if length := utf8.RuneCountInString(normalized); length > SessionIDMaxLength { return "", fmt.Errorf("session_id length %d exceeds maximum %d", length, SessionIDMaxLength) } - return normalized, nil + return strings.Clone(normalized), nil } diff --git a/internal/usecase/prepared_execution.go b/internal/usecase/prepared_execution.go index b24cafb..492e9ef 100644 --- a/internal/usecase/prepared_execution.go +++ b/internal/usecase/prepared_execution.go @@ -22,7 +22,7 @@ const ( // PreparedExecution owns one frozen, single-use runner execution. type PreparedExecution struct { - owner *Runner + owner *runnerIdentity mu sync.Mutex state preparedExecutionState details *domain.PreparedRun @@ -63,7 +63,7 @@ func (r *Runner) PrepareExecution(ctx context.Context, req domain.RunRequest) (* } return &PreparedExecution{ - owner: r, + owner: r.identity, state: preparedExecutionReady, details: details, payload: &preparedExecutionPayload{ @@ -170,7 +170,7 @@ func (r *Runner) RunPrepared(ctx context.Context, prepared *PreparedExecution) ( } 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) } diff --git a/internal/usecase/prepared_execution_test.go b/internal/usecase/prepared_execution_test.go index 1135564..d7d8430 100644 --- a/internal/usecase/prepared_execution_test.go +++ b/internal/usecase/prepared_execution_test.go @@ -600,6 +600,9 @@ func TestRunnerPreparedExecutionOwnershipUseAndDiscard(t *testing.T) { } 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) if err != nil { t.Fatalf("prepare execution: %v", err) @@ -616,6 +619,9 @@ func TestRunnerPreparedExecutionOwnershipUseAndDiscard(t *testing.T) { if prepared.Details() == nil { 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) if err != nil { @@ -623,6 +629,9 @@ func TestRunnerPreparedExecutionOwnershipUseAndDiscard(t *testing.T) { } 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) { t.Fatalf("discarded execution error=%v, want ErrInvalidRequest", err) } diff --git a/internal/usecase/runner.go b/internal/usecase/runner.go index 0878797..9092a64 100644 --- a/internal/usecase/runner.go +++ b/internal/usecase/runner.go @@ -43,6 +43,7 @@ var ( // Runner executes the Promptkit core use case. type Runner struct { + identity *runnerIdentity promptDefs promptdef.Repository profiles profile.Repository artifacts artifact.Reader @@ -54,6 +55,10 @@ type Runner struct { 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. type RunAdmitter interface { Admit(context.Context, string) (func(), error) @@ -113,6 +118,7 @@ func NewRunnerWithRepairer( retries = defaultTransientRetries[0] } return &Runner{ + identity: &runnerIdentity{}, promptDefs: promptDefs, profiles: profiles, artifacts: artifacts,