Skip to content
Open
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
26 changes: 17 additions & 9 deletions backend/internal/daemon/lifecycle_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,14 @@ func (l *lifecycleStack) Stop() {
// store + LCM, the per-session agent resolver (AO_AGENT default), and the
// agent messenger. The returned service is mounted at httpd APIDeps.Sessions.
func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store, lcm *lifecycle.Manager, messenger ports.AgentMessenger, log *slog.Logger) (*sessionsvc.Service, error) {
agents, err := buildAgentResolver(cfg.Agent, log)
// 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
}
agents, err := buildAgentResolver(defaultAgent, log)
if err != nil {
return nil, err
}
Expand All @@ -72,14 +79,15 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store,
return 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,
Logger: log,
Runtime: runtime,
Agents: agents,
Workspace: ws,
Store: store,
Messenger: messenger,
Lifecycle: lcm,
DataDir: cfg.DataDir,
DefaultHarness: domain.AgentHarness(defaultAgent),
Logger: log,
})
scmProvider, err := newGitHubSCMProvider(log)
if err != nil {
Expand Down
43 changes: 30 additions & 13 deletions backend/internal/session_manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@ type Manager struct {
messenger ports.AgentMessenger
lcm lifecycleRecorder
dataDir string
clock func() time.Time
// defaultHarness is the daemon's configured default agent (AO_AGENT). A spawn
// that names no harness resolves to it before the seed row is written, so the
// stored/returned harness matches the agent the resolver actually launches.
defaultHarness domain.AgentHarness
clock func() time.Time
// lookPath is exec.LookPath in production; tests substitute a stub so
// they don't need real binaries on PATH. Returns ports.ErrAgentBinaryNotFound
// when the binary is missing so the sentinel propagates through toAPIError.
Expand All @@ -105,7 +109,12 @@ type Deps struct {
// DataDir is exported to spawned agents as AO_DATA_DIR so their hook
// commands can open the same store.
DataDir string
Clock func() time.Time
// DefaultHarness is the daemon's configured default agent (AO_AGENT), used to
// resolve a spawn that names no harness. Wiring passes config.DefaultAgent;
// left empty, an unspecified harness stays empty (the resolver still defaults
// it at launch, but the record won't reflect the real agent).
DefaultHarness domain.AgentHarness
Clock func() time.Time
// LookPath overrides exec.LookPath for the pre-launch agent-binary check.
// Production wiring leaves this nil and the manager defaults to
// exec.LookPath; tests inject a stub so they need not seed real binaries.
Expand All @@ -123,17 +132,18 @@ type Deps struct {
// time.Now when Deps.Clock is nil.
func New(d Deps) *Manager {
m := &Manager{
runtime: d.Runtime,
agents: d.Agents,
workspace: d.Workspace,
store: d.Store,
messenger: d.Messenger,
lcm: d.Lifecycle,
dataDir: d.DataDir,
clock: d.Clock,
lookPath: d.LookPath,
executable: d.Executable,
logger: d.Logger,
runtime: d.Runtime,
agents: d.Agents,
workspace: d.Workspace,
store: d.Store,
messenger: d.Messenger,
lcm: d.Lifecycle,
dataDir: d.DataDir,
defaultHarness: d.DefaultHarness,
clock: d.Clock,
lookPath: d.LookPath,
executable: d.Executable,
logger: d.Logger,
}
if m.clock == nil {
m.clock = time.Now
Expand Down Expand Up @@ -162,6 +172,13 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
// A per-project role override picks the harness when the spawn names none,
// so a project can default workers to one agent and orchestrators to another.
cfg.Harness = effectiveHarness(cfg.Harness, cfg.Kind, project.Config)
// Resolve an unspecified harness to the daemon default BEFORE the seed row is
// written, so the stored/returned harness matches the agent the resolver
// launches (otherwise a default-agent session persists an empty harness and
// the UI can't tell which agent is running).
if cfg.Harness == "" {
cfg.Harness = m.defaultHarness
}

prompt, systemPrompt, err := m.buildSpawnTexts(ctx, cfg)
if err != nil {
Expand Down
28 changes: 28 additions & 0 deletions backend/internal/session_manager/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,34 @@ func TestSpawn_ResolvesProjectConfig(t *testing.T) {
}
}

// TestSpawn_PersistsResolvedDefaultHarness locks the fix for the mislabelled
// agent: a spawn that names no harness must persist the daemon's default agent
// (so the API/UI report what actually runs), while an explicit harness wins.
func TestSpawn_PersistsResolvedDefaultHarness(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
m := New(Deps{
Runtime: &fakeRuntime{}, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st,
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
LookPath: func(string) (string, error) { return "/bin/true", nil },
DefaultHarness: domain.HarnessClaudeCode,
})

if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
t.Fatal(err)
}
if got := st.sessions["mer-1"].Harness; got != domain.HarnessClaudeCode {
t.Fatalf("unspecified harness = %q, want resolved default %q", got, domain.HarnessClaudeCode)
}

if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil {
t.Fatal(err)
}
if got := st.sessions["mer-2"].Harness; got != domain.HarnessCodex {
t.Fatalf("explicit harness = %q, want %q", got, domain.HarnessCodex)
}
}

func TestSpawn_AssignsIDAndGoesIdle(t *testing.T) {
m, st, rt, _ := newManager()
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"})
Expand Down
Loading