# Concurrency Locks

CleverAgents uses advisory locking to prevent concurrent modifications
to plans and projects.  The lock mechanism is backed by a SQLite table
and enforced at the application layer before lifecycle transitions.

## Overview

The `LockService` provides mutual-exclusion primitives for two resource
types: **plan** and **project**.  Each resource can have at most one
active lock at any time.  Locks are identified by the tuple
`(resource_type, resource_id)` and scoped to an **owner** (typically
the session or process identity).

## Lock Lifecycle

```text
acquire  -->  active  -->  release / expire
               |
               +--> renew (extend TTL)
```

1. **Acquire**: Creates a new lock row or re-acquires for the same owner.
2. **Renew**: Extends the TTL of an active lock.
3. **Release**: Explicitly removes the lock.
4. **Expire**: The lock becomes stale after `expires_at` passes.

## Re-entrant Acquisition

If the same owner calls `acquire()` on a resource it already holds,
the TTL is silently extended.  No error is raised.

## Conflict Handling

If a **different** owner attempts to acquire a lock that is still active
(not expired), a `LockConflictError` is raised with details about the
current holder.  Expired locks from other owners are transparently
replaced.

## TTL Defaults and Renewal Strategy

| Constant                  | Default | Description                    |
|---------------------------|---------|--------------------------------|
| `DEFAULT_LOCK_TTL_SECS`  | 300     | Default lock lifetime (5 min)  |
| `MAX_LOCK_TTL_SECS`      | 3600    | Maximum allowed TTL (1 hr)     |
| `MIN_LOCK_TTL_SECS`      | 5       | Minimum allowed TTL (5 sec)    |

For long-running phases (e.g. Execute), the caller should periodically
call `renew()` with the desired TTL before the current lock expires.
A recommended renewal interval is `ttl / 2` (e.g. every 150 seconds
for the default 300-second TTL).

## Database Schema

The `locks` table (migration `m4_001_concurrency_locks`):

| Column          | Type         | Description                   |
|-----------------|-------------|-------------------------------|
| `id`            | INTEGER PK  | Auto-increment primary key    |
| `owner_id`      | VARCHAR(255) | Lock owner identity           |
| `resource_type` | VARCHAR(50)  | `plan` or `project`           |
| `resource_id`   | VARCHAR(255) | Resource identifier           |
| `acquired_at`   | VARCHAR(30)  | ISO-8601 acquisition time     |
| `expires_at`    | VARCHAR(30)  | ISO-8601 expiry time          |

A unique constraint on `(resource_type, resource_id)` enforces at most
one lock per resource.

## Startup Cleanup

On application startup, `cleanup_expired()` is called to purge any
locks whose `expires_at` has passed.  This handles the case where a
previous process terminated without releasing its locks.

## Graceful Shutdown

During graceful shutdown, `release_all_for_owner(owner_id)` removes all
locks held by the current process so that resources become immediately
available to other instances.

## Diagnostics

The `agents diagnostics` command includes a **Stale locks** check that
reports the number of expired-but-not-yet-cleaned locks.  A non-zero
count indicates that the cleanup routine has not run recently and may
warrant investigation.

## Integration Points

- **PlanLifecycleService**: Transitions acquire a plan-level lock before
  mutating phase/state and release it after persistence.
- **Startup**: The application container calls `cleanup_expired()` during
  initialisation.

## Error Types

| Exception           | Condition                                      |
|---------------------|------------------------------------------------|
| `LockConflictError` | Another owner holds an active lock             |
| `LockExpiredError`  | Attempted to renew an already-expired lock     |
| `ValidationError`   | Invalid parameters (empty strings, bad TTL)    |
