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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ sao plan
sao once
```

When a dispatched agent produces file changes, `sao` creates an isolated git worktree under `~/.local/state/sao/worktrees/`, commits the diff on a task branch, pushes it, opens a draft pull request, and prints the PR URL. The PR URL, branch, worktree path, commit SHA, and agent summary are also stored in the local state file under `~/.local/state/sao/`.

6. Or run the foreground loop:

```bash
Expand Down Expand Up @@ -193,4 +195,5 @@ State:

- This is currently an MVP.
- Agent execution is routed through `acpx`.
- Completed tasks run in isolated git worktrees and are delivered as draft pull requests when they produce a git diff.
- Supported agent runtimes today are `codex` and `claude`.
155 changes: 153 additions & 2 deletions internal/gh/gh.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
Expand Down Expand Up @@ -38,6 +39,20 @@ type Issue struct {
Assignees []string
}

type DeliveryResult struct {
Branch string
WorktreePath string
CommitSHA string
PullRequestURL string
HasChanges bool
}

type TaskWorktree struct {
Name string
BaseBranch string
Path string
}

type issueJSON struct {
Number int `json:"number"`
Title string `json:"title"`
Expand Down Expand Up @@ -130,6 +145,93 @@ func ListIssues(ctx context.Context, repo Repository, repoCfg config.RepoConfig)
return all, nil
}

func PrepareTaskWorktree(ctx context.Context, repo Repository, issue Issue) (TaskWorktree, error) {
repoPath := repo.LocalPath
baseBranch, err := gitOutput(ctx, repoPath, "branch", "--show-current")
if err != nil {
return TaskWorktree{}, fmt.Errorf("read current branch: %w", err)
}
baseBranch = strings.TrimSpace(baseBranch)
if baseBranch == "" {
return TaskWorktree{}, errors.New("cannot dispatch from detached HEAD")
}

stateDir, err := config.MachineStateDir()
if err != nil {
return TaskWorktree{}, err
}

suffix := time.Now().UTC().Format("20060102-150405")
branch := fmt.Sprintf("sao/issue-%d-%s", issue.Number, suffix)
worktreePath := filepath.Join(stateDir, "worktrees", sanitizePathComponent(repo.Slug), fmt.Sprintf("issue-%d-%s", issue.Number, suffix))
if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
return TaskWorktree{}, fmt.Errorf("create worktree parent: %w", err)
}

if _, err := gitOutput(ctx, repoPath, "worktree", "add", "-b", branch, worktreePath, "HEAD"); err != nil {
return TaskWorktree{}, fmt.Errorf("create task worktree %s: %w", worktreePath, err)
}
return TaskWorktree{Name: branch, BaseBranch: baseBranch, Path: worktreePath}, nil
}

func PublishTaskChanges(ctx context.Context, repo Repository, issue Issue, worktree TaskWorktree, agentSummary string) (DeliveryResult, error) {
clean, err := WorkingTreeClean(ctx, worktree.Path)
if err != nil {
return DeliveryResult{}, err
}

result := DeliveryResult{
Branch: worktree.Name,
WorktreePath: worktree.Path,
HasChanges: !clean,
}
if clean {
return result, nil
}

if _, err := gitOutput(ctx, worktree.Path, "add", "-A"); err != nil {
return DeliveryResult{}, fmt.Errorf("stage changes: %w", err)
}

commitTitle := fmt.Sprintf("Fix #%d: %s", issue.Number, issue.Title)
commitBody := strings.TrimSpace(fmt.Sprintf("Implemented by sao.\n\nIssue: %s\n\nAgent summary:\n%s", issue.URL, strings.TrimSpace(agentSummary)))
if _, err := gitOutput(ctx, worktree.Path, "commit", "-m", commitTitle, "-m", commitBody); err != nil {
return DeliveryResult{}, fmt.Errorf("commit task changes: %w", err)
}

commitSHA, err := gitOutput(ctx, worktree.Path, "rev-parse", "--short", "HEAD")
if err != nil {
return DeliveryResult{}, fmt.Errorf("read commit sha: %w", err)
}
result.CommitSHA = strings.TrimSpace(commitSHA)

if _, err := gitOutput(ctx, worktree.Path, "push", "-u", "origin", worktree.Name); err != nil {
return DeliveryResult{}, fmt.Errorf("push task branch: %w", err)
}

prURL, err := createDraftPR(ctx, repo, issue, worktree.Name, agentSummary)
if err != nil {
return DeliveryResult{}, err
}
result.PullRequestURL = prURL
return result, nil
}

