plandex-ai/plandex · error

error scanning repo lock: %v

Error message

error scanning repo lock: %v

What it means

After querying existing repo_lock rows, lockRepoDB scans each row into a repoLock struct with nine columns (Id, OrgId, UserId, PlanId, PlanBuildId, Scope, Branch, LastHeartbeatAt, CreatedAt). A scan failure — NULL in a non-pointer column or a type mismatch — aborts the lock attempt with "error scanning repo lock: %v".

Source

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

		if forUpdate {
			log.Printf("[Lock][%d] SELECT FOR UPDATE took %v | reason: %s",
				goroutineID, time.Since(selectStart), params.Reason)
		} else {
			log.Printf("[Lock][%d] SELECT FOR SHARE took %v | reason: %s",
				goroutineID, time.Since(selectStart), params.Reason)
		}
	}

	defer repoLockRows.Close()

	var expiredLockIds []string
	expiredLockIdsSet := make(map[string]bool)

	now := time.Now()
	for repoLockRows.Next() {
		var lock repoLock
		if err := repoLockRows.Scan(&lock.Id, &lock.OrgId, &lock.UserId, &lock.PlanId, &lock.PlanBuildId, &lock.Scope, &lock.Branch, &lock.LastHeartbeatAt, &lock.CreatedAt); err != nil {
			return "", fmt.Errorf("error scanning repo lock: %v", err)
		}

		// ensure heartbeat hasn't timed out
		if now.Sub(lock.LastHeartbeatAt) < lockHeartbeatTimeout {
			locks = append(locks, &lock)
		} else {
			expiredLockIds = append(expiredLockIds, lock.Id)
			expiredLockIdsSet[lock.Id] = true
		}
	}

	if err := repoLockRows.Err(); err != nil {
		log.Printf("[Lock][%d] error iterating over repo locks: %v | reason: %s", goroutineID, err, params.Reason)
		return "", fmt.Errorf("error iterating over repo locks: %v", err)
	}

	log.Printf("[Lock][%d] %d locks found, %d expired | reason: %s", goroutineID, len(locks), len(expiredLockIds), params.Reason)

View on GitHub (pinned to e2d772072e)

Solutions

  1. Find the offending row via the wrapped SQL error and fix or delete the NULL/malformed lock row — it will also block acquisition until its heartbeat expires.
  2. Make nullable columns pointer types (or sql.NullString/NullTime) in repoLock and in Scan so NULLs scan cleanly.
  3. Confirm the SELECT column list matches repoLock field order/types after any schema migration.
  4. Wait out the 60s heartbeat timeout — expired locks are deleted automatically, which often clears the bad row.

Example fix

// before
var lock repoLock
repoLockRows.Scan(&lock.Id, &lock.OrgId, &lock.UserId, ..., &lock.Branch, ...)
// after
Branch *string // nullable column in repoLock struct
...repoLockRows.Scan(&lock.Id, &lock.OrgId, &lock.UserId, ..., &lock.Branch, ...) // NULL-safe
Defensive patterns

Strategy: retry

Validate before calling

// detect stale/broken lock rows before locking
rows, err := db.Conn.Queryx("SELECT * FROM repo_locks WHERE plan_id=$1", planId)
for rows.Next() {
    var l db.RepoLock
    if err := rows.StructScan(&l); err != nil {
        log.Printf("corrupt lock row for plan %s, will rely on heartbeat expiry", planId)
    }
}

Type guard

func lockRowSane(l *repoLock) bool {
    return l.Id != "" && l.OrgId != "" && !l.LastHeartbeatAt.IsZero() && !l.CreatedAt.IsZero()
}

Try / catch

lockId, err := db.LockRepo(ctx, cancel, params)
if err != nil && strings.Contains(err.Error(), "error scanning repo lock") {
    // corrupt/expired row — retry after heartbeat timeout (60s) clears it
    time.Sleep(db.HeartbeatTimeout)
    lockId, err = db.LockRepo(ctx, cancel, params)
}

Prevention

When it happens

Trigger: A repo_locks row containing NULL for a column scanned into a non-pointer field (e.g. Branch, PlanBuildId, UserId); schema drift where the SELECT column list/type no longer matches repoLock fields.

Common situations: Old rows written by a prior schema version that lacked a column now scanned; manual DB edits or migrations inserting NULLs; mixing up column order after an ALTER TABLE without updating the query.

Related errors


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