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

  1. Retry with a larger opts.Wait, or add your own backoff/retry around the Acquire call and check errors.Is(err, workspacegate.ErrBusy).
  2. 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.
  3. Serialize work: use a queue or OnWait callback to surface contention rather than firing concurrent operations.
  4. 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

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


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/615b2b6e2cde5079. Report an issue: GitHub.