Files
beads/docs/EXCLUSIVE_LOCK.md

230 lines
6.8 KiB
Markdown

# Exclusive Lock Protocol
The exclusive lock protocol allows external tools to claim exclusive management of a beads database, preventing the bd daemon from interfering with their operations.
## Use Cases
- **Deterministic execution systems** (e.g., VibeCoder) that need full control over database state
- **CI/CD pipelines** that perform atomic issue updates without daemon interference
- **Custom automation tools** that manage their own git sync workflow
## How It Works
### Lock File Format
The lock file is located at `.beads/.exclusive-lock` and contains JSON:
```json
{
"holder": "vc-executor",
"pid": 12345,
"hostname": "dev-machine",
"started_at": "2025-10-25T12:00:00Z",
"version": "1.0.0"
}
```
**Fields:**
- `holder` (string, required): Name of the tool holding the lock (e.g., "vc-executor", "ci-runner")
- `pid` (int, required): Process ID of the lock holder
- `hostname` (string, required): Hostname where the process is running
- `started_at` (RFC3339 timestamp, required): When the lock was acquired
- `version` (string, optional): Version of the lock holder
### Daemon Behavior
The bd daemon checks for exclusive locks at the start of each sync cycle:
1. **No lock file**: Daemon proceeds normally with sync operations
2. **Valid lock (process alive)**: Daemon skips all operations for this database
3. **Stale lock (process dead)**: Daemon removes the lock and proceeds
4. **Malformed lock**: Daemon fails safe and skips the database
### Stale Lock Detection
A lock is considered stale if:
- The hostname matches the current machine (case-insensitive) AND
- The PID does not exist on the local system (returns ESRCH)
**Important:** The daemon only removes locks when it can definitively determine the process is dead (ESRCH error). If the daemon lacks permission to signal a PID (EPERM), it treats the lock as valid and skips the database. This fail-safe approach prevents accidentally removing locks owned by other users.
**Remote locks** (different hostname) are always assumed to be valid since the daemon cannot verify remote processes.
When a stale lock is successfully removed, the daemon logs: `Removed stale lock (holder-name), proceeding with sync`
## Usage Examples
### Creating a Lock (Go)
```go
import (
"encoding/json"
"os"
"path/filepath"
"github.com/steveyegge/beads/internal/types"
)
func acquireLock(beadsDir, holder, version string) error {
lock, err := types.NewExclusiveLock(holder, version)
if err != nil {
return err
}
data, err := json.MarshalIndent(lock, "", " ")
if err != nil {
return err
}
lockPath := filepath.Join(beadsDir, ".exclusive-lock")
return os.WriteFile(lockPath, data, 0644)
}
```
### Releasing a Lock (Go)
```go
func releaseLock(beadsDir string) error {
lockPath := filepath.Join(beadsDir, ".exclusive-lock")
return os.Remove(lockPath)
}
```
### Creating a Lock (Shell)
```bash
#!/bin/bash
BEADS_DIR=".beads"
LOCK_FILE="$BEADS_DIR/.exclusive-lock"
# Create lock
cat > "$LOCK_FILE" <<EOF
{
"holder": "my-tool",
"pid": $$,
"hostname": "$(hostname)",
"started_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"version": "1.0.0"
}
EOF
# Do work...
bd create "My issue" -p 1
bd update bd-42 --status in_progress
# Release lock
rm "$LOCK_FILE"
```
### Recommended Pattern
Always use cleanup handlers to ensure locks are released:
```go
func main() {
beadsDir := ".beads"
// Acquire lock
if err := acquireLock(beadsDir, "my-tool", "1.0.0"); err != nil {
log.Fatal(err)
}
// Ensure lock is released on exit
defer func() {
if err := releaseLock(beadsDir); err != nil {
log.Printf("Warning: failed to release lock: %v", err)
}
}()
// Do work with beads database...
}
```
## Edge Cases and Limitations
### Multiple Writers Without Daemon
The exclusive lock protocol **only prevents daemon interference**. It does NOT provide:
- ❌ Mutual exclusion between multiple external tools
- ❌ Transaction isolation or ACID guarantees
- ❌ Protection against direct file system manipulation
If you need coordination between multiple tools, implement your own locking mechanism.
### Git Worktrees
The daemon already has issues with git worktrees (see AGENTS.md). The exclusive lock protocol doesn't solve this—use `--no-daemon` mode in worktrees instead.
### Remote Hosts
Locks from remote hosts are always assumed valid because the daemon cannot verify remote PIDs. This means:
- Stale locks from remote hosts will **not** be automatically cleaned up
- You must manually remove stale remote locks
### Lock File Corruption
If the lock file becomes corrupted (invalid JSON), the daemon **fails safe** and skips the database. You must manually fix or remove the lock file.
## Daemon Logging
The daemon logs lock-related events:
```
Skipping database (locked by vc-executor)
Removed stale lock (vc-executor), proceeding with sync
Skipping database (lock check failed: malformed lock file: unexpected EOF)
```
Check daemon logs (default: `.beads/daemon.log`) to troubleshoot lock issues.
**Note:** The daemon checks for locks at the start of each sync cycle. If a lock is created during a sync cycle, that cycle will complete, but subsequent cycles will skip the database.
## Testing Your Integration
1. **Start the daemon**: `bd daemon --interval 1m`
2. **Create a lock**: Use your tool to create `.beads/.exclusive-lock`
3. **Verify daemon skips**: Check daemon logs for "Skipping database" message
4. **Release lock**: Remove `.beads/.exclusive-lock`
5. **Verify daemon resumes**: Check daemon logs for normal sync cycle
## Security Considerations
- Lock files are **not secure**. Any process can create, modify, or delete them.
- PID reuse could theoretically cause issues (very rare, especially with hostname check)
- This is a **cooperative** protocol, not a security mechanism
## API Reference
### Go Types
```go
// ExclusiveLock represents the lock file format
type ExclusiveLock struct {
Holder string `json:"holder"`
PID int `json:"pid"`
Hostname string `json:"hostname"`
StartedAt time.Time `json:"started_at"`
Version string `json:"version"`
}
// NewExclusiveLock creates a lock for the current process
func NewExclusiveLock(holder, version string) (*ExclusiveLock, error)
// Validate checks if the lock has valid field values
func (e *ExclusiveLock) Validate() error
// ShouldSkipDatabase checks if database should be skipped due to lock
func ShouldSkipDatabase(beadsDir string) (skip bool, holder string, err error)
// IsProcessAlive checks if a process is running
func IsProcessAlive(pid int, hostname string) bool
```
## Questions?
For integration help, see:
- **AGENTS.md** - General workflow guidance
- **README.md** - Daemon configuration
- **examples/** - Sample integrations
File issues at: https://github.com/steveyegge/beads/issues