feat(mol): filter ephemeral issues from JSONL export (bd-687g)
Ephemeral issues should never be exported to issues.jsonl. They exist only in SQLite and are shared via .beads/redirect pointers. This prevents "zombie" issues from resurrecting after mol squash deletes them. Changes: - Filter ephemeral issues in autoflush, export, and multirepo_export - Add --summary flag to bd mol squash for agent-provided summaries - Fix DeleteIssue to also remove comments (missing cascade) - Add tests for ephemeral filtering and comment deletion 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -754,7 +754,7 @@ history/
|
|||||||
- ✅ Link discovered work with `discovered-from` dependencies
|
- ✅ Link discovered work with `discovered-from` dependencies
|
||||||
- ✅ Run `gt mail inbox` from your cwd, not ~/gt
|
- ✅ Run `gt mail inbox` from your cwd, not ~/gt
|
||||||
- ✅ Store AI planning docs in `history/` directory
|
- ✅ Store AI planning docs in `history/` directory
|
||||||
- ❌ Do NOT use `bd ready` or `bd list` to find work - the overseer directs your work
|
- ✅ Use `bd ready` to see unblocked work available for pickup
|
||||||
- ❌ Do NOT create markdown TODO lists
|
- ❌ Do NOT create markdown TODO lists
|
||||||
- ❌ Do NOT clutter repo root with planning documents
|
- ❌ Do NOT clutter repo root with planning documents
|
||||||
|
|
||||||
|
|||||||
@@ -704,10 +704,21 @@ func flushToJSONLWithState(state flushState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert map to slice (will be sorted by writeJSONLAtomic)
|
// Convert map to slice (will be sorted by writeJSONLAtomic)
|
||||||
|
// Filter out ephemeral issues - they should never be exported to JSONL (bd-687g)
|
||||||
|
// Ephemeral issues exist only in SQLite and are shared via .beads/redirect, not JSONL.
|
||||||
|
// This prevents "zombie" issues that resurrect after mol squash deletes them.
|
||||||
issues := make([]*types.Issue, 0, len(issueMap))
|
issues := make([]*types.Issue, 0, len(issueMap))
|
||||||
|
ephemeralSkipped := 0
|
||||||
for _, issue := range issueMap {
|
for _, issue := range issueMap {
|
||||||
|
if issue.Ephemeral {
|
||||||
|
ephemeralSkipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
issues = append(issues, issue)
|
issues = append(issues, issue)
|
||||||
}
|
}
|
||||||
|
if ephemeralSkipped > 0 {
|
||||||
|
debug.Logf("auto-flush: filtered %d ephemeral issues from export", ephemeralSkipped)
|
||||||
|
}
|
||||||
|
|
||||||
// Filter issues by prefix in multi-repo mode for non-primary repos (fixes GH #437)
|
// Filter issues by prefix in multi-repo mode for non-primary repos (fixes GH #437)
|
||||||
// In multi-repo mode, non-primary repos should only export issues that match
|
// In multi-repo mode, non-primary repos should only export issues that match
|
||||||
|
|||||||
@@ -346,6 +346,16 @@ Examples:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter out ephemeral issues - they should never be exported to JSONL (bd-687g)
|
||||||
|
// Ephemeral issues exist only in SQLite and are shared via .beads/redirect, not JSONL.
|
||||||
|
filtered := make([]*types.Issue, 0, len(issues))
|
||||||
|
for _, issue := range issues {
|
||||||
|
if !issue.Ephemeral {
|
||||||
|
filtered = append(filtered, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues = filtered
|
||||||
|
|
||||||
// Sort by ID for consistent output
|
// Sort by ID for consistent output
|
||||||
sort.Slice(issues, func(i, j int) bool {
|
sort.Slice(issues, func(i, j int) bool {
|
||||||
return issues[i].ID < issues[j].ID
|
return issues[i].ID < issues[j].ID
|
||||||
|
|||||||
+23
-6
@@ -30,13 +30,20 @@ The squash operation:
|
|||||||
4. Creates a non-ephemeral digest issue
|
4. Creates a non-ephemeral digest issue
|
||||||
5. Deletes the ephemeral children (unless --keep-children)
|
5. Deletes the ephemeral children (unless --keep-children)
|
||||||
|
|
||||||
|
AGENT INTEGRATION:
|
||||||
|
Use --summary to provide an AI-generated summary. This keeps bd as a pure
|
||||||
|
tool - the calling agent (Gas Town polecat, Claude Code, etc.) is responsible
|
||||||
|
for generating intelligent summaries. Without --summary, a basic concatenation
|
||||||
|
of child issue content is used.
|
||||||
|
|
||||||
This is part of the ephemeral workflow: spawn creates ephemeral issues,
|
This is part of the ephemeral workflow: spawn creates ephemeral issues,
|
||||||
execution happens, squash compresses the trace into an outcome.
|
execution happens, squash compresses the trace into an outcome.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
bd mol squash bd-abc123 # Squash molecule children
|
bd mol squash bd-abc123 # Squash with auto-generated digest
|
||||||
bd mol squash bd-abc123 --dry-run # Preview what would be squashed
|
bd mol squash bd-abc123 --dry-run # Preview what would be squashed
|
||||||
bd mol squash bd-abc123 --keep-children # Create digest but keep children`,
|
bd mol squash bd-abc123 --keep-children # Create digest but keep children
|
||||||
|
bd mol squash bd-abc123 --summary "Agent-generated summary of work done"`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Run: runMolSquash,
|
Run: runMolSquash,
|
||||||
}
|
}
|
||||||
@@ -69,6 +76,7 @@ func runMolSquash(cmd *cobra.Command, args []string) {
|
|||||||
|
|
||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
keepChildren, _ := cmd.Flags().GetBool("keep-children")
|
keepChildren, _ := cmd.Flags().GetBool("keep-children")
|
||||||
|
summary, _ := cmd.Flags().GetString("summary")
|
||||||
|
|
||||||
// Resolve molecule ID
|
// Resolve molecule ID
|
||||||
moleculeID, err := utils.ResolvePartialID(ctx, store, args[0])
|
moleculeID, err := utils.ResolvePartialID(ctx, store, args[0])
|
||||||
@@ -132,7 +140,7 @@ func runMolSquash(cmd *cobra.Command, args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Perform the squash
|
// Perform the squash
|
||||||
result, err := squashMolecule(ctx, store, subgraph.Root, ephemeralChildren, keepChildren, actor)
|
result, err := squashMolecule(ctx, store, subgraph.Root, ephemeralChildren, keepChildren, summary, actor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error squashing molecule: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error squashing molecule: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -205,7 +213,10 @@ func generateDigest(root *types.Issue, children []*types.Issue) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// squashMolecule performs the squash operation
|
// squashMolecule performs the squash operation
|
||||||
func squashMolecule(ctx context.Context, s storage.Storage, root *types.Issue, children []*types.Issue, keepChildren bool, actorName string) (*SquashResult, error) {
|
// If summary is provided (non-empty), it's used as the digest content.
|
||||||
|
// Otherwise, generateDigest() creates a basic concatenation.
|
||||||
|
// This enables agents to provide AI-generated summaries while keeping bd as a pure tool.
|
||||||
|
func squashMolecule(ctx context.Context, s storage.Storage, root *types.Issue, children []*types.Issue, keepChildren bool, summary string, actorName string) (*SquashResult, error) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return nil, fmt.Errorf("no database connection")
|
return nil, fmt.Errorf("no database connection")
|
||||||
}
|
}
|
||||||
@@ -216,8 +227,13 @@ func squashMolecule(ctx context.Context, s storage.Storage, root *types.Issue, c
|
|||||||
childIDs[i] = c.ID
|
childIDs[i] = c.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate digest content
|
// Use agent-provided summary if available, otherwise generate basic digest
|
||||||
digestContent := generateDigest(root, children)
|
var digestContent string
|
||||||
|
if summary != "" {
|
||||||
|
digestContent = summary
|
||||||
|
} else {
|
||||||
|
digestContent = generateDigest(root, children)
|
||||||
|
}
|
||||||
|
|
||||||
// Create digest issue (non-ephemeral)
|
// Create digest issue (non-ephemeral)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -301,6 +317,7 @@ func deleteEphemeralChildren(ctx context.Context, s storage.Storage, ids []strin
|
|||||||
func init() {
|
func init() {
|
||||||
molSquashCmd.Flags().Bool("dry-run", false, "Preview what would be squashed")
|
molSquashCmd.Flags().Bool("dry-run", false, "Preview what would be squashed")
|
||||||
molSquashCmd.Flags().Bool("keep-children", false, "Don't delete ephemeral children after squash")
|
molSquashCmd.Flags().Bool("keep-children", false, "Don't delete ephemeral children after squash")
|
||||||
|
molSquashCmd.Flags().String("summary", "", "Agent-provided summary (bypasses auto-generation)")
|
||||||
|
|
||||||
molCmd.AddCommand(molSquashCmd)
|
molCmd.AddCommand(molSquashCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-2
@@ -527,7 +527,7 @@ func TestSquashMolecule(t *testing.T) {
|
|||||||
|
|
||||||
// Test squash with keep-children
|
// Test squash with keep-children
|
||||||
children := []*types.Issue{child1, child2}
|
children := []*types.Issue{child1, child2}
|
||||||
result, err := squashMolecule(ctx, s, root, children, true, "test")
|
result, err := squashMolecule(ctx, s, root, children, true, "", "test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("squashMolecule failed: %v", err)
|
t.Fatalf("squashMolecule failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -609,7 +609,7 @@ func TestSquashMoleculeWithDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Squash with delete (keepChildren=false)
|
// Squash with delete (keepChildren=false)
|
||||||
result, err := squashMolecule(ctx, s, root, []*types.Issue{child}, false, "test")
|
result, err := squashMolecule(ctx, s, root, []*types.Issue{child}, false, "", "test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("squashMolecule failed: %v", err)
|
t.Fatalf("squashMolecule failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -674,3 +674,134 @@ func TestGenerateDigest(t *testing.T) {
|
|||||||
t.Error("Digest should include close reasons")
|
t.Error("Digest should include close reasons")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSquashMoleculeWithAgentSummary verifies that agent-provided summaries are used
|
||||||
|
func TestSquashMoleculeWithAgentSummary(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dbPath := t.TempDir() + "/test.db"
|
||||||
|
s, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to set config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a molecule with ephemeral child
|
||||||
|
root := &types.Issue{
|
||||||
|
Title: "Agent Summary Test",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeEpic,
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, root, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create root: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
child := &types.Issue{
|
||||||
|
Title: "Ephemeral Step",
|
||||||
|
Description: "This should NOT appear in digest",
|
||||||
|
Status: types.StatusClosed,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
Ephemeral: true,
|
||||||
|
CloseReason: "Done",
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, child, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create child: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.AddDependency(ctx, &types.Dependency{
|
||||||
|
IssueID: child.ID,
|
||||||
|
DependsOnID: root.ID,
|
||||||
|
Type: types.DepParentChild,
|
||||||
|
}, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to add dependency: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Squash with agent-provided summary
|
||||||
|
agentSummary := "## AI-Generated Summary\n\nThe agent completed the task successfully."
|
||||||
|
result, err := squashMolecule(ctx, s, root, []*types.Issue{child}, true, agentSummary, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("squashMolecule failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify digest uses agent summary, not auto-generated content
|
||||||
|
digest, err := s.GetIssue(ctx, result.DigestID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get digest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if digest.Description != agentSummary {
|
||||||
|
t.Errorf("Digest should use agent summary.\nGot: %s\nWant: %s", digest.Description, agentSummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify auto-generated content is NOT present
|
||||||
|
if strings.Contains(digest.Description, "Ephemeral Step") {
|
||||||
|
t.Error("Digest should NOT contain auto-generated content when agent summary provided")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEphemeralFilteringFromExport verifies that ephemeral issues are filtered
|
||||||
|
// from JSONL export (bd-687g). Ephemeral issues should only exist in SQLite,
|
||||||
|
// not in issues.jsonl, to prevent "zombie" resurrection after mol squash.
|
||||||
|
func TestEphemeralFilteringFromExport(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dbPath := t.TempDir() + "/test.db"
|
||||||
|
s, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to set config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a mix of ephemeral and non-ephemeral issues
|
||||||
|
normalIssue := &types.Issue{
|
||||||
|
Title: "Normal Issue",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
Ephemeral: false,
|
||||||
|
}
|
||||||
|
ephemeralIssue := &types.Issue{
|
||||||
|
Title: "Ephemeral Issue",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
Ephemeral: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.CreateIssue(ctx, normalIssue, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create normal issue: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.CreateIssue(ctx, ephemeralIssue, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create ephemeral issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all issues from DB - should include both
|
||||||
|
allIssues, err := s.SearchIssues(ctx, "", types.IssueFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to search issues: %v", err)
|
||||||
|
}
|
||||||
|
if len(allIssues) != 2 {
|
||||||
|
t.Fatalf("Expected 2 issues in DB, got %d", len(allIssues))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter ephemeral issues (simulating export behavior)
|
||||||
|
exportableIssues := make([]*types.Issue, 0)
|
||||||
|
for _, issue := range allIssues {
|
||||||
|
if !issue.Ephemeral {
|
||||||
|
exportableIssues = append(exportableIssues, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only have the non-ephemeral issue
|
||||||
|
if len(exportableIssues) != 1 {
|
||||||
|
t.Errorf("Expected 1 exportable issue, got %d", len(exportableIssues))
|
||||||
|
}
|
||||||
|
if exportableIssues[0].ID != normalIssue.ID {
|
||||||
|
t.Errorf("Expected normal issue %s, got %s", normalIssue.ID, exportableIssues[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -236,6 +236,57 @@ func TestDeleteIssue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDeleteIssueWithComments verifies that DeleteIssue also removes comments (bd-687g)
|
||||||
|
func TestDeleteIssueWithComments(t *testing.T) {
|
||||||
|
store := newTestStore(t, "file::memory:?mode=memory&cache=private")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
issue := &types.Issue{
|
||||||
|
ID: "bd-1",
|
||||||
|
Title: "Issue with Comments",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a comment to the comments table (not events)
|
||||||
|
if _, err := store.AddIssueComment(ctx, "bd-1", "test-author", "This is a test comment"); err != nil {
|
||||||
|
t.Fatalf("Failed to add comment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify comment exists
|
||||||
|
commentsMap, err := store.GetCommentsForIssues(ctx, []string{"bd-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get comments: %v", err)
|
||||||
|
}
|
||||||
|
if len(commentsMap["bd-1"]) != 1 {
|
||||||
|
t.Fatalf("Expected 1 comment, got %d", len(commentsMap["bd-1"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the issue
|
||||||
|
if err := store.DeleteIssue(ctx, "bd-1"); err != nil {
|
||||||
|
t.Fatalf("DeleteIssue failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify issue deleted
|
||||||
|
if issue, _ := store.GetIssue(ctx, "bd-1"); issue != nil {
|
||||||
|
t.Error("Issue should be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify comments also deleted (should not leak)
|
||||||
|
commentsMap, err = store.GetCommentsForIssues(ctx, []string{"bd-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get comments after delete: %v", err)
|
||||||
|
}
|
||||||
|
if len(commentsMap["bd-1"]) != 0 {
|
||||||
|
t.Errorf("Comments should be deleted, but found %d", len(commentsMap["bd-1"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildIDSet(t *testing.T) {
|
func TestBuildIDSet(t *testing.T) {
|
||||||
ids := []string{"bd-1", "bd-2", "bd-3"}
|
ids := []string{"bd-1", "bd-2", "bd-3"}
|
||||||
idSet := buildIDSet(ids)
|
idSet := buildIDSet(ids)
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ func (s *SQLiteStorage) ExportToMultiRepo(ctx context.Context) (map[string]int,
|
|||||||
issue.Labels = labels
|
issue.Labels = labels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter out ephemeral issues - they should never be exported to JSONL (bd-687g)
|
||||||
|
// Ephemeral issues exist only in SQLite and are shared via .beads/redirect, not JSONL.
|
||||||
|
filtered := make([]*types.Issue, 0, len(allIssues))
|
||||||
|
for _, issue := range allIssues {
|
||||||
|
if !issue.Ephemeral {
|
||||||
|
filtered = append(filtered, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allIssues = filtered
|
||||||
|
|
||||||
// Group issues by source_repo
|
// Group issues by source_repo
|
||||||
issuesByRepo := make(map[string][]*types.Issue)
|
issuesByRepo := make(map[string][]*types.Issue)
|
||||||
for _, issue := range allIssues {
|
for _, issue := range allIssues {
|
||||||
|
|||||||
@@ -1093,6 +1093,12 @@ func (s *SQLiteStorage) DeleteIssue(ctx context.Context, id string) error {
|
|||||||
return fmt.Errorf("failed to delete events: %w", err)
|
return fmt.Errorf("failed to delete events: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete comments (no FK cascade on this table) (bd-687g)
|
||||||
|
_, err = tx.ExecContext(ctx, `DELETE FROM comments WHERE issue_id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete comments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Delete from dirty_issues
|
// Delete from dirty_issues
|
||||||
_, err = tx.ExecContext(ctx, `DELETE FROM dirty_issues WHERE issue_id = ?`, id)
|
_, err = tx.ExecContext(ctx, `DELETE FROM dirty_issues WHERE issue_id = ?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user