feat: add gate issue type and CLI commands for async coordination (bd-udsi)

Add async gates - coordination primitives for agents to wait on external events
like CI completion, PR merges, timers, or human approval.

Changes:
- Add 'gate' issue type to types.go with gate-specific fields:
  - AwaitType: condition type (gh:run, gh:pr, timer, human, mail)
  - AwaitID: condition identifier
  - Timeout: max wait duration
  - Waiters: mail addresses to notify when gate clears
- Add SQLite migration 027_gate_columns for new fields
- Update all SQLite storage queries to handle gate fields
- Add bd gate commands: create, show, list, close, wait
- All commands support --json output and --no-daemon mode

Closes: bd-2v0f, bd-lz49, bd-u66e

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 12:06:42 -08:00
parent cfd412b2d7
commit 47b86b35d8
18 changed files with 756 additions and 24 deletions

View File

@@ -428,7 +428,7 @@ func init() {
countCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
countCmd.Flags().IntP("priority", "p", 0, "Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)")
countCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
countCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
countCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate)")
countCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)")
countCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE)")
countCmd.Flags().String("title", "", "Filter by title text (case-insensitive substring match)")

View File

@@ -430,7 +430,7 @@ func init() {
createCmd.Flags().String("title", "", "Issue title (alternative to positional argument)")
createCmd.Flags().Bool("silent", false, "Output only the issue ID (for scripting)")
registerPriorityFlag(createCmd, "2")
createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule)")
createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule|gate)")
registerCommonIssueFlags(createCmd)
createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)")
createCmd.Flags().StringSlice("label", []string{}, "Alias for --labels")

View File

@@ -537,7 +537,7 @@ func init() {
// Filter flags
exportCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
exportCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
exportCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate)")
exportCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)")
exportCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE)")

524
cmd/bd/gate.go Normal file
View File

