fix(spawn): persist the resolved default agent on the session (#221)

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
This commit is contained in:
Khushi Diwan 2026-06-15 00:24:20 +05:30 committed by GitHub
parent 724b9a2d81
commit a197ff6d88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 75 additions and 22 deletions

View File

@ -59,7 +59,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
}
@ -83,6 +90,7 @@ func startSession(cfg config.Config, runtime ports.Runtime, store *sqlite.Store,
Messenger: messenger,
Lifecycle: lcm,
DataDir: cfg.DataDir,
DefaultHarness: domain.AgentHarness(defaultAgent),
Logger: log,
})
scmProvider, err := newGitHubSCMProvider(log)

View File

@ -82,6 +82,10 @@ type Manager struct {
messenger ports.AgentMessenger
lcm lifecycleRecorder
dataDir string
// 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
@ -105,6 +109,11 @@ type Deps struct {
// DataDir is exported to spawned agents as AO_DATA_DIR so their hook
// commands can open the same store.
DataDir string
// 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
@ -130,6 +139,7 @@ func New(d Deps) *Manager {
messenger: d.Messenger,
lcm: d.Lifecycle,
dataDir: d.DataDir,
defaultHarness: d.DefaultHarness,
clock: d.Clock,
lookPath: d.LookPath,
executable: d.Executable,
@ -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 {

View File

@ -279,6 +279,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"})