func DeliveryForWorktree(worktree TaskWorktree) DeliveryResult {
return DeliveryResult{
Branch: worktree.Name,
WorktreePath: worktree.Path,
}
}

func WorkingTreeClean(ctx context.Context, repoPath string) (bool, error) {
out, err := gitOutput(ctx, repoPath, "status", "--porcelain")
if err != nil {
return false, fmt.Errorf("check working tree: %w", err)
}
return strings.TrimSpace(out) == "", nil
}

func gitOutput(ctx context.Context, repoPath string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", repoPath}, args...)...)
var stderr bytes.Buffer
Expand All @@ -145,7 +247,56 @@ func gitOutput(ctx context.Context, repoPath string, args ...string) (string, er
return string(out), nil
}

func createDraftPR(ctx context.Context, repo Repository, issue Issue, branch, agentSummary string) (string, error) {
title := fmt.Sprintf("Fix #%d: %s", issue.Number, issue.Title)
body := strings.TrimSpace(fmt.Sprintf("Closes %s\n\nAgent summary:\n%s", issue.URL, strings.TrimSpace(agentSummary)))
if body == "" {
body = fmt.Sprintf("Closes %s", issue.URL)
}
out, err := ghOutput(
ctx,
repo.LocalPath,
"pr", "create",
"--repo", repo.Slug,
"--draft",
"--head", branch,
"--title", title,
"--body", body,
)
if err != nil {
return "", fmt.Errorf("create draft pull request: %w", err)
}
return strings.TrimSpace(out), nil
}

func sanitizePathComponent(value string) string {
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_', r == '.':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
return strings.Trim(b.String(), "-")
}

func ghJSON(ctx context.Context, repoPath string, args ...string) ([]byte, error) {
out, err := ghOutput(ctx, repoPath, args...)
if err != nil {
return nil, err
}
return []byte(out), nil
}

func ghOutput(ctx context.Context, repoPath string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "gh", args...)
if repoPath != "" {
cmd.Dir = repoPath
Expand All @@ -158,9 +309,9 @@ func ghJSON(ctx context.Context, repoPath string, args ...string) ([]byte, error
if msg == "" {
msg = err.Error()
}
return nil, errors.New(msg)
return "", errors.New(msg)
}
return out, nil
return string(out), nil
}

func mapIssue(item issueJSON) (Issue, error) {
Expand Down
55 changes: 48 additions & 7 deletions internal/sao/sao.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,12 +507,35 @@ func runCycle(ctx context.Context, cfg config.MachineConfig, stdout, stderr io.W
wg.Add(1)
go func(plan dispatchPlan) {
defer wg.Done()
prompt := buildPrompt(plan.Candidate)
worktree, err := gh.PrepareTaskWorktree(ctx, plan.Candidate.Repo, plan.Candidate.Issue)
if err != nil {
outcomes <- dispatchOutcome{
Plan: plan,
Err: fmt.Errorf("prepare task worktree: %w", err),
CompletedAt: time.Now().UTC(),
}
return
}

prompt := buildPrompt(plan.Candidate, worktree.Path)
runner := acpx.NewRunner(plan.Agent.Command)
response, err := runner.Exec(ctx, plan.Candidate.ProjectPath, plan.RuntimeName, prompt)
response, err := runner.Exec(ctx, worktree.Path, plan.RuntimeName, prompt)
if err != nil {
outcomes <- dispatchOutcome{
Plan: plan,
Response: response,
Delivery: gh.DeliveryForWorktree(worktree),
Err: err,
CompletedAt: time.Now().UTC(),
}
return
}

delivery, err := gh.PublishTaskChanges(ctx, plan.Candidate.Repo, plan.Candidate.Issue, worktree, response.AssistantText)
outcomes <- dispatchOutcome{
Plan: plan,
Response: response,
Delivery: delivery,
Err: err,
CompletedAt: time.Now().UTC(),
}
Expand All @@ -526,7 +549,7 @@ func runCycle(ctx context.Context, cfg config.MachineConfig, stdout, stderr io.W
var errs []error
for outcome := range outcomes {
if outcome.Err != nil {
markTaskFailure(store, outcome.Plan.Candidate.Issue.URL, outcome.Plan.Candidate.Issue.UpdatedAt, outcome.CompletedAt, outcome.Err)
markTaskFailure(store, outcome.Plan.Candidate.Issue.URL, outcome.Plan.Candidate.Issue.UpdatedAt, outcome.CompletedAt, outcome.Err, outcome.Delivery)
fmt.Fprintf(
stderr,
"failed %s #%d with %s: %v\n",
Expand All @@ -547,13 +570,22 @@ func runCycle(ctx context.Context, cfg config.MachineConfig, stdout, stderr io.W
UpdatedAt: outcome.CompletedAt,
CompletedAt: outcome.CompletedAt,
LastResponse: outcome.Response.AssistantText,
Branch: outcome.Delivery.Branch,
WorktreePath: outcome.Delivery.WorktreePath,
CommitSHA: outcome.Delivery.CommitSHA,
PullRequestURL: outcome.Delivery.PullRequestURL,
}
if outcome.Response.AssistantText != "" {
fmt.Fprintf(stdout, "completed %s #%d\n", outcome.Plan.Candidate.Repo.Slug, outcome.Plan.Candidate.Issue.Number)
fmt.Fprintf(stdout, "agent summary:\n%s\n", outcome.Response.AssistantText)
} else {
fmt.Fprintf(stdout, "completed %s #%d with no summary returned\n", outcome.Plan.Candidate.Repo.Slug, outcome.Plan.Candidate.Issue.Number)
}
if outcome.Delivery.PullRequestURL != "" {
fmt.Fprintf(stdout, "pull request: %s\n", outcome.Delivery.PullRequestURL)
} else if outcome.Delivery.Branch != "" && !outcome.Delivery.HasChanges {
fmt.Fprintf(stdout, "no changes to publish for %s #%d\n", outcome.Plan.Candidate.Repo.Slug, outcome.Plan.Candidate.Issue.Number)
}
}

if err := state.Save(store); err != nil {
Expand All @@ -576,6 +608,7 @@ type dispatchPlan struct {
type dispatchOutcome struct {
Plan dispatchPlan
Response acpx.Result
Delivery gh.DeliveryResult
Err error
CompletedAt time.Time
}
Expand Down Expand Up @@ -702,14 +735,15 @@ func runtimeAgentName(agent config.InstalledAgent) (string, bool) {
return acpx.ResolveAgentName(agent.Name)
}

func buildPrompt(candidate planner.Candidate) string {
func buildPrompt(candidate planner.Candidate, worktreePath string) string {
body := strings.TrimSpace(candidate.Issue.Body)
if len(body) > 4000 {
body = body[:4000] + "\n...[truncated]"
}

return strings.TrimSpace(fmt.Sprintf(`
You are working inside the repository at: %s
You are working inside a git worktree at: %s
Original watched repository path: %s

GitHub issue:
- Repository: %s
Expand All @@ -726,17 +760,24 @@ Please:
3. run any relevant local validation if available
4. summarize the changes you made

Do not create a branch, commit, push, or open a pull request. The orchestrator will publish your file changes after you finish.
Do not ask for orchestration help. Work directly in the repository.
`, candidate.ProjectPath, candidate.Repo.Slug, candidate.Issue.Number, candidate.Issue.URL, candidate.Issue.Title, body))
`, worktreePath, candidate.ProjectPath, candidate.Repo.Slug, candidate.Issue.Number, candidate.Issue.URL, candidate.Issue.Title, body))
}

func markTaskFailure(store state.Store, issueURL string, issueUpdatedAt, completedAt time.Time, err error) {
func markTaskFailure(store state.Store, issueURL string, issueUpdatedAt, completedAt time.Time, err error, delivery gh.DeliveryResult) {
record := store.Tasks[issueURL]
record.Status = "failed"
record.IssueUpdatedAt = issueUpdatedAt
record.UpdatedAt = completedAt
record.CompletedAt = completedAt
record.LastError = err.Error()
if delivery.Branch != "" {
record.Branch = delivery.Branch
}
if delivery.WorktreePath != "" {
record.WorktreePath = delivery.WorktreePath
}
store.Tasks[issueURL] = record
}

Expand Down
4 changes: 4 additions & 0 deletions internal/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ type TaskRecord struct {
CompletedAt time.Time `json:"completed_at,omitempty"`
LastError string `json:"last_error,omitempty"`
LastResponse string `json:"last_response,omitempty"`
Branch string `json:"branch,omitempty"`
WorktreePath string `json:"worktree_path,omitempty"`
CommitSHA string `json:"commit_sha,omitempty"`
PullRequestURL string `json:"pull_request_url,omitempty"`
}

func Load() (Store, error) {
Expand Down