Files
Agent-System/internal/app/intentflow/memory.go
T
weichenandCursor da0b3d812b feat(intentflow): persist plans with CAS lease and fencing
Own plan/step/decision writes behind UoW so two workers cannot both execute the same step.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 23:43:46 +00:00

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
}