From b42d199d37859fa76f6578a1f74f79a49c2c5bfd Mon Sep 17 00:00:00 2001 From: codebanditssss Date: Sat, 13 Jun 2026 03:59:22 +0530 Subject: [PATCH] fix(spawn): persist the resolved default agent on the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spawn with no explicit harness ran the daemon default (claude-code) but stored an empty harness: effectiveHarness returned "", seedRecord persisted it, and the empty->default resolution lived only inside agentRegistry.Agent. The API then omitted harness and the UI defaulted to "codex" — mislabelling a Claude Code session. Inject the daemon's default agent (AO_AGENT / config.DefaultAgent) into the session manager and resolve an unspecified harness to it before the seed row is written, so the stored/returned harness matches the agent that actually runs. Closes #220 --- backend/internal/daemon/lifecycle_wiring.go | 26 +++++++---- backend/internal/session_manager/manager.go | 43 +++++++++++++------ .../internal/session_manager/manager_test.go | 28 ++++++++++++ 3 files changed, 75 insertions(+), 22 deletions(-) diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index b4d40905..185a6489 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -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 } @@ -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 { diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 9a563e96..f6659759 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -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. @@ -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. @@ -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 @@ -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 { diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index cc7832e2..741ed87f 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -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"})