perf(rpc): use bd daemon protocol to reduce subprocess overhead

Replace bd subprocess calls in gt commands with daemon RPC when available.
Each subprocess call has ~40ms overhead for Go binary startup, so using
the daemon's Unix socket protocol significantly reduces latency.

Changes:
- Add RPC client to beads package (beads_rpc.go)
- Modify List/Show/Update/Close methods to try RPC first, fall back to subprocess
- Replace runBdPrime() with direct content output (avoids bd subprocess)
- Replace checkPendingEscalations() to use beads.List() with RPC
- Replace hook.go bd subprocess calls with beads package methods

The RPC client:
- Connects to daemon via Unix socket at .beads/bd.sock
- Uses JSON-based request/response protocol (same as bd daemon)
- Falls back gracefully to subprocess if daemon unavailable
- Lazy-initializes connection on first use

Performance improvement targets (from bd-2zd.2):
- gt prime < 100ms (was 5.8s with subprocess chain)
- gt hook < 100ms (was ~323ms)

Closes: bd-2zd.2
This commit is contained in:
obsidian
2026-01-24 17:13:07 -08:00
committed by John Ogle
parent bd0f30cfdd
commit 6f0282f1c6
4 changed files with 440 additions and 66 deletions

View File

@@ -119,6 +119,12 @@ type Beads struct {
// Populated on first call to getTownRoot() to avoid filesystem walk on every operation.
townRoot string
searchedRoot bool
// RPC client for daemon communication (lazy-initialized).
// When available, RPC is preferred over subprocess for performance.
rpcClient *rpcClient
rpcChecked bool
rpcAvailable bool
}
// New creates a new Beads wrapper for the given directory.
@@ -287,7 +293,14 @@ func filterBeadsEnv(environ []string) []string {
}
// List returns issues matching the given options.
// Uses daemon RPC when available for better performance (~40ms faster).
func (b *Beads) List(opts ListOptions) ([]*Issue, error) {
// Try RPC first (faster when daemon is running)
if issues, err := b.listViaRPC(opts); err == nil {
return issues, nil
}
// Fall back to subprocess
args := []string{"list", "--json"}
if opts.Status != "" {
@@ -400,7 +413,14 @@ func (b *Beads) ReadyWithType(issueType string) ([]*Issue, error) {
}
// Show returns detailed information about an issue.
// Uses daemon RPC when available for better performance (~40ms faster).
func (b *Beads) Show(id string) (*Issue, error) {
// Try RPC first (faster when daemon is running)
if issue, err := b.showViaRPC(id); err == nil {
return issue, nil
}
// Fall back to subprocess
out, err := b.run("show", id, "--json")
if err != nil {
return nil, err
@@ -559,7 +579,14 @@ func (b *Beads) CreateWithID(id string, opts CreateOptions) (*Issue, error) {
}
// Update updates an existing issue.
// Uses daemon RPC when available for better performance (~40ms faster).
func (b *Beads) Update(id string, opts UpdateOptions) error {
// Try RPC first (faster when daemon is running)
if err := b.updateViaRPC(id, opts); err == nil {
return nil
}
// Fall back to subprocess
args := []string{"update", id}
if opts.Title != nil {
@@ -598,15 +625,26 @@ func (b *Beads) Update(id string, opts UpdateOptions) error {
// Close closes one or more issues.
// If a runtime session ID is set in the environment, it is passed to bd close
// for work attribution tracking (see decision 009-session-events-architecture.md).
// Uses daemon RPC when available for better performance (~40ms faster per call).
func (b *Beads) Close(ids ...string) error {
if len(ids) == 0 {
return nil
}
sessionID := runtime.SessionIDFromEnv()
// Try RPC for single-issue closes (faster when daemon is running)
if len(ids) == 1 {
if err := b.closeViaRPC(ids[0], "", sessionID, false); err == nil {
return nil
}
}
// Fall back to subprocess
args := append([]string{"close"}, ids...)
// Pass session ID for work attribution if available
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
if sessionID != "" {
args = append(args, "--session="+sessionID)
}
@@ -617,16 +655,51 @@ func (b *Beads) Close(ids ...string) error {
// CloseWithReason closes one or more issues with a reason.
// If a runtime session ID is set in the environment, it is passed to bd close
// for work attribution tracking (see decision 009-session-events-architecture.md).
// Uses daemon RPC when available for better performance (~40ms faster per call).
func (b *Beads) CloseWithReason(reason string, ids ...string) error {
if len(ids) == 0 {
return nil
}
sessionID := runtime.SessionIDFromEnv()
// Try RPC for single-issue closes (faster when daemon is running)
if len(ids) == 1 {
if err := b.closeViaRPC(ids[0], reason, sessionID, false); err == nil {
return nil
}
}
// Fall back to subprocess
args := append([]string{"close"}, ids...)
args = append(args, "--reason="+reason)
// Pass session ID for work attribution if available
if sessionID := runtime.SessionIDFromEnv(); sessionID != "" {
if sessionID != "" {
args = append(args, "--session="+sessionID)
}
_, err := b.run(args...)
return err
}
// CloseForced closes an issue with force flag and optional reason.
// The force flag bypasses blockers and other validation checks.
// Uses daemon RPC when available for better performance (~40ms faster).
func (b *Beads) CloseForced(id, reason string) error {
sessionID := runtime.SessionIDFromEnv()
// Try RPC first (faster when daemon is running)
if err := b.closeViaRPC(id, reason, sessionID, true); err == nil {
return nil
}
// Fall back to subprocess
args := []string{"close", id, "--force"}
if reason != "" {
args = append(args, "--reason="+reason)
}
if sessionID != "" {
args = append(args, "--session="+sessionID)
}
@@ -800,3 +873,19 @@ func ProvisionPrimeMDForWorktree(worktreePath string) error {
// Provision PRIME.md in the target directory
return ProvisionPrimeMD(beadsDir)
}
// GetPrimeContent returns the beads workflow context content.
// It checks for a custom PRIME.md file first, otherwise returns the default.
// This eliminates the need to spawn a bd subprocess for gt prime.
func GetPrimeContent(workDir string) string {
beadsDir := ResolveBeadsDir(workDir)
primePath := filepath.Join(beadsDir, "PRIME.md")
// Check for custom PRIME.md
if content, err := os.ReadFile(primePath); err == nil {
return strings.TrimSpace(string(content))
}
// Return default content
return strings.TrimSpace(primeContent)
}