Amp-Thread-ID: https://ampcode.com/threads/T-7a71671d-dd5c-4c7c-b557-fa427fceb04f Co-authored-by: Amp <amp@ampcode.com>
230 lines
6.8 KiB
Markdown
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
|