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.
|
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
|
- **Issue Tracking** - How to use bd for work management
|
||||||
- **Development Guidelines** - Code standards and testing
|
- **Development Guidelines** - Code standards and testing
|
||||||
@@ -18,7 +18,7 @@ This file exists for compatibility with tools that look for AGENTS.md.
|
|||||||
- Status: `○ ◐ ● ✓ ❄`
|
- Status: `○ ◐ ● ✓ ❄`
|
||||||
- Priority: `● P0` (filled circle with color)
|
- 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
|
## 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
|
// Skip attempting to start and avoid the 5-second wait if not in git repo
|
||||||
if !isGitRepo() {
|
if !isGitRepo() {
|
||||||
debugLog("not in a git repository, skipping daemon start")
|
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
|
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) {
|
func TestDaemonAutostart_RestartDaemonForVersionMismatch_Stubbed(t *testing.T) {
|
||||||
oldExec := execCommandFn
|
oldExec := execCommandFn
|
||||||
oldWait := waitForSocketReadinessFn
|
oldWait := waitForSocketReadinessFn
|
||||||
|
|||||||
@@ -233,9 +233,6 @@ var rootCmd = &cobra.Command{
|
|||||||
// Set up signal-aware context for graceful cancellation
|
// Set up signal-aware context for graceful cancellation
|
||||||
rootCtx, rootCancel = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
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)
|
// Apply verbosity flags early (before any output)
|
||||||
debug.SetVerbose(verboseFlag)
|
debug.SetVerbose(verboseFlag)
|
||||||
debug.SetQuiet(quietFlag)
|
debug.SetQuiet(quietFlag)
|
||||||
@@ -332,27 +329,9 @@ var rootCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protect forks from accidentally committing upstream issue database
|
// GH#1093: Check noDbCommands BEFORE expensive operations (ensureForkProtection,
|
||||||
ensureForkProtection()
|
// signalOrchestratorActivity) to avoid spawning git subprocesses for simple commands
|
||||||
|
// like "bd version" that don't need database access.
|
||||||
// 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
|
|
||||||
noDbCommands := []string{
|
noDbCommands := []string{
|
||||||
cmdDaemon,
|
cmdDaemon,
|
||||||
"__complete", // Cobra's internal completion command (shell completions work without db)
|
"__complete", // Cobra's internal completion command (shell completions work without db)
|
||||||
@@ -398,6 +377,30 @@ var rootCmd = &cobra.Command{
|
|||||||
return
|
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)
|
// Auto-detect sandboxed environment (Phase 2 for GH #353)
|
||||||
// Only auto-enable if user hasn't explicitly set --sandbox or --no-daemon
|
// Only auto-enable if user hasn't explicitly set --sandbox or --no-daemon
|
||||||
if !cmd.Flags().Changed("sandbox") && !cmd.Flags().Changed("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"
|
- 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> --status=in_progress`" + ` - Claim work
|
||||||
- ` + "`bd update <id> --assignee=username`" + ` - Assign to someone
|
- ` + "`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 <id>`" + ` - Mark complete
|
||||||
- ` + "`bd close <id1> <id2> ...`" + ` - Close multiple issues at once (more efficient)
|
- ` + "`bd close <id1> <id2> ...`" + ` - Close multiple issues at once (more efficient)
|
||||||
- ` + "`bd close <id> --reason=\"explanation\"`" + ` - Close with reason
|
- ` + "`bd close <id> --reason=\"explanation\"`" + ` - Close with reason
|
||||||
- **Tip**: When creating multiple issues/tasks/epics, use parallel subagents for efficiency
|
- **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
|
### Dependencies & Blocking
|
||||||
- ` + "`bd dep add <issue> <depends-on>`" + ` - Add dependency (issue depends on depends-on)
|
- ` + "`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() }()
|
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)
|
// Delete dependencies (both directions)
|
||||||
_, err = tx.ExecContext(ctx, `DELETE FROM dependencies WHERE issue_id = ? OR depends_on_id = ?`, id, id)
|
_, err = tx.ExecContext(ctx, `DELETE FROM dependencies WHERE issue_id = ? OR depends_on_id = ?`, id, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user