gastownhall/beads · warning
workspacegate: %s (%s mode) held by %s: %w
Error message
workspacegate: %s (%s mode) held by %s: %w
What it means
Acquire exhausted its wait budget (opts.Wait <= 0 or the deadline passed) while the gate was still held by another process; it returns ErrBusy wrapped with details of the current holder (from the sidecar info written by writeInfo). This is the deliberate, expected timeout outcome of contention, not an internal failure.
Source
Thrown at internal/workspacegate/gate.go:423
if mode == Exclusive {
g.writeInfo(opts.Reason)
}
return h, nil
}
if !errors.Is(err, lockfile.ErrLockBusy) && !lockfile.IsLocked(err) {
_ = f.Close()
return nil, fmt.Errorf("workspacegate: lock %s: %w", g.path, err)
}
if !notified {
notified = true
if opts.OnWait != nil {
opts.OnWait(g.busyDetail(mode))
}
}
remaining := time.Until(deadline)
if opts.Wait <= 0 || remaining <= 0 {
_ = f.Close()
return nil, fmt.Errorf("workspacegate: %s (%s mode) held by %s: %w",
g.path, mode, g.busyDetail(mode), ErrBusy)
}
// Never sleep past the wait budget: a Wait shorter than the poll
// interval must still come back within (about) Wait, and the
// deadline is re-checked above before any further attempt.
sleep := poll
if remaining < sleep {
sleep = remaining
}
select {
case <-ctx.Done():
_ = f.Close()
return nil, fmt.Errorf("workspacegate: waiting for %s: %w", g.path, ctx.Err())
case <-time.After(sleep):
}
}
}
View on GitHub (pinned to 71377f2769)
Solutions
- Retry with a larger opts.Wait, or add your own backoff/retry around the Acquire call and check errors.Is(err, workspacegate.ErrBusy).
- Read the busy detail in the error message to identify the holder (pid/command from the sidecar) and wait for it or kill it if stale.
- Serialize work: use a queue or OnWait callback to surface contention rather than firing concurrent operations.
- If the holder is a dead process on a local filesystem, the flock was already released; investigate who relaunches the conflicting command.
Example fix
// before
h, err := g.Acquire(ctx, workspacegate.Exclusive, workspacegate.AcquireOpts{})
// after
h, err := g.Acquire(ctx, workspacegate.Exclusive, workspacegate.AcquireOpts{
Wait: 30 * time.Second,
OnWait: func(d string) { log.Printf("gate busy: %s", d) },
})
if errors.Is(err, workspacegate.ErrBusy) {
return fmt.Errorf("workspace in use; retry later: %w", err)
} Defensive patterns
Strategy: retry
Validate before calling
// pre-check before committing to a long wait
held, detail, err := g.ExclusiveHolder(ctx)
if err == nil && held {
log.Printf("gate currently held: %s — deciding whether to wait", detail)
} Type guard
func IsBusy(err error) bool { return errors.Is(err, workspacegate.ErrBusy) } Try / catch
h, err := g.Acquire(ctx, mode, workspacegate.AcquireOpts{Wait: 30 * time.Second})
if errors.Is(err, workspacegate.ErrBusy) {
// surface holder detail from err.Error(), schedule retry or abort
return fmt.Errorf("workspace busy: %w", err)
} Prevention
- Always set a sane opts.Wait and an OnWait callback to observe contention.
- Check errors.Is(err, workspacegate.ErrBusy) to distinguish timeout from hard failure.
- Serialize long operations through a queue instead of hammering the gate.
- Use ExclusiveHolder to inspect stale holders before retrying.
When it happens
Trigger: Calling Acquire with Exclusive (or Shared) while another process holds a conflicting flock and either opts.Wait is zero (non-blocking attempt) or the deadline elapses before the holder releases.
Common situations: Two commands (e.g. two bd/beads processes) operating on the same workspace concurrently; a stale process or crashed run left a long-lived holder; user scripts acquiring exclusive gates for long operations; parallel CI jobs sharing one checkout.
Related errors
- timeout waiting for cache lock on %s
- workspacegate: acquiring %s: %w
- workspacegate: open gate %s: %w
- workspacegate: lock %s: %w
- workspacegate: waiting for %s: %w
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/615b2b6e2cde5079.
Report an issue: GitHub.