Merge branch 'main' of github.com:steveyegge/beads into mayor-rig

This commit is contained in:
tom
2026-01-17 00:37:17 -08:00
committed by gastown/crew/dennis
7 changed files with 186 additions and 27 deletions

View File

@@ -4,7 +4,7 @@ See [AGENT_INSTRUCTIONS.md](AGENT_INSTRUCTIONS.md) for full instructions.
This file exists for compatibility with tools that look for AGENTS.md.
## Key Sections in CLAUDE.md
## Key Sections
- **Issue Tracking** - How to use bd for work management
- **Development Guidelines** - Code standards and testing
@@ -18,7 +18,7 @@ This file exists for compatibility with tools that look for AGENTS.md.
- Status: `○ ◐ ● ✓ ❄`
- Priority: `● P0` (filled circle with color)
See CLAUDE.md "Visual Design System" section for full guidance.
See [AGENT_INSTRUCTIONS.md](AGENT_INSTRUCTIONS.md) for full development guidelines.
## Agent Warning: Interactive Commands

View File

@@ -355,7 +355,9 @@ func startDaemonProcess(socketPath string) bool {
// Skip attempting to start and avoid the 5-second wait if not in git repo
if !isGitRepo() {
debugLog("not in a git repository, skipping daemon start")
fmt.Fprintf(os.Stderr, "%s No git repository initialized - running without background sync\n", ui.RenderMuted("Note:"))
if !quietFlag {
fmt.Fprintf(os.Stderr, "%s No git repository initialized - running without background sync\n", ui.RenderMuted("Note:"))
}
return false
}

View File

@@ -361,6 +361,41 @@ func TestDaemonAutostart_StartDaemonProcess_NoGitRepo(t *testing.T) {
}
}
func TestDaemonAutostart_StartDaemonProcess_NoGitRepo_Quiet(t *testing.T) {
// Test that startDaemonProcess suppresses the note when quietFlag is true
tmpDir := t.TempDir()
oldDir, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
oldQuiet := quietFlag
defer func() {
_ = os.Chdir(oldDir)
quietFlag = oldQuiet
}()
// Change to a temp directory that is NOT a git repo
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Chdir: %v", err)
}
// Enable quiet mode
quietFlag = true
// Capture stderr to verify the message is suppressed
output := captureStderr(t, func() {
result := startDaemonProcess(filepath.Join(tmpDir, "bd.sock"))
if result {
t.Errorf("expected startDaemonProcess to return false when not in git repo")
}
})
// Verify the message is NOT shown in quiet mode
if strings.Contains(output, "No git repository initialized") {
t.Errorf("expected no output in quiet mode, got: %q", output)
}
}
func TestDaemonAutostart_RestartDaemonForVersionMismatch_Stubbed(t *testing.T) {
oldExec := execCommandFn
oldWait := waitForSocketReadinessFn

View File

@@ -233,9 +233,6 @@ var rootCmd = &cobra.Command{
// Set up signal-aware context for graceful cancellation
rootCtx, rootCancel = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
// Signal orchestrator daemon about bd activity (best-effort, for exponential backoff)
defer signalOrchestratorActivity()
// Apply verbosity flags early (before any output)
debug.SetVerbose(verboseFlag)
debug.SetQuiet(quietFlag)
@@ -332,27 +329,9 @@ var rootCmd = &cobra.Command{
}
}
// Protect forks from accidentally committing upstream issue database
ensureForkProtection()
// Performance profiling setup
// When --profile is enabled, force direct mode to capture actual database operations
// rather than just RPC serialization/network overhead. This gives accurate profiles
// of the storage layer, query performance, and business logic.
if profileEnabled {
noDaemon = true
timestamp := time.Now().Format("20060102-150405")
if f, _ := os.Create(fmt.Sprintf("bd-profile-%s-%s.prof", cmd.Name(), timestamp)); f != nil {
profileFile = f
_ = pprof.StartCPUProfile(f)
}
if f, _ := os.Create(fmt.Sprintf("bd-trace-%s-%s.out", cmd.Name(), timestamp)); f != nil {
traceFile = f
_ = trace.Start(f)
}
}
// Skip database initialization for commands that don't need a database
// GH#1093: Check noDbCommands BEFORE expensive operations (ensureForkProtection,
// signalOrchestratorActivity) to avoid spawning git subprocesses for simple commands
// like "bd version" that don't need database access.
noDbCommands := []string{
cmdDaemon,
"__complete", // Cobra's internal completion command (shell completions work without db)
@@ -398,6 +377,30 @@ var rootCmd = &cobra.Command{
return
}
// Signal orchestrator daemon about bd activity (best-effort, for exponential backoff)
// GH#1093: Moved after noDbCommands check to avoid git subprocesses for simple commands
defer signalOrchestratorActivity()
// Protect forks from accidentally committing upstream issue database
ensureForkProtection()
// Performance profiling setup
// When --profile is enabled, force direct mode to capture actual database operations
// rather than just RPC serialization/network overhead. This gives accurate profiles
// of the storage layer, query performance, and business logic.
if profileEnabled {
noDaemon = true
timestamp := time.Now().Format("20060102-150405")
if f, _ := os.Create(fmt.Sprintf("bd-profile-%s-%s.prof", cmd.Name(), timestamp)); f != nil {
profileFile = f
_ = pprof.StartCPUProfile(f)
}
if f, _ := os.Create(fmt.Sprintf("bd-trace-%s-%s.out", cmd.Name(), timestamp)); f != nil {
traceFile = f
_ = trace.Start(f)
}
}
// Auto-detect sandboxed environment (Phase 2 for GH #353)
// Only auto-enable if user hasn't explicitly set --sandbox or --no-daemon
if !cmd.Flags().Changed("sandbox") && !cmd.Flags().Changed("no-daemon") {

View File

@@ -390,10 +390,12 @@ bd sync # Push to remote
- Priority: 0-4 or P0-P4 (0=critical, 2=medium, 4=backlog). NOT "high"/"medium"/"low"
- ` + "`bd update <id> --status=in_progress`" + ` - Claim work
- ` + "`bd update <id> --assignee=username`" + ` - Assign to someone
- ` + "`bd update <id> --title/--description/--notes/--design`" + ` - Update fields inline
- ` + "`bd close <id>`" + ` - Mark complete
- ` + "`bd close <id1> <id2> ...`" + ` - Close multiple issues at once (more efficient)
- ` + "`bd close <id> --reason=\"explanation\"`" + ` - Close with reason
- **Tip**: When creating multiple issues/tasks/epics, use parallel subagents for efficiency
- **WARNING**: Do NOT use ` + "`bd edit`" + ` - it opens $EDITOR (vim/nano) which blocks agents
### Dependencies & Blocking
- ` + "`bd dep add <issue> <depends-on>`" + ` - Add dependency (issue depends on depends-on)

View File

@@ -325,3 +325,94 @@ func TestBuildSQLInClause(t *testing.T) {
}
}
}
// TestDeleteIssueMarksDependentsDirty verifies that when an issue is deleted,
// all issues that depend on it are marked dirty so their stale dependencies
// are removed on next JSONL export. This prevents orphan dependencies in JSONL.
func TestDeleteIssueMarksDependentsDirty(t *testing.T) {
store := newTestStore(t, "file::memory:?mode=memory&cache=private")
ctx := context.Background()
// Create a wisp (will be deleted)
wisp := &types.Issue{
ID: "bd-wisp-1",
Title: "Ephemeral Wisp",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeTask,
Ephemeral: true,
}
if err := store.CreateIssue(ctx, wisp, "test"); err != nil {
t.Fatalf("Failed to create wisp: %v", err)
}
// Create a digest that depends on the wisp
digest := &types.Issue{
ID: "bd-digest-1",
Title: "Digest: Test",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeTask,
Ephemeral: false, // Digest is persistent
}
if err := store.CreateIssue(ctx, digest, "test"); err != nil {
t.Fatalf("Failed to create digest: %v", err)
}
// Create dependency: digest depends on wisp (parent-child)
dep := &types.Dependency{
IssueID: "bd-digest-1",
DependsOnID: "bd-wisp-1",
Type: types.DepParentChild,
}
if err := store.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("Failed to add dependency: %v", err)
}
// Clear dirty state (simulate post-flush state)
if err := store.ClearDirtyIssuesByID(ctx, []string{"bd-wisp-1", "bd-digest-1"}); err != nil {
t.Fatalf("Failed to clear dirty state: %v", err)
}
// Verify digest is NOT dirty initially
dirtyBefore, err := store.GetDirtyIssues(ctx)
if err != nil {
t.Fatalf("Failed to get dirty issues: %v", err)
}
for _, id := range dirtyBefore {
if id == "bd-digest-1" {
t.Fatal("Digest should not be dirty before wisp deletion")
}
}
// Delete the wisp
if err := store.DeleteIssue(ctx, "bd-wisp-1"); err != nil {
t.Fatalf("Failed to delete wisp: %v", err)
}
// Verify digest IS now dirty (so it gets re-exported without stale dep)
dirtyAfter, err := store.GetDirtyIssues(ctx)
if err != nil {
t.Fatalf("Failed to get dirty issues after delete: %v", err)
}
found := false
for _, id := range dirtyAfter {
if id == "bd-digest-1" {
found = true
break
}
}
if !found {
t.Error("Digest should be marked dirty after wisp deletion to remove orphan dependency")
}
// Verify the dependency is gone from the digest
digestIssue, err := store.GetIssue(ctx, "bd-digest-1")
if err != nil {
t.Fatalf("Failed to get digest: %v", err)
}
if len(digestIssue.Dependencies) != 0 {
t.Errorf("Digest should have no dependencies after wisp deleted, got %d", len(digestIssue.Dependencies))
}
}

View File

@@ -1377,6 +1377,32 @@ func (s *SQLiteStorage) DeleteIssue(ctx context.Context, id string) error {
}
defer func() { _ = tx.Rollback() }()
// Mark issues that depend on this one as dirty so they get re-exported
// without the stale dependency reference (fixes orphan deps in JSONL)
rows, err := tx.QueryContext(ctx, `SELECT issue_id FROM dependencies WHERE depends_on_id = ?`, id)
if err != nil {
return fmt.Errorf("failed to query dependent issues: %w", err)
}
var dependentIDs []string
for rows.Next() {
var depID string
if err := rows.Scan(&depID); err != nil {
_ = rows.Close()
return fmt.Errorf("failed to scan dependent issue ID: %w", err)
}
dependentIDs = append(dependentIDs, depID)
}
_ = rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("failed to iterate dependent issues: %w", err)
}
if len(dependentIDs) > 0 {
if err := markIssuesDirtyTx(ctx, tx, dependentIDs); err != nil {
return fmt.Errorf("failed to mark dependent issues dirty: %w", err)
}
}
// Delete dependencies (both directions)
_, err = tx.ExecContext(ctx, `DELETE FROM dependencies WHERE issue_id = ? OR depends_on_id = ?`, id, id)
if err != nil {