Skip to content
Draft
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ progress (what's shipped vs. in flight) see [`docs/STATUS.md`](docs/STATUS.md).
`opencode`, `aider`, `amp`, `goose`, `copilot`, `grok`, `qwen`, `kimi`,
`crush`, `cline`, `droid`, `devin`, `auggie`, `continue`, `kiro`, `kilocode`,
and more), registered through a shared registry with common
activity-dispatch / hook utilities. The default is set by `AO_AGENT`.
activity-dispatch / hook utilities. App-wide worker and orchestrator defaults
are stored in daemon-backed settings.
- **Isolated workspaces.** Worker and orchestrator sessions spawn into their own
`git worktree` (`backend/internal/adapters/workspace/gitworktree/`), launched
inside a `zellij` runtime adapter (`backend/internal/adapters/runtime/`) so
Expand Down Expand Up @@ -94,7 +95,7 @@ go build -o /tmp/ao ./cmd/ao
# base of --path; pass --id explicitly when the directory name doesn't match.
/tmp/ao project add --path /path/to/your/repo --id your-repo --name your-repo

# Spawn a worker session running the default agent.
# Spawn a worker session running the configured default agent.
/tmp/ao spawn --project your-repo --prompt "Refactor the auth module"

# Inspect what's running.
Expand Down Expand Up @@ -167,7 +168,6 @@ exposing it beyond loopback would be a security regression.
| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful-shutdown hard cap. |
| `AO_RUN_FILE` | `<UserConfigDir>/agent-orchestrator/running.json` | PID + port handshake path. |
| `AO_DATA_DIR` | `<UserConfigDir>/agent-orchestrator/data` | SQLite DB, WAL files, managed state. |
| `AO_AGENT` | `claude-code` | Default agent adapter id used by `ao spawn`. |
| `AO_SESSION_ID` | _(unset)_ | Set inside spawned sessions; read by `ao send` and `ao hooks`. |
| `GITHUB_TOKEN` | _(unset)_ | Used by the GitHub SCM and tracker adapters. Falls back to `gh auth token`. |

Expand Down
5 changes: 5 additions & 0 deletions backend/internal/adapters/runtime/zellij/zellij.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru
if cfg.WorkspacePath == "" {
return ports.RuntimeHandle{}, errors.New("zellij runtime: workspace path is required")
}
if stat, err := os.Stat(cfg.WorkspacePath); err != nil {
return ports.RuntimeHandle{}, fmt.Errorf("zellij runtime: workspace path %q: %w", cfg.WorkspacePath, err)
} else if !stat.IsDir() {
return ports.RuntimeHandle{}, fmt.Errorf("zellij runtime: workspace path %q is not a directory", cfg.WorkspacePath)
}
if len(cfg.Argv) == 0 {
return ports.RuntimeHandle{}, errors.New("zellij runtime: launch command is required")
}
Expand Down
20 changes: 17 additions & 3 deletions backend/internal/adapters/runtime/zellij/zellij_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"strings"
Expand Down Expand Up @@ -271,7 +272,7 @@ func TestCreateRejectsInvalidEnvKeys(t *testing.T) {
r.runner = &fakeRunner{}
_, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "sess-1",
WorkspacePath: "/tmp/ws",
WorkspacePath: t.TempDir(),
Argv: []string{"echo", "ready"},
Env: map[string]string{"BAD KEY": "x"},
})
Expand All @@ -280,14 +281,27 @@ func TestCreateRejectsInvalidEnvKeys(t *testing.T) {
}
}

func TestCreateRejectsMissingWorkspacePath(t *testing.T) {
r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh"})
r.runner = &fakeRunner{}
_, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "sess-1",
WorkspacePath: filepath.Join(t.TempDir(), "missing"),
Argv: []string{"echo", "ready"},
})
if err == nil || !strings.Contains(err.Error(), "workspace path") {
t.Fatalf("Create err = %v, want workspace path error", err)
}
}

