@@ -79,16 +79,25 @@ type PullMutationsResponse struct {
7979type LocalStore interface {
8080 GetSyncState (targetKey string ) (* store.SyncState , error )
8181 ListPendingSyncMutations (targetKey string , limit int ) ([]store.SyncMutation , error )
82+ CountPendingNonEnrolledSyncMutations (targetKey string ) ([]store.PendingSyncMutationProjectCount , error )
8283 AckSyncMutations (targetKey string , lastAckedSeq int64 ) error
8384 AckSyncMutationSeqs (targetKey string , seqs []int64 ) error
84- SkipAckNonEnrolledMutations (targetKey string ) (int64 , error )
8585 AcquireSyncLease (targetKey , owner string , ttl time.Duration , now time.Time ) (bool , error )
8686 ReleaseSyncLease (targetKey , owner string ) error
8787 ApplyPulledMutation (targetKey string , mutation store.SyncMutation ) error
8888 MarkSyncFailure (targetKey , message string , backoffUntil time.Time ) error
89+ MarkSyncBlocked (targetKey , reasonCode , message string ) error
8990 MarkSyncHealthy (targetKey string ) error
9091}
9192
93+ type nonEnrolledPendingError struct {
94+ counts []store.PendingSyncMutationProjectCount
95+ }
96+
97+ func (e * nonEnrolledPendingError ) Error () string {
98+ return nonEnrolledPendingMessage (e .counts )
99+ }
100+
92101// CloudTransport is the subset of remote.MutationTransport methods the manager needs.
93102type CloudTransport interface {
94103 PushMutations (mutations []MutationEntry ) (* PushMutationsResult , error )
@@ -388,6 +397,11 @@ func (m *Manager) cycle(ctx context.Context) {
388397
389398 // Push, then pull.
390399 if err := m .push (ctx ); err != nil {
400+ var blocked * nonEnrolledPendingError
401+ if errors .As (err , & blocked ) {
402+ m .recordBlocked (err .Error (), constants .ReasonNonEnrolledPendingMutations )
403+ return
404+ }
391405 reasonCode := classifyTransportError (err )
392406 m .recordFailureWithReason (autosyncFailureMessage (m .cfg .TargetKey , fmt .Sprintf ("push: %v" , err ), err ), reasonCode )
393407 return
@@ -453,16 +467,18 @@ func (m *Manager) push(ctx context.Context) error {
453467
454468 m .setPhase (PhasePushing )
455469
456- // Skip-ack mutations for non-enrolled projects.
457- if _ , err := m .store .SkipAckNonEnrolledMutations (m .cfg .TargetKey ); err != nil {
458- return fmt .Errorf ("skip-ack non-enrolled: %w" , err )
459- }
460-
461470 pending , err := m .store .ListPendingSyncMutations (m .cfg .TargetKey , m .cfg .PushBatchSize )
462471 if err != nil {
463472 return fmt .Errorf ("list pending: %w" , err )
464473 }
465474 if len (pending ) == 0 {
475+ counts , err := m .store .CountPendingNonEnrolledSyncMutations (m .cfg .TargetKey )
476+ if err != nil {
477+ return fmt .Errorf ("count pending non-enrolled mutations: %w" , err )
478+ }
479+ if len (counts ) > 0 {
480+ return & nonEnrolledPendingError {counts : counts }
481+ }
466482 return nil
467483 }
468484
@@ -589,6 +605,18 @@ func (m *Manager) recordFailureWithReason(msg, reasonCode string) {
589605 _ = m .store .MarkSyncFailure (m .cfg .TargetKey , msg , bu )
590606}
591607
608+ func (m * Manager ) recordBlocked (msg , reasonCode string ) {
609+ m .mu .Lock ()
610+ m .status .Phase = PhasePushFailed
611+ m .status .LastError = msg
612+ m .status .ReasonCode = reasonCode
613+ m .status .ReasonMessage = msg
614+ m .status .BackoffUntil = nil
615+ m .mu .Unlock ()
616+
617+ _ = m .store .MarkSyncBlocked (m .cfg .TargetKey , reasonCode , msg )
618+ }
619+
592620func (m * Manager ) recordSuccess () {
593621 now := time .Now ()
594622 m .mu .Lock ()
@@ -604,6 +632,14 @@ func (m *Manager) recordSuccess() {
604632 _ = m .store .MarkSyncHealthy (m .cfg .TargetKey )
605633}
606634
635+ func nonEnrolledPendingMessage (counts []store.PendingSyncMutationProjectCount ) string {
636+ parts := make ([]string , 0 , len (counts ))
637+ for _ , count := range counts {
638+ parts = append (parts , fmt .Sprintf ("%s=%d" , count .Project , count .Count ))
639+ }
640+ return fmt .Sprintf ("pending cloud sync mutations are blocked because project(s) are not enrolled: %s. Run `engram cloud enroll <project>` for each intended project or review enrollment." , strings .Join (parts , ", " ))
641+ }
642+
607643// computeBackoff returns exponential backoff with ±25% jitter.
608644// Formula: min(base * 2^(failures-1), maxBackoff) ± jitter where jitter ∈ [-base*0.25, +base*0.25]
609645// BW1: ±25% means jitter can be negative, so result ∈ [base*0.75, base*1.25].
0 commit comments