package main import ( "bytes" "context" "database/sql" "encoding/json" "fmt" "os" "os/exec" "strings" "time" _ "github.com/ncruces/go-sqlite3/driver" _ "github.com/ncruces/go-sqlite3/embed" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/routing" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/ui" ) // gateCmd is the parent command for gate operations var gateCmd = &cobra.Command{ Use: "gate", GroupID: "issues", Short: "Manage async coordination gates", Long: `Gates are async wait conditions that block workflow steps. Gates are created automatically when a formula step has a gate field. They must be closed (manually or via watchers) for the blocked step to proceed. Gate types: human - Requires manual bd close (Phase 1) timer - Expires after timeout (Phase 2) gh:run - Waits for GitHub workflow (Phase 3) gh:pr - Waits for PR merge (Phase 3) bead - Waits for cross-rig bead to close (Phase 4) For bead gates, await_id format is : (e.g., "gastown:gt-abc123"). Examples: bd gate list # Show all open gates bd gate list --all # Show all gates including closed bd gate check # Evaluate all open gates bd gate check --type=bead # Evaluate only bead gates bd gate resolve # Close a gate manually`, } // gateListCmd lists gate issues var gateListCmd = &cobra.Command{ Use: "list", Short: "List gate issues", Long: `List all gate issues in the current beads database. By default, shows only open gates. Use --all to include closed gates.`, Run: func(cmd *cobra.Command, args []string) { allFlag, _ := cmd.Flags().GetBool("all") limit, _ := cmd.Flags().GetInt("limit") // Build filter for gate type issues gateType := types.IssueType("gate") filter := types.IssueFilter{ IssueType: &gateType, Limit: limit, } // By default, exclude closed gates if !allFlag { filter.ExcludeStatus = []types.Status{types.StatusClosed} } ctx := rootCtx // If daemon is running, use RPC if daemonClient != nil { listArgs := &rpc.ListArgs{ IssueType: "gate", Limit: limit, } if !allFlag { listArgs.ExcludeStatus = []string{"closed"} } resp, err := daemonClient.List(listArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } var issues []*types.Issue if err := json.Unmarshal(resp.Data, &issues); err != nil { fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) os.Exit(1) } if jsonOutput { outputJSON(issues) return } displayGates(issues, allFlag) return } // Direct mode issues, err := store.SearchIssues(ctx, "", filter) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if jsonOutput { outputJSON(issues) return } displayGates(issues, allFlag) }, } // displayGates formats and displays gate issues, separating open and closed gates func displayGates(gates []*types.Issue, showAll bool) { if len(gates) == 0 { fmt.Println("No gates found.") return } // Separate open and closed gates var openGates, closedGates []*types.Issue for _, gate := range gates { if gate.Status == types.StatusClosed { closedGates = append(closedGates, gate) } else { openGates = append(openGates, gate) } } // Display open gates if len(openGates) > 0 { fmt.Printf("\n%s Open Gates (%d):\n\n", ui.RenderAccent("⏳"), len(openGates)) for _, gate := range openGates { displaySingleGate(gate) } } // Display closed gates only if --all was used if showAll && len(closedGates) > 0 { fmt.Printf("\n%s Closed Gates (%d):\n\n", ui.RenderMuted("●"), len(closedGates)) for _, gate := range closedGates { displaySingleGate(gate) } } if len(openGates) == 0 && (!showAll || len(closedGates) == 0) { fmt.Println("No gates found.") return } fmt.Printf("To resolve a gate: bd close \n") } // displaySingleGate formats and displays a single gate issue func displaySingleGate(gate *types.Issue) { statusSym := "○" if gate.Status == types.StatusClosed { statusSym = "●" } // Format gate info gateInfo := gate.AwaitType if gate.AwaitID != "" { gateInfo = fmt.Sprintf("%s %s", gate.AwaitType, gate.AwaitID) } // Format timeout if present timeoutStr := "" if gate.Timeout > 0 { timeoutStr = fmt.Sprintf(" (timeout: %s)", gate.Timeout) } // Find blocked step from ID (gate ID format: parent.gate-stepid) blockedStep := "" if strings.Contains(gate.ID, ".gate-") { parts := strings.Split(gate.ID, ".gate-") if len(parts) == 2 { blockedStep = fmt.Sprintf("%s.%s", parts[0], parts[1]) } } fmt.Printf("%s %s - %s%s\n", statusSym, ui.RenderID(gate.ID), gateInfo, timeoutStr) if blockedStep != "" { fmt.Printf(" Blocks: %s\n", blockedStep) } fmt.Println() } // gateAddWaiterCmd adds a waiter to a gate var gateAddWaiterCmd = &cobra.Command{ Use: "add-waiter ", Short: "Add a waiter to a gate", Long: `Register an agent as a waiter on a gate bead. When the gate closes, the waiter will receive a wake notification via 'gt gate wake'. The waiter is typically the polecat's address (e.g., "gastown/polecats/Toast"). This is used by 'gt done --phase-complete' to register for gate wake notifications.`, Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { CheckReadonly("gate add-waiter") gateID := args[0] waiter := args[1] ctx := rootCtx // Get the gate issue var issue *types.Issue var err error if daemonClient != nil { resp, rerr := daemonClient.Show(&rpc.ShowArgs{ID: gateID}) if rerr != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", rerr) os.Exit(1) } if !resp.Success { fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) os.Exit(1) } var details types.IssueDetails if uerr := json.Unmarshal(resp.Data, &details); uerr != nil { fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", uerr) os.Exit(1) } issue = &details.Issue } else { issue, err = store.GetIssue(ctx, gateID) if err != nil { fmt.Fprintf(os.Stderr, "Error: gate not found: %s\n", gateID) os.Exit(1) } } if issue.IssueType != "gate" { fmt.Fprintf(os.Stderr, "Error: %s is not a gate issue (type=%s)\n", gateID, issue.IssueType) os.Exit(1) } // Check if waiter is already registered for _, w := range issue.Waiters { if w == waiter { fmt.Printf("Waiter already registered on gate %s\n", gateID) return } } // Add waiter to the waiters list newWaiters := append(issue.Waiters, waiter) // Update the gate if daemonClient != nil { // Use GateWait RPC which handles waiters correctly (bypasses allowedUpdateFields) gateWaitArgs := &rpc.GateWaitArgs{ ID: gateID, Waiters: []string{waiter}, // GateWait handles deduplication internally } resp, uerr := daemonClient.GateWait(gateWaitArgs) if uerr != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", uerr) os.Exit(1) } if !resp.Success { fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) os.Exit(1) } } else { updates := map[string]interface{}{ "waiters": newWaiters, } if err := store.UpdateIssue(ctx, gateID, updates, actor); err != nil { fmt.Fprintf(os.Stderr, "Error updating gate: %v\n", err) os.Exit(1) } markDirtyAndScheduleFlush() } fmt.Printf("%s Added waiter to gate %s: %s\n", ui.RenderPass("✓"), gateID, waiter) }, } // gateShowCmd shows a gate issue var gateShowCmd = &cobra.Command{ Use: "show ", Short: "Show a gate issue", Long: `Display details of a gate issue including its waiters. This is similar to 'bd show' but validates that the issue is a gate.`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { gateID := args[0] ctx := rootCtx // Get the gate issue var issue *types.Issue var err error if daemonClient != nil { resp, rerr := daemonClient.Show(&rpc.ShowArgs{ID: gateID}) if rerr != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", rerr) os.Exit(1) } if !resp.Success { fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) os.Exit(1) } var details types.IssueDetails if uerr := json.Unmarshal(resp.Data, &details); uerr != nil { fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", uerr) os.Exit(1) } issue = &details.Issue } else { issue, err = store.GetIssue(ctx, gateID) if err != nil { fmt.Fprintf(os.Stderr, "Error: gate not found: %s\n", gateID) os.Exit(1) } } if issue.IssueType != "gate" { fmt.Fprintf(os.Stderr, "Error: %s is not a gate issue (type=%s)\n", gateID, issue.IssueType) os.Exit(1) } if jsonOutput { outputJSON(issue) return } // Display gate details statusSym := "○" if issue.Status == types.StatusClosed { statusSym = "●" } fmt.Printf("%s %s - %s\n", statusSym, ui.RenderID(issue.ID), issue.Title) fmt.Printf(" Status: %s\n", issue.Status) fmt.Printf(" Await Type: %s\n", issue.AwaitType) if issue.AwaitID != "" { fmt.Printf(" Await ID: %s\n", issue.AwaitID) } if issue.Timeout > 0 { fmt.Printf(" Timeout: %s\n", issue.Timeout) } if len(issue.Waiters) > 0 { fmt.Printf(" Waiters:\n") for _, w := range issue.Waiters { fmt.Printf(" - %s\n", w) } } if issue.Description != "" { fmt.Printf(" Description: %s\n", issue.Description) } }, } // gateResolveCmd manually closes a gate var gateResolveCmd = &cobra.Command{ Use: "resolve ", Short: "Manually resolve (close) a gate", Long: `Close a gate issue to unblock the step waiting on it. This is equivalent to 'bd close ' but with a more explicit name. Use --reason to provide context for why the gate was resolved.`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { CheckReadonly("gate resolve") gateID := args[0] reason, _ := cmd.Flags().GetString("reason") // Verify it's a gate issue ctx := rootCtx var issue *types.Issue var err error if daemonClient != nil { resp, rerr := daemonClient.Show(&rpc.ShowArgs{ID: gateID}) if rerr != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", rerr) os.Exit(1) } if !resp.Success { fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) os.Exit(1) } var details types.IssueDetails if uerr := json.Unmarshal(resp.Data, &details); uerr != nil { fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", uerr) os.Exit(1) } issue = &details.Issue } else { issue, err = store.GetIssue(ctx, gateID) if err != nil { fmt.Fprintf(os.Stderr, "Error: gate not found: %s\n", gateID) os.Exit(1) } } if issue.IssueType != "gate" { fmt.Fprintf(os.Stderr, "Error: %s is not a gate issue (type=%s)\n", gateID, issue.IssueType) os.Exit(1) } // Close the gate if daemonClient != nil { closeArgs := &rpc.CloseArgs{ ID: gateID, Reason: reason, } resp, cerr := daemonClient.CloseIssue(closeArgs) if cerr != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", cerr) os.Exit(1) } if !resp.Success { fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) os.Exit(1) } } else { if err := store.CloseIssue(ctx, gateID, reason, actor, ""); err != nil { fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err) os.Exit(1) } markDirtyAndScheduleFlush() } fmt.Printf("%s Gate resolved: %s\n", ui.RenderPass("✓"), gateID) if reason != "" { fmt.Printf(" Reason: %s\n", reason) } }, } // gateCheckCmd evaluates gates and closes those that are resolved var gateCheckCmd = &cobra.Command{ Use: "check", Short: "Evaluate gates and close resolved ones", Long: `Evaluate gate conditions and automatically close resolved gates. By default, checks all open gates. Use --type to filter by gate type. Gate types: gh - Check all GitHub gates (gh:run and gh:pr) gh:run - Check GitHub Actions workflow runs gh:pr - Check pull request merge status timer - Check timer gates (auto-expire based on timeout) bead - Check cross-rig bead gates all - Check all gate types GitHub gates use the 'gh' CLI to query status: - gh:run checks 'gh run view --json status,conclusion' - gh:pr checks 'gh pr view --json state,merged' A gate is resolved when: - gh:run: status=completed AND conclusion=success - gh:pr: state=MERGED - timer: current time > created_at + timeout - bead: target bead status=closed A gate is escalated when: - gh:run: status=completed AND conclusion in (failure, canceled) - gh:pr: state=CLOSED AND merged=false Examples: bd gate check # Check all gates bd gate check --type=gh # Check only GitHub gates bd gate check --type=gh:run # Check only workflow run gates bd gate check --type=timer # Check only timer gates bd gate check --type=bead # Check only cross-rig bead gates bd gate check --dry-run # Show what would happen without changes bd gate check --escalate # Escalate expired/failed gates`, Run: func(cmd *cobra.Command, args []string) { CheckReadonly("gate check") gateTypeFilter, _ := cmd.Flags().GetString("type") dryRun, _ := cmd.Flags().GetBool("dry-run") escalateFlag, _ := cmd.Flags().GetBool("escalate") limit, _ := cmd.Flags().GetInt("limit") // Get open gates gateType := types.IssueType("gate") filter := types.IssueFilter{ IssueType: &gateType, ExcludeStatus: []types.Status{types.StatusClosed}, Limit: limit, } ctx := rootCtx var gates []*types.Issue var err error if daemonClient != nil { listArgs := &rpc.ListArgs{ IssueType: "gate", ExcludeStatus: []string{"closed"}, Limit: limit, } resp, rerr := daemonClient.List(listArgs) if rerr != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", rerr) os.Exit(1) } if uerr := json.Unmarshal(resp.Data, &gates); uerr != nil { fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", uerr) os.Exit(1) } } else { gates, err = store.SearchIssues(ctx, "", filter) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } // Filter by type if specified var filteredGates []*types.Issue for _, gate := range gates { if shouldCheckGate(gate, gateTypeFilter) { filteredGates = append(filteredGates, gate) } } if len(filteredGates) == 0 { if gateTypeFilter != "" { fmt.Printf("No open gates of type '%s' found.\n", gateTypeFilter) } else { fmt.Println("No open gates found.") } return } // Results tracking type checkResult struct { gate *types.Issue resolved bool escalated bool reason string err error } results := make([]checkResult, 0, len(filteredGates)) // Check each gate now := time.Now() for _, gate := range filteredGates { result := checkResult{gate: gate} switch { case strings.HasPrefix(gate.AwaitType, "gh:run"): result.resolved, result.escalated, result.reason, result.err = checkGHRun(gate) case strings.HasPrefix(gate.AwaitType, "gh:pr"): result.resolved, result.escalated, result.reason, result.err = checkGHPR(gate) case gate.AwaitType == "timer": result.resolved, result.escalated, result.reason, result.err = checkTimer(gate, now) case gate.AwaitType == "bead": result.resolved, result.reason = checkBeadGate(ctx, gate.AwaitID) default: // Skip unsupported gate types (human gates need manual resolution) continue } results = append(results, result) } // Process results resolvedCount := 0 escalatedCount := 0 errorCount := 0 for _, r := range results { if r.err != nil { errorCount++ fmt.Fprintf(os.Stderr, "%s %s: error checking - %v\n", ui.RenderFail("✗"), r.gate.ID, r.err) continue } if r.resolved { resolvedCount++ if dryRun { fmt.Printf("%s %s: would resolve - %s\n", ui.RenderPass("✓"), r.gate.ID, r.reason) } else { // Close the gate closeErr := closeGate(ctx, r.gate.ID, r.reason) if closeErr != nil { fmt.Fprintf(os.Stderr, "%s %s: error closing - %v\n", ui.RenderFail("✗"), r.gate.ID, closeErr) errorCount++ } else { fmt.Printf("%s %s: resolved - %s\n", ui.RenderPass("✓"), r.gate.ID, r.reason) } } } else if r.escalated { escalatedCount++ if dryRun { fmt.Printf("%s %s: would escalate - %s\n", ui.RenderWarn("⚠"), r.gate.ID, r.reason) } else { fmt.Printf("%s %s: ESCALATE - %s\n", ui.RenderWarn("⚠"), r.gate.ID, r.reason) // Actually escalate if flag is set if escalateFlag { escalateGate(r.gate, r.reason) } } } else { // Still pending fmt.Printf("%s %s: pending - %s\n", ui.RenderAccent("○"), r.gate.ID, r.reason) } } // Summary fmt.Println() fmt.Printf("Checked %d gates: %d resolved, %d escalated, %d errors\n", len(results), resolvedCount, escalatedCount, errorCount) if jsonOutput { summary := map[string]interface{}{ "checked": len(results), "resolved": resolvedCount, "escalated": escalatedCount, "errors": errorCount, "dry_run": dryRun, } outputJSON(summary) } }, } // shouldCheckGate returns true if the gate matches the type filter func shouldCheckGate(gate *types.Issue, typeFilter string) bool { if typeFilter == "" || typeFilter == "all" { return true } if typeFilter == "gh" { return strings.HasPrefix(gate.AwaitType, "gh:") } return gate.AwaitType == typeFilter } // ghRunStatus holds the JSON response from 'gh run view' type ghRunStatus struct { Status string `json:"status"` Conclusion string `json:"conclusion"` Name string `json:"name"` } // ghPRStatus holds the JSON response from 'gh pr view' type ghPRStatus struct { State string `json:"state"` Merged bool `json:"merged"` Title string `json:"title"` } // isNumericID returns true if the string contains only digits (a GitHub run ID) func isNumericID(s string) bool { if s == "" { return false } for _, c := range s { if c < '0' || c > '9' { return false } } return true } // queryGitHubRunsForWorkflow queries recent runs for a specific workflow using gh CLI. // Returns runs sorted newest-first (GitHub API default). func queryGitHubRunsForWorkflow(workflow string, limit int) ([]GHWorkflowRun, error) { if _, err := exec.LookPath("gh"); err != nil { return nil, fmt.Errorf("gh CLI not found: install from https://cli.github.com") } args := []string{ "run", "list", "--workflow", workflow, "--json", "databaseId,name,status,conclusion,createdAt,workflowName", "--limit", fmt.Sprintf("%d", limit), } cmd := exec.Command("gh", args...) output, err := cmd.Output() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { return nil, fmt.Errorf("gh run list --workflow=%s failed: %s", workflow, string(exitErr.Stderr)) } return nil, fmt.Errorf("gh run list: %w", err) } var runs []GHWorkflowRun if err := json.Unmarshal(output, &runs); err != nil { return nil, fmt.Errorf("parse gh output: %w", err) } return runs, nil } // discoverRunIDByWorkflowName queries GitHub for the most recent run of a workflow. // Returns (runID, error). This is ZFC-compliant: "most recent run" is deterministic. func discoverRunIDByWorkflowName(workflowHint string) (string, error) { // Query GitHub directly for this workflow (efficient, avoids limit issues) runs, err := queryGitHubRunsForWorkflow(workflowHint, 5) if err != nil { return "", fmt.Errorf("failed to query workflow runs: %w", err) } if len(runs) == 0 { return "", fmt.Errorf("no runs found for workflow '%s'", workflowHint) } // Take the most recent run (gh returns newest-first) // This is deterministic: "most recent" is a total ordering by creation time return fmt.Sprintf("%d", runs[0].DatabaseID), nil } // checkGHRun checks a GitHub Actions workflow run gate func checkGHRun(gate *types.Issue) (resolved, escalated bool, reason string, err error) { if gate.AwaitID == "" { return false, false, "no run ID specified - set await_id or use workflow name hint", nil } runID := gate.AwaitID // If await_id is a workflow name hint (non-numeric), auto-discover the run ID if !isNumericID(gate.AwaitID) { discoveredID, discoverErr := discoverRunIDByWorkflowName(gate.AwaitID) if discoverErr != nil { return false, false, fmt.Sprintf("workflow hint '%s': %v", gate.AwaitID, discoverErr), nil } // Update the gate with the discovered run ID if updateErr := updateGateAwaitID(nil, gate.ID, discoveredID); updateErr != nil { return false, false, "", fmt.Errorf("failed to update gate with discovered run ID: %w", updateErr) } runID = discoveredID } // Run: gh run view --json status,conclusion,name cmd := exec.Command("gh", "run", "view", runID, "--json", "status,conclusion,name") // #nosec G204 -- runID is a validated GitHub run ID var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if runErr := cmd.Run(); runErr != nil { // Check if gh CLI is not found if strings.Contains(stderr.String(), "command not found") || strings.Contains(runErr.Error(), "executable file not found") { return false, false, "", fmt.Errorf("gh CLI not installed") } // Check if run not found if strings.Contains(stderr.String(), "not found") { return false, true, "workflow run not found", nil } return false, false, "", fmt.Errorf("gh run view failed: %s", stderr.String()) } var status ghRunStatus if parseErr := json.Unmarshal(stdout.Bytes(), &status); parseErr != nil { return false, false, "", fmt.Errorf("failed to parse gh output: %w", parseErr) } // Evaluate status switch status.Status { case "completed": switch status.Conclusion { case "success": return true, false, fmt.Sprintf("workflow '%s' succeeded", status.Name), nil case "failure": return false, true, fmt.Sprintf("workflow '%s' failed", status.Name), nil case "cancelled", "canceled": return false, true, fmt.Sprintf("workflow '%s' was canceled", status.Name), nil case "skipped": return true, false, fmt.Sprintf("workflow '%s' was skipped", status.Name), nil default: return false, true, fmt.Sprintf("workflow '%s' concluded with %s", status.Name, status.Conclusion), nil } case "in_progress", "queued", "pending", "waiting": return false, false, fmt.Sprintf("workflow '%s' is %s", status.Name, status.Status), nil default: return false, false, fmt.Sprintf("workflow '%s' status: %s", status.Name, status.Status), nil } } // checkGHPR checks a GitHub pull request gate func checkGHPR(gate *types.Issue) (resolved, escalated bool, reason string, err error) { if gate.AwaitID == "" { return false, false, "no PR number specified", nil } // Run: gh pr view --json state,merged,title cmd := exec.Command("gh", "pr", "view", gate.AwaitID, "--json", "state,merged,title") // #nosec G204 -- gate.AwaitID is a validated GitHub PR number var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if runErr := cmd.Run(); runErr != nil { // Check if gh CLI is not found if strings.Contains(stderr.String(), "command not found") || strings.Contains(runErr.Error(), "executable file not found") { return false, false, "", fmt.Errorf("gh CLI not installed") } // Check if PR not found if strings.Contains(stderr.String(), "not found") || strings.Contains(stderr.String(), "Could not resolve") { return false, true, "pull request not found", nil } return false, false, "", fmt.Errorf("gh pr view failed: %s", stderr.String()) } var status ghPRStatus if parseErr := json.Unmarshal(stdout.Bytes(), &status); parseErr != nil { return false, false, "", fmt.Errorf("failed to parse gh output: %w", parseErr) } // Evaluate status switch status.State { case "MERGED": return true, false, fmt.Sprintf("PR '%s' was merged", status.Title), nil case "CLOSED": if status.Merged { return true, false, fmt.Sprintf("PR '%s' was merged", status.Title), nil } return false, true, fmt.Sprintf("PR '%s' was closed without merging", status.Title), nil case "OPEN": return false, false, fmt.Sprintf("PR '%s' is still open", status.Title), nil default: return false, false, fmt.Sprintf("PR '%s' state: %s", status.Title, status.State), nil } } // checkTimer checks a timer gate for expiration // Note: timers resolve but never escalate (escalated is always false by design) func checkTimer(gate *types.Issue, now time.Time) (resolved, escalated bool, reason string, err error) { //nolint:unparam // escalated intentionally always false if gate.Timeout == 0 { return false, false, "timer gate without timeout configured", fmt.Errorf("no timeout set") } expiresAt := gate.CreatedAt.Add(gate.Timeout) if now.After(expiresAt) { expired := now.Sub(expiresAt).Round(time.Second) return true, false, fmt.Sprintf("timer expired %s ago", expired), nil } remaining := expiresAt.Sub(now).Round(time.Second) return false, false, fmt.Sprintf("expires in %s", remaining), nil } // checkBeadGate checks if a cross-rig bead gate is satisfied. // await_id format: : (e.g., "gastown:gt-abc123") // Returns (satisfied, reason). func checkBeadGate(ctx context.Context, awaitID string) (bool, string) { // Parse await_id format: : parts := strings.SplitN(awaitID, ":", 2) if len(parts) != 2 { return false, fmt.Sprintf("invalid await_id format: expected :, got %q", awaitID) } rigName := parts[0] beadID := parts[1] if rigName == "" || beadID == "" { return false, "await_id missing rig name or bead ID" } // Resolve the target rig's beads directory currentBeadsDir := beads.FindBeadsDir() if currentBeadsDir == "" { return false, "could not find current beads directory" } targetBeadsDir, _, err := routing.ResolveBeadsDirForRig(rigName, currentBeadsDir) if err != nil { return false, fmt.Sprintf("rig %q not found: %v", rigName, err) } // Load config to get database path cfg, err := configfile.Load(targetBeadsDir) if err != nil { return false, fmt.Sprintf("failed to load config for rig %q: %v", rigName, err) } dbPath := cfg.DatabasePath(targetBeadsDir) // Open the target database (read-only) db, err := sql.Open("sqlite3", dbPath+"?mode=ro") if err != nil { return false, fmt.Sprintf("failed to open database for rig %q: %v", rigName, err) } defer func() { _ = db.Close() }() // Check if the target bead exists and is closed var status string err = db.QueryRowContext(ctx, ` SELECT status FROM issues WHERE id = ? `, beadID).Scan(&status) if err != nil { if err == sql.ErrNoRows { return false, fmt.Sprintf("bead %s not found in rig %s", beadID, rigName) } return false, fmt.Sprintf("database query failed: %v", err) } if status == string(types.StatusClosed) { return true, fmt.Sprintf("target bead %s is closed", beadID) } return false, fmt.Sprintf("target bead %s status is %q (waiting for closed)", beadID, status) } // closeGate closes a gate issue with the given reason func closeGate(_ interface{}, gateID, reason string) error { if daemonClient != nil { closeArgs := &rpc.CloseArgs{ ID: gateID, Reason: reason, } resp, err := daemonClient.CloseIssue(closeArgs) if err != nil { return err } if !resp.Success { return fmt.Errorf("%s", resp.Error) } return nil } if err := store.CloseIssue(rootCtx, gateID, reason, actor, ""); err != nil { return err } markDirtyAndScheduleFlush() return nil } // escalateGate sends an escalation for a failed/expired gate func escalateGate(gate *types.Issue, reason string) { topic := fmt.Sprintf("Gate escalation: %s", gate.ID) message := fmt.Sprintf("Gate %s needs attention.\nType: %s\nReason: %s\nCreated: %s", gate.ID, gate.AwaitType, reason, gate.CreatedAt.Format(time.RFC3339)) // Call gt escalate if available escalateCmd := exec.Command("gt", "escalate", topic, "-s", "HIGH", "-m", message) escalateCmd.Stdout = os.Stdout escalateCmd.Stderr = os.Stderr if err := escalateCmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "Warning: escalation failed for %s: %v\n", gate.ID, err) } } func init() { // gate list flags gateListCmd.Flags().BoolP("all", "a", false, "Show all gates including closed") gateListCmd.Flags().IntP("limit", "n", 50, "Limit results (default 50)") // gate resolve flags gateResolveCmd.Flags().StringP("reason", "r", "", "Reason for resolving the gate") // gate check flags gateCheckCmd.Flags().StringP("type", "t", "", "Gate type to check (gh, gh:run, gh:pr, timer, bead, all)") gateCheckCmd.Flags().Bool("dry-run", false, "Show what would happen without making changes") gateCheckCmd.Flags().BoolP("escalate", "e", false, "Escalate failed/expired gates") gateCheckCmd.Flags().IntP("limit", "l", 100, "Limit results (default 100)") // Issue ID completions gateShowCmd.ValidArgsFunction = issueIDCompletion gateResolveCmd.ValidArgsFunction = issueIDCompletion gateAddWaiterCmd.ValidArgsFunction = issueIDCompletion // Add subcommands gateCmd.AddCommand(gateListCmd) gateCmd.AddCommand(gateShowCmd) gateCmd.AddCommand(gateResolveCmd) gateCmd.AddCommand(gateCheckCmd) gateCmd.AddCommand(gateAddWaiterCmd) rootCmd.AddCommand(gateCmd) }