131 lines
4.7 KiB
Go
131 lines
4.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/zellij"
|
|
)
|
|
|
|
type spawnOptions struct {
|
|
project string
|
|
harness string
|
|
branch string
|
|
prompt string
|
|
issue string
|
|
rules string
|
|
claimPR string
|
|
noTakeover bool
|
|
}
|
|
|
|
// spawnRequest mirrors the daemon's SpawnSessionRequest body for
|
|
// POST /api/v1/sessions. The CLI keeps its own copy so it need not import httpd.
|
|
type spawnRequest struct {
|
|
ProjectID string `json:"projectId"`
|
|
IssueID string `json:"issueId,omitempty"`
|
|
Harness string `json:"harness,omitempty"`
|
|
Branch string `json:"branch,omitempty"`
|
|
Prompt string `json:"prompt,omitempty"`
|
|
AgentRules string `json:"agentRules,omitempty"`
|
|
}
|
|
|
|
type spawnResult struct {
|
|
Session struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
} `json:"session"`
|
|
}
|
|
|
|
func newSpawnCommand(ctx *commandContext) *cobra.Command {
|
|
var opts spawnOptions
|
|
cmd := &cobra.Command{
|
|
Use: "spawn",
|
|
Short: "Spawn a worker agent session in a registered project",
|
|
Long: "Spawn a worker agent session in a registered project.\n\n" +
|
|
"The session runs the chosen agent (default: the daemon's AO_AGENT) in a\n" +
|
|
"fresh git worktree. Register the project first with `ao project add`.",
|
|
Args: noArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if opts.project == "" {
|
|
return usageError{fmt.Errorf("--project is required")}
|
|
}
|
|
if opts.noTakeover && opts.claimPR == "" {
|
|
return usageError{fmt.Errorf("--no-takeover requires --claim-pr")}
|
|
}
|
|
claimRef := ""
|
|
if opts.claimPR != "" {
|
|
project, err := ctx.fetchProjectDetails(cmd.Context(), opts.project)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
claimRef, err = ctx.resolvePRRef(cmd.Context(), opts.claimPR, project)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
req := spawnRequest{
|
|
ProjectID: opts.project,
|
|
IssueID: opts.issue,
|
|
Harness: opts.harness,
|
|
Branch: opts.branch,
|
|
Prompt: opts.prompt,
|
|
AgentRules: opts.rules,
|
|
}
|
|
var res spawnResult
|
|
if err := ctx.postJSON(cmd.Context(), "sessions", req, &res); err != nil {
|
|
return err
|
|
}
|
|
claimed := ""
|
|
if opts.claimPR != "" {
|
|
var claim claimPRResponse
|
|
if err := ctx.postJSON(cmd.Context(), "sessions/"+url.PathEscape(res.Session.ID)+"/pr/claim", claimPRRequest{PR: claimRef, AllowTakeover: !opts.noTakeover}, &claim); err != nil {
|
|
if killErr := ctx.rollbackSpawnedSession(cmd.Context(), res.Session.ID); killErr != nil {
|
|
return fmt.Errorf("failed to claim PR %s: %w; rollback of session %s failed: %w", opts.claimPR, err, res.Session.ID, killErr)
|
|
}
|
|
return fmt.Errorf("failed to claim PR %s: %w; rolled back session %s", opts.claimPR, err, res.Session.ID)
|
|
}
|
|
if len(claim.PRs) > 0 {
|
|
claimed = claim.PRs[0].URL
|
|
}
|
|
}
|
|
out := cmd.OutOrStdout()
|
|
claimLabel := ""
|
|
if claimed != "" {
|
|
claimLabel = fmt.Sprintf(" (claimed %s)", claimed)
|
|
}
|
|
if _, err := fmt.Fprintf(out, "spawned session %s (%s)%s\n", res.Session.ID, res.Session.Status, claimLabel); err != nil {
|
|
return err
|
|
}
|
|
// The daemon runs zellij under a short, non-default socket dir (see
|
|
// zellij.DefaultSocketDir), so a plain `zellij attach` wouldn't find
|
|
// the session — prefix the env so the hint is copy-pasteable. Use the
|
|
// sanitised name zellij actually registers (zellij.SessionName): a long
|
|
// session id maps to a different name than the raw id.
|
|
attach := fmt.Sprintf("zellij attach %s", zellij.SessionName(res.Session.ID))
|
|
if dir := zellij.DefaultSocketDir(); dir != "" {
|
|
attach = fmt.Sprintf("ZELLIJ_SOCKET_DIR=%s %s", dir, attach)
|
|
}
|
|
_, err := fmt.Fprintf(out, "attach with: %s\n", attach)
|
|
return err
|
|
},
|
|
}
|
|
f := cmd.Flags()
|
|
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)")
|
|
f.StringVar(&opts.harness, "harness", "", "Agent harness: claude-code, codex, … (default: the daemon's AO_AGENT)")
|
|
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>)")
|
|
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
|
|
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
|
|
f.StringVar(&opts.rules, "rules", "", "Agent rules appended to the prompt")
|
|
f.StringVar(&opts.claimPR, "claim-pr", "", "Immediately claim an existing PR for the spawned session")
|
|
f.BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the claimed PR (requires --claim-pr)")
|
|
return cmd
|
|
}
|
|
|
|
func (c *commandContext) rollbackSpawnedSession(ctx context.Context, id string) error {
|
|
var res killSessionResponse
|
|
return c.postJSON(ctx, "sessions/"+url.PathEscape(id)+"/kill", struct{}{}, &res)
|
|
}
|