plandex-ai/plandex · error

delete lock failed after 10 attempts: %w

Error message

delete lock failed after 10 attempts: %w

What it means

retryDeleteLock gives up after maxDeleteRetries (10) attempts to delete a repo_locks row and returns 'delete lock failed after 10 attempts' wrapping the original delete error. The DB lock row may remain, so the plan appears locked until the heartbeat/timeout path cleans it up.

Source

Thrown at app/server/db/locks.go:602

		wait = 0
	}

	log.Printf("[Lock][Retry][%d] Lock/transaction conflict (attempt #%d). Retrying in %s... (cause: %v)", getGoroutineID(), attempt, wait, cause)

	select {
	case <-ctx.Done():
		log.Printf("[Lock][Retry][%d] Context canceled while waiting to retry: %v", getGoroutineID(), ctx.Err())
		return "", fmt.Errorf("context canceled while waiting to retry: %w", ctx.Err())
	case <-time.After(wait):
		// Proceed with the next attempt.
	}

	return nextCall(attempt + 1)
}

func retryDeleteLock(ctx context.Context, cause error, attempt int, nextCall func(int) error) error {
	if attempt >= maxDeleteRetries {
		return fmt.Errorf("delete lock failed after 10 attempts: %w", cause)
	}
	// retry 10 times, no backoff or maybe a tiny 50ms
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-time.After(deleteRetryDelay):
	}
	return nextCall(attempt + 1)
}

func CleanupActiveLocks(ctx context.Context) error {
	log.Println("Cleaning up any active repo locks...")

	// Start a transaction with repeatable read isolation level
	tx, err := Conn.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
	if err != nil {
		return fmt.Errorf("error starting transaction: %v", err)
	}

View on GitHub (pinned to e2d772072e)

Solutions

  1. Read the wrapped cause — if it is 40001/40P01 the retry loop will likely succeed once the competing transaction ends
  2. Verify DB connectivity; a full outage will exhaust all 10 fast retries quickly
  3. Manually delete the orphaned lock row (DELETE FROM repo_locks WHERE id = ...) or wait for heartbeat-based cleanup
  4. Increase maxDeleteRetries or add exponential backoff to retryDeleteLock for outage resilience
  5. On failure, ensure the caller still clears its in-memory activeLockIds entry only after a successful delete

Example fix

// before
err := deleteRepoLockDB(id, planId, reason, 0)
if err != nil { log.Printf("release failed: %v", err) }
// after
err := deleteRepoLockDB(id, planId, reason, 0)
if err != nil {
    if strings.Contains(err.Error(), "delete lock failed after 10 attempts") {
        // alert: lock row may be orphaned; needs manual/cron cleanup
        notify.NotifyErr(notify.SeverityError, fmt.Errorf("lock %s orphaned: %w", id, err))
    }
}
Defensive patterns

Strategy: retry

Validate before calling

if err := Conn.PingContext(shutdown.ShutdownCtx); err != nil {
    return fmt.Errorf("db unavailable, release will fail: %w", err)
}

Type guard

func isDeleteExhausted(err error) bool {
    return err != nil && strings.Contains(err.Error(), "delete lock failed after 10 attempts")
}

Try / catch

err := deleteRepoLockDB(id, planId, reason, 0)
if isDeleteExhausted(err) {
    // lock row may be orphaned — alert and schedule manual/cron cleanup
    notify.NotifyErr(notify.SeverityError, fmt.Errorf("lock %s not released: %w", id, err))
}

Prevention

When it happens

Trigger: Ten consecutive DELETE failures against repo_locks: connection errors, statement timeouts, deadlock (40P01) or serialization (40001) aborts on the delete, each retried after deleteRetryDelay without backoff.

Common situations: Postgres outage or connection pool exhaustion lasting longer than 10 * deleteRetryDelay; deadlock between delete and a concurrent insert/select on repo_locks; network partition during release on shutdown.

Related errors


AI-assisted analysis of plandex-ai/plandex@e2d772072e (2026-09-05). Data as JSON: /api/errors/11d69790cb21a0b8. Report an issue: GitHub.