Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/extending/SOCKET_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Each command below matches a `case` in `handleRequest`.
| `add_agent` | Register an agent in state | `repo`, `name`, `type`, `worktree_path`, `tmux_window`, `session_id`, `pid` |
| `remove_agent` | Remove agent from state | `repo`, `name` |
| `list_agents` | List agents for a repo | `repo` |
| `complete_agent` | Mark agent ready for cleanup | `repo`, `name`, `summary`, `failure_reason` |
| `complete_agent` | Mark agent ready for cleanup | `repo`, `name`, `summary`, `failure_reason`, `pr_url` |
| `restart_agent` | Restart a persistent agent | `repo`, `name` |
| `trigger_cleanup` | Force cleanup cycle | none |
| `repair_state` | Run state repair routine | none |
Expand Down Expand Up @@ -540,7 +540,8 @@ class MulticlaudeClient {
"repo": "my-app",
"name": "clever-fox",
"summary": "Added JWT authentication with refresh tokens",
"failure_reason": ""
"failure_reason": "",
"pr_url": "https://github.com/owner/my-app/pull/42"
}
}
```
Expand All @@ -550,6 +551,7 @@ class MulticlaudeClient {
- `name` (string, required): Agent name
- `summary` (string, optional): Completion summary
- `failure_reason` (string, optional): Failure reason (if task failed)
- `pr_url` (string, optional): URL of the pull request created by the worker

**Response:**
```json
Expand Down
3 changes: 2 additions & 1 deletion docs/extending/STATE_FILE_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<!-- state-struct: State repos current_repo -->
<!-- state-struct: Repository github_url tmux_session agents task_history merge_queue_config pr_shepherd_config fork_config target_branch -->
<!-- state-struct: Agent type worktree_path tmux_window session_id pid task summary failure_reason created_at last_nudge ready_for_cleanup -->
<!-- state-struct: Agent type worktree_path tmux_window session_id pid task summary failure_reason pr_url created_at last_nudge ready_for_cleanup -->
<!-- state-struct: TaskHistoryEntry name task branch pr_url pr_number status summary failure_reason created_at completed_at -->
<!-- state-struct: MergeQueueConfig enabled track_mode -->
<!-- state-struct: PRShepherdConfig enabled track_mode -->
Expand Down Expand Up @@ -50,6 +50,7 @@ The daemon persists state to `~/.multiclaude/state.json` and writes it atomicall
"task": "Implement feature X", // Only for workers
"summary": "Added auth module", // Only for workers (completion summary)
"failure_reason": "Tests failed", // Only for workers (if task failed)
"pr_url": "https://github.com/user/repo/pull/42", // Only for workers (PR URL if created)
"created_at": "2024-01-15T10:30:00Z",
"last_nudge": "2024-01-15T10:35:00Z",
"ready_for_cleanup": false // Only for workers (signals completion)
Expand Down
44 changes: 37 additions & 7 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ func (c *CLI) registerCommands() {
agentCmd.Subcommands["complete"] = &Command{
Name: "complete",
Description: "Signal worker completion",
Usage: "multiclaude agent complete [--summary <text>] [--failure <reason>]",
Usage: "multiclaude agent complete [--summary <text>] [--failure <reason>] [--pr-url <url>]",
Run: c.completeWorker,
}

Expand Down Expand Up @@ -2702,9 +2702,11 @@ func (c *CLI) showHistory(args []string) error {
// Try to get PR status from GitHub if we have a branch
prStatus, prLink := c.getPRStatusForBranch(repoPath, branch, prURL)

// Use stored status if it indicates failure
// Use stored status if it indicates failure or a terminal state
if storedStatus == "failed" {
prStatus = "failed"
} else if storedStatus == "merged" || storedStatus == "closed" {
prStatus = storedStatus
}

// Apply status filter
Expand Down Expand Up @@ -2814,12 +2816,34 @@ func (c *CLI) showHistory(args []string) error {

// getPRStatusForBranch queries GitHub for the PR status of a branch
func (c *CLI) getPRStatusForBranch(repoPath, branch, existingPRURL string) (status, prLink string) {
// If we already have a PR URL, just return it formatted
// If we already have a PR URL, query its status directly
if existingPRURL != "" {
// Extract PR number from URL for shorter display
parts := strings.Split(existingPRURL, "/")
if len(parts) > 0 {
prNum := parts[len(parts)-1]
// Extract PR number from URL
parts := strings.Split(strings.TrimRight(existingPRURL, "/"), "/")
prNum := ""
if len(parts) >= 2 && parts[len(parts)-2] == "pull" {
prNum = parts[len(parts)-1]
}

// Try to get live status from GitHub
if prNum != "" {
cmd := exec.Command("gh", "pr", "view", prNum, "--json", "state")
cmd.Dir = repoPath
if output, err := cmd.Output(); err == nil {
var pr struct {
State string `json:"state"`
}
if json.Unmarshal(output, &pr) == nil {
switch strings.ToUpper(pr.State) {
case "MERGED":
return "merged", "#" + prNum
case "OPEN":
return "open", "#" + prNum
case "CLOSED":
return "closed", "#" + prNum
}
}
}
return "unknown", "#" + prNum
}
return "unknown", existingPRURL
Expand Down Expand Up @@ -4228,6 +4252,12 @@ func (c *CLI) completeWorker(args []string) error {
fmt.Printf("Failure reason: %s\n", failureReason)
}

// Add optional PR URL
if prURL, ok := flags["pr-url"]; ok && prURL != "" {
reqArgs["pr_url"] = prURL
fmt.Printf("PR URL: %s\n", prURL)
}

client := socket.NewClient(c.paths.DaemonSock)
resp, err := client.Send(socket.Request{
Command: "complete_agent",
Expand Down
180 changes: 177 additions & 3 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package daemon

import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
Expand Down Expand Up @@ -106,12 +108,13 @@ func (d *Daemon) Start() error {
d.restoreTrackedRepos()

// Start core loops after restore completes
d.wg.Add(5)
d.wg.Add(6)
go d.healthCheckLoop()
go d.messageRouterLoop()
go d.wakeLoop()
go d.serverLoop()
go d.worktreeRefreshLoop()
go d.prOutcomeTrackingLoop()

return nil
}
Expand Down Expand Up @@ -611,6 +614,149 @@ func (d *Daemon) TriggerWorktreeRefresh() {
d.refreshWorktrees()
}

// prOutcomeTrackingLoop periodically checks pending task history entries for PR outcome changes
func (d *Daemon) prOutcomeTrackingLoop() {
defer d.wg.Done()
d.logger.Info("Starting PR outcome tracking loop")

ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()

// Run once after a short delay on startup
select {
case <-time.After(1 * time.Minute):
d.updatePROutcomes()
case <-d.ctx.Done():
d.logger.Info("PR outcome tracking loop stopped")
return
}

for {
select {
case <-ticker.C:
d.updatePROutcomes()
case <-d.ctx.Done():
d.logger.Info("PR outcome tracking loop stopped")
return
}
}
}

// TriggerPROutcomeTracking triggers an immediate PR outcome check (for testing)
func (d *Daemon) TriggerPROutcomeTracking() {
d.updatePROutcomes()
}

// updatePROutcomes checks all pending task history entries and updates their PR status
func (d *Daemon) updatePROutcomes() {
d.logger.Debug("Checking PR outcomes for pending tasks")

repos := d.state.ListRepos()
for _, repoName := range repos {
pending, err := d.state.GetPendingTaskHistory(repoName)
if err != nil {
d.logger.Debug("Could not get pending task history for %s: %v", repoName, err)
continue
}

if len(pending) == 0 {
continue
}

repoPath := d.paths.RepoDir(repoName)
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
continue
}

for _, entry := range pending {
status, prURL, prNumber := d.checkPRStatus(repoPath, entry)
if status == "" || status == state.TaskStatusUnknown {
continue
}

// Update the entry if status changed
if status != entry.Status || (prURL != "" && entry.PRURL == "") {
if err := d.state.UpdateTaskHistoryStatus(repoName, entry.Name, status, prURL, prNumber); err != nil {
d.logger.Debug("Could not update task history for %s/%s: %v", repoName, entry.Name, err)
} else {
d.logger.Info("Updated PR outcome for %s/%s: %s (PR #%d)", repoName, entry.Name, status, prNumber)
}
}
}
}
}

// checkPRStatus queries GitHub for the PR status of a task history entry
func (d *Daemon) checkPRStatus(repoPath string, entry state.TaskHistoryEntry) (state.TaskStatus, string, int) {
// If we already have a PR URL with a number, check by number (faster)
if entry.PRNumber > 0 {
return d.checkPRStatusByNumber(repoPath, entry.PRNumber, entry.PRURL)
}

// Otherwise query by branch
if entry.Branch == "" {
return "", "", 0
}

cmd := exec.Command("gh", "pr", "list", "--head", entry.Branch, "--state", "all",
"--json", "number,state,url", "--limit", "1")
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
return "", "", 0
}

var prs []struct {
Number int `json:"number"`
State string `json:"state"`
URL string `json:"url"`
}
if err := json.Unmarshal(output, &prs); err != nil || len(prs) == 0 {
return "", "", 0
}

pr := prs[0]
return ghStateToTaskStatus(pr.State), pr.URL, pr.Number
}

// checkPRStatusByNumber queries GitHub for a specific PR number
func (d *Daemon) checkPRStatusByNumber(repoPath string, prNumber int, prURL string) (state.TaskStatus, string, int) {
cmd := exec.Command("gh", "pr", "view", strconv.Itoa(prNumber), "--json", "state,url")
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
return "", "", 0
}

var pr struct {
State string `json:"state"`
URL string `json:"url"`
}
if err := json.Unmarshal(output, &pr); err != nil {
return "", "", 0
}

url := prURL
if pr.URL != "" {
url = pr.URL
}
return ghStateToTaskStatus(pr.State), url, prNumber
}

// ghStateToTaskStatus converts GitHub PR state to TaskStatus
func ghStateToTaskStatus(ghState string) state.TaskStatus {
switch strings.ToUpper(ghState) {
case "MERGED":
return state.TaskStatusMerged
case "OPEN":
return state.TaskStatusOpen
case "CLOSED":
return state.TaskStatusClosed
default:
return state.TaskStatusUnknown
}
}

// handleRequest handles incoming socket requests
func (d *Daemon) handleRequest(req socket.Request) socket.Response {
d.logger.Debug("Handling request: %s", req.Command)
Expand Down Expand Up @@ -1047,6 +1193,9 @@ func (d *Daemon) handleCompleteAgent(req socket.Request) socket.Response {
if failureReason := getOptionalStringArg(req.Args, "failure_reason", ""); failureReason != "" {
agent.FailureReason = failureReason
}
if prURL := getOptionalStringArg(req.Args, "pr_url", ""); prURL != "" {
agent.PRURL = prURL
}

if err := d.state.UpdateAgent(repoName, agentName, agent); err != nil {
return socket.ErrorResponse("%s", err.Error())
Expand Down Expand Up @@ -1468,11 +1617,24 @@ func (d *Daemon) recordTaskHistory(repoName, agentName string, agent state.Agent
status = state.TaskStatusFailed
}

// Extract PR URL and number from agent
prURL := agent.PRURL
prNumber := 0
if prURL != "" {
prNumber = parsePRNumber(prURL)
// If we have a PR URL and no failure, mark as open
if status != state.TaskStatusFailed {
status = state.TaskStatusOpen
}
}

entry := state.TaskHistoryEntry{
Name: agentName,
Task: agent.Task,
Branch: branch,
Status: status, // Will be updated when displaying if a PR exists
PRURL: prURL,
PRNumber: prNumber,
Status: status,
Summary: agent.Summary,
FailureReason: agent.FailureReason,
CreatedAt: agent.CreatedAt,
Expand All @@ -1482,8 +1644,20 @@ func (d *Daemon) recordTaskHistory(repoName, agentName string, agent state.Agent
if err := d.state.AddTaskHistory(repoName, entry); err != nil {
d.logger.Warn("Failed to record task history for %s: %v", agentName, err)
} else {
d.logger.Info("Recorded task history for %s (branch: %s, summary: %q)", agentName, branch, agent.Summary)
d.logger.Info("Recorded task history for %s (branch: %s, pr: %s, summary: %q)", agentName, branch, prURL, agent.Summary)
}
}

// parsePRNumber extracts the PR number from a GitHub PR URL
// e.g., "https://github.com/owner/repo/pull/123" -> 123
func parsePRNumber(prURL string) int {
parts := strings.Split(strings.TrimRight(prURL, "/"), "/")
if len(parts) >= 2 && parts[len(parts)-2] == "pull" {
if n, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
return n
}
}
return 0
}

// handleTaskHistory returns the task history for a repository
Expand Down
Loading