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:
parent
724b9a2d81
commit
a197ff6d88
|
|
@ -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
|
||||
}
|
||||
|
|
@ -76,14 +83,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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
Loading…
Reference in New Issue