feat: add session_id field to issue close/update mutations (bd-tksk)

Adds closed_by_session tracking for entity CV building per Gas Town
decision 009-session-events-architecture.md.

Changes:
- Add ClosedBySession field to Issue struct
- Add closed_by_session column to issues table (migration 034)
- Add --session flag to bd close command
- Support CLAUDE_SESSION_ID env var as fallback
- Add --session flag to bd update for status=closed
- Display closed_by_session in bd show output
- Update Storage interface to include session parameter in CloseIssue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
This commit is contained in:
beads/crew/dave
2025-12-31 13:13:49 -08:00
committed by Steve Yegge
parent 7c9b975436
commit b362b36824
42 changed files with 165 additions and 82 deletions

View File

@@ -126,7 +126,7 @@ func TestCacheInvalidationOnStatusChange(t *testing.T) {
}
// Close the blocker
if err := store.CloseIssue(ctx, blocker.ID, "Done", "test-user"); err != nil {
if err := store.CloseIssue(ctx, blocker.ID, "Done", "test-user", ""); err != nil {
t.Fatalf("CloseIssue failed: %v", err)
}
@@ -186,7 +186,7 @@ func TestCacheConsistencyAcrossOperations(t *testing.T) {
}
// Operation 3: Close blocker1
store.CloseIssue(ctx, blocker1.ID, "Done", "test-user")
store.CloseIssue(ctx, blocker1.ID, "Done", "test-user", "")
cached = getCachedBlockedIssues(t, store)
if cached[blocked1.ID] || !cached[blocked2.ID] {
@@ -322,7 +322,7 @@ func TestDeepHierarchyCacheCorrectness(t *testing.T) {
}
// Close the blocker and verify all become unblocked
store.CloseIssue(ctx, blocker.ID, "Done", "test-user")
store.CloseIssue(ctx, blocker.ID, "Done", "test-user", "")
cached = getCachedBlockedIssues(t, store)
if len(cached) != 0 {
@@ -359,7 +359,7 @@ func TestMultipleBlockersInCache(t *testing.T) {
}
// Close one blocker - should still be blocked
store.CloseIssue(ctx, blocker1.ID, "Done", "test-user")
store.CloseIssue(ctx, blocker1.ID, "Done", "test-user", "")
cached = getCachedBlockedIssues(t, store)
if !cached[blocked.ID] {
@@ -367,7 +367,7 @@ func TestMultipleBlockersInCache(t *testing.T) {
}
// Close the second blocker - should be unblocked
store.CloseIssue(ctx, blocker2.ID, "Done", "test-user")
store.CloseIssue(ctx, blocker2.ID, "Done", "test-user", "")
cached = getCachedBlockedIssues(t, store)
if cached[blocked.ID] {
@@ -400,7 +400,7 @@ func TestConditionalBlocksCache(t *testing.T) {
}
// Close A with SUCCESS (no failure keywords) - B should STILL be blocked
store.CloseIssue(ctx, issueA.ID, "Completed successfully", "test-user")
store.CloseIssue(ctx, issueA.ID, "Completed successfully", "test-user", "")
cached = getCachedBlockedIssues(t, store)
if !cached[issueB.ID] {
@@ -411,7 +411,7 @@ func TestConditionalBlocksCache(t *testing.T) {
store.UpdateIssue(ctx, issueA.ID, map[string]interface{}{"status": types.StatusOpen}, "test-user")
// Close A with FAILURE - B should now be UNBLOCKED
store.CloseIssue(ctx, issueA.ID, "Task failed due to timeout", "test-user")
store.CloseIssue(ctx, issueA.ID, "Task failed due to timeout", "test-user", "")
cached = getCachedBlockedIssues(t, store)
if cached[issueB.ID] {
@@ -451,7 +451,7 @@ func TestConditionalBlocksVariousFailureKeywords(t *testing.T) {
store.AddDependency(ctx, dep, "test-user")
// Close A with failure reason
store.CloseIssue(ctx, issueA.ID, "Closed: "+reason, "test-user")
store.CloseIssue(ctx, issueA.ID, "Closed: "+reason, "test-user", "")
cached := getCachedBlockedIssues(t, store)
if cached[issueB.ID] {
@@ -501,7 +501,7 @@ func TestWaitsForAllChildren(t *testing.T) {
}
// Close first child - waiter should still be blocked (second child still open)
store.CloseIssue(ctx, child1.ID, "Done", "test-user")
store.CloseIssue(ctx, child1.ID, "Done", "test-user", "")
cached = getCachedBlockedIssues(t, store)
if !cached[waiter.ID] {
@@ -509,7 +509,7 @@ func TestWaitsForAllChildren(t *testing.T) {
}
// Close second child - waiter should now be unblocked
store.CloseIssue(ctx, child2.ID, "Done", "test-user")
store.CloseIssue(ctx, child2.ID, "Done", "test-user", "")
cached = getCachedBlockedIssues(t, store)
if cached[waiter.ID] {
@@ -557,7 +557,7 @@ func TestWaitsForAnyChildren(t *testing.T) {
}
// Close first child - waiter should now be unblocked (any-children gate satisfied)
store.CloseIssue(ctx, child1.ID, "Done", "test-user")
store.CloseIssue(ctx, child1.ID, "Done", "test-user", "")
cached = getCachedBlockedIssues(t, store)
if cached[waiter.ID] {
@@ -636,7 +636,7 @@ func TestWaitsForDynamicChildrenAdded(t *testing.T) {
}
// Close the child - waiter should be unblocked again
store.CloseIssue(ctx, child.ID, "Done", "test-user")
store.CloseIssue(ctx, child.ID, "Done", "test-user", "")
cached = getCachedBlockedIssues(t, store)
if cached[waiter.ID] {

View File

@@ -57,7 +57,7 @@ func (h *epicTestHelper) addParentChildDependency(childID, parentID string) {
}
func (h *epicTestHelper) closeIssue(id, reason string) {
if err := h.store.CloseIssue(h.ctx, id, reason, "test-user"); err != nil {
if err := h.store.CloseIssue(h.ctx, id, reason, "test-user", ""); err != nil {
h.t.Fatalf("CloseIssue (%s) failed: %v", id, err)
}
}

View File

@@ -311,7 +311,7 @@ func TestEventTypesInHistory(t *testing.T) {
t.Fatalf("AddLabel failed: %v", err)
}
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "")
if err != nil {
t.Fatalf("CloseIssue failed: %v", err)
}

View File

@@ -179,7 +179,7 @@ func TestDuplicateOf(t *testing.T) {
}
// Close the duplicate
if err := store.CloseIssue(ctx, duplicate.ID, "Closed as duplicate", "test"); err != nil {
if err := store.CloseIssue(ctx, duplicate.ID, "Closed as duplicate", "test", ""); err != nil {
t.Fatalf("Failed to close duplicate: %v", err)
}
@@ -251,7 +251,7 @@ func TestSupersededBy(t *testing.T) {
}
// Close old version
if err := store.CloseIssue(ctx, oldVersion.ID, "Superseded by v2", "test"); err != nil {
if err := store.CloseIssue(ctx, oldVersion.ID, "Superseded by v2", "test", ""); err != nil {
t.Fatalf("Failed to close old version: %v", err)
}

View File

@@ -50,6 +50,7 @@ var migrationsList = []Migration{
{"mol_type_column", migrations.MigrateMolTypeColumn},
{"hooked_status_migration", migrations.MigrateHookedStatus},
{"event_fields", migrations.MigrateEventFields},
{"closed_by_session_column", migrations.MigrateClosedBySessionColumn},
}
// MigrationInfo contains metadata about a migration for inspection
@@ -107,6 +108,7 @@ func getMigrationDescription(name string) string {
"mol_type_column": "Adds mol_type column for molecule type classification (swarm/patrol/work)",
"hooked_status_migration": "Migrates blocked hooked issues to in_progress status",
"event_fields": "Adds event fields (event_kind, actor, target, payload) for operational state change beads",
"closed_by_session_column": "Adds closed_by_session column for tracking which Claude Code session closed an issue",
}
if desc, ok := descriptions[name]; ok {

View File

@@ -0,0 +1,34 @@
package migrations
import (
"database/sql"
"fmt"
)
// MigrateClosedBySessionColumn adds the closed_by_session column to the issues table.
// This tracks which Claude Code session closed the issue, enabling work attribution
// for entity CV building. See Gas Town decision 009-session-events-architecture.md.
func MigrateClosedBySessionColumn(db *sql.DB) error {
// Check if column already exists
var columnExists bool
err := db.QueryRow(`
SELECT COUNT(*) > 0
FROM pragma_table_info('issues')
WHERE name = 'closed_by_session'
`).Scan(&columnExists)
if err != nil {
return fmt.Errorf("failed to check closed_by_session column: %w", err)
}
if columnExists {
return nil
}
// Add the closed_by_session column
_, err = db.Exec(`ALTER TABLE issues ADD COLUMN closed_by_session TEXT DEFAULT ''`)
if err != nil {
return fmt.Errorf("failed to add closed_by_session column: %w", err)
}
return nil
}

View File

@@ -664,6 +664,8 @@ var allowedUpdateFields = map[string]bool{
"estimated_minutes": true,
"external_ref": true,
"closed_at": true,
"close_reason": true,
"closed_by_session": true,
// Messaging fields
"sender": true,
"wisp": true, // Database column is 'ephemeral', mapped in UpdateIssue
@@ -1070,8 +1072,9 @@ func (s *SQLiteStorage) ResetCounter(ctx context.Context, prefix string) error {
return nil
}
// CloseIssue closes an issue with a reason
func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error {
// CloseIssue closes an issue with a reason.
// The session parameter tracks which Claude Code session closed the issue (can be empty).
func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error {
now := time.Now()
// Update with special event handling
@@ -1086,9 +1089,9 @@ func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string
// 2. events.comment - for audit history (when was it closed, by whom)
// Keep both in sync. If refactoring, consider deriving one from the other.
result, err := tx.ExecContext(ctx, `
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?, closed_by_session = ?
WHERE id = ?
`, types.StatusClosed, now, now, reason, id)
`, types.StatusClosed, now, now, reason, session, id)
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}

View File

@@ -515,7 +515,7 @@ func TestDeepHierarchyBlocking(t *testing.T) {
}
// Now close the blocker and verify all levels become ready
store.CloseIssue(ctx, blocker.ID, "Done", "test-user")
store.CloseIssue(ctx, blocker.ID, "Done", "test-user", "")
ready, err = store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
if err != nil {
@@ -564,7 +564,7 @@ func TestGetReadyWorkIncludesInProgress(t *testing.T) {
store.UpdateIssue(ctx, issue3.ID, map[string]interface{}{"status": types.StatusInProgress}, "test-user")
store.CreateIssue(ctx, issue4, "test-user")
store.CreateIssue(ctx, issue5, "test-user")
store.CloseIssue(ctx, issue5.ID, "Done", "test-user")
store.CloseIssue(ctx, issue5.ID, "Done", "test-user", "")
// Add dependency: issue3 blocks on issue4
store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue4.ID, Type: types.DepBlocks}, "test-user")
@@ -1018,7 +1018,7 @@ func TestGetReadyWorkExternalDeps(t *testing.T) {
}
// Close the capability issue
if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user"); err != nil {
if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user", ""); err != nil {
t.Fatalf("failed to close capability issue: %v", err)
}
@@ -1246,7 +1246,7 @@ func TestGetBlockedIssuesFiltersExternalDeps(t *testing.T) {
}
// Close the capability issue
if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user"); err != nil {
if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user", ""); err != nil {
t.Fatalf("failed to close capability issue: %v", err)
}
@@ -1371,7 +1371,7 @@ func TestGetBlockedIssuesPartialExternalDeps(t *testing.T) {
if err := externalStore.AddLabel(ctx, cap1Issue.ID, "provides:cap1", "test-user"); err != nil {
t.Fatalf("failed to add provides label: %v", err)
}
if err := externalStore.CloseIssue(ctx, cap1Issue.ID, "Shipped", "test-user"); err != nil {
if err := externalStore.CloseIssue(ctx, cap1Issue.ID, "Shipped", "test-user", ""); err != nil {
t.Fatalf("failed to close cap1 issue: %v", err)
}

View File

@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS issues (
created_by TEXT DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME,
closed_by_session TEXT DEFAULT '',
external_ref TEXT,
compaction_level INTEGER DEFAULT 0,
compacted_at DATETIME,

View File

@@ -180,7 +180,7 @@ func BenchmarkBulkCloseIssues(b *testing.B) {
for i := 0; i < b.N; i++ {
for j, issue := range issues {
if err := store.CloseIssue(ctx, issue.ID, "Bulk closed", "bench"); err != nil {
if err := store.CloseIssue(ctx, issue.ID, "Bulk closed", "bench", ""); err != nil {
b.Fatalf("CloseIssue failed: %v", err)
}
// Re-open for next iteration (except last one)

View File

@@ -640,7 +640,7 @@ func TestCloseIssue(t *testing.T) {
t.Fatalf("CreateIssue failed: %v", err)
}
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "")
if err != nil {
t.Fatalf("CloseIssue failed: %v", err)
}
@@ -717,7 +717,7 @@ func TestClosedAtInvariant(t *testing.T) {
}
// Close the issue
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "")
if err != nil {
t.Fatalf("CloseIssue failed: %v", err)
}
@@ -812,7 +812,7 @@ func TestSearchIssues(t *testing.T) {
}
// Close the third issue
if issue.Title == "Another bug" {
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "")
if err != nil {
t.Fatalf("CloseIssue failed: %v", err)
}
@@ -977,7 +977,7 @@ func TestGetStatistics(t *testing.T) {
}
// Close the one that should be closed
if issue.Title == "Closed task" {
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user", "")
if err != nil {
t.Fatalf("CloseIssue failed: %v", err)
}
@@ -1532,7 +1532,7 @@ func TestConvoyReactiveCompletion(t *testing.T) {
}
// Close first issue - convoy should still be open
err = store.CloseIssue(ctx, issue1.ID, "Done", "test-user")
err = store.CloseIssue(ctx, issue1.ID, "Done", "test-user", "")
if err != nil {
t.Fatalf("CloseIssue issue1 failed: %v", err)
}
@@ -1546,7 +1546,7 @@ func TestConvoyReactiveCompletion(t *testing.T) {
}
// Close second issue - convoy should auto-close now
err = store.CloseIssue(ctx, issue2.ID, "Done", "test-user")
err = store.CloseIssue(ctx, issue2.ID, "Done", "test-user", "")
if err != nil {
t.Fatalf("CloseIssue issue2 failed: %v", err)
}

View File

@@ -105,7 +105,7 @@ func (e *testEnv) AddParentChild(child, parent *types.Issue) {
// Close closes the issue with the given reason.
func (e *testEnv) Close(issue *types.Issue, reason string) {
e.t.Helper()
if err := e.Store.CloseIssue(e.Ctx, issue.ID, reason, "test-user"); err != nil {
if err := e.Store.CloseIssue(e.Ctx, issue.ID, reason, "test-user", ""); err != nil {
e.t.Fatalf("CloseIssue(%s) failed: %v", issue.ID, err)
}
}

View File

@@ -72,7 +72,7 @@ func TestCreateTombstone(t *testing.T) {
}
// Close the issue to set closed_at
if err := store.CloseIssue(ctx, "bd-closed-1", "closing for test", "tester"); err != nil {
if err := store.CloseIssue(ctx, "bd-closed-1", "closing for test", "tester", ""); err != nil {
t.Fatalf("Failed to close issue: %v", err)
}

View File

@@ -523,13 +523,14 @@ func applyUpdatesToIssue(issue *types.Issue, updates map[string]interface{}) {
// CloseIssue closes an issue within the transaction.
// NOTE: close_reason is stored in both issues table and events table - see SQLiteStorage.CloseIssue.
func (t *sqliteTxStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error {
// The session parameter tracks which Claude Code session closed the issue (can be empty).
func (t *sqliteTxStorage) CloseIssue(ctx context.Context, id string, reason string, actor string, session string) error {
now := time.Now()
result, err := t.conn.ExecContext(ctx, `
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?, close_reason = ?, closed_by_session = ?
WHERE id = ?
`, types.StatusClosed, now, now, reason, id)
`, types.StatusClosed, now, now, reason, session, id)
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}

View File

@@ -254,7 +254,7 @@ func TestTransactionCloseIssue(t *testing.T) {
// Close in transaction
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error {
return tx.CloseIssue(ctx, issue.ID, "Done", "test-actor")
return tx.CloseIssue(ctx, issue.ID, "Done", "test-actor", "")
})
if err != nil {