Own plan/step/decision writes behind UoW so two workers cannot both execute the same step. Co-authored-by: Cursor <cursoragent@cursor.com>
323 lines
7.8 KiB
Go
323 lines
7.8 KiB
Go
package intentflow
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/app/uow"
|
|
)
|
|
|
|
type serialScope struct{ ctx context.Context }
|
|
|
|
func (s serialScope) Context() context.Context { return s.ctx }
|
|
|
|
// SerialUoW serializes commands. Memory tests use it so CAS races have one winner.
|
|
type SerialUoW struct{ mu sync.Mutex }
|
|
|
|
func (s *SerialUoW) Within(ctx context.Context, fn func(uow.TxScope) error) error {
|
|
if s == nil {
|
|
return fmt.Errorf("intentflow: serial uow required")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return fn(serialScope{ctx: ctx})
|
|
}
|
|
|
|
type MemoryStore struct {
|
|
mu sync.Mutex
|
|
plans map[string]*Plan
|
|
turn map[string]string
|
|
decisions map[string]*Decision
|
|
}
|
|
|
|
func NewMemoryStore() *MemoryStore {
|
|
return &MemoryStore{
|
|
plans: map[string]*Plan{},
|
|
turn: map[string]string{},
|
|
decisions: map[string]*Decision{},
|
|
}
|
|
}
|
|
|
|
func turnKey(env, session, turn string) string {
|
|
return env + "\x1f" + session + "\x1f" + turn
|
|
}
|
|
|
|
func nonceKey(planID, nonce string) string {
|
|
return planID + "\x1f" + nonce
|
|
}
|
|
|
|
func (m *MemoryStore) InsertPlan(ctx context.Context, scope uow.TxScope, plan *Plan) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if plan == nil || plan.PlanID == "" {
|
|
return fmt.Errorf("intentflow: plan required")
|
|
}
|
|
tk := turnKey(plan.Principal.Environment, plan.Principal.SessionID, plan.ClientTurnID)
|
|
if existingID, ok := m.turn[tk]; ok {
|
|
_ = existingID
|
|
return errTurnExists
|
|
}
|
|
if plan.WritePlan && isActiveStatus(plan.Status) {
|
|
for _, p := range m.plans {
|
|
if sameScope(p.Principal, plan.Principal) && p.WritePlan && isActiveStatus(p.Status) {
|
|
return uow.ErrBusy
|
|
}
|
|
}
|
|
}
|
|
cloned := clonePlan(plan)
|
|
m.plans[plan.PlanID] = cloned
|
|
m.turn[tk] = plan.PlanID
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) GetPlan(ctx context.Context, scope uow.TxScope, planID string) (*Plan, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
p := m.plans[planID]
|
|
if p == nil {
|
|
return nil, nil
|
|
}
|
|
return clonePlan(p), nil
|
|
}
|
|
|
|
func (m *MemoryStore) GetPlanForUpdate(ctx context.Context, scope uow.TxScope, planID string) (*Plan, error) {
|
|
return m.GetPlan(ctx, scope, planID)
|
|
}
|
|
|
|
func (m *MemoryStore) GetByTurn(ctx context.Context, scope uow.TxScope, env, sessionID, clientTurnID string) (*Plan, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
id := m.turn[turnKey(env, sessionID, clientTurnID)]
|
|
if id == "" {
|
|
return nil, nil
|
|
}
|
|
return clonePlan(m.plans[id]), nil
|
|
}
|
|
|
|
func (m *MemoryStore) GetActiveWrite(ctx context.Context, scope uow.TxScope, p Principal) (*Plan, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, plan := range m.plans {
|
|
if sameScope(plan.Principal, p) && plan.WritePlan && isActiveStatus(plan.Status) {
|
|
return clonePlan(plan), nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *MemoryStore) GetActive(ctx context.Context, scope uow.TxScope, p Principal) (*Plan, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var best *Plan
|
|
for _, plan := range m.plans {
|
|
if !sameScope(plan.Principal, p) || !isActiveStatus(plan.Status) {
|
|
continue
|
|
}
|
|
if best == nil || plan.UpdatedAt.After(best.UpdatedAt) {
|
|
cp := clonePlan(plan)
|
|
best = cp
|
|
}
|
|
}
|
|
return best, nil
|
|
}
|
|
|
|
func (m *MemoryStore) CASPlan(ctx context.Context, scope uow.TxScope, planID string, expectedRev int64, from []PlanStatus, to PlanStatus, now time.Time) (int64, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
p := m.plans[planID]
|
|
if p == nil || p.Revision != expectedRev || !slices.Contains(from, p.Status) {
|
|
return 0, nil
|
|
}
|
|
p.Status = to
|
|
p.Revision++
|
|
p.UpdatedAt = now
|
|
return p.Revision, nil
|
|
}
|
|
|
|
func (m *MemoryStore) InsertDecision(ctx context.Context, scope uow.TxScope, d *Decision) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
k := nonceKey(d.PlanID, d.Nonce)
|
|
if _, ok := m.decisions[k]; ok {
|
|
return uow.ErrDigestConflict
|
|
}
|
|
cp := *d
|
|
cp.StepIDs = slices.Clone(d.StepIDs)
|
|
m.decisions[k] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) GetDecisionByNonce(ctx context.Context, scope uow.TxScope, planID, nonce string) (*Decision, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
d := m.decisions[nonceKey(planID, nonce)]
|
|
if d == nil {
|
|
return nil, nil
|
|
}
|
|
cp := *d
|
|
cp.StepIDs = slices.Clone(d.StepIDs)
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *MemoryStore) ReplaceSteps(ctx context.Context, scope uow.TxScope, planID string, steps []Step) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
p := m.plans[planID]
|
|
if p == nil {
|
|
return fmt.Errorf("intentflow: plan %s not found", planID)
|
|
}
|
|
p.Steps = cloneSteps(steps)
|
|
p.UpdatedAt = time.Now().UTC()
|
|
return nil
|
|
}
|
|
|
|
func (m *MemoryStore) ClaimStep(ctx context.Context, scope uow.TxScope, planID, stepID, worker string, until, now time.Time, idempotencyKey string) (*Step, int64, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
p := m.plans[planID]
|
|
if p == nil {
|
|
return nil, 0, nil
|
|
}
|
|
for i := range p.Steps {
|
|
s := &p.Steps[i]
|
|
if s.StepID != stepID {
|
|
continue
|
|
}
|
|
claimable := s.Status == StepEligible ||
|
|
(s.Status == StepRunning && (s.LeaseUntil == nil || s.LeaseUntil.Before(now)))
|
|
if !claimable {
|
|
return nil, 0, nil
|
|
}
|
|
p.LastFencingToken++
|
|
s.Status = StepRunning
|
|
s.LeaseOwner = worker
|
|
untilCopy := until
|
|
s.LeaseUntil = &untilCopy
|
|
s.Attempt++
|
|
s.Version++
|
|
s.FencingToken = p.LastFencingToken
|
|
if s.IdempotencyKey == "" {
|
|
s.IdempotencyKey = idempotencyKey
|
|
}
|
|
s.UpdatedAt = now
|
|
p.UpdatedAt = now
|
|
return cloneStep(s), s.FencingToken, nil
|
|
}
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *MemoryStore) WriteStepReceipt(ctx context.Context, scope uow.TxScope, worker string, token int64, step Step, now time.Time) (bool, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
p := m.plans[step.PlanID]
|
|
if p == nil {
|
|
return false, nil
|
|
}
|
|
for i := range p.Steps {
|
|
s := &p.Steps[i]
|
|
if s.StepID != step.StepID {
|
|
continue
|
|
}
|
|
if s.Status == StepSucceeded && s.ResultDigest == step.ResultDigest {
|
|
return true, nil
|
|
}
|
|
if s.Status != StepRunning || s.LeaseOwner != worker || s.FencingToken != token {
|
|
return false, nil
|
|
}
|
|
s.Status = StepSucceeded
|
|
s.ResultRef = step.ResultRef
|
|
s.ResultDigest = step.ResultDigest
|
|
s.SafeSummary = step.SafeSummary
|
|
s.LeaseOwner = ""
|
|
s.LeaseUntil = nil
|
|
s.Version++
|
|
s.UpdatedAt = now
|
|
p.UpdatedAt = now
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (m *MemoryStore) ExpireWaiting(ctx context.Context, scope uow.TxScope, now time.Time) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
n := 0
|
|
for _, p := range m.plans {
|
|
if !isWaitingStatus(p.Status) || !now.After(p.ExpiresAt) {
|
|
continue
|
|
}
|
|
p.Status = PlanExpired
|
|
p.Revision++
|
|
p.UpdatedAt = now
|
|
for i := range p.Steps {
|
|
if p.Steps[i].Status == StepPending || p.Steps[i].Status == StepEligible {
|
|
p.Steps[i].Status = StepCancelled
|
|
p.Steps[i].UpdatedAt = now
|
|
}
|
|
}
|
|
n++
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (m *MemoryStore) PurgeClosed(ctx context.Context, scope uow.TxScope, before time.Time) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
n := 0
|
|
for id, p := range m.plans {
|
|
switch p.Status {
|
|
case PlanCompleted, PlanFailed, PlanCancelled, PlanExpired, PlanSuperseded:
|
|
default:
|
|
continue
|
|
}
|
|
if !p.UpdatedAt.Before(before) {
|
|
continue
|
|
}
|
|
delete(m.turn, turnKey(p.Principal.Environment, p.Principal.SessionID, p.ClientTurnID))
|
|
for k, d := range m.decisions {
|
|
if d.PlanID == id {
|
|
delete(m.decisions, k)
|
|
}
|
|
}
|
|
delete(m.plans, id)
|
|
n++
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func sameScope(a, b Principal) bool {
|
|
return a.Environment == b.Environment && a.ProjectID == b.ProjectID && a.SessionID == b.SessionID && a.UserID == b.UserID
|
|
}
|
|
|
|
func clonePlan(p *Plan) *Plan {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
cp := *p
|
|
cp.DisplaySnapshot = slices.Clone(p.DisplaySnapshot)
|
|
cp.Steps = cloneSteps(p.Steps)
|
|
return &cp
|
|
}
|
|
|
|
func cloneSteps(in []Step) []Step {
|
|
out := make([]Step, len(in))
|
|
for i := range in {
|
|
out[i] = *cloneStep(&in[i])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneStep(s *Step) *Step {
|
|
cp := *s
|
|
cp.Slots = slices.Clone(s.Slots)
|
|
cp.DependsOn = slices.Clone(s.DependsOn)
|
|
if s.LeaseUntil != nil {
|
|
t := *s.LeaseUntil
|
|
cp.LeaseUntil = &t
|
|
}
|
|
return &cp
|
|
}
|