@@ -0,0 +1,524 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
// Gate commands - async coordination primitives for agent workflows (bd-udsi)
//
// Gates are wisp issues that block until external conditions are met.
// They are managed by the Deacon patrol and enable agents to wait on:
// - GitHub Actions run completion (gh:run:<id>)
// - Pull request merge/close (gh:pr:<id>)
// - Simple timer delay (timer:<duration>)
// - Human approval (human:<prompt>)
// - Mail matching a pattern (mail:<pattern>)
//
// Usage:
// bd gate create --await gh:run:123 --timeout 30m --notify beads/dave
// bd gate show <id>
// bd gate list
// bd gate close <id> --reason "completed"
// bd gate wait <id> --notify beads/alice
var gateCmd = &cobra.Command{
Use: "gate",
Short: "Gate commands (async coordination)",
Long: `Manage gates - async coordination primitives for agent workflows.
Gates are ephemeral (wisp) issues that block until external conditions are met.
They are typically managed by the Deacon patrol.
Await types:
gh:run:<id> Wait for GitHub Actions run to complete
gh:pr:<id> Wait for pull request to be merged/closed
timer:<duration> Simple delay (e.g., timer:30m, timer:1h)
human:<prompt> Wait for human approval
mail:<pattern> Wait for mail matching pattern
Commands:
create Create a new gate
show Show gate details
list List open gates
close Close a gate
wait Add a waiter to an existing gate`,
}
var gateCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new gate",
Long: `Create a new gate to wait on an external condition.
The gate will be created as a wisp issue (ephemeral) and assigned to the
Deacon patrol for monitoring.
Examples:
bd gate create --await gh:run:123456789 --timeout 30m --notify beads/dave
bd gate create --await gh:pr:42 --timeout 1h --notify beads/dave beads/alice
bd gate create --await timer:15m --notify beads/dave
bd gate create --await human:approve-deploy --timeout 4h --notify beads/dave`,
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("gate create")
ctx := rootCtx
awaitSpec, _ := cmd.Flags().GetString("await")
timeoutStr, _ := cmd.Flags().GetString("timeout")
notifyAddrs, _ := cmd.Flags().GetStringSlice("notify")
title, _ := cmd.Flags().GetString("title")
if awaitSpec == "" {
fmt.Fprintf(os.Stderr, "Error: --await is required\n")
os.Exit(1)
}
// Parse await spec into type and ID
awaitType, awaitID := parseAwaitSpec(awaitSpec)
if awaitType == "" {
fmt.Fprintf(os.Stderr, "Error: invalid await spec %q, expected format: type:id\n", awaitSpec)
fmt.Fprintf(os.Stderr, "Valid types: gh:run, gh:pr, timer, human, mail\n")
os.Exit(1)
}
// Parse timeout
var timeout time.Duration
if timeoutStr != "" {
var err error
timeout, err = time.ParseDuration(timeoutStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid timeout %q: %v\n", timeoutStr, err)
os.Exit(1)
}
}
// Generate title if not provided
if title == "" {
title = fmt.Sprintf("Gate: %s:%s", awaitType, awaitID)
}
// Gate creation requires direct store access
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: gate create requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon gate create ...\n")
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
now := time.Now()
gate := &types.Issue{
// ID will be generated by CreateIssue
Title: title,
IssueType: types.TypeGate,
Status: types.StatusOpen,
Priority: 1, // Gates are typically high priority
Assignee: "deacon/",
Wisp: true, // Gates are wisps (ephemeral)
AwaitType: awaitType,
AwaitID: awaitID,
Timeout: timeout,
Waiters: notifyAddrs,
CreatedAt: now,
UpdatedAt: now,
}
gate.ContentHash = gate.ComputeContentHash()
if err := store.CreateIssue(ctx, gate, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error creating gate: %v\n", err)
os.Exit(1)
}
markDirtyAndScheduleFlush()
if jsonOutput {
outputJSON(gate)
return
}
fmt.Printf("%s Created gate: %s\n", ui.RenderPass("✓"), gate.ID)
fmt.Printf(" Await: %s:%s\n", awaitType, awaitID)
if timeout > 0 {
fmt.Printf(" Timeout: %v\n", timeout)
}
if len(notifyAddrs) > 0 {
fmt.Printf(" Notify: %s\n", strings.Join(notifyAddrs, ", "))
}
},
}
// parseAwaitSpec parses an await specification like "gh:run:123" or "timer:30m"
// Returns (awaitType, awaitID) or ("", "") if invalid
func parseAwaitSpec(spec string) (string, string) {
// Handle compound types like gh:run:123 and gh:pr:42
if strings.HasPrefix(spec, "gh:run:") {
return "gh:run", strings.TrimPrefix(spec, "gh:run:")
}
if strings.HasPrefix(spec, "gh:pr:") {
return "gh:pr", strings.TrimPrefix(spec, "gh:pr:")
}
// Handle simple types like timer:30m, human:approve, mail:pattern
parts := strings.SplitN(spec, ":", 2)
if len(parts) != 2 {
return "", ""
}
awaitType := parts[0]
awaitID := parts[1]
// Validate type
validTypes := map[string]bool{
"timer": true,
"human": true,
"mail": true,
}
if !validTypes[awaitType] {
return "", ""
}
return awaitType, awaitID
}
var gateShowCmd = &cobra.Command{
Use: "show <gate-id>",
Short: "Show gate details",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
// Gate show requires direct store access
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: gate show requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon gate show %s\n", args[0])
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
gateID, err := utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
gate, err := store.GetIssue(ctx, gateID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if gate == nil {
fmt.Fprintf(os.Stderr, "Error: gate %s not found\n", gateID)
os.Exit(1)
}
if gate.IssueType != types.TypeGate {
fmt.Fprintf(os.Stderr, "Error: %s is not a gate (type: %s)\n", gateID, gate.IssueType)
os.Exit(1)
}
if jsonOutput {
outputJSON(gate)
return
}
fmt.Printf("\n%s Gate: %s\n", ui.RenderAccent("🚧"), gate.ID)
fmt.Printf(" Title: %s\n", gate.Title)
fmt.Printf(" Status: %s\n", gate.Status)
fmt.Printf(" Await: %s:%s\n", gate.AwaitType, gate.AwaitID)
if gate.Timeout > 0 {
elapsed := time.Since(gate.CreatedAt)
remaining := gate.Timeout - elapsed
if remaining < 0 {
remaining = 0
}
fmt.Printf(" Timeout: %v (remaining: %v)\n", gate.Timeout, remaining.Round(time.Second))
}
if len(gate.Waiters) > 0 {
fmt.Printf(" Waiters: %s\n", strings.Join(gate.Waiters, ", "))
}
fmt.Printf(" Created: %s\n", gate.CreatedAt.Format("2006-01-02 15:04:05"))
if gate.CloseReason != "" {
fmt.Printf(" Close reason: %s\n", gate.CloseReason)
}
fmt.Println()
},
}
var gateListCmd = &cobra.Command{
Use: "list",
Short: "List open gates",
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
showAll, _ := cmd.Flags().GetBool("all")
// Gate list requires direct store access
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: gate list requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon gate list\n")
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
// Build filter for gates
gateType := types.TypeGate
filter := types.IssueFilter{
IssueType: &gateType,
}
if !showAll {
openStatus := types.StatusOpen
filter.Status = &openStatus
}
issues, err := store.SearchIssues(ctx, "", filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error listing gates: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(issues)
return
}
if len(issues) == 0 {
fmt.Println("No gates found")
return
}
fmt.Printf("\n%s Gates (%d):\n\n", ui.RenderAccent("🚧"), len(issues))
for _, gate := range issues {
statusIcon := "⏳"
if gate.Status == types.StatusClosed {
statusIcon = "✓"
}
awaitSpec := fmt.Sprintf("%s:%s", gate.AwaitType, gate.AwaitID)
timeoutInfo := ""
if gate.Timeout > 0 {
elapsed := time.Since(gate.CreatedAt)
remaining := gate.Timeout - elapsed
if remaining < 0 {
timeoutInfo = " (timed out)"
} else {
timeoutInfo = fmt.Sprintf(" (%v left)", remaining.Round(time.Second))
}
}
fmt.Printf(" %s %s: %s%s\n", statusIcon, gate.ID, awaitSpec, timeoutInfo)
if len(gate.Waiters) > 0 {
fmt.Printf(" Waiters: %s\n", strings.Join(gate.Waiters, ", "))
}
}
fmt.Println()
},
}
var gateCloseCmd = &cobra.Command{
Use: "close <gate-id>",
Short: "Close a gate",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("gate close")
ctx := rootCtx
reason, _ := cmd.Flags().GetString("reason")
if reason == "" {
reason = "Gate closed"
}
// Gate close requires direct store access
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: gate close requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon gate close %s\n", args[0])
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
gateID, err := utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Verify it's a gate
gate, err := store.GetIssue(ctx, gateID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if gate == nil {
fmt.Fprintf(os.Stderr, "Error: gate %s not found\n", gateID)
os.Exit(1)
}
if gate.IssueType != types.TypeGate {
fmt.Fprintf(os.Stderr, "Error: %s is not a gate (type: %s)\n", gateID, gate.IssueType)
os.Exit(1)
}
if err := store.CloseIssue(ctx, gateID, reason, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err)
os.Exit(1)
}
markDirtyAndScheduleFlush()
if jsonOutput {
closedGate, _ := store.GetIssue(ctx, gateID)
outputJSON(closedGate)
return
}
fmt.Printf("%s Closed gate: %s\n", ui.RenderPass("✓"), gateID)
fmt.Printf(" Reason: %s\n", reason)
},
}
var gateWaitCmd = &cobra.Command{
Use: "wait <gate-id>",
Short: "Add a waiter to an existing gate",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("gate wait")
ctx := rootCtx
notifyAddrs, _ := cmd.Flags().GetStringSlice("notify")
if len(notifyAddrs) == 0 {
fmt.Fprintf(os.Stderr, "Error: --notify is required\n")
os.Exit(1)
}
// Gate wait requires direct store access for now
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: gate wait requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon gate wait %s --notify ...\n", args[0])
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
gateID, err := utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Get existing gate
gate, err := store.GetIssue(ctx, gateID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if gate == nil {
fmt.Fprintf(os.Stderr, "Error: gate %s not found\n", gateID)
os.Exit(1)
}
if gate.IssueType != types.TypeGate {
fmt.Fprintf(os.Stderr, "Error: %s is not a gate (type: %s)\n", gateID, gate.IssueType)
os.Exit(1)
}
if gate.Status == types.StatusClosed {
fmt.Fprintf(os.Stderr, "Error: gate %s is already closed\n", gateID)
os.Exit(1)
}
// Add new waiters (avoiding duplicates)
waiterSet := make(map[string]bool)
for _, w := range gate.Waiters {
waiterSet[w] = true
}
newWaiters := []string{}
for _, addr := range notifyAddrs {
if !waiterSet[addr] {
newWaiters = append(newWaiters, addr)
waiterSet[addr] = true
}
}
if len(newWaiters) == 0 {
fmt.Println("All specified waiters are already registered on this gate")
return
}
// Update waiters - need to use SQLite directly for Waiters field
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
if !ok {
fmt.Fprintf(os.Stderr, "Error: gate wait requires SQLite storage\n")
os.Exit(1)
}
allWaiters := append(gate.Waiters, newWaiters...)
waitersJSON, _ := json.Marshal(allWaiters)
// Use raw SQL to update the waiters field
_, err = sqliteStore.UnderlyingDB().ExecContext(ctx, `UPDATE issues SET waiters = ?, updated_at = ? WHERE id = ?`,
string(waitersJSON), time.Now(), gateID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error adding waiters: %v\n", err)
os.Exit(1)
}
markDirtyAndScheduleFlush()
if jsonOutput {
updatedGate, _ := store.GetIssue(ctx, gateID)
outputJSON(updatedGate)
return
}
fmt.Printf("%s Added waiter(s) to gate %s:\n", ui.RenderPass("✓"), gateID)
for _, addr := range newWaiters {
fmt.Printf(" + %s\n", addr)
}
},
}
func init() {
// Gate create flags
gateCreateCmd.Flags().String("await", "", "Await spec: gh:run:<id>, gh:pr:<id>, timer:<duration>, human:<prompt>, mail:<pattern> (required)")
gateCreateCmd.Flags().String("timeout", "", "Timeout duration (e.g., 30m, 1h)")
gateCreateCmd.Flags().StringSlice("notify", nil, "Mail addresses to notify when gate clears (repeatable)")
gateCreateCmd.Flags().String("title", "", "Custom title for the gate")
gateCreateCmd.Flags().Bool("json", false, "Output JSON format")
// Gate show flags
gateShowCmd.Flags().Bool("json", false, "Output JSON format")
// Gate list flags
gateListCmd.Flags().Bool("all", false, "Show all gates including closed")
gateListCmd.Flags().Bool("json", false, "Output JSON format")
// Gate close flags
gateCloseCmd.Flags().StringP("reason", "r", "", "Reason for closing")
gateCloseCmd.Flags().Bool("json", false, "Output JSON format")
// Gate wait flags
gateWaitCmd.Flags().StringSlice("notify", nil, "Mail addresses to add as waiters (repeatable, required)")
gateWaitCmd.Flags().Bool("json", false, "Output JSON format")
// Add subcommands to gate command
gateCmd.AddCommand(gateCreateCmd)
gateCmd.AddCommand(gateShowCmd)
gateCmd.AddCommand(gateListCmd)
gateCmd.AddCommand(gateCloseCmd)
gateCmd.AddCommand(gateWaitCmd)
// Add gate command to root
rootCmd.AddCommand(gateCmd)
}

