Merge branch 'main' of github.com:steveyegge/beads into mayor-rig
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user