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
10 changes: 6 additions & 4 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. Worker and orchestrator defaults are set
per project.
- **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 @@ -92,9 +93,10 @@ go build -o /tmp/ao ./cmd/ao

# Register a local git repo as a project. The id defaults to the lowercased
# 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
/tmp/ao project add --path /path/to/your/repo --id your-repo --name your-repo \
--worker-agent codex --orchestrator-agent codex

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

# Inspect what's running.
Expand Down Expand Up @@ -167,7 +169,7 @@ 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_AGENT` | `claude-code` | Compatibility agent adapter id validated at daemon startup. |
| `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
11 changes: 11 additions & 0 deletions backend/internal/cli/dto_drift_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
"--path", "/repo/mer",
"--id", "demo",
"--name", "Demo",
"--worker-agent", "codex",
"--orchestrator-agent", "claude-code",
"--as-workspace",
})
if err := root.Execute(); err != nil {
Expand All @@ -238,6 +240,15 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) {
if got.Name == nil || *got.Name != "Demo" {
t.Errorf("Name = %v, want %q", got.Name, "Demo")
}
if got.Config == nil {
t.Fatal("Config = nil, want role agent config")
}
if got.Config.Worker.Harness != domain.HarnessCodex {
t.Errorf("Config.Worker.Harness = %q, want codex", got.Config.Worker.Harness)
}
if got.Config.Orchestrator.Harness != domain.HarnessClaudeCode {
t.Errorf("Config.Orchestrator.Harness = %q, want claude-code", got.Config.Orchestrator.Harness)
}
if !got.AsWorkspace {
t.Errorf("AsWorkspace = false, want true (CLI json:\"asWorkspace\" vs AddInput)")
}
Expand Down
31 changes: 21 additions & 10 deletions backend/internal/cli/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import (
)

type projectAddOptions struct {
path string
id string
name string
asWorkspace bool
path string
id string
name string
workerAgent string
orchestratorAgent string
asWorkspace bool
}

type projectListOptions struct {
Expand All @@ -37,10 +39,11 @@ type projectRemoveOptions struct {
// addProjectRequest mirrors the daemon's project AddInput body for
// POST /api/v1/projects. projectId and name are optional (pointers omit them).
type addProjectRequest struct {
Path string `json:"path"`
ProjectID *string `json:"projectId,omitempty"`
Name *string `json:"name,omitempty"`
AsWorkspace bool `json:"asWorkspace,omitempty"`
Path string `json:"path"`
ProjectID *string `json:"projectId,omitempty"`
Name *string `json:"name,omitempty"`
Config *projectConfig `json:"config,omitempty"`
AsWorkspace bool `json:"asWorkspace,omitempty"`
}

type projectSummary struct {
Expand All @@ -58,7 +61,7 @@ type projectDetails struct {
Path string `json:"path"`
Repo string `json:"repo"`
DefaultBranch string `json:"defaultBranch"`
DefaultHarness string `json:"agent,omitempty"`
Agent string `json:"agent,omitempty"`
Config *projectConfig `json:"config,omitempty"`
WorkspaceRepos []workspaceRepoDetails `json:"workspaceRepos,omitempty"`
ResolveError string `json:"resolveError,omitempty"`
Expand Down Expand Up @@ -226,6 +229,12 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
if opts.name != "" {
req.Name = &opts.name
}
if opts.workerAgent != "" || opts.orchestratorAgent != "" {
req.Config = &projectConfig{
Worker: roleOverride{Agent: opts.workerAgent},
Orchestrator: roleOverride{Agent: opts.orchestratorAgent},
}
}
var res projectResult
if err := ctx.postJSON(cmd.Context(), "projects", req, &res); err != nil {
return err
Expand All @@ -238,6 +247,8 @@ func newProjectAddCommand(ctx *commandContext) *cobra.Command {
f.StringVar(&opts.path, "path", "", "Absolute path to the local git repo (required)")
f.StringVar(&opts.id, "id", "", "Project id (default: derived by the daemon from the path)")
f.StringVar(&opts.name, "name", "", "Display name")
f.StringVar(&opts.workerAgent, "worker-agent", "", "Default worker session agent")
f.StringVar(&opts.orchestratorAgent, "orchestrator-agent", "", "Default orchestrator session agent")
f.BoolVar(&opts.asWorkspace, "as-workspace", false, "Register a parent folder as a workspace project (root-as-repo plus direct child repos)")
return cmd
}
Expand Down Expand Up @@ -443,7 +454,7 @@ func writeProjectDetails(cmd *cobra.Command, res projectGetResult) error {
{label: "path", value: p.Path},
{label: "repo", value: p.Repo},
{label: "default branch", value: p.DefaultBranch},
{label: "default harness", value: p.DefaultHarness},
{label: "agent", value: p.Agent},
{label: "config", value: formatProjectConfig(p.Config)},
{label: "resolve error", value: p.ResolveError},
}
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/cli/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ func TestProjectGet_Success(t *testing.T) {
if capture.method != http.MethodGet || capture.path != "/api/v1/projects/demo" {
t.Fatalf("request = %s %s, want GET /api/v1/projects/demo", capture.method, capture.path)
}
for _, want := range []string{"Project demo (ok)", "name: Demo", "path: /repo/demo", "default branch: main", "default harness: codex"} {
for _, want := range []string{"Project demo (ok)", "name: Demo", "path: /repo/demo", "default branch: main", "agent: codex"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out)
}
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/cli/spawn.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ 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 in 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 +120,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: project worker.agent; required if the project has none)")
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
12 changes: 6 additions & 6 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ 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 is the compatibility value used when AO_AGENT is unset. The
// daemon validates it at startup, but worker/orchestrator spawns resolve from
// explicit requests or project role config instead of falling back to it.
DefaultAgent = "claude-code"
// DefaultTelemetryPostHogHost is the default PostHog ingestion host when
// remote telemetry is enabled and AO_TELEMETRY_POSTHOG_HOST is unset.
Expand Down Expand Up @@ -85,9 +86,8 @@ 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 is the compatibility agent adapter id 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.
Expand All @@ -113,7 +113,7 @@ 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_AGENT compatibility agent id (default claude-code)
// AO_ALLOWED_ORIGINS CORS origins, comma-separated (default DefaultAllowedOrigins)
// AO_TELEMETRY_EVENTS local event capture off|on (default off)
// AO_TELEMETRY_METRICS local metric capture off|on (default off)
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func Run() error {

// 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
// (AO_AGENT validated here for compatibility), and the agent messenger, then mount it
// on the API.
sessionSvc, reviewSvc, err := startSession(cfg, runtimeAdapter, store, lcStack.LCM, messenger, telemetrySink, log)
if err != nil {
Expand Down
46 changes: 18 additions & 28 deletions backend/internal/daemon/lifecycle_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,9 @@ func (l *lifecycleStack) Stop() {

// startSession builds the controller-facing session service: a session manager
// over the real zellij runtime, a per-session gitworktree workspace, the shared
// store + LCM, the per-session agent resolver (AO_AGENT default), and the
// agent messenger. The returned service is mounted at httpd APIDeps.Sessions.
// store + LCM, the per-session agent resolver, and the agent messenger. The
// returned service is mounted at httpd APIDeps.Sessions.
func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, telemetry ports.EventSink, log *slog.Logger) (*sessionsvc.Service, reviewsvc.Manager, error) {
// Resolve the default agent once and share it with both the resolver (which
// launches it for an unspecified harness) and the session manager (which
// persists it onto the seed row), so the stored harness matches what runs.
defaultAgent := cfg.Agent
if defaultAgent == "" {
defaultAgent = config.DefaultAgent
Expand All @@ -87,15 +84,14 @@ func startSession(cfg config.Config, runtime *zellij.Runtime, store *sqlite.Stor
return nil, nil, fmt.Errorf("session workspace: %w", err)
}
mgr := sessionmanager.New(sessionmanager.Deps{
Runtime: runtime,
Agents: agents,
Workspace: ws,
Store: store,
Messenger: messenger,
Lifecycle: lcm,
DataDir: cfg.DataDir,
DefaultHarness: domain.AgentHarness(defaultAgent),
Logger: log,
Runtime: runtime,
Agents: agents,
Workspace: ws,
Store: store,
Messenger: messenger,
Lifecycle: lcm,
DataDir: cfg.DataDir,
Logger: log,
})
scmProvider, err := newGitHubSCMProvider(log)
if err != nil {
Expand Down Expand Up @@ -179,20 +175,15 @@ func buildAgentRegistry() (*adapters.Registry, error) {

// agentRegistry adapts the generic adapter Registry to ports.AgentResolver: it
// maps a session's harness onto the registered adapter of the same id and
// asserts that adapter drives an agent. An empty harness falls back to the
// daemon's configured default (AO_AGENT), so a spawn that names no harness still
// gets a real agent.
// asserts that adapter drives an agent. Empty harnesses are invalid at the
// session manager boundary and deliberately do not resolve here.
type agentRegistry struct {
reg *adapters.Registry
defaultHarness domain.AgentHarness
reg *adapters.Registry
}

var _ ports.AgentResolver = agentRegistry{}

func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) {
if harness == "" {
harness = a.defaultHarness
}
adapter, ok := a.reg.Get(string(harness))
if !ok {
return nil, false
Expand All @@ -203,10 +194,9 @@ func (a agentRegistry) Agent(harness domain.AgentHarness) (ports.Agent, bool) {

// buildAgentResolver constructs the per-session agent resolver the Session
// Manager consumes (sessionmanager.Deps.Agents): a registry of the shipped
// adapters plus the configured default harness. It fails fast if the default
// does not resolve, so a typo'd AO_AGENT surfaces at startup. The session lane
// plugs this in when it mounts the controller-facing session service at the
// httpd APIDeps.Sessions slot.
// adapters. It still validates AO_AGENT at startup for compatibility with the
// config surface, but worker/orchestrator spawns must provide a resolved
// harness before calling Agent.
func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResolver, error) {
if defaultAgent == "" {
defaultAgent = config.DefaultAgent
Expand All @@ -215,8 +205,8 @@ func buildAgentResolver(defaultAgent string, log *slog.Logger) (ports.AgentResol
if err != nil {
return nil, err
}
resolver := agentRegistry{reg: reg, defaultHarness: domain.AgentHarness(defaultAgent)}
if _, ok := resolver.Agent(""); !ok {
resolver := agentRegistry{reg: reg}
if _, ok := resolver.Agent(domain.AgentHarness(defaultAgent)); !ok {
return nil, fmt.Errorf("configured default agent %q is not a registered adapter", defaultAgent)
}
ids := make([]string, 0)
Expand Down
7 changes: 4 additions & 3 deletions backend/internal/daemon/wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ func TestWiring_WriteFlowsToBroadcaster(t *testing.T) {

// TestWiring_AgentResolverResolvesRealAdapters asserts buildAgentResolver wires a
// real registry-backed per-session resolver: each harness resolves to the
// matching registered adapter, an empty harness falls back to the AO_AGENT
// default, and an unknown harness misses.
// matching registered adapter, while empty and unknown harnesses miss.
func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
resolver, err := buildAgentResolver("", log) // empty default → claude-code
Expand Down Expand Up @@ -114,7 +113,6 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
{domain.HarnessVibe, "vibe"},
{domain.HarnessPi, "pi"},
{domain.HarnessAutohand, "autohand"},
{"", config.DefaultAgent}, // empty harness falls back to the AO_AGENT default
} {
agent, ok := resolver.Agent(tc.harness)
if !ok {
Expand All @@ -131,6 +129,9 @@ func TestWiring_AgentResolverResolvesRealAdapters(t *testing.T) {
if _, ok := resolver.Agent("definitely-not-an-agent"); ok {
t.Fatal("unknown harness resolved to an agent; want a miss")
}
if _, ok := resolver.Agent(""); ok {
t.Fatal("empty harness resolved to an agent; want a miss")
}
}

// TestWiring_StartSessionBuildsSessionService asserts the daemon's startSession
Expand Down
10 changes: 9 additions & 1 deletion backend/internal/integration/lifecycle_sqlite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,15 @@ func newStack(t *testing.T) *stack {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
if err := store.UpsertProject(ctx, domain.ProjectRecord{ID: "mer", Path: "/repo/mer", RegisteredAt: time.Now()}); err != nil {
if err := store.UpsertProject(ctx, domain.ProjectRecord{
ID: "mer",
Path: "/repo/mer",
RegisteredAt: time.Now(),
Config: domain.ProjectConfig{
Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
Orchestrator: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
},
}); err != nil {
t.Fatal(err)
}
msg := &captureMessenger{}
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/service/session/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,8 @@ func toAPIError(err error) error {
return apierr.Invalid("PROJECT_NOT_RESOLVABLE", "Project is not registered or has no repo — register it with `ao project add`", nil)
case errors.Is(err, sessionmanager.ErrUnknownHarness):
return apierr.Invalid("UNKNOWN_HARNESS", err.Error(), nil)
case errors.Is(err, sessionmanager.ErrMissingHarness):
return apierr.Invalid("AGENT_REQUIRED", err.Error(), nil)
case errors.Is(err, ports.ErrWorkspaceBranchCheckedOutElsewhere):
return apierr.Conflict("BRANCH_CHECKED_OUT_ELSEWHERE", err.Error(), nil)
case errors.Is(err, ports.ErrWorkspaceBranchNotFetched):
Expand Down
1 change: 1 addition & 0 deletions backend/internal/service/session/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ func TestToAPIErrorMapsWorkspaceBranchSentinels(t *testing.T) {
{"invalid branch", fmt.Errorf("spawn mer-1: workspace: %w: \"bad!!\" (exit 1)", ports.ErrWorkspaceBranchInvalid), apierr.KindInvalid, "INVALID_BRANCH"},
{"agent binary not found", fmt.Errorf("spawn mer-1: %w", ports.ErrAgentBinaryNotFound), apierr.KindInvalid, "AGENT_BINARY_NOT_FOUND"},
{"unknown harness", fmt.Errorf("spawn: %w: %q", sessionmanager.ErrUnknownHarness, "bogus"), apierr.KindInvalid, "UNKNOWN_HARNESS"},
{"missing harness", fmt.Errorf("spawn: %w: configure project worker.agent or pass --harness", sessionmanager.ErrMissingHarness), apierr.KindInvalid, "AGENT_REQUIRED"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
Loading
Loading