View File

@@ -636,7 +636,7 @@ func init() {
listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
registerPriorityFlag(listCmd, "")
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule)")
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate)")
listCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL). Can combine with --label-any")
listCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label")
listCmd.Flags().String("title", "", "Filter by title text (case-insensitive substring match)")

View File

@@ -373,7 +373,7 @@ func init() {
searchCmd.Flags().String("query", "", "Search query (alternative to positional argument)")
searchCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
searchCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
searchCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
searchCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate)")
searchCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)")
searchCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE)")
searchCmd.Flags().IntP("limit", "n", 50, "Limit results (default: 50)")

View File

@@ -527,7 +527,7 @@ var updateCmd = &cobra.Command{
issueType, _ := cmd.Flags().GetString("type")
// Validate issue type
if !types.IssueType(issueType).IsValid() {
fmt.Fprintf(os.Stderr, "Error: invalid issue type %q. Valid types: bug, feature, task, epic, chore\n", issueType)
fmt.Fprintf(os.Stderr, "Error: invalid issue type %q. Valid types: bug, feature, task, epic, chore, merge-request, molecule, gate\n", issueType)
os.Exit(1)
}
updates["issue_type"] = issueType
@@ -1390,7 +1390,7 @@ func init() {
updateCmd.Flags().StringP("status", "s", "", "New status")
registerPriorityFlag(updateCmd, "")
updateCmd.Flags().String("title", "", "New title")
updateCmd.Flags().StringP("type", "t", "", "New type (bug|feature|task|epic|chore|merge-request|molecule)")
updateCmd.Flags().StringP("type", "t", "", "New type (bug|feature|task|epic|chore|merge-request|molecule|gate)")
registerCommonIssueFlags(updateCmd)
updateCmd.Flags().String("notes", "", "Additional notes")
updateCmd.Flags().String("acceptance-criteria", "", "DEPRECATED: use --acceptance")

View File

@@ -250,6 +250,7 @@ func (s *SQLiteStorage) GetDependenciesWithMetadata(ctx context.Context, issueID
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.pinned, i.is_template,
i.await_type, i.await_id, i.timeout_ns, i.waiters,
d.type
FROM issues i
JOIN dependencies d ON i.id = d.depends_on_id
@@ -272,6 +273,7 @@ func (s *SQLiteStorage) GetDependentsWithMetadata(ctx context.Context, issueID s
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.pinned, i.is_template,
i.await_type, i.await_id, i.timeout_ns, i.waiters,
d.type
FROM issues i
JOIN dependencies d ON i.id = d.issue_id
@@ -827,6 +829,11 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
var pinned sql.NullInt64
// Template field (beads-1ra)
var isTemplate sql.NullInt64
// Gate fields (bd-udsi)
var awaitType sql.NullString
var awaitID sql.NullString
var timeoutNs sql.NullInt64
var waiters sql.NullString
err := rows.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
@@ -835,6 +842,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &wisp, &pinned, &isTemplate,
&awaitType, &awaitID, &timeoutNs, &waiters,
)
if err != nil {
return nil, fmt.Errorf("failed to scan issue: %w", err)
@@ -887,6 +895,19 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
if isTemplate.Valid && isTemplate.Int64 != 0 {
issue.IsTemplate = true
}
// Gate fields (bd-udsi)
if awaitType.Valid {
issue.AwaitType = awaitType.String
}
if awaitID.Valid {
issue.AwaitID = awaitID.String
}
if timeoutNs.Valid {
issue.Timeout = time.Duration(timeoutNs.Int64)
}
if waiters.Valid && waiters.String != "" {
issue.Waiters = parseJSONStringArray(waiters.String)
}
issues = append(issues, &issue)
issueIDs = append(issueIDs, issue.ID)
@@ -930,6 +951,11 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
var pinned sql.NullInt64
// Template field (beads-1ra)
var isTemplate sql.NullInt64
// Gate fields (bd-udsi)
var awaitType sql.NullString
var awaitID sql.NullString
var timeoutNs sql.NullInt64
var waiters sql.NullString
var depType types.DependencyType
err := rows.Scan(
@@ -939,6 +965,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &wisp, &pinned, &isTemplate,
&awaitType, &awaitID, &timeoutNs, &waiters,
&depType,
)
if err != nil {
@@ -989,6 +1016,19 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
if isTemplate.Valid && isTemplate.Int64 != 0 {
issue.IsTemplate = true
}
// Gate fields (bd-udsi)
if awaitType.Valid {
issue.AwaitType = awaitType.String
}
if awaitID.Valid {
issue.AwaitID = awaitID.String
}
if timeoutNs.Valid {
issue.Timeout = time.Duration(timeoutNs.Int64)
}
if waiters.Valid && waiters.String != "" {
issue.Waiters = parseJSONStringArray(waiters.String)
}
// Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID)

View File

@@ -46,8 +46,9 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status,
@@ -56,6 +57,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, wisp, pinned, isTemplate,
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
)
if err != nil {
// INSERT OR IGNORE should handle duplicates, but driver may still return error
@@ -76,8 +78,9 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
@@ -111,6 +114,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, wisp, pinned, isTemplate,
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
)
if err != nil {
// INSERT OR IGNORE should handle duplicates, but driver may still return error

View File

@@ -159,7 +159,8 @@ func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.pinned, i.is_template
i.sender, i.ephemeral, i.pinned, i.is_template,
i.await_type, i.await_id, i.timeout_ns, i.waiters
FROM issues i
JOIN labels l ON i.id = l.issue_id
WHERE l.label = ?

View File

@@ -43,6 +43,7 @@ var migrationsList = []Migration{
{"is_template_column", migrations.MigrateIsTemplateColumn},
{"remove_depends_on_fk", migrations.MigrateRemoveDependsOnFK},
{"additional_indexes", migrations.MigrateAdditionalIndexes},
{"gate_columns", migrations.MigrateGateColumns},
}
// MigrationInfo contains metadata about a migration for inspection
@@ -93,6 +94,7 @@ func getMigrationDescription(name string) string {
"is_template_column": "Adds is_template column for template molecules (beads-1ra)",
"remove_depends_on_fk": "Removes FK constraint on depends_on_id to allow external references (bd-zmmy)",
"additional_indexes": "Adds performance optimization indexes for common query patterns (bd-h0we)",
"gate_columns": "Adds gate columns (await_type, await_id, timeout_ns, waiters) for async coordination (bd-udsi)",
}
if desc, ok := descriptions[name]; ok {

View File

@@ -0,0 +1,51 @@
package migrations
import (
"database/sql"
"fmt"
)
// MigrateGateColumns adds gate-related columns to the issues table for async coordination (bd-udsi).
// Gate fields enable agents to wait on external conditions (CI completion, human approval, etc.)
func MigrateGateColumns(db *sql.DB) error {
columns := []struct {
name string
sqlType string
}{
{"await_type", "TEXT"}, // Condition type: gh:run, gh:pr, timer, human, mail
{"await_id", "TEXT"}, // Condition identifier (e.g., run ID, PR number)
{"timeout_ns", "INTEGER"}, // Timeout in nanoseconds (Go's time.Duration)
{"waiters", "TEXT"}, // JSON array of mail addresses to notify
}
for _, col := range columns {
// Check if column already exists
var columnExists bool
err := db.QueryRow(`
SELECT COUNT(*) > 0
FROM pragma_table_info('issues')
WHERE name = ?
`, col.name).Scan(&columnExists)
if err != nil {
return fmt.Errorf("failed to check %s column: %w", col.name, err)
}
if columnExists {
continue
}
// Add the column
_, err = db.Exec(fmt.Sprintf(`ALTER TABLE issues ADD COLUMN %s %s`, col.name, col.sqlType))
if err != nil {
return fmt.Errorf("failed to add %s column: %w", col.name, err)
}
}
// Add index for gate type issues (for efficient gate queries)
_, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_gate ON issues(issue_type) WHERE issue_type = 'gate'`)
if err != nil {
return fmt.Errorf("failed to create gate index: %w", err)
}
return nil
}

View File

@@ -491,9 +491,13 @@ func TestMigrateContentHashColumn(t *testing.T) {
relates_to TEXT DEFAULT '',
duplicate_of TEXT DEFAULT '',
superseded_by TEXT DEFAULT '',
await_type TEXT DEFAULT '',
await_id TEXT DEFAULT '',
timeout_ns INTEGER DEFAULT 0,
waiters TEXT DEFAULT '',
CHECK ((status = 'closed') = (closed_at IS NOT NULL))
);
INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '' FROM issues_backup;
INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '', '', '', 0, '' FROM issues_backup;
DROP TABLE issues_backup;
`)
if err != nil {

View File

@@ -302,8 +302,9 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status,
@@ -312,6 +313,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.CloseReason,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, wisp, pinned, isTemplate,
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
)
if err != nil {
return fmt.Errorf("failed to insert issue: %w", err)
@@ -335,7 +337,8 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
issue_type = ?, assignee = ?, estimated_minutes = ?,
updated_at = ?, closed_at = ?, external_ref = ?, source_repo = ?,
deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ?,
sender = ?, ephemeral = ?, pinned = ?, is_template = ?
sender = ?, ephemeral = ?, pinned = ?, is_template = ?,
await_type = ?, await_id = ?, timeout_ns = ?, waiters = ?
WHERE id = ?
`,
issue.ContentHash, issue.Title, issue.Description, issue.Design,
@@ -344,6 +347,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, wisp, pinned, isTemplate,
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
issue.ID,
)
if err != nil {

View File

@@ -252,6 +252,11 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
var pinned sql.NullInt64
// Template field (beads-1ra)
var isTemplate sql.NullInt64
// Gate fields (bd-udsi)
var awaitType sql.NullString
var awaitID sql.NullString
var timeoutNs sql.NullInt64
var waiters sql.NullString
var contentHash sql.NullString
var compactedAtCommit sql.NullString
@@ -261,7 +266,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
created_at, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters
FROM issues
WHERE id = ?
`, id).Scan(
@@ -272,6 +278,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &wisp, &pinned, &isTemplate,
&awaitType, &awaitID, &timeoutNs, &waiters,
)
if err == sql.ErrNoRows {
@@ -337,6 +344,19 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
if isTemplate.Valid && isTemplate.Int64 != 0 {
issue.IsTemplate = true
}
// Gate fields (bd-udsi)
if awaitType.Valid {
issue.AwaitType = awaitType.String
}
if awaitID.Valid {
issue.AwaitID = awaitID.String
}
if timeoutNs.Valid {
issue.Timeout = time.Duration(timeoutNs.Int64)
}
if waiters.Valid && waiters.String != "" {
issue.Waiters = parseJSONStringArray(waiters.String)
}
// Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID)
@@ -447,6 +467,11 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
var pinned sql.NullInt64
// Template field (beads-1ra)
var isTemplate sql.NullInt64
// Gate fields (bd-udsi)
var awaitType sql.NullString
var awaitID sql.NullString
var timeoutNs sql.NullInt64
var waiters sql.NullString
err := s.db.QueryRowContext(ctx, `
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
@@ -454,7 +479,8 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
created_at, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters
FROM issues
WHERE external_ref = ?
`, externalRef).Scan(
@@ -465,6 +491,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &wisp, &pinned, &isTemplate,
&awaitType, &awaitID, &timeoutNs, &waiters,
)
if err == sql.ErrNoRows {
@@ -530,6 +557,19 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
if isTemplate.Valid && isTemplate.Int64 != 0 {
issue.IsTemplate = true
}
// Gate fields (bd-udsi)
if awaitType.Valid {
issue.AwaitType = awaitType.String
}
if awaitID.Valid {
issue.AwaitID = awaitID.String
}
if timeoutNs.Valid {
issue.Timeout = time.Duration(timeoutNs.Int64)
}
if waiters.Valid && waiters.String != "" {
issue.Waiters = parseJSONStringArray(waiters.String)
}
// Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID)
@@ -1646,7 +1686,8 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters
FROM issues
%s
ORDER BY priority ASC, created_at DESC

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/types"
@@ -111,7 +112,8 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.pinned, i.is_template
i.sender, i.ephemeral, i.pinned, i.is_template,
i.await_type, i.await_id, i.timeout_ns, i.waiters
FROM issues i
WHERE %s
AND NOT EXISTS (
@@ -247,7 +249,8 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
created_at, updated_at, closed_at, external_ref, source_repo,
compaction_level, compacted_at, compacted_at_commit, original_size, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters
FROM issues
WHERE status != 'closed'
AND datetime(updated_at) < datetime('now', '-' || ? || ' days')
@@ -300,6 +303,11 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
var pinned sql.NullInt64
// Template field (beads-1ra)
var isTemplate sql.NullInt64
// Gate fields (bd-udsi)
var awaitType sql.NullString
var awaitID sql.NullString
var timeoutNs sql.NullInt64
var waiters sql.NullString
err := rows.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
@@ -309,6 +317,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
&compactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &ephemeral, &pinned, &isTemplate,
&awaitType, &awaitID, &timeoutNs, &waiters,
)
if err != nil {
return nil, fmt.Errorf("failed to scan stale issue: %w", err)
@@ -373,6 +382,19 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
if isTemplate.Valid && isTemplate.Int64 != 0 {
issue.IsTemplate = true
}
// Gate fields (bd-udsi)
if awaitType.Valid {
issue.AwaitType = awaitType.String
}
if awaitID.Valid {
issue.AwaitID = awaitID.String
}
if timeoutNs.Valid {
issue.Timeout = time.Duration(timeoutNs.Int64)
}
if waiters.Valid && waiters.String != "" {
issue.Waiters = parseJSONStringArray(waiters.String)
}
issues = append(issues, &issue)
}

View File

@@ -306,7 +306,8 @@ func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue
created_at, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters
FROM issues
WHERE id = ?
`, id)
@@ -1122,7 +1123,8 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter
created_at, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters
FROM issues
%s
ORDER BY priority ASC, created_at DESC
@@ -1169,6 +1171,11 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
var pinned sql.NullInt64
// Template field (beads-1ra)
var isTemplate sql.NullInt64
// Gate fields (bd-udsi)
var awaitType sql.NullString
var awaitID sql.NullString
var timeoutNs sql.NullInt64
var waiters sql.NullString
err := row.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
@@ -1178,6 +1185,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &wisp, &pinned, &isTemplate,
&awaitType, &awaitID, &timeoutNs, &waiters,
)
if err != nil {
return nil, fmt.Errorf("failed to scan issue: %w", err)
@@ -1239,6 +1247,19 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
if isTemplate.Valid && isTemplate.Int64 != 0 {
issue.IsTemplate = true
}
// Gate fields (bd-udsi)
if awaitType.Valid {
issue.AwaitType = awaitType.String
}
if awaitID.Valid {
issue.AwaitID = awaitID.String
}
if timeoutNs.Valid {
issue.Timeout = time.Duration(timeoutNs.Int64)
}
if waiters.Valid && waiters.String != "" {
issue.Waiters = parseJSONStringArray(waiters.String)
}
return &issue, nil
}

View File

@@ -59,6 +59,12 @@ type Issue struct {
// HOP fields (bd-7pwh): entity tracking for CV chains
Creator *EntityRef `json:"creator,omitempty"` // Who created this issue (human, agent, or org)
Validations []Validation `json:"validations,omitempty"` // Who validated/approved this work
// Gate fields (bd-udsi): async coordination primitives
AwaitType string `json:"await_type,omitempty"` // Condition type: gh:run, gh:pr, timer, human, mail
AwaitID string `json:"await_id,omitempty"` // Condition identifier (e.g., run ID, PR number)
Timeout time.Duration `json:"timeout,omitempty"` // Max wait time before escalation
Waiters []string `json:"waiters,omitempty"` // Mail addresses to notify when gate clears
}
// ComputeContentHash creates a deterministic hash of the issue's content.
@@ -140,6 +146,17 @@ func (i *Issue) ComputeContentHash() string {
}
h.Write([]byte{0})
}
// Hash gate fields for async coordination (bd-udsi)
h.Write([]byte(i.AwaitType))
h.Write([]byte{0})
h.Write([]byte(i.AwaitID))
h.Write([]byte{0})
h.Write([]byte(fmt.Sprintf("%d", i.Timeout)))
h.Write([]byte{0})
for _, waiter := range i.Waiters {
h.Write([]byte(waiter))
h.Write([]byte{0})
}
return fmt.Sprintf("%x", h.Sum(nil))
}
@@ -313,12 +330,13 @@ const (
TypeMessage IssueType = "message" // Ephemeral communication between workers
TypeMergeRequest IssueType = "merge-request" // Merge queue entry for refinery processing
TypeMolecule IssueType = "molecule" // Template molecule for issue hierarchies (beads-1ra)
TypeGate IssueType = "gate" // Async coordination gate (bd-udsi)
)
// IsValid checks if the issue type value is valid
func (t IssueType) IsValid() bool {
switch t {
case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule:
case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule, TypeGate:
return true
}
return false