func TestCreateStartsSessionAndDiscoversPane(t *testing.T) {
fr := &fakeRunner{outputs: [][]byte{[]byte("zellij 0.44.3"), nil, nil, []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`)}}
r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh", SocketDir: "/tmp/zj", ConfigDir: "/tmp/cfg"})
r.runner = fr

handle, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "sess-1",
WorkspacePath: "/tmp/ws",
WorkspacePath: t.TempDir(),
Argv: []string{"echo", "ready"},
Env: map[string]string{"AO_SESSION_ID": "sess-1"},
})
Expand Down Expand Up @@ -329,7 +343,7 @@ func TestCreateClearsStaleSessionBeforeCreating(t *testing.T) {

if _, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "sess-1",
WorkspacePath: "/tmp/ws",
WorkspacePath: t.TempDir(),
Argv: []string{"echo", "ready"},
}); err != nil {
t.Fatalf("Create: %v", err)
Expand Down
54 changes: 48 additions & 6 deletions backend/internal/adapters/workspace/gitworktree/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,14 +229,56 @@ func (w *Workspace) existingWorktree(ctx context.Context, repo, path string, cfg
if err != nil {
return ports.WorkspaceInfo{}, false, err
}
if rec, ok := findWorktree(records, path); ok {
branch := rec.Branch
if branch == "" {
branch = cfg.Branch
rec, ok := findWorktree(records, path)
if !ok {
return ports.WorkspaceInfo{}, false, nil
}
usable, err := registeredWorktreeUsable(rec)
if err != nil {
return ports.WorkspaceInfo{}, false, err
}
if !usable {
if _, err := w.run(ctx, w.binary, worktreePruneArgs(repo)...); err != nil {
return ports.WorkspaceInfo{}, false, fmt.Errorf("gitworktree: worktree prune stale %q: %w", path, err)
}
records, err = w.listRecords(ctx, repo)
if err != nil {
return ports.WorkspaceInfo{}, false, err
}
rec, ok = findWorktree(records, path)
if !ok {
return ports.WorkspaceInfo{}, false, nil
}
usable, err = registeredWorktreeUsable(rec)
if err != nil {
return ports.WorkspaceInfo{}, false, err
}
return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, true, nil
if !usable {
return ports.WorkspaceInfo{}, false, fmt.Errorf("gitworktree: refusing to reuse stale registered worktree %q after prune", path)
}
}
branch := rec.Branch
if branch == "" {
branch = cfg.Branch
}
return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, true, nil
}

func registeredWorktreeUsable(rec worktreeRecord) (bool, error) {
if rec.Prunable {
return false, nil
}
stat, err := os.Stat(rec.Path)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("gitworktree: stat registered worktree %q: %w", rec.Path, err)
}
if !stat.IsDir() {
return false, fmt.Errorf("gitworktree: registered worktree %q is not a directory", rec.Path)
}
return ports.WorkspaceInfo{}, false, nil
return true, nil
}

func (w *Workspace) addWorktree(ctx context.Context, repo, path, branch, baseBranch string) error {
Expand Down
69 changes: 69 additions & 0 deletions backend/internal/adapters/workspace/gitworktree/workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ func TestBaseRefCandidates(t *testing.T) {
}
}

func containsJoined(values []string, want string) bool {
for _, value := range values {
if strings.Contains(value, want) {
return true
}
}
return false
}

func TestParseWorktreePorcelain(t *testing.T) {
input := strings.Join([]string{
"worktree /repo",
Expand Down Expand Up @@ -182,6 +191,9 @@ func TestCreateReusesRegisteredWorktreeAtExpectedPath(t *testing.T) {
t.Fatalf("new: %v", err)
}
path := filepath.Join(ws.managedRoot, "proj", "orchestrator", "proj-orchestrator")
if err := os.MkdirAll(path, 0o750); err != nil {
t.Fatalf("mkdir path: %v", err)
}
cfg := ports.WorkspaceConfig{
ProjectID: "proj",
SessionID: "proj-1",
Expand Down Expand Up @@ -211,6 +223,63 @@ func TestCreateReusesRegisteredWorktreeAtExpectedPath(t *testing.T) {
}
}

func TestCreatePrunesStaleRegisteredWorktreeBeforeAdd(t *testing.T) {
root := t.TempDir()
repo := t.TempDir()
ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}})
if err != nil {
t.Fatalf("new: %v", err)
}
path := filepath.Join(ws.managedRoot, "proj", "orchestrator", "proj-orchestrator")
cfg := ports.WorkspaceConfig{
ProjectID: "proj",
SessionID: "proj-1",
Kind: domain.KindOrchestrator,
SessionPrefix: "proj",
Branch: "ao/proj-orchestrator",
}

listCalls := 0
var calls []string
ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
joined := strings.Join(args, " ")
calls = append(calls, joined)
switch {
case strings.Contains(joined, "check-ref-format"):
return nil, nil
case strings.Contains(joined, "worktree list --porcelain"):
listCalls++
if listCalls == 1 {
return []byte("worktree " + path + "\nHEAD abc123\nbranch refs/heads/ao/proj-orchestrator\nprunable gitdir file points to non-existent location\n"), nil
}
return []byte("worktree " + repo + "\nHEAD root123\nbranch refs/heads/main\n"), nil
case strings.Contains(joined, "worktree prune"):
return nil, nil
case strings.Contains(joined, "rev-parse --verify --quiet refs/heads/ao/proj-orchestrator"):
return []byte("abc123\n"), nil
case strings.Contains(joined, "worktree add"):
return nil, nil
default:
t.Fatalf("unexpected git invocation: %v", args)
return nil, nil
}
}

info, err := ws.Create(context.Background(), cfg)
if err != nil {
t.Fatalf("Create: %v", err)
}
if info.Path != path || info.Branch != "ao/proj-orchestrator" {
t.Fatalf("info = %#v, want path %q branch ao/proj-orchestrator", info, path)
}
if !containsJoined(calls, "worktree prune") {
t.Fatalf("calls missing prune: %#v", calls)
}
if !containsJoined(calls, "worktree add "+path+" ao/proj-orchestrator") {
t.Fatalf("calls missing worktree add: %#v", calls)
}
}

// TestValidateConfigRejectsPathEscapingIDs covers review item RB: filepath.Join
// in managedPath cleans `..` segments before validateManagedPath sees them, so a
// session id of "../other" would stay inside managedRoot while jumping projects.
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/cli/dto_drift_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (do
}, nil
}

func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, _ bool) (domain.Session, error) {
return f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator})
func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, _ bool, harness domain.AgentHarness) (domain.Session, error) {
return f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator, Harness: harness})
}

func (f *fakeSessionService) Get(context.Context, domain.SessionID) (domain.Session, error) {
Expand Down
5 changes: 3 additions & 2 deletions backend/internal/cli/spawn.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ func newSpawnCommand(ctx *commandContext) *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" +
"The session runs the chosen agent, the project's role override, or the\n" +
"app-wide default agent configured in Settings. It uses a\n" +
"fresh git worktree. Register the project first with `ao project add`.",
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -120,7 +121,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command {
return pflag.NormalizedName(name)
})
f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)")
f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: the daemon's AO_AGENT)")
f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: app setting)")
f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)")
f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent")
f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session")
Expand Down
13 changes: 0 additions & 13 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,6 @@ const (
// DefaultShutdownTimeout is the hard cap on graceful shutdown. After this
// the process exits even if connections are still draining.
DefaultShutdownTimeout = 10 * time.Second
// DefaultAgent is the agent adapter id the daemon wires when AO_AGENT is
// unset. It matches the claude-code adapter's manifest id.
DefaultAgent = "claude-code"
)

// DefaultAllowedOrigins are the browser origins the daemon's CORS boundary
Expand Down Expand Up @@ -63,10 +60,6 @@ type Config struct {
// DataDir is the directory holding durable SQLite state: DB and WAL files.
// It is created on first use by the storage layer.
DataDir string
// Agent is the id of the agent adapter the daemon wires into the Session
// Manager (see DefaultAgent). Selected by AO_AGENT; startSession fails fast
// if no adapter with this id is registered.
Agent string
// AllowedOrigins are the browser origins granted CORS read access (see
// DefaultAllowedOrigins). Overridden by AO_ALLOWED_ORIGINS.
AllowedOrigins []string
Expand All @@ -89,7 +82,6 @@ func (c Config) Addr() string {
// AO_SHUTDOWN_TIMEOUT shutdown deadline (Go duration > 0, default 10s)
// AO_RUN_FILE running.json path (default ~/.ao/running.json)
// AO_DATA_DIR durable state dir (default ~/.ao/data)
// AO_AGENT agent adapter id (default claude-code)
// AO_ALLOWED_ORIGINS CORS origins, comma-separated (default DefaultAllowedOrigins)
//
// The bind host is not configurable: the daemon is loopback-only by design.
Expand All @@ -99,7 +91,6 @@ func Load() (Config, error) {
Port: DefaultPort,
RequestTimeout: DefaultRequestTimeout,
ShutdownTimeout: DefaultShutdownTimeout,
Agent: DefaultAgent,
AllowedOrigins: DefaultAllowedOrigins,
}

Expand Down Expand Up @@ -130,10 +121,6 @@ func Load() (Config, error) {
cfg.ShutdownTimeout = d
}

if raw := os.Getenv("AO_AGENT"); raw != "" {
cfg.Agent = raw
}

if raw, ok := os.LookupEnv("AO_ALLOWED_ORIGINS"); ok && raw != "" {
// Explicit override replaces the defaults entirely so a deployment can
// also narrow the list. The "null" origin is rejected, never silently
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
func TestLoadDefaults(t *testing.T) {
// Clear every recognised var so we observe pure defaults regardless of the
// surrounding environment.
for _, k := range []string{"AO_PORT", "AO_REQUEST_TIMEOUT", "AO_SHUTDOWN_TIMEOUT", "AO_RUN_FILE", "AO_DATA_DIR", "AO_AGENT", "AO_ALLOWED_ORIGINS"} {
for _, k := range []string{"AO_PORT", "AO_REQUEST_TIMEOUT", "AO_SHUTDOWN_TIMEOUT", "AO_RUN_FILE", "AO_DATA_DIR", "AO_ALLOWED_ORIGINS"} {
t.Setenv(k, "")
}

Expand Down
7 changes: 4 additions & 3 deletions backend/internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
settingssvc "github.com/aoagents/agent-orchestrator/backend/internal/service/settings"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
)
Expand Down Expand Up @@ -94,9 +95,8 @@ func Run() error {
lcStack.scmDone = startSCMObserver(ctx, store, lcStack.LCM, log)

// Wire the controller-facing session service over the same store + LCM, the
// zellij runtime, a gitworktree workspace, the per-session agent resolver
// (AO_AGENT default, validated here), and the agent messenger, then mount it
// on the API.
// zellij runtime, a gitworktree workspace, the per-session agent resolver,
// and the agent messenger, then mount it on the API.
sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, log)
if err != nil {
stop()
Expand All @@ -111,6 +111,7 @@ func Run() error {
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc}),
Sessions: sessionSvc,
Reviews: reviewSvc,
Settings: settingssvc.New(store),
Notifications: notifier,
NotificationStream: notificationHub,
CDC: store,
Expand Down
Loading
Loading