diff --git a/.github/workflows/notify-docs.yml b/.github/workflows/notify-docs.yml index 9f5b0c95f2..0f12e44e57 100644 --- a/.github/workflows/notify-docs.yml +++ b/.github/workflows/notify-docs.yml @@ -71,7 +71,7 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: - version: 10.29.3 + version: 9.15.9 - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 diff --git a/apps/backend/internal/agent/runtime/lifecycle/event_types.go b/apps/backend/internal/agent/runtime/lifecycle/event_types.go index 99b25b0b52..f0e68dbc53 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/event_types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/event_types.go @@ -9,17 +9,18 @@ import ( // AgentEventPayload is the payload for agent lifecycle events (started, stopped, ready, completed, failed). type AgentEventPayload struct { - AgentExecutionID string `json:"agent_execution_id"` - TaskID string `json:"task_id"` - SessionID string `json:"session_id,omitempty"` - AgentProfileID string `json:"agent_profile_id"` - ContainerID string `json:"container_id,omitempty"` - Status string `json:"status"` - StartedAt time.Time `json:"started_at"` - FinishedAt *time.Time `json:"finished_at,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - ExitCode *int `json:"exit_code,omitempty"` - PromptGeneration uint64 `json:"prompt_generation,omitempty"` + AgentExecutionID string `json:"agent_execution_id"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id,omitempty"` + AgentProfileID string `json:"agent_profile_id"` + ExecutionProfileID string `json:"execution_profile_id,omitempty"` + ContainerID string `json:"container_id,omitempty"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + PromptGeneration uint64 `json:"prompt_generation,omitempty"` } // AgentctlEventPayload is the payload for agentctl lifecycle events (starting, ready, error). diff --git a/apps/backend/internal/agent/runtime/lifecycle/events.go b/apps/backend/internal/agent/runtime/lifecycle/events.go index dbd2ea0d20..c867c6ea47 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/events.go +++ b/apps/backend/internal/agent/runtime/lifecycle/events.go @@ -56,17 +56,18 @@ func (p *EventPublisher) publishAgentEventPayload(ctx context.Context, eventType func newAgentEventPayload(execution *AgentExecution) AgentEventPayload { return AgentEventPayload{ - AgentExecutionID: execution.ID, - TaskID: execution.TaskID, - SessionID: execution.SessionID, - AgentProfileID: execution.AgentProfileID, - ContainerID: execution.ContainerID, - Status: string(execution.Status), - StartedAt: execution.StartedAt, - FinishedAt: execution.FinishedAt, - ErrorMessage: execution.ErrorMessage, - ExitCode: execution.ExitCode, - PromptGeneration: execution.promptGeneration, + AgentExecutionID: execution.ID, + TaskID: execution.TaskID, + SessionID: execution.SessionID, + AgentProfileID: execution.officeProfileID(), + ExecutionProfileID: execution.AgentProfileID, + ContainerID: execution.ContainerID, + Status: string(execution.Status), + StartedAt: execution.StartedAt, + FinishedAt: execution.FinishedAt, + ErrorMessage: execution.ErrorMessage, + ExitCode: execution.ExitCode, + PromptGeneration: execution.promptGeneration, } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/events_profile_test.go b/apps/backend/internal/agent/runtime/lifecycle/events_profile_test.go new file mode 100644 index 0000000000..3408edae8f --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/events_profile_test.go @@ -0,0 +1,15 @@ +package lifecycle + +import "testing" + +func TestAgentEventPayloadSeparatesOfficeAndExecutionProfiles(t *testing.T) { + payload := newAgentEventPayload(&AgentExecution{ + ID: "exec-1", AgentProfileID: "claude-opus", OfficeAgentProfileID: "office-cto", + }) + if payload.AgentProfileID != "office-cto" { + t.Fatalf("agent profile = %q, want stable Office identity", payload.AgentProfileID) + } + if payload.ExecutionProfileID != "claude-opus" { + t.Fatalf("execution profile = %q, want concrete CLI profile", payload.ExecutionProfileID) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index 2ad1c22116..6e72d8ee28 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -301,6 +301,7 @@ type ExecutorCreateRequest struct { SessionID string TaskEnvironmentID string // Env this execution belongs to (shared across sessions in same task) AgentProfileID string + OfficeAgentProfileID string WorkspacePath string Protocol string Env map[string]string @@ -391,6 +392,7 @@ func (ri *ExecutorInstance) ToAgentExecution(req *ExecutorCreateRequest) *AgentE SessionID: req.SessionID, TaskEnvironmentID: req.TaskEnvironmentID, AgentProfileID: req.AgentProfileID, + OfficeAgentProfileID: req.OfficeAgentProfileID, AgentID: agentID, ContainerID: ri.ContainerID, ContainerIP: ri.ContainerIP, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go index abee46d8e9..d3f56c96c7 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go @@ -317,13 +317,14 @@ func (m *Manager) createExecutionFromSessionInfo(ctx context.Context, sessionID } // Verify this session should use passthrough mode - if err := m.verifyPassthroughEnabled(ctx, sessionID, info.AgentProfileID); err != nil { + if err := m.verifyPassthroughEnabled(ctx, sessionID, workspaceExecutionProfileID(info)); err != nil { return nil, err } // If agent ID not in workspace info (snapshot missing/empty), resolve from profile - if info.AgentID == "" && info.AgentProfileID != "" && m.profileResolver != nil { - profileInfo, err := m.profileResolver.ResolveProfile(ctx, info.AgentProfileID) + executionProfileID := workspaceExecutionProfileID(info) + if info.AgentID == "" && executionProfileID != "" && m.profileResolver != nil { + profileInfo, err := m.profileResolver.ResolveProfile(ctx, executionProfileID) if err != nil { return nil, fmt.Errorf("resolve agent for session %s: %w", sessionID, err) } @@ -412,11 +413,12 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work // found. env := map[string]string{} var profileInfo *AgentProfileInfo - if info.AgentProfileID != "" && m.profileResolver != nil { - resolvedProfile, err := m.profileResolver.ResolveProfile(ctx, info.AgentProfileID) + executionProfileID := workspaceExecutionProfileID(info) + if executionProfileID != "" && m.profileResolver != nil { + resolvedProfile, err := m.profileResolver.ResolveProfile(ctx, executionProfileID) if err != nil { m.logger.Warn("failed to resolve profile for workspace execution", - zap.String("agent_profile_id", info.AgentProfileID), + zap.String("execution_profile_id", executionProfileID), zap.Error(err)) } else { profileInfo = resolvedProfile @@ -438,7 +440,8 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work TaskID: taskID, SessionID: info.SessionID, TaskEnvironmentID: info.TaskEnvironmentID, - AgentProfileID: info.AgentProfileID, + AgentProfileID: executionProfileID, + OfficeAgentProfileID: info.AgentProfileID, WorkspacePath: info.WorkspacePath, Protocol: string(agentConfig.Runtime().Protocol), Env: env, @@ -522,6 +525,16 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work return execution, nil } +func workspaceExecutionProfileID(info *WorkspaceInfo) string { + if info == nil { + return "" + } + if info.ExecutionProfileID != "" { + return info.ExecutionProfileID + } + return info.AgentProfileID +} + // rollbackRacedExecution tears down an execution that lost a session-conflict // race in the store. Without this the runtime instance (agentctl + agent // subprocess if any) keeps running with no tracking entry, and no cleanup path diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index 81da3f9f40..21cf12ece8 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -1009,6 +1009,12 @@ func (m *Manager) RecoverAgentPromptStream(ctx context.Context, sessionID string if execution.PassthroughProcessID != "" || execution.IsPassthrough || execution.agentctl == nil { return nil } + // InitializeAndPrompt owns the first updates stream. Starting a recovery + // stream before ACP initialization finishes creates competing consumers and + // can split one prompt's events across them. + if !execution.sessionInitialized || execution.ACPSessionID == "" { + return nil + } if execution.agentctl.HasAgentStream() { return nil } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go index 00c8654e08..8a530606b0 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go @@ -819,6 +819,23 @@ func TestRecoverAgentPromptStream(t *testing.T) { require.NoError(t, mgr.RecoverAgentPromptStream(context.Background(), "session-passthrough")) }) + t.Run("session initialization owns the initial stream connection", func(t *testing.T) { + mgr := &Manager{ + executionStore: NewExecutionStore(), + logger: newTestLogger().WithFields(), + } + client := createTestClient(t, "http://127.0.0.1:1") + t.Cleanup(client.Close) + require.NoError(t, mgr.executionStore.Add(&AgentExecution{ + ID: "exec-initializing", + SessionID: "session-initializing", + Status: v1.AgentStatusStarting, + agentctl: client, + })) + + require.NoError(t, mgr.RecoverAgentPromptStream(context.Background(), "session-initializing")) + }) + t.Run("missing stream manager errors when stream is absent", func(t *testing.T) { mgr := &Manager{ executionStore: NewExecutionStore(), @@ -827,11 +844,12 @@ func TestRecoverAgentPromptStream(t *testing.T) { client := createTestClient(t, "http://127.0.0.1:1") t.Cleanup(client.Close) require.NoError(t, mgr.executionStore.Add(&AgentExecution{ - ID: "exec-no-stream-manager", - SessionID: "session-no-stream-manager", - ACPSessionID: "acp-session-1", - Status: v1.AgentStatusFailed, - agentctl: client, + ID: "exec-no-stream-manager", + SessionID: "session-no-stream-manager", + ACPSessionID: "acp-session-1", + Status: v1.AgentStatusFailed, + agentctl: client, + sessionInitialized: true, })) err := mgr.RecoverAgentPromptStream(context.Background(), "session-no-stream-manager") diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index 8843778a67..2f89a5ab48 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -24,29 +24,48 @@ import ( // resolveAgentProfile resolves the agent profile and returns the agent type name and profile info. func (m *Manager) resolveAgentProfile(ctx context.Context, req *LaunchRequest) (string, *AgentProfileInfo, error) { + profileID := executionProfileID(req) if m.profileResolver == nil { // Fallback: treat AgentProfileID as agent type directly (for backward compat) m.logger.Warn("no profile resolver configured, using profile ID as agent type", - zap.String("agent_type", req.AgentProfileID)) - return req.AgentProfileID, nil, nil + zap.String("agent_type", profileID)) + return profileID, nil, nil } - profileInfo, err := m.profileResolver.ResolveProfile(ctx, req.AgentProfileID) + profileInfo, err := m.profileResolver.ResolveProfile(ctx, profileID) if err != nil { return "", nil, fmt.Errorf("failed to resolve agent profile: %w", err) } - applyRouteOverrideToProfile(profileInfo, req) + // Legacy model-only routes still use overlays until their persisted config + // is migrated. A concrete execution profile is authoritative and must not + // be mixed with fields from another provider. + if !hasConcreteRouteExecutionProfile(req) { + applyRouteOverrideToProfile(profileInfo, req) + } m.logger.Debug("resolved agent profile", - zap.String("profile_id", req.AgentProfileID), + zap.String("profile_id", profileID), zap.String("agent_name", profileInfo.AgentName), zap.String("agent_type", profileInfo.AgentName)) return profileInfo.AgentName, profileInfo, nil } -// appendRouteOverrideFlags returns tokens with the route override's -// extra CLI flags appended (when set). Order: user-configured flags -// from the base profile first, route-override flags after. +func executionProfileID(req *LaunchRequest) string { + if req == nil { + return "" + } + if req.ExecutionProfileID != "" { + return req.ExecutionProfileID + } + return req.AgentProfileID +} + +func hasConcreteRouteExecutionProfile(req *LaunchRequest) bool { + return req != nil && req.RouteOverride != nil && req.RouteOverride.ExecutionProfileID != "" +} + +// appendRouteOverrideFlags preserves legacy model-only routing overlays. +// Concrete execution profiles own their complete CLI configuration. func appendRouteOverrideFlags(tokens []string, req *LaunchRequest) []string { - if req == nil || req.RouteOverride == nil || len(req.RouteOverride.Flags) == 0 { + if req == nil || hasConcreteRouteExecutionProfile(req) || req.RouteOverride == nil || len(req.RouteOverride.Flags) == 0 { return tokens } out := make([]string, 0, len(tokens)+len(req.RouteOverride.Flags)) @@ -401,10 +420,10 @@ func (m *Manager) launchPrepareRequest(req *LaunchRequest, profileInfo *AgentPro return reqWithWorktree, executionID } -// mergeRouteOverrideEnv copies the routing override's Env map into the -// launch request's Env map (last-write-wins, route overrides base env). +// mergeRouteOverrideEnv preserves legacy model-only routing overlays. +// Concrete execution profiles own their complete environment. func mergeRouteOverrideEnv(req *LaunchRequest) { - if req == nil || req.RouteOverride == nil || len(req.RouteOverride.Env) == 0 { + if req == nil || hasConcreteRouteExecutionProfile(req) || req.RouteOverride == nil || len(req.RouteOverride.Env) == 0 { return } if req.Env == nil { @@ -506,7 +525,7 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st return nil, nil, nil, fmt.Errorf("build launch environment: %w", err) } - acpMcpServers, err := m.resolveMcpServersWithParams(ctx, reqWithWorktree.AgentProfileID, reqWithWorktree.Metadata, agentConfig) + acpMcpServers, err := m.resolveMcpServersWithParams(ctx, executionProfileID(reqWithWorktree), reqWithWorktree.Metadata, agentConfig) if err != nil { m.logger.Warn("failed to resolve MCP servers for launch", zap.Error(err)) } @@ -536,7 +555,8 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st TaskTitle: reqWithWorktree.TaskTitle, SessionID: reqWithWorktree.SessionID, TaskEnvironmentID: reqWithWorktree.TaskEnvironmentID, - AgentProfileID: reqWithWorktree.AgentProfileID, + AgentProfileID: executionProfileID(reqWithWorktree), + OfficeAgentProfileID: reqWithWorktree.AgentProfileID, WorkspacePath: reqWithWorktree.WorkspacePath, Protocol: string(agentConfig.Runtime().Protocol), Env: env, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go index 942800e32e..0bc88ede8c 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go @@ -201,6 +201,126 @@ func TestBuildEnvForExecution_ResolvesSecretBackedProfileEnv(t *testing.T) { } } +func TestBuildEnvForExecution_SeparatesOfficeAndExecutionProfiles(t *testing.T) { + mgr := newTestManager(t) + profileInfo := &AgentProfileInfo{ + ProfileID: "claude-profile", + EnvVars: []settingsmodels.ProfileEnvVar{ + {Key: "CLAUDE_CONFIG_DIR", Value: "/accounts/claude"}, + {Key: "KANDEV_AGENT_PROFILE_ID", Value: "must-not-win"}, + }, + } + + env, err := mgr.buildEnvForExecution( + context.Background(), + "exec-1", + &LaunchRequest{ + AgentProfileID: "office-cto", + ExecutionProfileID: "claude-profile", + TaskID: "task-1", + SessionID: "session-1", + }, + nil, + profileInfo, + ) + if err != nil { + t.Fatalf("buildEnvForExecution: %v", err) + } + if env["CLAUDE_CONFIG_DIR"] != "/accounts/claude" { + t.Fatalf("execution profile env missing: %+v", env) + } + if env["KANDEV_AGENT_PROFILE_ID"] != "office-cto" { + t.Fatalf("KANDEV_AGENT_PROFILE_ID = %q, want office-cto", env["KANDEV_AGENT_PROFILE_ID"]) + } + if env["KANDEV_EXECUTION_PROFILE_ID"] != "claude-profile" { + t.Fatalf("KANDEV_EXECUTION_PROFILE_ID = %q, want claude-profile", env["KANDEV_EXECUTION_PROFILE_ID"]) + } +} + +type recordingEnvProfileResolver struct { + profileID string +} + +func (r *recordingEnvProfileResolver) ResolveProfile( + _ context.Context, profileID string, +) (*AgentProfileInfo, error) { + r.profileID = profileID + return &AgentProfileInfo{ + ProfileID: profileID, + EnvVars: []settingsmodels.ProfileEnvVar{{Key: "PROFILE_ENV", Value: profileID}}, + }, nil +} + +func TestBuildEnvForExecution_NilSnapshotResolvesExecutionProfile(t *testing.T) { + mgr := newTestManager(t) + resolver := &recordingEnvProfileResolver{} + mgr.profileResolver = resolver + env, err := mgr.buildEnvForExecution(context.Background(), "exec-1", &LaunchRequest{ + AgentProfileID: "office-cto", ExecutionProfileID: "claude-profile", + }, nil, nil) + if err != nil { + t.Fatalf("build env: %v", err) + } + if resolver.profileID != "claude-profile" || env["PROFILE_ENV"] != "claude-profile" { + t.Fatalf("resolved profile=%q env=%q, want execution profile", + resolver.profileID, env["PROFILE_ENV"]) + } +} + +func TestExecutionProfileIDFallsBackToOfficeProfile(t *testing.T) { + req := &LaunchRequest{AgentProfileID: "profile-1"} + if got := executionProfileID(req); got != "profile-1" { + t.Fatalf("executionProfileID = %q, want profile-1", got) + } + req.ExecutionProfileID = "profile-2" + if got := executionProfileID(req); got != "profile-2" { + t.Fatalf("executionProfileID = %q, want profile-2", got) + } +} + +func TestConcreteExecutionProfileIgnoresLegacyRouteFlagsAndEnv(t *testing.T) { + req := &LaunchRequest{ + ExecutionProfileID: "claude-profile", + Env: map[string]string{"PROFILE_ENV": "profile"}, + RouteOverride: &RouteOverride{ + ExecutionProfileID: "claude-profile", + Flags: []string{"--legacy-route-flag"}, + Env: map[string]string{"PROFILE_ENV": "route", "ROUTE_ONLY": "value"}, + }, + } + + flags := appendRouteOverrideFlags([]string{"--profile-flag"}, req) + if len(flags) != 1 || flags[0] != "--profile-flag" { + t.Fatalf("flags = %v, want execution profile flags only", flags) + } + mergeRouteOverrideEnv(req) + if req.Env["PROFILE_ENV"] != "profile" { + t.Fatalf("PROFILE_ENV = %q, want execution profile value", req.Env["PROFILE_ENV"]) + } + if _, ok := req.Env["ROUTE_ONLY"]; ok { + t.Fatalf("legacy route env leaked into concrete execution profile: %v", req.Env) + } +} + +func TestLegacyRouteStillAppliesFlagsAndEnv(t *testing.T) { + req := &LaunchRequest{ + Env: map[string]string{"BASE": "value"}, + RouteOverride: &RouteOverride{ + Flags: []string{"--legacy-route-flag"}, + Env: map[string]string{"ROUTE_ONLY": "value"}, + }, + } + + flags := appendRouteOverrideFlags([]string{"--profile-flag"}, req) + if len(flags) != 2 || flags[1] != "--legacy-route-flag" { + t.Fatalf("flags = %v, want legacy route flag appended", flags) + } + mergeRouteOverrideEnv(req) + if req.Env["ROUTE_ONLY"] != "value" { + t.Fatalf("legacy route env missing: %v", req.Env) + } +} + func TestBuildEnvForExecution_DoesNotCopyTaskDescriptionToEnv(t *testing.T) { mgr := newTestManager(t) env, err := mgr.buildEnvForExecution( diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go index 1fadb8f093..a9e0a3e1ca 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go @@ -147,11 +147,12 @@ func (m *Manager) GetRecoveredExecutions() []RecoveredExecution { result := make([]RecoveredExecution, 0, len(executions)) for _, exec := range executions { result = append(result, RecoveredExecution{ - ExecutionID: exec.ID, - TaskID: exec.TaskID, - SessionID: exec.SessionID, - ContainerID: exec.ContainerID, - AgentProfileID: exec.AgentProfileID, + ExecutionID: exec.ID, + TaskID: exec.TaskID, + SessionID: exec.SessionID, + ContainerID: exec.ContainerID, + AgentProfileID: exec.officeProfileID(), + ExecutionProfileID: exec.AgentProfileID, }) } return result diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go index 0b4e08ca82..359ff78012 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go @@ -141,7 +141,8 @@ func (m *Manager) buildPassthroughEnv(ctx context.Context, execution *AgentExecu env := make(map[string]string) env["KANDEV_TASK_ID"] = execution.TaskID env["KANDEV_SESSION_ID"] = execution.SessionID - env["KANDEV_AGENT_PROFILE_ID"] = execution.AgentProfileID + env["KANDEV_AGENT_PROFILE_ID"] = execution.officeProfileID() + env["KANDEV_EXECUTION_PROFILE_ID"] = execution.AgentProfileID m.mergeAgentProfileEnv(ctx, execution.AgentProfileID, env) if m.credsMgr != nil { for _, credKey := range requiredEnv { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go index afb732a83b..d2daeb734a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough_test.go @@ -1074,9 +1074,10 @@ func TestBuildPassthroughEnv_MergesProfileEnvVars(t *testing.T) { } env := mgr.buildPassthroughEnv(context.Background(), &AgentExecution{ - TaskID: "task-1", - SessionID: "session-1", - AgentProfileID: "profile-1", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: "profile-1", + OfficeAgentProfileID: "office-cto", }, nil) if env["PLAIN"] != "plain-value" { @@ -1085,6 +1086,12 @@ func TestBuildPassthroughEnv_MergesProfileEnvVars(t *testing.T) { if env["KANDEV_SESSION_ID"] != "session-1" { t.Fatalf("profile env var must not override KANDEV_SESSION_ID: %+v", env) } + if env["KANDEV_AGENT_PROFILE_ID"] != "office-cto" { + t.Fatalf("office identity missing: %+v", env) + } + if env["KANDEV_EXECUTION_PROFILE_ID"] != "profile-1" { + t.Fatalf("execution profile missing: %+v", env) + } } func TestBuildInteractiveStartRequestCarriesStripEnv(t *testing.T) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go b/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go index cf6990b15d..079186c082 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go @@ -235,7 +235,7 @@ func (m *Manager) buildEnvForExecution(ctx context.Context, executionID string, if profileInfo != nil { m.mergeAgentProfileEnvFromInfo(ctx, profileInfo, env) } else { - m.mergeAgentProfileEnv(ctx, req.AgentProfileID, env) + m.mergeAgentProfileEnv(ctx, executionProfileID(req), env) } // Add standard variables for recovery after backend restart @@ -243,6 +243,7 @@ func (m *Manager) buildEnvForExecution(ctx context.Context, executionID string, env["KANDEV_TASK_ID"] = req.TaskID env["KANDEV_SESSION_ID"] = req.SessionID env["KANDEV_AGENT_PROFILE_ID"] = req.AgentProfileID + env["KANDEV_EXECUTION_PROFILE_ID"] = executionProfileID(req) // Add agent runtime default env vars (e.g., MCP_TIMEOUT for Claude Code) if agentConfig != nil { diff --git a/apps/backend/internal/agent/runtime/lifecycle/persistence.go b/apps/backend/internal/agent/runtime/lifecycle/persistence.go index 14437edba9..7a614ea240 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/persistence.go +++ b/apps/backend/internal/agent/runtime/lifecycle/persistence.go @@ -72,27 +72,30 @@ func buildRunningFromExecution(execution *AgentExecution, prior *models.Executor } running := &models.ExecutorRunning{ - ID: execution.SessionID, - SessionID: execution.SessionID, - TaskID: execution.TaskID, - Runtime: execution.RuntimeName, - Status: executorRunningStatusFromExecution(execution), - Resumable: true, - AgentExecutionID: execution.ID, - ContainerID: execution.ContainerID, - AgentctlURL: agentctlURL, - AgentctlPort: agentctlPort, - PID: pid, - WorktreeID: getMetadataString(execution.Metadata, MetadataKeyWorktreeID), - WorktreePath: getMetadataString(execution.Metadata, "worktree_path"), - WorktreeBranch: getMetadataString(execution.Metadata, MetadataKeyWorktreeBranch), - Metadata: FilterPersistentMetadata(execution.Metadata), - LastSeenAt: lastSeenAt, + ID: execution.SessionID, + SessionID: execution.SessionID, + TaskID: execution.TaskID, + ExecutionProfileID: execution.AgentProfileID, + Runtime: execution.RuntimeName, + Status: executorRunningStatusFromExecution(execution), + Resumable: true, + AgentExecutionID: execution.ID, + ContainerID: execution.ContainerID, + AgentctlURL: agentctlURL, + AgentctlPort: agentctlPort, + PID: pid, + WorktreeID: getMetadataString(execution.Metadata, MetadataKeyWorktreeID), + WorktreePath: getMetadataString(execution.Metadata, "worktree_path"), + WorktreeBranch: getMetadataString(execution.Metadata, MetadataKeyWorktreeBranch), + Metadata: FilterPersistentMetadata(execution.Metadata), + LastSeenAt: lastSeenAt, } if prior != nil { running.ExecutorID = prior.ExecutorID - running.ResumeToken = prior.ResumeToken - running.LastMessageUUID = prior.LastMessageUUID + if prior.ExecutionProfileID == execution.AgentProfileID { + running.ResumeToken = prior.ResumeToken + running.LastMessageUUID = prior.LastMessageUUID + } // Preserve metadata keys the orchestrator owns (context_window, prepare_result, etc.) // by merging prior metadata under our own keys. FilterPersistentMetadata above stripped // transient lifecycle-only keys; the prior row's metadata has the orchestrator-owned @@ -272,6 +275,9 @@ func (m *Manager) persistExecutorRunning(ctx context.Context, execution *AgentEx return } } + if execution.AgentProfileID == "" && prior != nil { + execution.AgentProfileID = prior.ExecutionProfileID + } running := buildRunningFromExecution(execution, prior) // Attach the host-local liveness handle for local/standalone rows. Kept out diff --git a/apps/backend/internal/agent/runtime/lifecycle/persistence_test.go b/apps/backend/internal/agent/runtime/lifecycle/persistence_test.go index 161cd5b97b..dbdb722281 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/persistence_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/persistence_test.go @@ -12,9 +12,19 @@ import ( ) type captureExecutorRunningWriter struct { + prior *models.ExecutorRunning running *models.ExecutorRunning } +func (w *captureExecutorRunningWriter) GetExecutorRunningBySessionID( + context.Context, string, +) (*models.ExecutorRunning, error) { + if w.prior == nil { + return nil, models.ErrExecutorRunningNotFound + } + return w.prior, nil +} + func (w *captureExecutorRunningWriter) UpsertExecutorRunning(_ context.Context, running *models.ExecutorRunning) error { w.running = running return nil @@ -56,6 +66,53 @@ func TestBuildRunningFromExecutionPersistsLiveAgentctlEndpoint(t *testing.T) { } } +func TestBuildRunningFromExecutionBindsResumeTokenToExecutionProfile(t *testing.T) { + prior := &models.ExecutorRunning{ + ExecutionProfileID: "codex-profile", + ResumeToken: "codex-session", + LastMessageUUID: "last-codex-message", + } + running := buildRunningFromExecution(&AgentExecution{ + ID: "exec-2", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: "claude-profile", + }, prior) + + if running.ExecutionProfileID != "claude-profile" { + t.Fatalf("ExecutionProfileID = %q, want claude-profile", running.ExecutionProfileID) + } + if running.ResumeToken != "" || running.LastMessageUUID != "" { + t.Fatalf("cross-profile resume state leaked: %+v", running) + } +} + +func TestPersistExecutorRunningRestoresRecoveredExecutionProfile(t *testing.T) { + writer := &captureExecutorRunningWriter{prior: &models.ExecutorRunning{ + ExecutionProfileID: "claude-profile", + ResumeToken: "claude-session", + LastMessageUUID: "last-message", + }} + mgr := newTestManager(t) + mgr.SetExecutorRunningWriter(writer) + execution := &AgentExecution{ + ID: "exec-recovered", TaskID: "task-1", SessionID: "session-1", + Status: v1.AgentStatusRunning, + } + + mgr.persistExecutorRunning(context.Background(), execution) + + if execution.AgentProfileID != "claude-profile" { + t.Fatalf("recovered AgentProfileID = %q, want persisted execution profile", execution.AgentProfileID) + } + if writer.running == nil || writer.running.ExecutionProfileID != "claude-profile" { + t.Fatalf("persisted execution profile was cleared: %+v", writer.running) + } + if writer.running.ResumeToken != "claude-session" || writer.running.LastMessageUUID != "last-message" { + t.Fatalf("recovered resume state was cleared: %+v", writer.running) + } +} + func TestBuildRunningFromExecutionPersistsSSHRuntimePID(t *testing.T) { log := newNopLogger(t) client := agentctl.NewClient("127.0.0.1", 43001, log) diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index fe9406d11e..b70fdb2753 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -28,23 +28,29 @@ type AgentExecution struct { TaskID string SessionID string TaskEnvironmentID string // Env owning this execution; sessions in the same task share one env - AgentProfileID string - AgentID string // Agent type ID (e.g., "claude-acp", "codex") — used for fallback auth methods - ContainerID string - ContainerIP string // IP address of the container for agentctl communication - WorkspacePath string // Path to the workspace (worktree or repository path) - ACPSessionID string // ACP session ID to resume, if available - AgentCommand string // Command to start the agent subprocess - ContinueCommand string // Command for follow-up prompts (one-shot agents like Amp) - RuntimeName agentruntime.Runtime // Name of the runtime used (e.g., "docker", "standalone") - Status v1.AgentStatus - StartedAt time.Time - FinishedAt *time.Time - ExitCode *int - ErrorMessage string - Metadata map[string]interface{} - promptGeneration uint64 - promptLifecycleMu sync.Mutex + // AgentProfileID is the concrete profile used by the running CLI. The + // historical name is retained inside lifecycle because profile resolution, + // MCP, env, and command construction all consume this value. + AgentProfileID string + // OfficeAgentProfileID is the stable Office identity. Empty for non-Office + // launches, where AgentProfileID owns both identity and execution config. + OfficeAgentProfileID string + AgentID string // Agent type ID (e.g., "claude-acp", "codex") — used for fallback auth methods + ContainerID string + ContainerIP string // IP address of the container for agentctl communication + WorkspacePath string // Path to the workspace (worktree or repository path) + ACPSessionID string // ACP session ID to resume, if available + AgentCommand string // Command to start the agent subprocess + ContinueCommand string // Command for follow-up prompts (one-shot agents like Amp) + RuntimeName agentruntime.Runtime // Name of the runtime used (e.g., "docker", "standalone") + Status v1.AgentStatus + StartedAt time.Time + FinishedAt *time.Time + ExitCode *int + ErrorMessage string + Metadata map[string]interface{} + promptGeneration uint64 + promptLifecycleMu sync.Mutex // PrepareResult carries the environment preparation result back to the caller // so it can be persisted synchronously before UpdateTaskSession clobbers metadata. @@ -148,6 +154,16 @@ type AgentExecution struct { sessionSpanMu sync.RWMutex } +func (e *AgentExecution) officeProfileID() string { + if e == nil { + return "" + } + if e.OfficeAgentProfileID != "" { + return e.OfficeAgentProfileID + } + return e.AgentProfileID +} + // PromptCompletionSignal carries the result from a complete event or disconnect. type PromptCompletionSignal struct { StopReason string @@ -327,12 +343,13 @@ type RepoLaunchSpec struct { // — so when the dispatcher does NOT supply an override, launch behavior // is byte-identical to today. type RouteOverride struct { - ProviderID string - Model string - Tier string - Mode string - Flags []string - Env map[string]string + ExecutionProfileID string + ProviderID string + Model string + Tier string + Mode string + Flags []string + Env map[string]string } // LaunchRequest contains parameters for launching an agent @@ -342,15 +359,20 @@ type LaunchRequest struct { SessionID string TaskEnvironmentID string // Env this session belongs to (shared across sessions in same task) TaskTitle string // Human-readable task title for semantic worktree naming - AgentProfileID string - WorkspacePath string // Host path to workspace (original repository path) - TaskDescription string // Task description to send via ACP prompt - Attachments []MessageAttachment // Attachments (images/files) for the initial prompt - Env map[string]string // Additional env vars - ACPSessionID string // ACP session ID to resume, if available - Metadata map[string]interface{} - ModelOverride string // If set, use this model instead of the profile's model - RouteOverride *RouteOverride // If set, overrides agent_id/model/mode/etc per provider routing + // AgentProfileID is the stable Office identity for routed Office launches. + // For non-Office launches it is also the concrete execution profile. + AgentProfileID string + // ExecutionProfileID selects the complete CLI runtime profile. Empty keeps + // backward-compatible behavior by using AgentProfileID. + ExecutionProfileID string + WorkspacePath string // Host path to workspace (original repository path) + TaskDescription string // Task description to send via ACP prompt + Attachments []MessageAttachment // Attachments (images/files) for the initial prompt + Env map[string]string // Additional env vars + ACPSessionID string // ACP session ID to resume, if available + Metadata map[string]interface{} + ModelOverride string // If set, use this model instead of the profile's model + RouteOverride *RouteOverride // If set, overrides agent_id/model/mode/etc per provider routing // Ephemeral tasks (quick chat) get fallback workspace directories when no repo is configured. // Non-ephemeral tasks without a workspace path will not receive a fallback directory. @@ -507,13 +529,14 @@ type McpConfigProvider interface { // WorkspaceInfo contains information about a task's workspace for on-demand execution creation type WorkspaceInfo struct { - TaskID string - SessionID string // Task session ID (from task_sessions table) - TaskEnvironmentID string // Env this session belongs to (shared across sessions in same task) - WorkspacePath string // Path to the workspace/repository - AgentProfileID string // Optional - agent profile for the task - AgentID string // Agent type ID (e.g., "auggie", "codex") - required for runtime creation - ACPSessionID string // Agent's session ID for conversation resumption (from session metadata) + TaskID string + SessionID string // Task session ID (from task_sessions table) + TaskEnvironmentID string // Env this session belongs to (shared across sessions in same task) + WorkspacePath string // Path to the workspace/repository + AgentProfileID string // Stable Office agent identity (or the execution profile for legacy sessions) + ExecutionProfileID string // Concrete CLI profile selected for this execution + AgentID string // Agent type ID (e.g., "auggie", "codex") - required for runtime creation + ACPSessionID string // Agent's session ID for conversation resumption (from session metadata) // SessionMode is the persisted session permission mode (e.g. "acceptEdits") // from session metadata, declared via the set_session_mode workflow action or // a user toggle. Applied as a mode override at ACP session init so a fresh @@ -543,11 +566,12 @@ type WorkspaceInfoProvider interface { // RecoveredExecution contains info about an execution recovered from a runtime. type RecoveredExecution struct { - ExecutionID string - TaskID string - SessionID string - ContainerID string - AgentProfileID string + ExecutionID string + TaskID string + SessionID string + ContainerID string + AgentProfileID string + ExecutionProfileID string } // PromptResult contains the result of a prompt operation diff --git a/apps/backend/internal/agent/settings/controller/reconciler.go b/apps/backend/internal/agent/settings/controller/reconciler.go index b96fbd7931..cf72f92bee 100644 --- a/apps/backend/internal/agent/settings/controller/reconciler.go +++ b/apps/backend/internal/agent/settings/controller/reconciler.go @@ -413,9 +413,9 @@ func (r *ProfileReconciler) healProfile( // healProfileName updates the profile name when it still matches the agent // display name (stale seed from before we started naming profiles after the -// current model). User-modified profiles are skipped. +// current model). Workspace-scoped and user-modified profiles are skipped. func healProfileName(p *models.AgentProfile, caps hostutility.AgentCapabilities) bool { - if p.UserModified || p.Name != p.AgentDisplayName || caps.CurrentModelID == "" { + if p.WorkspaceID != "" || p.UserModified || p.Name != p.AgentDisplayName || caps.CurrentModelID == "" { return false } for _, m := range caps.Models { diff --git a/apps/backend/internal/agent/settings/controller/reconciler_test.go b/apps/backend/internal/agent/settings/controller/reconciler_test.go index efd3226d5a..18ece9fbf8 100644 --- a/apps/backend/internal/agent/settings/controller/reconciler_test.go +++ b/apps/backend/internal/agent/settings/controller/reconciler_test.go @@ -338,6 +338,27 @@ func TestProfileReconciler_HealsStaleModel(t *testing.T) { } } +func TestProfileReconciler_PreservesOfficeAgentName(t *testing.T) { + profile := &models.AgentProfile{ + Name: "Researcher", + AgentDisplayName: "Researcher", + WorkspaceID: "workspace-1", + } + caps := hostutility.AgentCapabilities{ + CurrentModelID: "claude-sonnet", + Models: []hostutility.Model{ + {ID: "claude-sonnet", Name: "Default (recommended)"}, + }, + } + + if changed := healProfileName(profile, caps); changed { + t.Fatal("office agent name was treated as an untouched global profile name") + } + if profile.Name != "Researcher" { + t.Fatalf("name = %q, want Researcher", profile.Name) + } +} + func TestProfileReconciler_HealsStaleCodexModeAfterBridgeSwap(t *testing.T) { st := newFakeStore() dbAgent := &models.Agent{Name: "codex-acp"} diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index 9440a98580..ee7dc2cf90 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -734,7 +734,7 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode } result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE agent_profiles - SET name = ?, agent_display_name = ?, model = ?, mode = ?, migrated_from = ?, + SET agent_id = ?, name = ?, agent_display_name = ?, model = ?, mode = ?, migrated_from = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, cli_passthrough = ?, user_modified = ?, cli_flags = ?, env_vars = ?, updated_at = ?, workspace_id = ?, role = ?, icon = ?, reports_to = ?, @@ -745,7 +745,7 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode executor_preference = ?, budget_monthly_cents = ?, settings = ?, permissions = ? WHERE id = ? AND deleted_at IS NULL - `), profile.Name, profile.AgentDisplayName, profile.Model, + `), profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, nullableString(profile.Mode), nullableString(profile.MigratedFrom), dialect.BoolToInt(profile.AutoApprove), dialect.BoolToInt(profile.DangerouslySkipPermissions), dialect.BoolToInt(profile.AllowIndexing), diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index fd7f67c72c..31254881aa 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -90,13 +90,18 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch if workspacePath == "" { workspacePath = req.RepositoryURL } + officeProfileID := req.OfficeAgentProfileID + if officeProfileID == "" { + officeProfileID = req.AgentProfileID + } launchReq := &lifecycle.LaunchRequest{ TaskID: req.TaskID, WorkspaceID: req.WorkspaceID, SessionID: req.SessionID, TaskEnvironmentID: req.TaskEnvironmentID, TaskTitle: req.TaskTitle, - AgentProfileID: req.AgentProfileID, + AgentProfileID: officeProfileID, + ExecutionProfileID: req.AgentProfileID, WorkspacePath: workspacePath, TaskDescription: req.TaskDescription, Attachments: convertToLifecycleAttachments(req.Attachments), @@ -134,12 +139,13 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch if req.RouteOverride != nil { launchReq.RouteOverride = &lifecycle.RouteOverride{ - ProviderID: req.RouteOverride.ProviderID, - Model: req.RouteOverride.Model, - Tier: req.RouteOverride.Tier, - Mode: req.RouteOverride.Mode, - Flags: req.RouteOverride.Flags, - Env: req.RouteOverride.Env, + ExecutionProfileID: req.RouteOverride.ExecutionProfileID, + ProviderID: req.RouteOverride.ProviderID, + Model: req.RouteOverride.Model, + Tier: req.RouteOverride.Tier, + Mode: req.RouteOverride.Mode, + Flags: req.RouteOverride.Flags, + Env: req.RouteOverride.Env, } } diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index e682d828c2..a43a7739a7 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -1024,12 +1024,14 @@ func wireOfficeProviderRouting( ) { scheduler := services.OfficeSvcs.Scheduler resolver := routing.NewResolver(&officeRoutingRepoAdapter{repo: repos.Office}, nil) + resolver.SetExecutionProfileStore(repos.AgentSettings, agentRegistry) scheduler.SetResolver(resolver) scheduler.SetTaskStarter(&schedulerTaskStarterAdapter{orch: orchestratorSvc}) scheduler.SetEventBus(eventBus) services.Office.SetRoutingDispatcher(scheduler) provider := routing.NewProvider(repos.Office, agentRegistry, resolver, scheduler) + provider.SetExecutionProfileStore(repos.AgentSettings) services.OfficeSvcs.Dashboard.SetRoutingProvider(provider) services.OfficeSvcs.Dashboard.SetRouteAttemptLister(repos.Office) services.OfficeSvcs.Agents.SetKnownProvidersFn(func() []routing.ProviderID { @@ -1092,12 +1094,13 @@ func (a *schedulerTaskStarterAdapter) StartTaskWithRoute( Env: launch.Env, }, orchexecutor.RouteOverride{ - ProviderID: route.ProviderID, - Model: route.Model, - Tier: route.Tier, - Mode: route.Mode, - Flags: route.Flags, - Env: route.Env, + ExecutionProfileID: route.ExecutionProfileID, + ProviderID: route.ProviderID, + Model: route.Model, + Tier: route.Tier, + Mode: route.Mode, + Flags: route.Flags, + Env: route.Env, }) } @@ -1506,6 +1509,7 @@ func buildOfficeFeatureServices( activity := officeshared.NewActivityLogger(repo, log) agentSvc := officeagents.NewAgentService(repo, log, activity) + agentSvc.SetProfileStore(settingsRepo) agentSvc.SetAuth(newAgentAuth(jwtSigningKey, log)) if services.Office != nil { services.Office.SetAgentTokenMinter(agentSvc) diff --git a/apps/backend/internal/office/agents/handler.go b/apps/backend/internal/office/agents/handler.go index 33154cf132..21cbb81511 100644 --- a/apps/backend/internal/office/agents/handler.go +++ b/apps/backend/internal/office/agents/handler.go @@ -151,6 +151,12 @@ func (h *Handler) createAgent(c *gin.Context) { DesiredSkills: req.DesiredSkills, ExecutorPreference: req.ExecutorPreference, } + if req.AgentProfileID != "" { + if err := h.svc.ApplyProfileConfiguration(c.Request.Context(), agent, req.AgentProfileID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } if err := h.svc.CreateAgentInstanceWithCaller(c.Request.Context(), agent, agentCallerFromCtx(c), req.Reason); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -196,6 +202,12 @@ func (h *Handler) updateAgent(c *gin.Context) { return } } + if req.AgentProfileID != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "agent_profile_id no longer selects an Office runtime; update the agent routing override or workspace tier profiles", + }) + return + } applyAgentUpdates(agent, &req) if err := h.svc.UpdateAgentInstance(ctx, agent); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -526,10 +538,6 @@ func applyAgentUpdates(agent *models.AgentInstance, req *UpdateAgentRequest) { if req.Name != nil { agent.Name = *req.Name } - // Wave G: AgentInstance.ID is the agent_profiles row id; the legacy - // AgentProfileID request field is now a no-op for updates (the row's id - // cannot change after creation). Accepting the field keeps existing - // clients valid; the value is intentionally ignored. if req.Role != nil { agent.Role = models.AgentRole(*req.Role) } diff --git a/apps/backend/internal/office/agents/handler_routing_test.go b/apps/backend/internal/office/agents/handler_routing_test.go index f35ad51820..ad74eebe7d 100644 --- a/apps/backend/internal/office/agents/handler_routing_test.go +++ b/apps/backend/internal/office/agents/handler_routing_test.go @@ -110,7 +110,12 @@ func TestUpdateAgent_AcceptsTierOverrideWhenMapped(t *testing.T) { DefaultTier: routing.TierBalanced, ProviderOrder: []routing.ProviderID{"claude-acp"}, ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{ - "claude-acp": {TierMap: routing.TierMap{Frontier: "opus", Balanced: "sonnet"}}, + "claude-acp": { + ExecutionProfileIDs: routing.ExecutionProfileIDs{ + Frontier: "claude-opus", Balanced: "claude-sonnet", + }, + TierMap: routing.TierMap{Frontier: "opus", Balanced: "sonnet"}, + }, }, } if err := svc.repo.UpsertWorkspaceRouting(ctx, "ws-1", cfg); err != nil { diff --git a/apps/backend/internal/office/agents/service.go b/apps/backend/internal/office/agents/service.go index 4bf5b03dbc..b96ea1e643 100644 --- a/apps/backend/internal/office/agents/service.go +++ b/apps/backend/internal/office/agents/service.go @@ -26,6 +26,14 @@ type UsageProvider interface { GetUsage(ctx context.Context, profileID string) (*agentusage.ProviderUsage, error) } +// ProfileStore owns the complete canonical agent profile shape. Office's +// repository intentionally projects only Office fields, so profile-selection +// writes use this store to preserve advanced runtime configuration. +type ProfileStore interface { + GetAgentProfile(ctx context.Context, id string) (*models.AgentInstance, error) + UpdateAgentProfile(ctx context.Context, profile *models.AgentInstance) error +} + // Sentinel errors for agent validation. var ( ErrAgentNameRequired = errors.New("agent name is required") @@ -100,6 +108,13 @@ type AgentService struct { sessionTerm SessionTerminator routineInstaller CoordinatorRoutineInstaller knownProvidersFn func() []routing.ProviderID + profileStore ProfileStore +} + +// SetProfileStore wires the canonical settings repository used when an Office +// agent copies a CLI profile's complete runtime configuration. +func (s *AgentService) SetProfileStore(store ProfileStore) { + s.profileStore = store } // SetKnownProvidersFn wires the source of routing-eligible provider IDs @@ -312,6 +327,8 @@ func (s *AgentService) prepareAgentDefaults(agent *models.AgentInstance) { if agent.Status == "" { agent.Status = models.AgentStatusIdle } + // Office names are user-facing identities, not generated profile labels. + agent.UserModified = true } // BackfillDefaultSkillsForWorkspace seeds default system skills onto @@ -481,6 +498,17 @@ func (s *AgentService) persistAgent(ctx context.Context, agent *models.AgentInst if err := s.repo.CreateAgentInstance(ctx, agent); err != nil { return fmt.Errorf("create agent: %w", err) } + if s.profileStore != nil { + if err := s.profileStore.UpdateAgentProfile(ctx, agent); err != nil { + if rollbackErr := s.repo.DeleteAgentInstance(ctx, agent.ID); rollbackErr != nil { + return fmt.Errorf( + "persist agent runtime configuration: %w (rollback failed: %v)", + err, rollbackErr, + ) + } + return fmt.Errorf("persist agent runtime configuration: %w", err) + } + } if err := s.CreateDefaultInstructions(ctx, agent.ID, string(agent.Role)); err != nil { s.logger.Warn("failed to create default instructions", zap.Error(err)) } @@ -488,6 +516,28 @@ func (s *AgentService) persistAgent(ctx context.Context, agent *models.AgentInst return nil } +// ApplyProfileConfiguration assigns the legacy provider-family foreign key +// needed by agent_profiles. Runtime configuration is intentionally not copied: +// Office launches resolve their complete execution profile through routing. +func (s *AgentService) ApplyProfileConfiguration( + ctx context.Context, + target *models.AgentInstance, + sourceProfileID string, +) error { + if s.profileStore == nil { + return errors.New("agent profile store is not configured") + } + source, err := s.profileStore.GetAgentProfile(ctx, sourceProfileID) + if err != nil { + return fmt.Errorf("get source agent profile: %w", err) + } + if source.WorkspaceID != "" && source.WorkspaceID != target.WorkspaceID { + return errors.New("source agent profile belongs to a different workspace") + } + target.AgentID = source.AgentID + return nil +} + // installCoordinatorRoutine triggers the default coordinator-heartbeat // routine install for CEO/coordinator agents. Pending-approval agents // skip the install — their routine is materialised once the approval diff --git a/apps/backend/internal/office/agents/service_test.go b/apps/backend/internal/office/agents/service_test.go index 8d5bc41990..540509a9b5 100644 --- a/apps/backend/internal/office/agents/service_test.go +++ b/apps/backend/internal/office/agents/service_test.go @@ -3,11 +3,14 @@ package agents import ( "context" "encoding/json" + "errors" + "net/http" "testing" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" + settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/office/models" @@ -17,6 +20,16 @@ import ( type testActivityLogger struct{} +type failingProfileStore struct{} + +func (f failingProfileStore) GetAgentProfile(context.Context, string) (*models.AgentInstance, error) { + return nil, errors.New("profile unavailable") +} + +func (f failingProfileStore) UpdateAgentProfile(context.Context, *models.AgentInstance) error { + return errors.New("profile update failed") +} + func (t *testActivityLogger) LogActivity(_ context.Context, _, _, _, _, _, _, _ string) {} func (t *testActivityLogger) LogActivityWithRun(_ context.Context, _, _, _, _, _, _, _, _, _ string) { } @@ -50,6 +63,7 @@ func newTestAgentService(t *testing.T) (*AgentService, *sqlite.Repository) { if err != nil { t.Fatalf("open sqlite: %v", err) } + db.SetMaxOpenConns(1) t.Cleanup(func() { _ = db.Close() }) if _, _, err := settingsstore.Provide(db, db, nil); err != nil { @@ -64,6 +78,161 @@ func newTestAgentService(t *testing.T) (*AgentService, *sqlite.Repository) { return svc, repo } +func newTestAgentServiceWithProfileStore( + t *testing.T, +) (*AgentService, *sqlite.Repository, settingsstore.Repository) { + t.Helper() + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + + profileStore, _, err := settingsstore.Provide(db, db, nil) + if err != nil { + t.Fatalf("settings store init: %v", err) + } + repo, err := sqlite.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + svc := NewAgentService(repo, logger.Default(), &testActivityLogger{}) + svc.SetProfileStore(profileStore) + return svc, repo, profileStore +} + +func TestCreateAgentInstance_RollsBackWhenCanonicalProfileUpdateFails(t *testing.T) { + svc, _ := newTestAgentService(t) + svc.SetProfileStore(failingProfileStore{}) + agent := &models.AgentInstance{ + WorkspaceID: "ws-1", Name: "CTO", Role: models.AgentRoleSpecialist, + } + + if err := svc.CreateAgentInstance(context.Background(), agent); err == nil { + t.Fatal("expected canonical profile update failure") + } + if _, err := svc.GetAgentFromConfig(context.Background(), agent.Name); err == nil { + t.Fatal("agent row survived failed canonical profile update") + } +} + +func TestUpdateAgent_ProfileSelectionDirectsClientsToRouting(t *testing.T) { + svc, _, profileStore := newTestAgentServiceWithProfileStore(t) + ctx := context.Background() + provider := &settingsmodels.Agent{ID: "provider-db-id", Name: "claude-acp"} + if err := profileStore.CreateAgent(ctx, provider); err != nil { + t.Fatalf("create provider: %v", err) + } + source := &settingsmodels.AgentProfile{ + ID: "source-profile", + AgentID: provider.ID, + Name: "Work", + AgentDisplayName: "Claude", + Model: "opus", + Mode: "bypassPermissions", + ConfigOptions: map[string]string{"effort": "high"}, + AutoApprove: true, + CLIFlags: []settingsmodels.CLIFlag{ + {Flag: "--dangerously-skip-permissions", Enabled: true}, + }, + EnvVars: []settingsmodels.ProfileEnvVar{ + {Key: "CLAUDE_CONFIG_DIR", Value: "/data/home/.claude-work"}, + }, + } + if err := profileStore.CreateAgentProfile(ctx, source); err != nil { + t.Fatalf("create source profile: %v", err) + } + target := &models.AgentInstance{ + WorkspaceID: "ws-1", + Name: "Researcher", + Role: models.AgentRoleSpecialist, + } + if err := svc.CreateAgentInstance(ctx, target); err != nil { + t.Fatalf("create office agent: %v", err) + } + + rec := newPatchAgentRecorder(t, svc, target.ID, `{"agent_profile_id":"source-profile"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + stored, err := profileStore.GetAgentProfile(ctx, target.ID) + if err != nil { + t.Fatalf("get updated office agent: %v", err) + } + if stored.Name != "Researcher" || stored.Role != models.AgentRoleSpecialist || stored.WorkspaceID != "ws-1" { + t.Fatalf("office identity changed: name=%q role=%q workspace=%q", stored.Name, stored.Role, stored.WorkspaceID) + } + if stored.Model == "opus" || stored.Mode == "bypassPermissions" { + t.Errorf("Office runtime fields were overwritten: model=%q mode=%q", stored.Model, stored.Mode) + } + if stored.AutoApprove || len(stored.CLIFlags) != 0 { + t.Errorf("Office permission fields were overwritten: auto=%v flags=%+v", stored.AutoApprove, stored.CLIFlags) + } + if len(stored.ConfigOptions) != 0 { + t.Errorf("Office config options were overwritten: %+v", stored.ConfigOptions) + } + if len(stored.EnvVars) != 0 { + t.Errorf("Office environment was overwritten: %+v", stored.EnvVars) + } +} + +func TestApplyProfileConfigurationCopiesOnlyProviderFamily(t *testing.T) { + svc, _, profileStore := newTestAgentServiceWithProfileStore(t) + ctx := context.Background() + provider := &settingsmodels.Agent{ID: "provider-db-id", Name: "claude-acp"} + if err := profileStore.CreateAgent(ctx, provider); err != nil { + t.Fatalf("create provider: %v", err) + } + source := &settingsmodels.AgentProfile{ + ID: "source-profile", AgentID: provider.ID, Name: "Work", Model: "opus", + AutoApprove: true, EnvVars: []settingsmodels.ProfileEnvVar{{Key: "SECRET", Value: "value"}}, + } + if err := profileStore.CreateAgentProfile(ctx, source); err != nil { + t.Fatalf("create profile: %v", err) + } + target := &models.AgentInstance{WorkspaceID: "ws-1", Name: "CTO", Model: "identity-model"} + if err := svc.ApplyProfileConfiguration(ctx, target, source.ID); err != nil { + t.Fatalf("assign provider family: %v", err) + } + if target.AgentID != provider.ID { + t.Fatalf("agent family = %q, want %q", target.AgentID, provider.ID) + } + if target.Model != "identity-model" || target.AutoApprove || len(target.EnvVars) != 0 { + t.Fatalf("runtime config copied onto Office identity: %+v", target) + } +} + +func TestApplyProfileConfiguration_RejectsCrossWorkspaceSource(t *testing.T) { + svc, _, profileStore := newTestAgentServiceWithProfileStore(t) + ctx := context.Background() + provider := &settingsmodels.Agent{ID: "provider-db-id", Name: "claude-acp"} + if err := profileStore.CreateAgent(ctx, provider); err != nil { + t.Fatalf("create provider: %v", err) + } + source := &settingsmodels.AgentProfile{ + ID: "other-workspace-profile", + AgentID: provider.ID, + Name: "Private", + WorkspaceID: "ws-other", + EnvVars: []settingsmodels.ProfileEnvVar{ + {Key: "PRIVATE_CONFIG", Value: "/private/path"}, + }, + } + if err := profileStore.CreateAgentProfile(ctx, source); err != nil { + t.Fatalf("create source profile: %v", err) + } + target := &models.AgentInstance{WorkspaceID: "ws-1", Name: "Worker"} + + err := svc.ApplyProfileConfiguration(ctx, target, source.ID) + if err == nil { + t.Fatal("expected cross-workspace source profile to be rejected") + } + if len(target.EnvVars) != 0 { + t.Fatalf("cross-workspace environment was copied: %+v", target.EnvVars) + } +} + func TestCreateAgentInstanceWithCaller_PersistsPendingAgentWhenApprovalRequired(t *testing.T) { svc, repo := newTestAgentService(t) ctx := context.Background() diff --git a/apps/backend/internal/office/dashboard/handler_routing.go b/apps/backend/internal/office/dashboard/handler_routing.go index 63f77cf88b..093e0564a3 100644 --- a/apps/backend/internal/office/dashboard/handler_routing.go +++ b/apps/backend/internal/office/dashboard/handler_routing.go @@ -39,7 +39,14 @@ func (h *Handler) getWorkspaceRouting(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, RoutingConfigResponse{Config: cfg, KnownProviders: known}) + profiles, err := rp.ListExecutionProfiles(c.Request.Context(), c.Param("wsId")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, RoutingConfigResponse{ + Config: cfg, KnownProviders: known, ExecutionProfiles: profiles, + }) } // updateWorkspaceRouting serves PUT /workspaces/:wsId/routing. Strict @@ -206,9 +213,10 @@ func previewItemsToDTOs(items []routing.PreviewItem) []AgentRoutePreview { chain := make([]ProviderModelPair, len(it.FallbackChain)) for j, p := range it.FallbackChain { chain[j] = ProviderModelPair{ - ProviderID: p.ProviderID, - Model: p.Model, - Tier: p.Tier, + ExecutionProfileID: p.ExecutionProfileID, + ProviderID: p.ProviderID, + Model: p.Model, + Tier: p.Tier, } } missing := it.Missing @@ -216,17 +224,19 @@ func previewItemsToDTOs(items []routing.PreviewItem) []AgentRoutePreview { missing = []string{} } out[i] = AgentRoutePreview{ - AgentID: it.AgentID, - AgentName: it.AgentName, - TierSource: it.TierSource, - EffectiveTier: it.EffectiveTier, - PrimaryProviderID: it.PrimaryProviderID, - PrimaryModel: it.PrimaryModel, - CurrentProviderID: it.CurrentProviderID, - CurrentModel: it.CurrentModel, - FallbackChain: chain, - Missing: missing, - Degraded: it.Degraded, + AgentID: it.AgentID, + AgentName: it.AgentName, + TierSource: it.TierSource, + EffectiveTier: it.EffectiveTier, + PrimaryProviderID: it.PrimaryProviderID, + PrimaryExecutionProfileID: it.PrimaryExecutionProfileID, + PrimaryModel: it.PrimaryModel, + CurrentProviderID: it.CurrentProviderID, + CurrentExecutionProfileID: it.CurrentExecutionProfileID, + CurrentModel: it.CurrentModel, + FallbackChain: chain, + Missing: missing, + Degraded: it.Degraded, } } return out diff --git a/apps/backend/internal/office/dashboard/handler_routing_test.go b/apps/backend/internal/office/dashboard/handler_routing_test.go index 6002272c81..3652978756 100644 --- a/apps/backend/internal/office/dashboard/handler_routing_test.go +++ b/apps/backend/internal/office/dashboard/handler_routing_test.go @@ -20,17 +20,25 @@ import ( // fakeRoutingProvider is a hand-rolled stub for dashboard.RoutingProvider. type fakeRoutingProvider struct { - cfg *routing.WorkspaceConfig - known []routing.ProviderID - updateCfg routing.WorkspaceConfig - updateErr error - retryStatus string - retryErr error - healthRows []models.ProviderHealth - preview []routing.PreviewItem - previewAgent *routing.PreviewItem - agentOverrides routing.AgentOverrides - overridesErr error + cfg *routing.WorkspaceConfig + known []routing.ProviderID + updateCfg routing.WorkspaceConfig + updateErr error + retryStatus string + retryErr error + healthRows []models.ProviderHealth + preview []routing.PreviewItem + previewAgent *routing.PreviewItem + agentOverrides routing.AgentOverrides + overridesErr error + executionProfiles []routing.ExecutionProfileSummary + executionProfilesErr error +} + +func (f *fakeRoutingProvider) ListExecutionProfiles( + _ context.Context, _ string, +) ([]routing.ExecutionProfileSummary, error) { + return f.executionProfiles, f.executionProfilesErr } func (f *fakeRoutingProvider) GetConfig(_ context.Context, _ string) (*routing.WorkspaceConfig, []routing.ProviderID, error) { @@ -117,6 +125,17 @@ func TestRouting_GetReturnsDefaultsWhenNoRow(t *testing.T) { } } +func TestRouting_GetReturnsErrorWhenExecutionProfilesFail(t *testing.T) { + deps, fake := newRoutingTestDeps(t) + fake.executionProfilesErr = errors.New("profile catalogue unavailable") + req := httptest.NewRequest(http.MethodGet, "/api/v1/office/workspaces/ws-1/routing", nil) + rec := httptest.NewRecorder() + deps.router.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body=%s", rec.Code, rec.Body.String()) + } +} + func TestRouting_PutRejectsInvalidConfig(t *testing.T) { deps, fake := newRoutingTestDeps(t) fake.updateErr = &routing.ValidationError{ diff --git a/apps/backend/internal/office/dashboard/routing_dto.go b/apps/backend/internal/office/dashboard/routing_dto.go index 8f726f5c08..6cd974c047 100644 --- a/apps/backend/internal/office/dashboard/routing_dto.go +++ b/apps/backend/internal/office/dashboard/routing_dto.go @@ -11,8 +11,9 @@ import ( // The known-provider list is returned alongside the config so the UI // can render the catalogue without a second round-trip. type RoutingConfigResponse struct { - Config *routing.WorkspaceConfig `json:"config"` - KnownProviders []routing.ProviderID `json:"known_providers"` + Config *routing.WorkspaceConfig `json:"config"` + KnownProviders []routing.ProviderID `json:"known_providers"` + ExecutionProfiles []routing.ExecutionProfileSummary `json:"execution_profiles"` } // RoutingRetryResponse is the POST /workspaces/:wsId/routing/retry payload. @@ -39,25 +40,28 @@ type RoutingHealthResponse struct { // pick; equal to primary when not degraded. Both Current fields are // empty when every candidate is skipped. type AgentRoutePreview struct { - AgentID string `json:"agent_id"` - AgentName string `json:"agent_name"` - TierSource string `json:"tier_source"` - EffectiveTier string `json:"effective_tier"` - PrimaryProviderID string `json:"primary_provider_id,omitempty"` - PrimaryModel string `json:"primary_model,omitempty"` - CurrentProviderID string `json:"current_provider_id,omitempty"` - CurrentModel string `json:"current_model,omitempty"` - FallbackChain []ProviderModelPair `json:"fallback_chain"` - Missing []string `json:"missing"` - Degraded bool `json:"degraded"` + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name"` + TierSource string `json:"tier_source"` + EffectiveTier string `json:"effective_tier"` + PrimaryProviderID string `json:"primary_provider_id,omitempty"` + PrimaryExecutionProfileID string `json:"primary_execution_profile_id,omitempty"` + PrimaryModel string `json:"primary_model,omitempty"` + CurrentProviderID string `json:"current_provider_id,omitempty"` + CurrentExecutionProfileID string `json:"current_execution_profile_id,omitempty"` + CurrentModel string `json:"current_model,omitempty"` + FallbackChain []ProviderModelPair `json:"fallback_chain"` + Missing []string `json:"missing"` + Degraded bool `json:"degraded"` } -// ProviderModelPair is one (provider, model, tier) triple used to render -// an agent's fallback chain. +// ProviderModelPair is one concrete execution-profile route, including +// its provider, model, and tier, used to render an agent's fallback chain. type ProviderModelPair struct { - ProviderID string `json:"provider_id"` - Model string `json:"model"` - Tier string `json:"tier"` + ExecutionProfileID string `json:"execution_profile_id,omitempty"` + ProviderID string `json:"provider_id"` + Model string `json:"model"` + Tier string `json:"tier"` } // RoutingPreviewResponse wraps the workspace-level preview list. @@ -68,33 +72,35 @@ type RoutingPreviewResponse struct { // RouteAttemptDTO mirrors models.RouteAttempt with stable JSON tags for // the dashboard run-detail UI. Timestamps are formatted as RFC3339. type RouteAttemptDTO struct { - Seq int `json:"seq"` - ProviderID string `json:"provider_id"` - Model string `json:"model,omitempty"` - Tier string `json:"tier"` - Outcome string `json:"outcome"` - ErrorCode string `json:"error_code,omitempty"` - ErrorConfidence string `json:"error_confidence,omitempty"` - AdapterPhase string `json:"adapter_phase,omitempty"` - ClassifierRule string `json:"classifier_rule,omitempty"` - ExitCode *int `json:"exit_code,omitempty"` - RawExcerpt string `json:"raw_excerpt,omitempty"` - ResetHint *string `json:"reset_hint,omitempty"` - StartedAt string `json:"started_at"` - FinishedAt *string `json:"finished_at,omitempty"` + Seq int `json:"seq"` + ExecutionProfileID string `json:"execution_profile_id,omitempty"` + ProviderID string `json:"provider_id"` + Model string `json:"model,omitempty"` + Tier string `json:"tier"` + Outcome string `json:"outcome"` + ErrorCode string `json:"error_code,omitempty"` + ErrorConfidence string `json:"error_confidence,omitempty"` + AdapterPhase string `json:"adapter_phase,omitempty"` + ClassifierRule string `json:"classifier_rule,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + RawExcerpt string `json:"raw_excerpt,omitempty"` + ResetHint *string `json:"reset_hint,omitempty"` + StartedAt string `json:"started_at"` + FinishedAt *string `json:"finished_at,omitempty"` } // RunRouting is embedded on the run-detail response so the routing // metadata travels alongside the existing run fields. Empty // (zero-valued) when the run did not go through the routing path. type RunRouting struct { - LogicalProviderOrder []string `json:"logical_provider_order"` - RequestedTier string `json:"requested_tier,omitempty"` - ResolvedProviderID string `json:"resolved_provider_id,omitempty"` - ResolvedModel string `json:"resolved_model,omitempty"` - BlockedStatus string `json:"blocked_status,omitempty"` - EarliestRetryAt *string `json:"earliest_retry_at,omitempty"` - Attempts []RouteAttemptDTO `json:"attempts"` + LogicalProviderOrder []string `json:"logical_provider_order"` + RequestedTier string `json:"requested_tier,omitempty"` + ResolvedExecutionProfileID string `json:"resolved_execution_profile_id,omitempty"` + ResolvedProviderID string `json:"resolved_provider_id,omitempty"` + ResolvedModel string `json:"resolved_model,omitempty"` + BlockedStatus string `json:"blocked_status,omitempty"` + EarliestRetryAt *string `json:"earliest_retry_at,omitempty"` + Attempts []RouteAttemptDTO `json:"attempts"` } // RouteAttemptsResponse is the GET /runs/:id/attempts payload. The UI @@ -119,18 +125,19 @@ type AgentRouteResponse struct { // routeAttemptToDTO converts the model into the JSON-shaped DTO. func routeAttemptToDTO(a models.RouteAttempt) RouteAttemptDTO { dto := RouteAttemptDTO{ - Seq: a.Seq, - ProviderID: a.ProviderID, - Model: a.Model, - Tier: a.Tier, - Outcome: string(a.Outcome), - ErrorCode: a.ErrorCode, - ErrorConfidence: string(a.ErrorConfidence), - AdapterPhase: string(a.AdapterPhase), - ClassifierRule: a.ClassifierRule, - ExitCode: a.ExitCode, - RawExcerpt: a.RawExcerpt, - StartedAt: a.StartedAt.UTC().Format(time.RFC3339), + Seq: a.Seq, + ExecutionProfileID: a.ExecutionProfileID, + ProviderID: a.ProviderID, + Model: a.Model, + Tier: a.Tier, + Outcome: string(a.Outcome), + ErrorCode: a.ErrorCode, + ErrorConfidence: string(a.ErrorConfidence), + AdapterPhase: string(a.AdapterPhase), + ClassifierRule: a.ClassifierRule, + ExitCode: a.ExitCode, + RawExcerpt: a.RawExcerpt, + StartedAt: a.StartedAt.UTC().Format(time.RFC3339), } if a.FinishedAt != nil && !a.FinishedAt.IsZero() { s := a.FinishedAt.UTC().Format(time.RFC3339) diff --git a/apps/backend/internal/office/dashboard/run_detail.go b/apps/backend/internal/office/dashboard/run_detail.go index 2a29c913a8..6e99004679 100644 --- a/apps/backend/internal/office/dashboard/run_detail.go +++ b/apps/backend/internal/office/dashboard/run_detail.go @@ -320,6 +320,9 @@ func buildRunRouting( if run.RequestedTier != nil { out.RequestedTier = *run.RequestedTier } + if run.ResolvedExecutionProfileID != nil { + out.ResolvedExecutionProfileID = *run.ResolvedExecutionProfileID + } if run.ResolvedProviderID != nil { out.ResolvedProviderID = *run.ResolvedProviderID } @@ -343,6 +346,9 @@ func runHasRoutingSnapshot(run *models.Run) bool { if run.LogicalProviderOrder != nil && *run.LogicalProviderOrder != "" { return true } + if run.ResolvedExecutionProfileID != nil && *run.ResolvedExecutionProfileID != "" { + return true + } if run.ResolvedProviderID != nil && *run.ResolvedProviderID != "" { return true } diff --git a/apps/backend/internal/office/dashboard/run_detail_routing_test.go b/apps/backend/internal/office/dashboard/run_detail_routing_test.go index a1df966667..edce82cbbf 100644 --- a/apps/backend/internal/office/dashboard/run_detail_routing_test.go +++ b/apps/backend/internal/office/dashboard/run_detail_routing_test.go @@ -49,6 +49,19 @@ func TestBuildRunRouting_NilWhenNoAttemptsAndNoSnapshot(t *testing.T) { } } +func TestBuildRunRouting_IncludesExecutionProfileOnlySnapshot(t *testing.T) { + profileID := "claude-opus" + out, err := buildRunRouting(context.Background(), &fakeRunDetailRepo{}, &models.Run{ + ID: "r", ResolvedExecutionProfileID: &profileID, + }) + if err != nil { + t.Fatalf("err = %v", err) + } + if out == nil || out.ResolvedExecutionProfileID != profileID { + t.Fatalf("execution-profile-only snapshot missing: %+v", out) + } +} + func TestBuildRunRouting_PopulatesAttemptsAndSnapshot(t *testing.T) { order := `["claude-acp","codex-acp"]` tier := "frontier" diff --git a/apps/backend/internal/office/dashboard/service.go b/apps/backend/internal/office/dashboard/service.go index d2f8841b09..384bcf9ef4 100644 --- a/apps/backend/internal/office/dashboard/service.go +++ b/apps/backend/internal/office/dashboard/service.go @@ -272,6 +272,7 @@ type ApprovalReactivityQueuer interface { // next to the rest of the routing code. type RoutingProvider interface { GetConfig(ctx context.Context, workspaceID string) (*routing.WorkspaceConfig, []routing.ProviderID, error) + ListExecutionProfiles(ctx context.Context, workspaceID string) ([]routing.ExecutionProfileSummary, error) UpdateConfig(ctx context.Context, workspaceID string, cfg routing.WorkspaceConfig) error Retry(ctx context.Context, workspaceID, providerID string) (string, *time.Time, error) Health(ctx context.Context, workspaceID string) ([]models.ProviderHealth, error) diff --git a/apps/backend/internal/office/models/models.go b/apps/backend/internal/office/models/models.go index 14f3af685f..06b64f51d8 100644 --- a/apps/backend/internal/office/models/models.go +++ b/apps/backend/internal/office/models/models.go @@ -376,10 +376,12 @@ type Run struct { // RequestedTier is the tier the resolver consumed (override > workspace // default) when the run was first dispatched. RequestedTier *string `json:"requested_tier,omitempty" db:"requested_tier"` - // ResolvedProviderID/Model is the candidate that actually launched; - // empty until a launch succeeds. - ResolvedProviderID *string `json:"resolved_provider_id,omitempty" db:"resolved_provider_id"` - ResolvedModel *string `json:"resolved_model,omitempty" db:"resolved_model"` + // ResolvedExecutionProfileID/ProviderID/Model identify the candidate + // that actually launched; empty until a launch succeeds. Provider and + // model are audit snapshots derived from the concrete profile. + ResolvedExecutionProfileID *string `json:"resolved_execution_profile_id,omitempty" db:"resolved_execution_profile_id"` + ResolvedProviderID *string `json:"resolved_provider_id,omitempty" db:"resolved_provider_id"` + ResolvedModel *string `json:"resolved_model,omitempty" db:"resolved_model"` // CurrentRouteAttemptSeq tracks the in-flight attempt so post-start // fallback can find the right row to update and exclude already-tried // providers when re-resolving. @@ -406,21 +408,22 @@ type Run struct { // Persisted by the routing scheduler dispatcher in // internal/office/scheduler/dispatch_routing.go. type RouteAttempt struct { - RunID string `json:"run_id" db:"run_id"` - Seq int `json:"seq" db:"seq"` - ProviderID string `json:"provider_id" db:"provider_id"` - Model string `json:"model" db:"model"` - Tier string `json:"tier" db:"tier"` - Outcome RouteAttemptOutcome `json:"outcome" db:"outcome"` - ErrorCode string `json:"error_code,omitempty" db:"error_code"` - ErrorConfidence ErrorConfidence `json:"error_confidence,omitempty" db:"error_confidence"` - AdapterPhase AdapterPhase `json:"adapter_phase,omitempty" db:"adapter_phase"` - ClassifierRule string `json:"classifier_rule,omitempty" db:"classifier_rule"` - ExitCode *int `json:"exit_code,omitempty" db:"exit_code"` - RawExcerpt string `json:"raw_excerpt,omitempty" db:"raw_excerpt"` - ResetHint *time.Time `json:"reset_hint,omitempty" db:"reset_hint"` - StartedAt time.Time `json:"started_at" db:"started_at"` - FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"` + RunID string `json:"run_id" db:"run_id"` + Seq int `json:"seq" db:"seq"` + ExecutionProfileID string `json:"execution_profile_id" db:"execution_profile_id"` + ProviderID string `json:"provider_id" db:"provider_id"` + Model string `json:"model" db:"model"` + Tier string `json:"tier" db:"tier"` + Outcome RouteAttemptOutcome `json:"outcome" db:"outcome"` + ErrorCode string `json:"error_code,omitempty" db:"error_code"` + ErrorConfidence ErrorConfidence `json:"error_confidence,omitempty" db:"error_confidence"` + AdapterPhase AdapterPhase `json:"adapter_phase,omitempty" db:"adapter_phase"` + ClassifierRule string `json:"classifier_rule,omitempty" db:"classifier_rule"` + ExitCode *int `json:"exit_code,omitempty" db:"exit_code"` + RawExcerpt string `json:"raw_excerpt,omitempty" db:"raw_excerpt"` + ResetHint *time.Time `json:"reset_hint,omitempty" db:"reset_hint"` + StartedAt time.Time `json:"started_at" db:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"` } // ProviderHealth records the health state of one (workspace, provider, diff --git a/apps/backend/internal/office/onboarding/service.go b/apps/backend/internal/office/onboarding/service.go index b0f5ad362e..35417f6fe1 100644 --- a/apps/backend/internal/office/onboarding/service.go +++ b/apps/backend/internal/office/onboarding/service.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/google/uuid" + settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/office/configloader" "github.com/kandev/kandev/internal/office/models" @@ -70,12 +71,16 @@ type CoordinatorRoutineInstaller interface { CreateDefaultCoordinatorRoutine(ctx context.Context, workspaceID, agentID string) (*models.Routine, error) } -// SourceProfileReader looks up an existing kanban CLI profile by id so -// onboarding can copy its CLI fields onto the new office agent. +// SourceProfileReader looks up an existing CLI profile so onboarding can +// seed execution-profile routing and satisfy the legacy provider-family FK. type SourceProfileReader interface { GetAgentProfile(ctx context.Context, id string) (*models.AgentInstance, error) } +type sourceProviderReader interface { + GetAgent(ctx context.Context, id string) (*settingsmodels.Agent, error) +} + // WorkflowEnsurer creates the system office workflows for a workspace. // // EnsureOfficeWorkflow materialises the office-default YAML workflow and @@ -527,10 +532,9 @@ func (s *OnboardingService) createOnboardingAgent(ctx context.Context, wsID stri execPref = fmt.Sprintf(`{"type":%q}`, req.ExecutorPreference) } // The office agent gets a fresh row in agent_profiles. When the user - // picked an existing kanban CLI profile (req.AgentProfileID), copy its - // CLI fields (agent_id, model, mode, permission flags) onto the new - // office row — agent_profiles.agent_id is FK to agents and may not be - // empty. Reusing the source id would collide with the existing row. + // picked an existing CLI profile, retain only its parent agent_id because + // agent_profiles still has that legacy FK. Launch-affecting fields come + // from the routed execution profile and are not copied onto the Office row. agent := &models.AgentInstance{ ID: uuid.New().String(), WorkspaceID: wsID, @@ -547,13 +551,6 @@ func (s *OnboardingService) createOnboardingAgent(ctx context.Context, wsID stri return "", fmt.Errorf("look up source profile %s: %w", req.AgentProfileID, err) } agent.AgentID = src.AgentID - agent.AgentDisplayName = src.AgentDisplayName - agent.Model = src.Model - agent.Mode = src.Mode - agent.AutoApprove = src.AutoApprove - agent.DangerouslySkipPermissions = src.DangerouslySkipPermissions - agent.AllowIndexing = src.AllowIndexing - agent.CLIPassthrough = src.CLIPassthrough } if err := s.agentCreator.CreateAgentInstance(ctx, agent); err != nil { return "", err @@ -562,11 +559,9 @@ func (s *OnboardingService) createOnboardingAgent(ctx context.Context, wsID stri return agent.ID, nil } -// seedWorkspaceRouting writes the workspace routing config row + the -// CEO agent's inherit markers. It looks the freshly created CEO agent -// up by ID so the chosen CLI + model end up in the routing seed even -// though the wizard only forwards an opaque AgentProfileID. Failures -// are warn-logged: onboarding must succeed even if seeding fails. +// seedWorkspaceRouting writes authoritative execution-profile references plus +// the CEO agent's inherit markers. Failures are warn-logged so onboarding can +// complete and the routing editor can surface any missing mapping. func (s *OnboardingService) seedWorkspaceRouting( ctx context.Context, workspaceID, agentID string, req CompleteRequest, ) { @@ -574,14 +569,13 @@ func (s *OnboardingService) seedWorkspaceRouting( return } tier := normalizeDefaultTier(req.DefaultTier) - agent, err := s.lookupOnboardingAgent(ctx, workspaceID, agentID) - if err != nil { - s.logger.Warn("routing seed: lookup CEO agent failed", - zap.String("workspace_id", workspaceID), - zap.String("agent_id", agentID), zap.Error(err)) - return + cfg := buildOnboardingRoutingConfig(tier) + if req.AgentProfileID != "" { + if err := s.applyOnboardingTierProfile(ctx, cfg, tier, req.AgentProfileID); err != nil { + s.logger.Warn("routing seed: coordinator profile lookup failed", + zap.String("workspace_id", workspaceID), zap.Error(err)) + } } - cfg := buildOnboardingRoutingConfig(tier, agent) if err := s.applyOnboardingTierProfiles(ctx, cfg, req.TierProfiles); err != nil { s.logger.Warn("routing seed: tier profile lookup failed", zap.String("workspace_id", workspaceID), zap.Error(err)) @@ -607,31 +601,9 @@ func normalizeDefaultTier(raw string) routing.Tier { return routing.TierBalanced } -// lookupOnboardingAgent fetches the freshly created CEO agent by ID so -// the routing seed can pull its agent_id / model / mode without -// re-threading those values from createOnboardingAgent. -func (s *OnboardingService) lookupOnboardingAgent( - ctx context.Context, workspaceID, agentID string, -) (*models.AgentInstance, error) { - agents, err := s.repo.ListAgentInstances(ctx, workspaceID) - if err != nil { - return nil, err - } - for _, a := range agents { - if a != nil && a.ID == agentID { - return a, nil - } - } - return nil, fmt.Errorf("agent %s not found in workspace %s", agentID, workspaceID) -} - -// buildOnboardingRoutingConfig produces the disabled-by-default routing -// seed for a workspace. The chosen tier is always persisted so the -// Workspace → Provider routing settings page lands on the right -// default. When the CEO agent has a concrete provider/model attached, -// that pairing is also seeded into the order + tier_map so enabling -// routing later requires zero edits to make the chosen CLI the default. -func buildOnboardingRoutingConfig(tier routing.Tier, agent *models.AgentInstance) *routing.WorkspaceConfig { +// buildOnboardingRoutingConfig produces the disabled-by-default routing seed. +// Concrete mappings are added from the coordinator and tier profile choices. +func buildOnboardingRoutingConfig(tier routing.Tier) *routing.WorkspaceConfig { cfg := &routing.WorkspaceConfig{ Enabled: false, DefaultTier: tier, @@ -647,23 +619,6 @@ func buildOnboardingRoutingConfig(tier routing.Tier, agent *models.AgentInstance routing.WakeReasonBudgetAlert: routing.TierEconomy, }, } - if agent == nil || agent.AgentID == "" { - return cfg - } - providerID := routing.ProviderID(agent.AgentID) - tierMap := routing.TierMap{} - switch tier { - case routing.TierFrontier: - tierMap.Frontier = agent.Model - case routing.TierEconomy: - tierMap.Economy = agent.Model - default: - tierMap.Balanced = agent.Model - } - cfg.ProviderOrder = []routing.ProviderID{providerID} - cfg.ProviderProfiles[providerID] = routing.ProviderProfile{ - TierMap: tierMap, Mode: agent.Mode, - } return cfg } @@ -698,14 +653,14 @@ func (s *OnboardingService) applyOnboardingTierProfile( if src.AgentID == "" { return nil } - providerID := routing.ProviderID(src.AgentID) + providerID, err := s.resolveProviderID(ctx, src.AgentID) + if err != nil { + return fmt.Errorf("resolve provider for tier profile %s: %w", profileID, err) + } if cfg.ProviderProfiles == nil { cfg.ProviderProfiles = map[routing.ProviderID]routing.ProviderProfile{} } profile := cfg.ProviderProfiles[providerID] - if profile.Mode == "" { - profile.Mode = src.Mode - } applyTierProfileSeed(&profile, tier, src.Model, profileID) cfg.ProviderProfiles[providerID] = profile if !providerInOrder(cfg.ProviderOrder, providerID) { @@ -714,17 +669,50 @@ func (s *OnboardingService) applyOnboardingTierProfile( return nil } +func (s *OnboardingService) resolveProviderID( + ctx context.Context, + agentID string, +) (routing.ProviderID, error) { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return "", nil + } + for _, known := range routing.KnownProviders(nil) { + if routing.ProviderID(agentID) == known { + return known, nil + } + } + reader, ok := s.sourceProfile.(sourceProviderReader) + if !ok { + return "", fmt.Errorf("source profile reader cannot resolve provider %s", agentID) + } + agent, err := reader.GetAgent(ctx, agentID) + if err != nil { + return "", fmt.Errorf("look up provider %s: %w", agentID, err) + } + if agent == nil || strings.TrimSpace(agent.Name) == "" { + return "", fmt.Errorf("provider %s has no logical name", agentID) + } + providerID := routing.ProviderID(strings.TrimSpace(agent.Name)) + for _, known := range routing.KnownProviders(nil) { + if providerID == known { + return providerID, nil + } + } + return "", fmt.Errorf("provider %s resolves to unsupported provider %q", agentID, providerID) +} + func applyTierProfileSeed(profile *routing.ProviderProfile, tier routing.Tier, model, profileID string) { switch tier { case routing.TierFrontier: profile.TierMap.Frontier = model - profile.TierProfileIDs.Frontier = profileID + profile.ExecutionProfileIDs.Frontier = profileID case routing.TierEconomy: profile.TierMap.Economy = model - profile.TierProfileIDs.Economy = profileID + profile.ExecutionProfileIDs.Economy = profileID default: profile.TierMap.Balanced = model - profile.TierProfileIDs.Balanced = profileID + profile.ExecutionProfileIDs.Balanced = profileID } } diff --git a/apps/backend/internal/office/onboarding/service_test.go b/apps/backend/internal/office/onboarding/service_test.go index 2a077a5a86..0989863085 100644 --- a/apps/backend/internal/office/onboarding/service_test.go +++ b/apps/backend/internal/office/onboarding/service_test.go @@ -9,6 +9,7 @@ import ( "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" + settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/office/configloader" @@ -98,6 +99,7 @@ func newMockWorkflowEnsurer() *mockWorkflowEnsurer { type fakeSourceProfileReader struct { profiles map[string]*models.AgentInstance + agents map[string]*settingsmodels.Agent } func (f fakeSourceProfileReader) GetAgentProfile(_ context.Context, id string) (*models.AgentInstance, error) { @@ -108,6 +110,34 @@ func (f fakeSourceProfileReader) GetAgentProfile(_ context.Context, id string) ( return profile, nil } +func (f fakeSourceProfileReader) GetAgent(_ context.Context, id string) (*settingsmodels.Agent, error) { + agent := f.agents[id] + if agent == nil { + return nil, os.ErrNotExist + } + return agent, nil +} + +type capturingAgentCreator struct { + agent *models.AgentInstance +} + +func TestResolveProviderIDRejectsUnsupportedAgentName(t *testing.T) { + svc := &OnboardingService{sourceProfile: fakeSourceProfileReader{ + agents: map[string]*settingsmodels.Agent{ + "provider-db-id": {ID: "provider-db-id", Name: "custom-unknown-provider"}, + }, + }} + if _, err := svc.resolveProviderID(context.Background(), "provider-db-id"); err == nil { + t.Fatal("expected unsupported provider to be rejected") + } +} + +func (c *capturingAgentCreator) CreateAgentInstance(_ context.Context, agent *models.AgentInstance) error { + c.agent = agent + return nil +} + // newTestOnboardingServiceWithRepo is like newTestOnboardingService but // also returns the concrete sqlite repo so callers can assert on rows // the service writes (e.g. office_workspace_routing) without needing to @@ -235,6 +265,47 @@ func TestCompleteOnboarding_CreatesEntities(t *testing.T) { } } +func TestCreateOnboardingAgent_KeepsRuntimeConfigurationIndirect(t *testing.T) { + svc, _, _ := newTestOnboardingService(t) + source := &models.AgentInstance{ + AgentID: "provider-db-id", + Model: "opus", + Mode: "bypassPermissions", + ConfigOptions: map[string]string{"effort": "high"}, + AutoApprove: true, + CLIFlags: []settingsmodels.CLIFlag{ + {Flag: "--dangerously-skip-permissions", Enabled: true}, + }, + EnvVars: []settingsmodels.ProfileEnvVar{ + {Key: "CLAUDE_CONFIG_DIR", Value: "/data/home/.claude-work"}, + }, + } + svc.sourceProfile = fakeSourceProfileReader{profiles: map[string]*models.AgentInstance{ + "source-profile": source, + }} + capture := &capturingAgentCreator{} + svc.agentCreator = capture + + if _, err := svc.createOnboardingAgent(context.Background(), "ws-1", CompleteRequest{ + AgentName: "CEO", AgentProfileID: "source-profile", + }); err != nil { + t.Fatalf("create onboarding agent: %v", err) + } + if capture.agent == nil { + t.Fatal("agent was not created") + } + if capture.agent.AgentID != "provider-db-id" { + t.Errorf("legacy provider family = %q", capture.agent.AgentID) + } + if len(capture.agent.ConfigOptions) != 0 || capture.agent.AutoApprove { + t.Errorf("runtime options copied onto Office identity: %+v", capture.agent) + } + if len(capture.agent.CLIFlags) != 0 || len(capture.agent.EnvVars) != 0 { + t.Errorf("CLI configuration copied onto Office identity: flags=%+v env=%+v", + capture.agent.CLIFlags, capture.agent.EnvVars) + } +} + func findOnboardingTaskCall(calls []mockTaskCallOnboarding, title string) *mockTaskCallOnboarding { for i := range calls { if calls[i].Title == title { @@ -508,23 +579,27 @@ func TestCompleteOnboarding_SeedsWorkspaceRoutingTier(t *testing.T) { func TestCompleteOnboarding_SeedsWorkspaceRoutingTierProfiles(t *testing.T) { svc, _, repo, _ := newTestOnboardingServiceWithRepo(t) - svc.sourceProfile = fakeSourceProfileReader{profiles: map[string]*models.AgentInstance{ - "profile-frontier": { - AgentID: "codex-acp", - Model: "gpt-5.5-high", - Mode: "max", + svc.sourceProfile = fakeSourceProfileReader{ + agents: map[string]*settingsmodels.Agent{ + "provider-db-id": {ID: "provider-db-id", Name: "codex-acp"}, }, - "profile-balanced": { - AgentID: "codex-acp", - Model: "gpt-5.5-medium", - Mode: "medium", - }, - "profile-economy": { - AgentID: "codex-acp", - Model: "gpt-5.5-low", - Mode: "low", - }, - }} + profiles: map[string]*models.AgentInstance{ + "profile-frontier": { + AgentID: "provider-db-id", + Model: "gpt-5.5-high", + Mode: "max", + }, + "profile-balanced": { + AgentID: "provider-db-id", + Model: "gpt-5.5-medium", + Mode: "medium", + }, + "profile-economy": { + AgentID: "provider-db-id", + Model: "gpt-5.5-low", + Mode: "low", + }, + }} ctx := context.Background() result, err := svc.CompleteOnboarding(ctx, CompleteRequest{ diff --git a/apps/backend/internal/office/repository/sqlite/base.go b/apps/backend/internal/office/repository/sqlite/base.go index 7a8955047b..b784431e29 100644 --- a/apps/backend/internal/office/repository/sqlite/base.go +++ b/apps/backend/internal/office/repository/sqlite/base.go @@ -319,6 +319,7 @@ func (r *Repository) createRunTables() error { -- Provider routing (office-provider-routing). logical_provider_order TEXT, requested_tier TEXT, + resolved_execution_profile_id TEXT, resolved_provider_id TEXT, resolved_model TEXT, current_route_attempt_seq INTEGER NOT NULL DEFAULT 0, diff --git a/apps/backend/internal/office/repository/sqlite/base_migrations.go b/apps/backend/internal/office/repository/sqlite/base_migrations.go index deb407de7a..ae31d14965 100644 --- a/apps/backend/internal/office/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/office/repository/sqlite/base_migrations.go @@ -32,11 +32,8 @@ func (r *Repository) runMigrations() { // recreate-table migrations (notably migrateTaskPriorityToText, which // drops + rebuilds `tasks` and would otherwise wipe the FTS triggers). r.migrateTaskFTS() - // Provider routing tables (office_workspace_routing, - // office_run_route_attempts, office_provider_health). The runs queue - // routing columns and tier_per_reason are part of the canonical - // CREATE TABLE statements, so this migration only creates the - // auxiliary tables. + // Provider routing tables and replayable column migrations. Fresh + // schemas include the columns inline; ALTERs converge existing databases. r.migrateProviderRouting() } @@ -49,9 +46,8 @@ func (r *Repository) migrateRunPayloadIndexes() { // migrateProviderRouting creates the office_workspace_routing, // office_run_route_attempts, and office_provider_health tables. The -// routing columns on the runs queue (logical_provider_order, -// requested_tier, etc.) live in the canonical CREATE TABLE runs in -// base.go, so they are not ALTERed here. +// routing columns are also added with replayable ALTER statements so +// databases created before a column shipped converge to the fresh schema. func (r *Repository) migrateProviderRouting() { _, _ = r.db.Exec(` CREATE TABLE IF NOT EXISTS office_workspace_routing ( @@ -68,6 +64,7 @@ func (r *Repository) migrateProviderRouting() { CREATE TABLE IF NOT EXISTS office_run_route_attempts ( run_id TEXT NOT NULL, seq INTEGER NOT NULL, + execution_profile_id TEXT NOT NULL DEFAULT '', provider_id TEXT NOT NULL, model TEXT NOT NULL, tier TEXT NOT NULL, @@ -85,6 +82,11 @@ func (r *Repository) migrateProviderRouting() { FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE )`) + r.migrate.Apply("runs.resolved_execution_profile_id", + `ALTER TABLE runs ADD COLUMN resolved_execution_profile_id TEXT`) + r.migrate.Apply("office_run_route_attempts.execution_profile_id", + `ALTER TABLE office_run_route_attempts ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`) + _, _ = r.db.Exec(` CREATE TABLE IF NOT EXISTS office_provider_health ( workspace_id TEXT NOT NULL, diff --git a/apps/backend/internal/office/repository/sqlite/base_test.go b/apps/backend/internal/office/repository/sqlite/base_test.go index a91c2e5b6a..b58b69ad35 100644 --- a/apps/backend/internal/office/repository/sqlite/base_test.go +++ b/apps/backend/internal/office/repository/sqlite/base_test.go @@ -1,6 +1,7 @@ package sqlite_test import ( + "fmt" "testing" "github.com/jmoiron/sqlx" @@ -21,6 +22,7 @@ func newTestRepo(t *testing.T) *sqlite.Repository { if err != nil { t.Fatalf("open sqlite: %v", err) } + db.SetMaxOpenConns(1) t.Cleanup(func() { _ = db.Close() }) if _, _, err := settingsstore.Provide(db, db, nil); err != nil { @@ -39,6 +41,7 @@ func TestInitSchema_AllTablesExist(t *testing.T) { if err != nil { t.Fatalf("open sqlite: %v", err) } + db.SetMaxOpenConns(1) defer func() { _ = db.Close() }() _, err = sqlite.NewWithDB(db, db, nil) @@ -99,6 +102,7 @@ func TestInitSchema_Idempotent(t *testing.T) { if err != nil { t.Fatalf("open sqlite: %v", err) } + db.SetMaxOpenConns(1) defer func() { _ = db.Close() }() // Create repo twice - should not error on second call @@ -111,3 +115,82 @@ func TestInitSchema_Idempotent(t *testing.T) { t.Fatalf("second init should be idempotent: %v", err) } } + +func TestInitSchema_ExecutionProfileRoutingColumnsExist(t *testing.T) { + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + + if _, err := sqlite.NewWithDB(db, db, nil); err != nil { + t.Fatalf("init schema: %v", err) + } + + assertTableColumn(t, db, "runs", "resolved_execution_profile_id") + assertTableColumn(t, db, "office_run_route_attempts", "execution_profile_id") +} + +func TestInitSchema_ExecutionProfileRoutingColumnsReplay(t *testing.T) { + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + + if _, err := sqlite.NewWithDB(db, db, nil); err != nil { + t.Fatalf("first init: %v", err) + } + dropColumnIfPresent(t, db, "runs", "resolved_execution_profile_id") + dropColumnIfPresent(t, db, "office_run_route_attempts", "execution_profile_id") + + if _, err := sqlite.NewWithDB(db, db, nil); err != nil { + t.Fatalf("replay init: %v", err) + } + + assertTableColumn(t, db, "runs", "resolved_execution_profile_id") + assertTableColumn(t, db, "office_run_route_attempts", "execution_profile_id") +} + +func dropColumnIfPresent(t *testing.T, db *sqlx.DB, table, column string) { + t.Helper() + if !tableHasColumn(t, db, table, column) { + return + } + if _, err := db.Exec(fmt.Sprintf(`ALTER TABLE %s DROP COLUMN %s`, table, column)); err != nil { + t.Fatalf("drop %s.%s: %v", table, column, err) + } +} + +func assertTableColumn(t *testing.T, db *sqlx.DB, table, column string) { + t.Helper() + if !tableHasColumn(t, db, table, column) { + t.Errorf("%s.%s not found", table, column) + } +} + +func tableHasColumn(t *testing.T, db *sqlx.DB, table, column string) bool { + t.Helper() + rows, err := db.Queryx(fmt.Sprintf(`PRAGMA table_info(%s)`, table)) + if err != nil { + t.Fatalf("table info %s: %v", table, err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var cid, notNull, primaryKey int + var name, dataType string + var defaultValue interface{} + if err := rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &primaryKey); err != nil { + t.Fatalf("scan table info %s: %v", table, err) + } + if name == column { + return true + } + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate table info %s: %v", table, err) + } + return false +} diff --git a/apps/backend/internal/office/repository/sqlite/route_attempts.go b/apps/backend/internal/office/repository/sqlite/route_attempts.go index 25e6d76bcc..7e46793e42 100644 --- a/apps/backend/internal/office/repository/sqlite/route_attempts.go +++ b/apps/backend/internal/office/repository/sqlite/route_attempts.go @@ -22,11 +22,11 @@ func (r *Repository) AppendRouteAttempt( } _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO office_run_route_attempts ( - run_id, seq, provider_id, model, tier, outcome, + run_id, seq, execution_profile_id, provider_id, model, tier, outcome, error_code, error_confidence, adapter_phase, classifier_rule, exit_code, raw_excerpt, reset_hint, started_at, finished_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `), a.RunID, a.Seq, a.ProviderID, a.Model, a.Tier, a.Outcome, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), a.RunID, a.Seq, a.ExecutionProfileID, a.ProviderID, a.Model, a.Tier, a.Outcome, nullableString(a.ErrorCode), nullableString(string(a.ErrorConfidence)), nullableString(string(a.AdapterPhase)), nullableString(a.ClassifierRule), a.ExitCode, nullableString(a.RawExcerpt), a.ResetHint, @@ -45,7 +45,7 @@ func (r *Repository) ListRouteAttempts( ctx context.Context, runID string, ) ([]models.RouteAttempt, error) { rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(` - SELECT run_id, seq, provider_id, model, tier, outcome, + SELECT run_id, seq, execution_profile_id, provider_id, model, tier, outcome, COALESCE(error_code,'') AS error_code, COALESCE(error_confidence,'') AS error_confidence, COALESCE(adapter_phase,'') AS adapter_phase, diff --git a/apps/backend/internal/office/repository/sqlite/route_attempts_test.go b/apps/backend/internal/office/repository/sqlite/route_attempts_test.go index 24c708e6cd..f361fc2f12 100644 --- a/apps/backend/internal/office/repository/sqlite/route_attempts_test.go +++ b/apps/backend/internal/office/repository/sqlite/route_attempts_test.go @@ -2,6 +2,7 @@ package sqlite_test import ( "context" + "reflect" "testing" "time" @@ -13,6 +14,25 @@ import ( "github.com/kandev/kandev/internal/office/repository/sqlite" ) +func TestExecutionProfilePersistence_ModelFields(t *testing.T) { + assertModelField(t, reflect.TypeOf(models.Run{}), "ResolvedExecutionProfileID", + "resolved_execution_profile_id") + assertModelField(t, reflect.TypeOf(models.RouteAttempt{}), "ExecutionProfileID", + "execution_profile_id") +} + +func assertModelField(t *testing.T, model reflect.Type, fieldName, dbName string) { + t.Helper() + field, ok := model.FieldByName(fieldName) + if !ok { + t.Errorf("%s.%s not found", model.Name(), fieldName) + return + } + if got := field.Tag.Get("db"); got != dbName { + t.Errorf("%s.%s db tag = %q, want %q", model.Name(), fieldName, got, dbName) + } +} + func TestAppendAndListRouteAttempts_RoundTrip(t *testing.T) { repo := newTestRepo(t) ctx := context.Background() @@ -23,33 +43,35 @@ func TestAppendAndListRouteAttempts_RoundTrip(t *testing.T) { exitCode := 137 finished := now.Add(time.Minute) first := &models.RouteAttempt{ - RunID: "run-1", - Seq: 1, - ProviderID: "claude-acp", - Model: "sonnet", - Tier: "balanced", - Outcome: "failed_provider_unavailable", - ErrorCode: "quota_limited", - ErrorConfidence: "high", - AdapterPhase: "session_init", - ClassifierRule: "claude.stderr.quota.v1", - ExitCode: &exitCode, - RawExcerpt: "anthropic_quota_exceeded", - StartedAt: now, - FinishedAt: &finished, + RunID: "run-1", + Seq: 1, + ExecutionProfileID: "profile-claude", + ProviderID: "claude-acp", + Model: "sonnet", + Tier: "balanced", + Outcome: "failed_provider_unavailable", + ErrorCode: "quota_limited", + ErrorConfidence: "high", + AdapterPhase: "session_init", + ClassifierRule: "claude.stderr.quota.v1", + ExitCode: &exitCode, + RawExcerpt: "anthropic_quota_exceeded", + StartedAt: now, + FinishedAt: &finished, } if err := repo.AppendRouteAttempt(ctx, first); err != nil { t.Fatalf("append first: %v", err) } second := &models.RouteAttempt{ - RunID: "run-1", - Seq: 2, - ProviderID: "codex-acp", - Model: "gpt-5.4", - Tier: "balanced", - Outcome: "launched", - StartedAt: now.Add(time.Minute), + RunID: "run-1", + Seq: 2, + ExecutionProfileID: "profile-codex", + ProviderID: "codex-acp", + Model: "gpt-5.4", + Tier: "balanced", + Outcome: "launched", + StartedAt: now.Add(time.Minute), } if err := repo.AppendRouteAttempt(ctx, second); err != nil { t.Fatalf("append second: %v", err) @@ -68,6 +90,10 @@ func TestAppendAndListRouteAttempts_RoundTrip(t *testing.T) { if attempts[0].ErrorCode != "quota_limited" { t.Errorf("error code lost: %q", attempts[0].ErrorCode) } + if attempts[0].ExecutionProfileID != "profile-claude" || + attempts[1].ExecutionProfileID != "profile-codex" { + t.Errorf("execution profile IDs lost: %#v", attempts) + } if attempts[0].ExitCode == nil || *attempts[0].ExitCode != 137 { t.Errorf("exit code lost: %v", attempts[0].ExitCode) } diff --git a/apps/backend/internal/office/repository/sqlite/run_routing.go b/apps/backend/internal/office/repository/sqlite/run_routing.go index a9ff67ba88..c74f4b6554 100644 --- a/apps/backend/internal/office/repository/sqlite/run_routing.go +++ b/apps/backend/internal/office/repository/sqlite/run_routing.go @@ -33,18 +33,19 @@ func (r *Repository) SetRunRoutingDecision( return nil } -// SetRunResolvedProvider records the provider/model that launched a -// run. Called from the dispatch success branch. -func (r *Repository) SetRunResolvedProvider( - ctx context.Context, runID, providerID, model string, +// SetRunResolvedRoute records the concrete execution profile that +// launched a run together with its derived provider/model snapshots. +func (r *Repository) SetRunResolvedRoute( + ctx context.Context, + runID, executionProfileID, providerID, model string, ) error { _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE runs - SET resolved_provider_id = ?, resolved_model = ? + SET resolved_execution_profile_id = ?, resolved_provider_id = ?, resolved_model = ? WHERE id = ? - `), providerID, model, runID) + `), executionProfileID, providerID, model, runID) if err != nil { - return fmt.Errorf("run_routing: set resolved: %w", err) + return fmt.Errorf("run_routing: set resolved route: %w", err) } return nil } diff --git a/apps/backend/internal/office/repository/sqlite/run_routing_test.go b/apps/backend/internal/office/repository/sqlite/run_routing_test.go index 928215e57f..27c0e03ff6 100644 --- a/apps/backend/internal/office/repository/sqlite/run_routing_test.go +++ b/apps/backend/internal/office/repository/sqlite/run_routing_test.go @@ -50,6 +50,41 @@ func seedAgentProfile(t *testing.T, db *sqlx.DB, id, workspaceID string) { } } +func TestSetRunResolvedRoute_PersistsExecutionProfileAndSnapshots(t *testing.T) { + repo, _ := newTestRepoWithDB(t) + ctx := context.Background() + run := &models.Run{ + AgentProfileID: "office-cto", + Reason: "task_assigned", + Payload: `{}`, + Status: "queued", + CoalescedCount: 1, + } + if err := repo.CreateRun(ctx, run); err != nil { + t.Fatalf("create run: %v", err) + } + + if err := repo.SetRunResolvedRoute(ctx, run.ID, + "profile-claude-opus", "claude-acp", "opus"); err != nil { + t.Fatalf("set resolved route: %v", err) + } + + got, err := repo.GetRunByID(ctx, run.ID) + if err != nil { + t.Fatalf("get run: %v", err) + } + if got.ResolvedExecutionProfileID == nil || + *got.ResolvedExecutionProfileID != "profile-claude-opus" { + t.Fatalf("resolved execution profile = %v", got.ResolvedExecutionProfileID) + } + if got.ResolvedProviderID == nil || *got.ResolvedProviderID != "claude-acp" { + t.Errorf("resolved provider = %v", got.ResolvedProviderID) + } + if got.ResolvedModel == nil || *got.ResolvedModel != "opus" { + t.Errorf("resolved model = %v", got.ResolvedModel) + } +} + func TestClearAllParkedRoutingForWorkspace_ClearsOnlyTargetWorkspace(t *testing.T) { repo, db := newTestRepoWithDB(t) ctx := context.Background() diff --git a/apps/backend/internal/office/repository/sqlite/workspace_routing.go b/apps/backend/internal/office/repository/sqlite/workspace_routing.go index a1695cc9e8..9a0a99148d 100644 --- a/apps/backend/internal/office/repository/sqlite/workspace_routing.go +++ b/apps/backend/internal/office/repository/sqlite/workspace_routing.go @@ -166,9 +166,16 @@ func (r *Repository) ListRoutingTierReferencesByAgentProfile( if !ok { continue } - refs = appendTierProfileReference(refs, workspaceID, providerID, routing.TierFrontier, profile.TierProfileIDs.Frontier, profileID) - refs = appendTierProfileReference(refs, workspaceID, providerID, routing.TierBalanced, profile.TierProfileIDs.Balanced, profileID) - refs = appendTierProfileReference(refs, workspaceID, providerID, routing.TierEconomy, profile.TierProfileIDs.Economy, profileID) + for _, tier := range routing.AllTiers { + refs = appendTierProfileReference( + refs, + workspaceID, + providerID, + tier, + profile.ExecutionProfileID(tier), + profileID, + ) + } } } if err := rows.Err(); err != nil { diff --git a/apps/backend/internal/office/repository/sqlite/workspace_routing_test.go b/apps/backend/internal/office/repository/sqlite/workspace_routing_test.go index c9bc75b592..7570678078 100644 --- a/apps/backend/internal/office/repository/sqlite/workspace_routing_test.go +++ b/apps/backend/internal/office/repository/sqlite/workspace_routing_test.go @@ -2,11 +2,48 @@ package sqlite_test import ( "context" + "strings" "testing" "github.com/kandev/kandev/internal/office/routing" ) +func TestGetWorkspaceRouting_LegacyProfileIDsRewriteCanonically(t *testing.T) { + repo, db := newTestRepoWithDB(t) + ctx := context.Background() + legacyProfiles := `{"claude-acp":{"tier_map":{"balanced":"sonnet"},"tier_profile_ids":{"balanced":"legacy-profile"}}}` + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO office_workspace_routing + (workspace_id, enabled, default_tier, provider_order, provider_profiles, updated_at) + VALUES (?, 1, 'balanced', '["claude-acp"]', ?, datetime('now')) + `, "ws-legacy", legacyProfiles); err != nil { + t.Fatalf("insert legacy routing row: %v", err) + } + + cfg, err := repo.GetWorkspaceRouting(ctx, "ws-legacy") + if err != nil { + t.Fatalf("get legacy routing: %v", err) + } + if got := cfg.ProviderProfiles["claude-acp"].ExecutionProfileID(routing.TierBalanced); got != "legacy-profile" { + t.Fatalf("balanced execution profile = %q, want legacy-profile", got) + } + if err := repo.UpsertWorkspaceRouting(ctx, "ws-legacy", cfg); err != nil { + t.Fatalf("rewrite legacy routing: %v", err) + } + var stored string + if err := db.GetContext(ctx, &stored, ` + SELECT provider_profiles FROM office_workspace_routing WHERE workspace_id = ? + `, "ws-legacy"); err != nil { + t.Fatalf("read rewritten profiles: %v", err) + } + if !strings.Contains(stored, `"execution_profile_ids":{"balanced":"legacy-profile"}`) { + t.Fatalf("canonical execution profile mapping missing: %s", stored) + } + if strings.Contains(stored, "tier_profile_ids") { + t.Fatalf("legacy mapping key survived rewrite: %s", stored) + } +} + func TestGetWorkspaceRouting_DefaultOnEmpty(t *testing.T) { repo := newTestRepo(t) ctx := context.Background() @@ -42,11 +79,11 @@ func TestUpsertAndGetWorkspaceRouting_RoundTrip(t *testing.T) { ProviderOrder: []routing.ProviderID{"claude-acp", "codex-acp"}, ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{ "claude-acp": { - TierMap: routing.TierMap{Frontier: "opus", Balanced: "sonnet"}, - TierProfileIDs: routing.TierProfileIDs{Frontier: "profile-frontier", Balanced: "profile-balanced"}, - Mode: "default", - Flags: []string{"--quiet"}, - Env: map[string]string{"FOO": "bar"}, + TierMap: routing.TierMap{Frontier: "opus", Balanced: "sonnet"}, + ExecutionProfileIDs: routing.ExecutionProfileIDs{Frontier: "profile-frontier", Balanced: "profile-balanced"}, + Mode: "default", + Flags: []string{"--quiet"}, + Env: map[string]string{"FOO": "bar"}, }, "codex-acp": { TierMap: routing.TierMap{Balanced: "gpt-5.4"}, @@ -82,8 +119,8 @@ func TestUpsertAndGetWorkspaceRouting_RoundTrip(t *testing.T) { if len(claude.Flags) != 1 || claude.Flags[0] != "--quiet" { t.Errorf("claude flags = %v", claude.Flags) } - if claude.TierProfileIDs.Frontier != "profile-frontier" { - t.Errorf("claude tier profile ids lost: %+v", claude.TierProfileIDs) + if claude.ExecutionProfileIDs.Frontier != "profile-frontier" { + t.Errorf("claude execution profile ids lost: %+v", claude.ExecutionProfileIDs) } } @@ -101,7 +138,7 @@ func TestListRoutingTierReferencesByAgentProfile(t *testing.T) { Balanced: "gpt-5-medium", Economy: "gpt-5-low", }, - TierProfileIDs: routing.TierProfileIDs{ + ExecutionProfileIDs: routing.ExecutionProfileIDs{ Frontier: "profile-frontier", Balanced: "profile-balanced", Economy: "profile-economy", @@ -142,10 +179,10 @@ func TestListRoutingTierReferencesByAgentProfile_IgnoresRemovedProviders(t *test ProviderOrder: []routing.ProviderID{"codex-acp"}, ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{ "codex-acp": { - TierProfileIDs: routing.TierProfileIDs{Balanced: "active-profile"}, + ExecutionProfileIDs: routing.ExecutionProfileIDs{Balanced: "active-profile"}, }, "claude-acp": { - TierProfileIDs: routing.TierProfileIDs{Balanced: "removed-profile"}, + ExecutionProfileIDs: routing.ExecutionProfileIDs{Balanced: "removed-profile"}, }, }, } diff --git a/apps/backend/internal/office/routing/provider.go b/apps/backend/internal/office/routing/provider.go index b0016f8dd1..a557af6b99 100644 --- a/apps/backend/internal/office/routing/provider.go +++ b/apps/backend/internal/office/routing/provider.go @@ -3,9 +3,11 @@ package routing import ( "context" "fmt" + "sort" "time" "github.com/kandev/kandev/internal/agent/registry" + settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/office/models" ) @@ -20,25 +22,28 @@ import ( // eligible candidate); when not degraded these equal the primary. // When every provider is skipped, both Current fields are empty. type PreviewItem struct { - AgentID string - AgentName string - TierSource string - EffectiveTier string - PrimaryProviderID string - PrimaryModel string - CurrentProviderID string - CurrentModel string - FallbackChain []PreviewProviderModel - Missing []string - Degraded bool + AgentID string + AgentName string + TierSource string + EffectiveTier string + PrimaryProviderID string + PrimaryExecutionProfileID string + PrimaryModel string + CurrentProviderID string + CurrentExecutionProfileID string + CurrentModel string + FallbackChain []PreviewProviderModel + Missing []string + Degraded bool } // PreviewProviderModel is one (provider, model, tier) triple used in a // PreviewItem.FallbackChain. type PreviewProviderModel struct { - ProviderID string - Model string - Tier string + ExecutionProfileID string + ProviderID string + Model string + Tier string } // ProviderRepo is the narrow interface the routing Provider needs over @@ -65,6 +70,26 @@ type RetryRunner interface { RetryProvider(ctx context.Context, workspaceID, providerID string) error } +// ExecutionProfileStore is the settings data needed to validate and list +// concrete CLI profiles without coupling routing to a repository implementation. +type ExecutionProfileStore interface { + GetAgent(ctx context.Context, id string) (*settingsmodels.Agent, error) + GetAgentProfile(ctx context.Context, id string) (*settingsmodels.AgentProfile, error) + ListAgents(ctx context.Context) ([]*settingsmodels.Agent, error) + ListAgentProfiles(ctx context.Context, agentID string) ([]*settingsmodels.AgentProfile, error) +} + +// ExecutionProfileSummary is the safe profile catalogue returned to the +// routing editor. Secret environment values and CLI configuration stay server-side. +type ExecutionProfileSummary struct { + ID string `json:"id"` + Name string `json:"name"` + ProviderID ProviderID `json:"provider_id"` + Model string `json:"model"` + Mode string `json:"mode,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` +} + // Provider implements the dashboard's RoutingProvider seam against the // office repo + agent registry + scheduler retry. Kept in the routing // package so the dashboard package stays repo-agnostic and the @@ -74,6 +99,13 @@ type Provider struct { registry *registry.Registry resolver *Resolver retry RetryRunner + profiles ExecutionProfileStore +} + +// SetExecutionProfileStore enables concrete profile validation and catalogue +// responses. It is separate from construction to keep routing package tests small. +func (p *Provider) SetExecutionProfileStore(store ExecutionProfileStore) { + p.profiles = store } // NewProvider builds a Provider over the supplied dependencies. registry @@ -105,9 +137,26 @@ func (p *Provider) GetConfig( ProviderProfiles: map[ProviderID]ProviderProfile{}, } } + if p.profiles != nil { + if changed, normalizeErr := p.normalizeProfileMappings(ctx, workspaceID, cfg); normalizeErr == nil && changed { + if err := p.repo.UpsertWorkspaceRouting(ctx, workspaceID, cfg); err != nil { + return nil, nil, err + } + } + } return cfg, KnownProviders(p.registry), nil } +// ListExecutionProfiles returns active launchable profiles visible to a workspace. +func (p *Provider) ListExecutionProfiles( + ctx context.Context, workspaceID string, +) ([]ExecutionProfileSummary, error) { + if p.profiles == nil { + return []ExecutionProfileSummary{}, nil + } + return p.executionProfileCatalog(ctx, workspaceID) +} + // UpdateConfig validates cfg via ValidateWorkspaceConfig and writes it // when the validator passes. Validation runs in strict mode whenever // cfg.Enabled is true. @@ -129,6 +178,11 @@ func (p *Provider) GetConfig( func (p *Provider) UpdateConfig( ctx context.Context, workspaceID string, cfg WorkspaceConfig, ) error { + if p.profiles != nil { + if _, err := p.normalizeProfileMappings(ctx, workspaceID, &cfg); err != nil { + return err + } + } known := KnownProviders(p.registry) if err := ValidateWorkspaceConfig(cfg, known); err != nil { return err @@ -145,6 +199,189 @@ func (p *Provider) UpdateConfig( return nil } +func (p *Provider) normalizeProfileMappings( + ctx context.Context, workspaceID string, cfg *WorkspaceConfig, +) (bool, error) { + return normalizeProfileMappings(ctx, workspaceID, cfg, p.profiles, p.registry) +} + +func normalizeProfileMappings( + ctx context.Context, workspaceID string, cfg *WorkspaceConfig, + profiles ExecutionProfileStore, reg *registry.Registry, +) (bool, error) { + catalog, err := executionProfileCatalog(ctx, workspaceID, profiles, reg) + if err != nil { + return false, fmt.Errorf("routing: list execution profiles: %w", err) + } + byID := make(map[string]ExecutionProfileSummary, len(catalog)) + for _, profile := range catalog { + byID[profile.ID] = profile + } + changed := false + for providerID, mapping := range cfg.ProviderProfiles { + for _, tier := range AllTiers { + profileID := mapping.ExecutionProfileID(tier) + if profileID == "" { + legacyModel := mapping.TierMap.Model(tier) + if legacyModel == "" { + continue + } + matches := matchingLegacyProfiles(catalog, providerID, legacyModel, mapping.Mode) + if len(matches) != 1 { + return false, profileMappingError(providerID, tier, + fmt.Sprintf("legacy model %q matches %d execution profiles; select one explicitly", legacyModel, len(matches))) + } + profileID = matches[0].ID + setExecutionProfileID(&mapping.ExecutionProfileIDs, tier, profileID) + changed = true + } + profile, ok := byID[profileID] + if !ok { + return false, profileMappingError(providerID, tier, + fmt.Sprintf("execution profile %q is missing, deleted, outside this workspace, or not launchable", profileID)) + } + if profile.ProviderID != providerID { + return false, profileMappingError(providerID, tier, + fmt.Sprintf("execution profile %q belongs to provider %q", profileID, profile.ProviderID)) + } + if mapping.TierMap.Model(tier) != profile.Model { + setTierModel(&mapping.TierMap, tier, profile.Model) + changed = true + } + } + mapping.TierProfileIDs = mapping.ExecutionProfileIDs + cfg.ProviderProfiles[providerID] = mapping + } + return changed, nil +} + +func (p *Provider) executionProfileCatalog( + ctx context.Context, workspaceID string, +) ([]ExecutionProfileSummary, error) { + return executionProfileCatalog(ctx, workspaceID, p.profiles, p.registry) +} + +func executionProfileCatalog( + ctx context.Context, workspaceID string, + profiles ExecutionProfileStore, reg *registry.Registry, +) ([]ExecutionProfileSummary, error) { + agents, err := profiles.ListAgents(ctx) + if err != nil { + return nil, err + } + out := make([]ExecutionProfileSummary, 0) + for _, agent := range agents { + agentProfiles, err := executionProfilesForAgent( + ctx, workspaceID, profiles, reg, agent, + ) + if err != nil { + return nil, err + } + out = append(out, agentProfiles...) + } + sort.Slice(out, func(i, j int) bool { + if out[i].ProviderID != out[j].ProviderID { + return out[i].ProviderID < out[j].ProviderID + } + if out[i].Name != out[j].Name { + return out[i].Name < out[j].Name + } + return out[i].ID < out[j].ID + }) + return out, nil +} + +func executionProfilesForAgent( + ctx context.Context, workspaceID string, + profiles ExecutionProfileStore, reg *registry.Registry, + agent *settingsmodels.Agent, +) ([]ExecutionProfileSummary, error) { + if !executionProviderAgentVisible(agent, workspaceID, reg) { + return nil, nil + } + agentProfiles, err := profiles.ListAgentProfiles(ctx, agent.ID) + if err != nil { + return nil, err + } + out := make([]ExecutionProfileSummary, 0, len(agentProfiles)) + for _, profile := range agentProfiles { + if profile == nil || profile.DeletedAt != nil || profile.Model == "" || profile.Role != "" { + continue + } + if profile.WorkspaceID != "" && profile.WorkspaceID != workspaceID { + continue + } + out = append(out, ExecutionProfileSummary{ + ID: profile.ID, Name: profile.Name, ProviderID: ProviderID(agent.Name), + Model: profile.Model, Mode: profile.Mode, WorkspaceID: profile.WorkspaceID, + }) + } + return out, nil +} + +func executionProviderAgentVisible( + agent *settingsmodels.Agent, workspaceID string, reg *registry.Registry, +) bool { + if agent == nil || agent.Name == "" { + return false + } + if agent.WorkspaceID != nil && *agent.WorkspaceID != "" && *agent.WorkspaceID != workspaceID { + return false + } + if reg == nil { + return true + } + _, ok := reg.Get(agent.Name) + return ok +} + +func matchingLegacyProfiles( + catalog []ExecutionProfileSummary, providerID ProviderID, model, mode string, +) []ExecutionProfileSummary { + var matches []ExecutionProfileSummary + for _, profile := range catalog { + if profile.ProviderID == providerID && profile.Model == model && + (mode == "" || profile.Mode == mode) { + matches = append(matches, profile) + } + } + return matches +} + +func profileMappingError(providerID ProviderID, tier Tier, message string) error { + return &ValidationError{ + Field: "provider_profiles", + Message: "execution profile mapping is invalid", + Details: []ValidationDetail{{ + ProviderID: providerID, + Field: "execution_profile_ids." + string(tier), + Message: message, + }}, + } +} + +func setExecutionProfileID(ids *ExecutionProfileIDs, tier Tier, value string) { + switch tier { + case TierFrontier: + ids.Frontier = value + case TierBalanced: + ids.Balanced = value + case TierEconomy: + ids.Economy = value + } +} + +func setTierModel(models *TierMap, tier Tier, value string) { + switch tier { + case TierFrontier: + models.Frontier = value + case TierBalanced: + models.Balanced = value + case TierEconomy: + models.Economy = value + } +} + // shouldClearParked reports whether the config transition warrants // clearing parked runs. Disabled→disabled is a no-op. func shouldClearParked(prev *WorkspaceConfig, next WorkspaceConfig) bool { @@ -197,7 +434,9 @@ func providerProfilesEqual(a, b map[ProviderID]ProviderProfile) bool { } func providerProfileEqual(a, b ProviderProfile) bool { - if a.TierMap != b.TierMap || a.TierProfileIDs != b.TierProfileIDs || a.Mode != b.Mode { + if a.TierMap != b.TierMap || + a.effectiveExecutionProfileIDs() != b.effectiveExecutionProfileIDs() || + a.Mode != b.Mode { return false } if !stringSliceEqual(a.Flags, b.Flags) { @@ -333,19 +572,21 @@ func (p *Provider) previewForAgent( if err != nil { return PreviewItem{}, fmt.Errorf("routing: resolve %s: %w", agent.ID, err) } - primaryProvider, primaryModel := primaryProviderModel(res, cfg) + primaryProvider, primaryProfile, primaryModel := primaryProviderModel(res, cfg) return PreviewItem{ - AgentID: agent.ID, - AgentName: agent.Name, - TierSource: tierSource, - EffectiveTier: string(effectivePreviewTier(res, cfg)), - PrimaryProviderID: primaryProvider, - PrimaryModel: primaryModel, - CurrentProviderID: firstCandidateProvider(res), - CurrentModel: firstCandidateModel(res), - FallbackChain: fallbackChain(res), - Missing: missingHints(res), - Degraded: hasDegradedSkip(res), + AgentID: agent.ID, + AgentName: agent.Name, + TierSource: tierSource, + EffectiveTier: string(effectivePreviewTier(res, cfg)), + PrimaryProviderID: primaryProvider, + PrimaryExecutionProfileID: primaryProfile, + PrimaryModel: primaryModel, + CurrentProviderID: firstCandidateProvider(res), + CurrentExecutionProfileID: firstCandidateExecutionProfile(res), + CurrentModel: firstCandidateModel(res), + FallbackChain: fallbackChain(res), + Missing: missingHints(res), + Degraded: hasDegradedSkip(res), }, nil } @@ -354,16 +595,16 @@ func (p *Provider) previewForAgent( // is currently degraded or missing a tier mapping. The model comes from // the workspace ProviderProfiles tier map for the requested tier; empty // when the mapping isn't set. -func primaryProviderModel(res *Resolution, cfg *WorkspaceConfig) (string, string) { +func primaryProviderModel(res *Resolution, cfg *WorkspaceConfig) (string, string, string) { if res == nil || cfg == nil || len(res.ProviderOrder) == 0 { - return "", "" + return "", "", "" } first := res.ProviderOrder[0] prof, ok := cfg.ProviderProfiles[first] if !ok { - return string(first), "" + return string(first), "", "" } - return string(first), prof.TierMap.Model(res.RequestedTier) + return string(first), prof.ExecutionProfileID(res.RequestedTier), prof.TierMap.Model(res.RequestedTier) } // tierSourceForAgent returns "override" when the agent's settings flip @@ -401,6 +642,13 @@ func firstCandidateProvider(res *Resolution) string { return string(res.Candidates[0].ProviderID) } +func firstCandidateExecutionProfile(res *Resolution) string { + if res == nil || len(res.Candidates) == 0 { + return "" + } + return res.Candidates[0].ExecutionProfileID +} + // firstCandidateModel returns the first non-skipped candidate's model. func firstCandidateModel(res *Resolution) string { if res == nil || len(res.Candidates) == 0 { @@ -418,9 +666,10 @@ func fallbackChain(res *Resolution) []PreviewProviderModel { out := make([]PreviewProviderModel, 0, len(res.Candidates)-1) for _, c := range res.Candidates[1:] { out = append(out, PreviewProviderModel{ - ProviderID: string(c.ProviderID), - Model: c.Model, - Tier: string(c.Tier), + ExecutionProfileID: c.ExecutionProfileID, + ProviderID: string(c.ProviderID), + Model: c.Model, + Tier: string(c.Tier), }) } return out diff --git a/apps/backend/internal/office/routing/provider_test.go b/apps/backend/internal/office/routing/provider_test.go index f4621c2eaa..ec60c7bcd2 100644 --- a/apps/backend/internal/office/routing/provider_test.go +++ b/apps/backend/internal/office/routing/provider_test.go @@ -2,9 +2,11 @@ package routing import ( "context" + "database/sql" "testing" "time" + settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/office/models" ) @@ -54,6 +56,61 @@ func (f *fakeRetry) RetryProvider(_ context.Context, _, _ string) error { return nil } +type fakeExecutionProfileStore struct { + agents []*settingsmodels.Agent + profiles map[string][]*settingsmodels.AgentProfile +} + +func (f *fakeExecutionProfileStore) GetAgent(_ context.Context, id string) (*settingsmodels.Agent, error) { + for _, agent := range f.agents { + if agent.ID == id { + return agent, nil + } + } + return nil, sql.ErrNoRows +} + +func (f *fakeExecutionProfileStore) GetAgentProfile(_ context.Context, id string) (*settingsmodels.AgentProfile, error) { + for _, profiles := range f.profiles { + for _, profile := range profiles { + if profile.ID == id { + return profile, nil + } + } + } + return nil, sql.ErrNoRows +} + +func (f *fakeExecutionProfileStore) ListAgents(_ context.Context) ([]*settingsmodels.Agent, error) { + return f.agents, nil +} + +func (f *fakeExecutionProfileStore) ListAgentProfiles( + _ context.Context, agentID string, +) ([]*settingsmodels.AgentProfile, error) { + return f.profiles[agentID], nil +} + +func testExecutionProfiles() *fakeExecutionProfileStore { + return &fakeExecutionProfileStore{ + agents: []*settingsmodels.Agent{ + {ID: "codex-agent", Name: "codex-acp"}, + {ID: "claude-agent", Name: "claude-acp"}, + }, + profiles: map[string][]*settingsmodels.AgentProfile{ + "codex-agent": { + {ID: "codex-global", AgentID: "codex-agent", Name: "Codex global", Model: "gpt-5.6"}, + {ID: "codex-local", AgentID: "codex-agent", Name: "Codex local", Model: "gpt-5.6", WorkspaceID: "ws-1"}, + {ID: "codex-other", AgentID: "codex-agent", Name: "Codex other", Model: "gpt-5.6", WorkspaceID: "ws-2"}, + {ID: "office-cto", AgentID: "codex-agent", Name: "CTO", Model: "gpt-5.6", WorkspaceID: "ws-1", Role: settingsmodels.AgentRole("cto")}, + }, + "claude-agent": { + {ID: "claude-opus", AgentID: "claude-agent", Name: "Claude Opus", Model: "opus"}, + }, + }, + } +} + func newProviderTest(cfg *WorkspaceConfig, agents []*models.AgentInstance) (*Provider, *fakeProviderRepo) { repo := &fakeProviderRepo{ cfg: cfg, @@ -67,6 +124,129 @@ func newProviderTest(cfg *WorkspaceConfig, agents []*models.AgentInstance) (*Pro return NewProvider(repo, nil, resolver, &fakeRetry{}), repo } +func TestProvider_UpdateConfigValidatesAndDerivesExecutionProfile(t *testing.T) { + p, repo := newProviderTest(nil, nil) + p.SetExecutionProfileStore(testExecutionProfiles()) + cfg := WorkspaceConfig{ + DefaultTier: TierBalanced, + ProviderOrder: []ProviderID{"claude-acp"}, + ProviderProfiles: map[ProviderID]ProviderProfile{ + "claude-acp": {ExecutionProfileIDs: ExecutionProfileIDs{Balanced: "claude-opus"}}, + }, + } + if err := p.UpdateConfig(context.Background(), "ws-1", cfg); err != nil { + t.Fatalf("update config: %v", err) + } + got := repo.cfg.ProviderProfiles["claude-acp"] + if got.TierMap.Balanced != "opus" { + t.Fatalf("derived model = %q, want opus", got.TierMap.Balanced) + } +} + +func TestProvider_OfficeIdentityIsNotAnExecutionProfile(t *testing.T) { + p, _ := newProviderTest(nil, nil) + p.SetExecutionProfileStore(testExecutionProfiles()) + + profiles, err := p.ListExecutionProfiles(context.Background(), "ws-1") + if err != nil { + t.Fatalf("list execution profiles: %v", err) + } + for _, profile := range profiles { + if profile.ID == "office-cto" { + t.Fatal("rich Office identity must not appear in execution profile catalogue") + } + } + + err = p.UpdateConfig(context.Background(), "ws-1", WorkspaceConfig{ + DefaultTier: TierBalanced, + ProviderOrder: []ProviderID{"codex-acp"}, + ProviderProfiles: map[ProviderID]ProviderProfile{ + "codex-acp": {ExecutionProfileIDs: ExecutionProfileIDs{Balanced: "office-cto"}}, + }, + }) + if err == nil { + t.Fatal("expected Office identity mapping to be rejected") + } +} + +func TestProvider_ExcludesProviderAgentsOwnedByAnotherWorkspace(t *testing.T) { + store := testExecutionProfiles() + otherWorkspace := "ws-2" + store.agents[0].WorkspaceID = &otherWorkspace + p, _ := newProviderTest(nil, nil) + p.SetExecutionProfileStore(store) + + profiles, err := p.ListExecutionProfiles(context.Background(), "ws-1") + if err != nil { + t.Fatalf("list execution profiles: %v", err) + } + for _, profile := range profiles { + if profile.ProviderID == "codex-acp" { + t.Fatalf("cross-workspace provider profile leaked into catalogue: %+v", profile) + } + } +} + +func TestProvider_UpdateConfigRejectsCrossWorkspaceAndWrongProvider(t *testing.T) { + tests := []struct { + name, profileID, providerID string + }{ + {name: "cross workspace", profileID: "codex-other", providerID: "codex-acp"}, + {name: "wrong provider", profileID: "claude-opus", providerID: "codex-acp"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, _ := newProviderTest(nil, nil) + p.SetExecutionProfileStore(testExecutionProfiles()) + err := p.UpdateConfig(context.Background(), "ws-1", WorkspaceConfig{ + DefaultTier: TierBalanced, + ProviderOrder: []ProviderID{ProviderID(tt.providerID)}, + ProviderProfiles: map[ProviderID]ProviderProfile{ + ProviderID(tt.providerID): { + ExecutionProfileIDs: ExecutionProfileIDs{Balanced: tt.profileID}, + }, + }, + }) + if err == nil { + t.Fatal("expected invalid execution profile mapping") + } + }) + } +} + +func TestProvider_UpdateConfigMigratesOnlyUniqueLegacyMapping(t *testing.T) { + store := testExecutionProfiles() + store.profiles["codex-agent"] = store.profiles["codex-agent"][:1] + p, repo := newProviderTest(nil, nil) + p.SetExecutionProfileStore(store) + err := p.UpdateConfig(context.Background(), "ws-1", WorkspaceConfig{ + DefaultTier: TierBalanced, + ProviderOrder: []ProviderID{"codex-acp"}, + ProviderProfiles: map[ProviderID]ProviderProfile{ + "codex-acp": {TierMap: TierMap{Balanced: "gpt-5.6"}}, + }, + }) + if err != nil { + t.Fatalf("migrate unique legacy mapping: %v", err) + } + if got := repo.cfg.ProviderProfiles["codex-acp"].ExecutionProfileID(TierBalanced); got != "codex-global" { + t.Fatalf("execution profile = %q, want codex-global", got) + } + + p, _ = newProviderTest(nil, nil) + p.SetExecutionProfileStore(testExecutionProfiles()) + err = p.UpdateConfig(context.Background(), "ws-1", WorkspaceConfig{ + DefaultTier: TierBalanced, + ProviderOrder: []ProviderID{"codex-acp"}, + ProviderProfiles: map[ProviderID]ProviderProfile{ + "codex-acp": {TierMap: TierMap{Balanced: "gpt-5.6"}}, + }, + }) + if err == nil { + t.Fatal("expected ambiguous legacy mapping to be rejected") + } +} + // providerResolverAdapter adapts ProviderRepo to the Resolver's narrower // Repo interface for the test (avoid needing two stubs). type providerResolverAdapter struct{ repo *fakeProviderRepo } @@ -85,7 +265,10 @@ func TestProvider_PreviewComposesCandidatesAndMissing(t *testing.T) { DefaultTier: TierBalanced, ProviderOrder: []ProviderID{"claude-acp", "codex-acp"}, ProviderProfiles: map[ProviderID]ProviderProfile{ - "claude-acp": {TierMap: TierMap{Balanced: "sonnet"}}, + "claude-acp": { + TierMap: TierMap{Balanced: "sonnet"}, + ExecutionProfileIDs: ExecutionProfileIDs{Balanced: "claude-sonnet-profile"}, + }, // codex-acp deliberately has no profile -> missing mapping }, } @@ -115,7 +298,10 @@ func TestProvider_PreviewPrimaryReflectsIntent(t *testing.T) { DefaultTier: TierBalanced, ProviderOrder: []ProviderID{"claude-acp"}, ProviderProfiles: map[ProviderID]ProviderProfile{ - "claude-acp": {TierMap: TierMap{Balanced: "sonnet"}}, + "claude-acp": { + TierMap: TierMap{Balanced: "sonnet"}, + ExecutionProfileIDs: ExecutionProfileIDs{Balanced: "claude-sonnet-profile"}, + }, }, } agents := []*models.AgentInstance{{ID: "a1", Name: "Alice", WorkspaceID: "ws-1"}} @@ -135,8 +321,14 @@ func TestProvider_PreviewCurrentDiffersWhenPrimaryDegraded(t *testing.T) { DefaultTier: TierBalanced, ProviderOrder: []ProviderID{"claude-acp", "codex-acp"}, ProviderProfiles: map[ProviderID]ProviderProfile{ - "claude-acp": {TierMap: TierMap{Balanced: "sonnet"}}, - "codex-acp": {TierMap: TierMap{Balanced: "gpt-5"}}, + "claude-acp": { + TierMap: TierMap{Balanced: "sonnet"}, + ExecutionProfileIDs: ExecutionProfileIDs{Balanced: "claude-sonnet-profile"}, + }, + "codex-acp": { + TierMap: TierMap{Balanced: "gpt-5"}, + ExecutionProfileIDs: ExecutionProfileIDs{Balanced: "codex-balanced-profile"}, + }, }, } agents := []*models.AgentInstance{{ID: "a1", Name: "Alice", WorkspaceID: "ws-1"}} @@ -204,7 +396,10 @@ func TestProvider_PreviewAgentRoundTrips(t *testing.T) { DefaultTier: TierBalanced, ProviderOrder: []ProviderID{"claude-acp"}, ProviderProfiles: map[ProviderID]ProviderProfile{ - "claude-acp": {TierMap: TierMap{Balanced: "sonnet"}}, + "claude-acp": { + ExecutionProfileIDs: ExecutionProfileIDs{Balanced: "claude-sonnet"}, + TierMap: TierMap{Balanced: "sonnet"}, + }, }, } agents := []*models.AgentInstance{{ID: "a1", Name: "Alice", WorkspaceID: "ws-1"}} @@ -286,7 +481,10 @@ func TestProvider_UpdateClearsParkedOnMaterialChange(t *testing.T) { DefaultTier: TierFrontier, ProviderOrder: []ProviderID{"claude-acp"}, ProviderProfiles: map[ProviderID]ProviderProfile{ - "claude-acp": {TierMap: TierMap{Frontier: "opus"}}, + "claude-acp": { + ExecutionProfileIDs: ExecutionProfileIDs{Frontier: "claude-opus"}, + TierMap: TierMap{Frontier: "opus"}, + }, }, } if err := p.UpdateConfig(context.Background(), "ws-1", next); err != nil { @@ -298,13 +496,45 @@ func TestProvider_UpdateClearsParkedOnMaterialChange(t *testing.T) { } } +func TestProvider_UpdateClearsParkedWhenExecutionProfileChanges(t *testing.T) { + prev := &WorkspaceConfig{ + Enabled: true, + DefaultTier: TierBalanced, + ProviderOrder: []ProviderID{"claude-acp"}, + ProviderProfiles: map[ProviderID]ProviderProfile{ + "claude-acp": { + TierMap: TierMap{Balanced: "sonnet"}, + ExecutionProfileIDs: ExecutionProfileIDs{Balanced: "claude-account-a"}, + }, + }, + } + p, repo := newProviderTest(prev, nil) + next := *prev + next.ProviderProfiles = map[ProviderID]ProviderProfile{ + "claude-acp": { + TierMap: TierMap{Balanced: "sonnet"}, + ExecutionProfileIDs: ExecutionProfileIDs{Balanced: "claude-account-b"}, + }, + } + + if err := p.UpdateConfig(context.Background(), "ws-1", next); err != nil { + t.Fatalf("update config: %v", err) + } + if len(repo.clearedParkedFor) != 1 || repo.clearedParkedFor[0] != "ws-1" { + t.Fatalf("execution profile change did not clear parked runs: %v", repo.clearedParkedFor) + } +} + func TestProvider_UpdateDoesNotClearWhenConfigUnchanged(t *testing.T) { prev := &WorkspaceConfig{ Enabled: true, DefaultTier: TierBalanced, ProviderOrder: []ProviderID{"claude-acp"}, ProviderProfiles: map[ProviderID]ProviderProfile{ - "claude-acp": {TierMap: TierMap{Balanced: "sonnet"}}, + "claude-acp": { + ExecutionProfileIDs: ExecutionProfileIDs{Balanced: "claude-sonnet"}, + TierMap: TierMap{Balanced: "sonnet"}, + }, }, } p, repo := newProviderTest(prev, nil) @@ -313,7 +543,10 @@ func TestProvider_UpdateDoesNotClearWhenConfigUnchanged(t *testing.T) { DefaultTier: TierBalanced, ProviderOrder: []ProviderID{"claude-acp"}, ProviderProfiles: map[ProviderID]ProviderProfile{ - "claude-acp": {TierMap: TierMap{Balanced: "sonnet"}}, + "claude-acp": { + ExecutionProfileIDs: ExecutionProfileIDs{Balanced: "claude-sonnet"}, + TierMap: TierMap{Balanced: "sonnet"}, + }, }, } if err := p.UpdateConfig(context.Background(), "ws-1", next); err != nil { diff --git a/apps/backend/internal/office/routing/resolver.go b/apps/backend/internal/office/routing/resolver.go index fc90660c20..188b75f78f 100644 --- a/apps/backend/internal/office/routing/resolver.go +++ b/apps/backend/internal/office/routing/resolver.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/kandev/kandev/internal/agent/registry" settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/office/models" ) @@ -68,8 +69,19 @@ type Repo interface { // Resolver turns workspace routing config + agent overrides + provider // health into an ordered Candidate list. Pure: no I/O beyond Repo reads. type Resolver struct { - repo Repo - clock func() time.Time + repo Repo + clock func() time.Time + profiles ExecutionProfileStore + registry *registry.Registry +} + +// SetExecutionProfileStore enables live candidate validation. Without it the +// resolver retains legacy behavior for isolated tests and non-composed callers. +func (r *Resolver) SetExecutionProfileStore( + profiles ExecutionProfileStore, reg *registry.Registry, +) { + r.profiles = profiles + r.registry = reg } // NewResolver builds a Resolver. When clock is nil, time.Now is used. @@ -85,12 +97,13 @@ func NewResolver(repo Repo, clock func() time.Time) *Resolver { // workspace ProviderProfile. Self-describing so the launch path does not // have to reach back into routing config. type Candidate struct { - ProviderID ProviderID - Model string - Tier Tier - Mode string - Flags []string - Env map[string]string + ExecutionProfileID string + ProviderID ProviderID + Model string + Tier Tier + Mode string + Flags []string + Env map[string]string } // SkippedCandidate records why a provider in the effective order was @@ -156,9 +169,16 @@ func (r *Resolver) Resolve( if err != nil { return nil, fmt.Errorf("routing: load workspace config: %w", err) } - if cfg == nil || !cfg.Enabled { + if cfg == nil || (!cfg.Enabled && len(cfg.ProviderOrder) == 0) { return &Resolution{Enabled: false}, nil } + if r.profiles != nil { + if _, err := normalizeProfileMappings( + ctx, workspaceID, cfg, r.profiles, r.registry, + ); err != nil { + return nil, err + } + } ov, err := ReadAgentOverrides(agent.Settings) if err != nil { return nil, fmt.Errorf("routing: load agent overrides: %w", err) @@ -168,18 +188,29 @@ func (r *Resolver) Resolve( if len(order) == 0 { return nil, ErrEmptyOrder } + res := &Resolution{Enabled: cfg.Enabled, RequestedTier: tier, ProviderOrder: order} + if !cfg.Enabled { + if err := r.evaluateProvider(ctx, workspaceID, res, cfg, nil, order[0], tier, r.clock()); err != nil { + return nil, err + } + if len(res.Candidates) == 0 { + res.BlockReason = aggregateBlock(res.SkippedDegraded) + } + return res, nil + } idx, err := r.loadHealthIndex(ctx, workspaceID) if err != nil { return nil, err } - res := &Resolution{Enabled: true, RequestedTier: tier, ProviderOrder: order} excluded := providerExcludeSet(opts.ExcludeProviders) now := r.clock() for _, pid := range order { if _, skip := excluded[pid]; skip { continue } - r.evaluateProvider(res, cfg, idx, pid, tier, now) + if err := r.evaluateProvider(ctx, workspaceID, res, cfg, idx, pid, tier, now); err != nil { + return nil, err + } } if len(res.Candidates) == 0 { res.BlockReason = aggregateBlock(res.SkippedDegraded) @@ -191,32 +222,82 @@ func (r *Resolver) Resolve( // missing-mapping check, scoped health lookup, then appends either a // Candidate or a SkippedCandidate to res. func (r *Resolver) evaluateProvider( + ctx context.Context, workspaceID string, res *Resolution, cfg *WorkspaceConfig, idx healthIndex, pid ProviderID, tier Tier, now time.Time, -) { +) error { prof, ok := cfg.ProviderProfiles[pid] model := prof.TierMap.Model(tier) - if !ok || model == "" { + executionProfileID := prof.ExecutionProfileID(tier) + if !ok || executionProfileID == "" || (r.profiles == nil && model == "") { res.SkippedDegraded = append(res.SkippedDegraded, SkippedCandidate{ ProviderID: pid, Reason: SkipReasonMissingModelMapping, }) - return + return nil + } + if r.profiles != nil { + resolvedModel, err := r.resolveExecutionProfile(ctx, workspaceID, pid, tier, executionProfileID) + if err != nil { + return err + } + model = resolvedModel } if hit, found := lookupHealth(idx, pid, tier, model); found { if sc, skip := classifyHealthHit(pid, hit, now); skip { res.SkippedDegraded = append(res.SkippedDegraded, sc) - return + return nil } } res.Candidates = append(res.Candidates, Candidate{ - ProviderID: pid, - Model: model, - Tier: tier, - Mode: prof.Mode, - Flags: prof.Flags, - Env: prof.Env, + ExecutionProfileID: executionProfileID, + ProviderID: pid, + Model: model, + Tier: tier, + Mode: prof.Mode, + Flags: prof.Flags, + Env: prof.Env, }) + return nil +} + +func (r *Resolver) resolveExecutionProfile( + ctx context.Context, workspaceID string, providerID ProviderID, + tier Tier, profileID string, +) (string, error) { + profile, err := r.profiles.GetAgentProfile(ctx, profileID) + if err != nil || profile == nil { + return "", profileMappingError(providerID, tier, + fmt.Sprintf("execution profile %q does not exist or is deleted", profileID)) + } + if profile.WorkspaceID != "" && profile.WorkspaceID != workspaceID { + return "", profileMappingError(providerID, tier, + fmt.Sprintf("execution profile %q belongs to another workspace", profileID)) + } + if profile.Role != "" { + return "", profileMappingError(providerID, tier, + fmt.Sprintf("profile %q is an Office agent identity, not an execution profile", profileID)) + } + agent, err := r.profiles.GetAgent(ctx, profile.AgentID) + if err != nil || agent == nil || agent.Name == "" { + return "", profileMappingError(providerID, tier, + fmt.Sprintf("execution profile %q has no launchable provider", profileID)) + } + if ProviderID(agent.Name) != providerID { + return "", profileMappingError(providerID, tier, + fmt.Sprintf("execution profile %q belongs to provider %q", profileID, agent.Name)) + } + if r.registry != nil { + if _, ok := r.registry.Get(agent.Name); !ok { + return "", profileMappingError(providerID, tier, + fmt.Sprintf("execution profile %q provider %q is not launchable", profileID, agent.Name)) + } + } + if profile.Model == "" { + return "", profileMappingError(providerID, tier, + fmt.Sprintf("execution profile %q has no model configured", profileID)) + } + return profile.Model, nil } // loadHealthIndex pulls every non-healthy row for the workspace and diff --git a/apps/backend/internal/office/routing/resolver_test.go b/apps/backend/internal/office/routing/resolver_test.go index dc6a2382cf..cff2bc237c 100644 --- a/apps/backend/internal/office/routing/resolver_test.go +++ b/apps/backend/internal/office/routing/resolver_test.go @@ -2,6 +2,7 @@ package routing_test import ( "context" + "database/sql" "encoding/json" "errors" "testing" @@ -22,6 +23,47 @@ type fakeRepo struct { healthErr error } +type resolverProfileStore struct { + agents map[string]*settingsmodels.Agent + profiles map[string]*settingsmodels.AgentProfile +} + +func (f *resolverProfileStore) GetAgent(_ context.Context, id string) (*settingsmodels.Agent, error) { + if agent := f.agents[id]; agent != nil { + return agent, nil + } + return nil, sql.ErrNoRows +} + +func (f *resolverProfileStore) GetAgentProfile( + _ context.Context, id string, +) (*settingsmodels.AgentProfile, error) { + if profile := f.profiles[id]; profile != nil { + return profile, nil + } + return nil, sql.ErrNoRows +} + +func (f *resolverProfileStore) ListAgents(context.Context) ([]*settingsmodels.Agent, error) { + agents := make([]*settingsmodels.Agent, 0, len(f.agents)) + for _, agent := range f.agents { + agents = append(agents, agent) + } + return agents, nil +} + +func (f *resolverProfileStore) ListAgentProfiles( + _ context.Context, agentID string, +) ([]*settingsmodels.AgentProfile, error) { + profiles := make([]*settingsmodels.AgentProfile, 0) + for _, profile := range f.profiles { + if profile.AgentID == agentID { + profiles = append(profiles, profile) + } + } + return profiles, nil +} + func (f *fakeRepo) GetWorkspaceRouting( _ context.Context, _ string, ) (*routing.WorkspaceConfig, error) { @@ -56,6 +98,11 @@ func twoProviderCfg() *routing.WorkspaceConfig { ProviderOrder: []routing.ProviderID{"claude-acp", "codex-acp"}, ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{ "claude-acp": { + ExecutionProfileIDs: routing.ExecutionProfileIDs{ + Frontier: "claude-opus-profile", + Balanced: "claude-sonnet-profile", + Economy: "claude-haiku-profile", + }, TierMap: routing.TierMap{ Frontier: "opus", Balanced: "sonnet", @@ -66,6 +113,11 @@ func twoProviderCfg() *routing.WorkspaceConfig { Env: map[string]string{"ANTHROPIC_REGION": "us"}, }, "codex-acp": { + ExecutionProfileIDs: routing.ExecutionProfileIDs{ + Frontier: "codex-frontier-profile", + Balanced: "codex-balanced-profile", + Economy: "codex-economy-profile", + }, TierMap: routing.TierMap{ Frontier: "gpt-5.5", Balanced: "gpt-5", @@ -91,7 +143,9 @@ func agentWithOverrides(t *testing.T, ov routing.AgentOverrides) settingsmodels. } func TestResolveRoutingDisabled(t *testing.T) { - repo := &fakeRepo{cfg: &routing.WorkspaceConfig{Enabled: false}} + cfg := twoProviderCfg() + cfg.Enabled = false + repo := &fakeRepo{cfg: cfg} r := newResolver(t, repo) res, err := r.Resolve(context.Background(), wsID, agentWithOverrides(t, routing.AgentOverrides{}), routing.ResolveOptions{}) if err != nil { @@ -100,8 +154,115 @@ func TestResolveRoutingDisabled(t *testing.T) { if res.Enabled { t.Fatalf("expected disabled resolution") } - if len(res.Candidates) != 0 { - t.Fatalf("expected no candidates, got %d", len(res.Candidates)) + if len(res.Candidates) != 1 { + t.Fatalf("expected first configured candidate, got %d", len(res.Candidates)) + } + if got := res.Candidates[0].ExecutionProfileID; got != "claude-sonnet-profile" { + t.Fatalf("execution profile = %q, want claude-sonnet-profile", got) + } +} + +func TestResolveValidatesLiveExecutionProfileAndDerivesModel(t *testing.T) { + cfg := twoProviderCfg() + cfg.ProviderOrder = []routing.ProviderID{"claude-acp"} + delete(cfg.ProviderProfiles, "codex-acp") + cfg.ProviderProfiles["claude-acp"] = routing.ProviderProfile{ + ExecutionProfileIDs: routing.ExecutionProfileIDs{Balanced: "claude-sonnet-profile"}, + TierMap: routing.TierMap{Balanced: "stale-model"}, + } + store := &resolverProfileStore{ + agents: map[string]*settingsmodels.Agent{ + "claude-agent": {ID: "claude-agent", Name: "claude-acp"}, + }, + profiles: map[string]*settingsmodels.AgentProfile{ + "claude-sonnet-profile": { + ID: "claude-sonnet-profile", AgentID: "claude-agent", Model: "sonnet-4.5", + }, + }, + } + r := newResolver(t, &fakeRepo{cfg: cfg}) + r.SetExecutionProfileStore(store, nil) + res, err := r.Resolve(context.Background(), wsID, + agentWithOverrides(t, routing.AgentOverrides{}), routing.ResolveOptions{}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got := res.Candidates[0].Model; got != "sonnet-4.5" { + t.Fatalf("candidate model = %q, want live profile model", got) + } + + store.profiles["claude-sonnet-profile"].WorkspaceID = "ws-other" + if _, err := r.Resolve(context.Background(), wsID, + agentWithOverrides(t, routing.AgentOverrides{}), routing.ResolveOptions{}); err == nil { + t.Fatal("expected cross-workspace execution profile to be rejected") + } + + store.profiles["claude-sonnet-profile"].WorkspaceID = wsID + store.profiles["claude-sonnet-profile"].Role = settingsmodels.AgentRole("cto") + if _, err := r.Resolve(context.Background(), wsID, + agentWithOverrides(t, routing.AgentOverrides{}), routing.ResolveOptions{}); err == nil { + t.Fatal("expected rich Office identity to be rejected as an execution profile") + } +} + +func TestResolveExecutionProfileMappingDerivesMissingModel(t *testing.T) { + cfg := twoProviderCfg() + cfg.ProviderOrder = []routing.ProviderID{"claude-acp"} + delete(cfg.ProviderProfiles, "codex-acp") + cfg.ProviderProfiles["claude-acp"] = routing.ProviderProfile{ + ExecutionProfileIDs: routing.ExecutionProfileIDs{Balanced: "claude-sonnet-profile"}, + } + store := &resolverProfileStore{ + agents: map[string]*settingsmodels.Agent{ + "claude-agent": {ID: "claude-agent", Name: "claude-acp"}, + }, + profiles: map[string]*settingsmodels.AgentProfile{ + "claude-sonnet-profile": { + ID: "claude-sonnet-profile", AgentID: "claude-agent", Model: "sonnet-4.5", + }, + }, + } + r := newResolver(t, &fakeRepo{cfg: cfg}) + r.SetExecutionProfileStore(store, nil) + + res, err := r.Resolve(context.Background(), wsID, + agentWithOverrides(t, routing.AgentOverrides{}), routing.ResolveOptions{}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if len(res.Candidates) != 1 || res.Candidates[0].Model != "sonnet-4.5" { + t.Fatalf("expected live execution profile candidate, got %+v", res.Candidates) + } +} + +func TestResolveLegacyModelMappingFindsUniqueExecutionProfile(t *testing.T) { + cfg := twoProviderCfg() + cfg.ProviderOrder = []routing.ProviderID{"claude-acp"} + delete(cfg.ProviderProfiles, "codex-acp") + cfg.ProviderProfiles["claude-acp"] = routing.ProviderProfile{ + TierMap: routing.TierMap{Balanced: "sonnet-4.5"}, + } + store := &resolverProfileStore{ + agents: map[string]*settingsmodels.Agent{ + "claude-agent": {ID: "claude-agent", Name: "claude-acp"}, + }, + profiles: map[string]*settingsmodels.AgentProfile{ + "claude-sonnet-profile": { + ID: "claude-sonnet-profile", AgentID: "claude-agent", Model: "sonnet-4.5", + }, + }, + } + r := newResolver(t, &fakeRepo{cfg: cfg}) + r.SetExecutionProfileStore(store, nil) + + res, err := r.Resolve(context.Background(), wsID, + agentWithOverrides(t, routing.AgentOverrides{}), routing.ResolveOptions{}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if len(res.Candidates) != 1 || + res.Candidates[0].ExecutionProfileID != "claude-sonnet-profile" { + t.Fatalf("expected migrated legacy mapping candidate, got %+v", res.Candidates) } } @@ -138,6 +299,9 @@ func TestResolveInheritedDefaults(t *testing.T) { if got.Mode != "default" || len(got.Flags) != 1 || got.Env["ANTHROPIC_REGION"] != "us" { t.Fatalf("candidate[0] provider profile not carried verbatim: %+v", got) } + if got.ExecutionProfileID != "claude-sonnet-profile" { + t.Fatalf("execution profile not carried: %+v", got) + } if res.Candidates[1].ProviderID != "codex-acp" || res.Candidates[1].Model != "gpt-5" { t.Fatalf("candidate[1] mismatch: %+v", res.Candidates[1]) } @@ -372,7 +536,8 @@ func TestResolveExcludeProviders(t *testing.T) { cfg := twoProviderCfg() cfg.ProviderOrder = append(cfg.ProviderOrder, "opencode-acp") cfg.ProviderProfiles["opencode-acp"] = routing.ProviderProfile{ - TierMap: routing.TierMap{Balanced: "oc-large"}, + TierMap: routing.TierMap{Balanced: "oc-large"}, + ExecutionProfileIDs: routing.ExecutionProfileIDs{Balanced: "opencode-balanced-profile"}, } repo := &fakeRepo{cfg: cfg} r := newResolver(t, repo) diff --git a/apps/backend/internal/office/routing/types.go b/apps/backend/internal/office/routing/types.go index 94596a67f4..922bdf10df 100644 --- a/apps/backend/internal/office/routing/types.go +++ b/apps/backend/internal/office/routing/types.go @@ -97,35 +97,30 @@ func (m TierMap) Model(t Tier) string { // IsConfigured reports whether tier t has a model assigned. func (m TierMap) IsConfigured(t Tier) bool { return m.Model(t) != "" } -// ProviderProfile holds the per-provider, per-workspace settings needed -// to launch via that provider. Cross-provider routing means CLI mode, -// flags, and env are provider-scoped — they cannot safely be inherited -// from a base AgentProfile authored against a different provider — so -// they live here alongside the tier→model map. -// -// Note: there is no provider-level permission preset in v1. The base -// AgentProfile's CLIFlags (resolved by cliflags.Resolve) carry every -// permission knob the agent runtime consults at launch time. Adding a -// per-provider permission override would require modelling the existing -// boolean/CLIFlag system as named presets first — out of scope here. +// ProviderProfile maps each tier to the complete execution profile used for +// that launch. TierMap, Mode, Flags, and Env are retained as legacy display +// snapshots during migration; execution profile references are authoritative. type ProviderProfile struct { - TierMap TierMap `json:"tier_map"` - TierProfileIDs TierProfileIDs `json:"tier_profile_ids,omitempty"` + TierMap TierMap `json:"tier_map"` + ExecutionProfileIDs ExecutionProfileIDs `json:"-"` + // TierProfileIDs is retained as a source-compatibility shim while callers + // migrate. JSON decoding normalizes the legacy key into + // ExecutionProfileIDs and JSON encoding always writes the canonical key. + TierProfileIDs TierProfileIDs `json:"-"` Mode string `json:"mode,omitempty"` Flags []string `json:"flags,omitempty"` Env map[string]string `json:"env,omitempty"` } -// MarshalJSON omits tier_profile_ids unless at least one tier records its -// source profile. A value struct with omitempty would otherwise serialize as -// an empty object. +// MarshalJSON writes the canonical execution_profile_ids key while accepting +// TierProfileIDs as the in-memory compatibility field during migration. func (p ProviderProfile) MarshalJSON() ([]byte, error) { type providerProfileJSON struct { - TierMap TierMap `json:"tier_map"` - TierProfileIDs *TierProfileIDs `json:"tier_profile_ids,omitempty"` - Mode string `json:"mode,omitempty"` - Flags []string `json:"flags,omitempty"` - Env map[string]string `json:"env,omitempty"` + TierMap TierMap `json:"tier_map"` + ExecutionProfileIDs *ExecutionProfileIDs `json:"execution_profile_ids,omitempty"` + Mode string `json:"mode,omitempty"` + Flags []string `json:"flags,omitempty"` + Env map[string]string `json:"env,omitempty"` } out := providerProfileJSON{ TierMap: p.TierMap, @@ -133,29 +128,91 @@ func (p ProviderProfile) MarshalJSON() ([]byte, error) { Flags: p.Flags, Env: p.Env, } - if !p.TierProfileIDs.IsZero() { - out.TierProfileIDs = &p.TierProfileIDs + ids := p.effectiveExecutionProfileIDs() + if !ids.IsZero() { + out.ExecutionProfileIDs = &ids } return json.Marshal(out) } -// TierProfileIDs records which source agent profile authored each tier map -// entry. Runtime routing uses TierMap; this metadata lets settings block -// deleting a profile while a workspace tier still points at it. -type TierProfileIDs struct { +// UnmarshalJSON accepts the canonical execution_profile_ids field and the +// legacy tier_profile_ids field. When both are present the canonical field is +// authoritative, including when it is explicitly empty. +func (p *ProviderProfile) UnmarshalJSON(data []byte) error { + type providerProfileJSON struct { + TierMap TierMap `json:"tier_map"` + ExecutionProfileIDs *ExecutionProfileIDs `json:"execution_profile_ids"` + TierProfileIDs *ExecutionProfileIDs `json:"tier_profile_ids"` + Mode string `json:"mode,omitempty"` + Flags []string `json:"flags,omitempty"` + Env map[string]string `json:"env,omitempty"` + } + var raw providerProfileJSON + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *p = ProviderProfile{ + TierMap: raw.TierMap, + Mode: raw.Mode, + Flags: raw.Flags, + Env: raw.Env, + } + if raw.ExecutionProfileIDs != nil { + p.ExecutionProfileIDs = *raw.ExecutionProfileIDs + p.TierProfileIDs = *raw.ExecutionProfileIDs + } else if raw.TierProfileIDs != nil { + p.ExecutionProfileIDs = *raw.TierProfileIDs + p.TierProfileIDs = *raw.TierProfileIDs + } + return nil +} + +// ExecutionProfileIDs maps each tier to the complete agent profile used to +// launch that candidate. +type ExecutionProfileIDs struct { Frontier string `json:"frontier,omitempty"` Balanced string `json:"balanced,omitempty"` Economy string `json:"economy,omitempty"` } -// IsZero reports whether no tier has source profile metadata. -func (ids TierProfileIDs) IsZero() bool { +// TierProfileIDs is the legacy source name kept while call sites migrate. +type TierProfileIDs = ExecutionProfileIDs + +// ProfileID returns the execution profile configured for tier. +func (ids ExecutionProfileIDs) ProfileID(tier Tier) string { + switch tier { + case TierFrontier: + return ids.Frontier + case TierBalanced: + return ids.Balanced + case TierEconomy: + return ids.Economy + default: + return "" + } +} + +// IsZero reports whether no tier has an execution profile. +func (ids ExecutionProfileIDs) IsZero() bool { return ids.Frontier == "" && ids.Balanced == "" && ids.Economy == "" } -// WorkspaceConfig is the persisted routing config for one workspace. -// When Enabled is false, downstream code uses the concrete agent -// profile and ignores ProviderOrder / DefaultTier / ProviderProfiles. +// ExecutionProfileID returns the complete runtime profile for tier. The +// deprecated source field is consulted only when no canonical mapping exists. +func (p ProviderProfile) ExecutionProfileID(tier Tier) string { + return p.effectiveExecutionProfileIDs().ProfileID(tier) +} + +func (p ProviderProfile) effectiveExecutionProfileIDs() ExecutionProfileIDs { + if !p.ExecutionProfileIDs.IsZero() { + return p.ExecutionProfileIDs + } + return p.TierProfileIDs +} + +// WorkspaceConfig is the persisted routing config for one workspace. Enabled +// controls automatic fallback; tier-to-execution-profile selection applies to +// every Office launch. type WorkspaceConfig struct { Enabled bool `json:"enabled"` ProviderOrder []ProviderID `json:"provider_order"` @@ -360,8 +417,8 @@ func checkTierPerReasonMapped( } // tierMappedOnAnyProvider reports whether at least one provider in -// order has tier mapped to a model. Helper shared by the two override -// checks above. +// order has a launchable execution profile for the tier. Helper shared +// by the two override checks above. func tierMappedOnAnyProvider( tier Tier, order []ProviderID, profiles map[ProviderID]ProviderProfile, @@ -371,7 +428,7 @@ func tierMappedOnAnyProvider( if !ok { continue } - if prof.TierMap.IsConfigured(tier) { + if prof.ExecutionProfileID(tier) != "" { return true } } @@ -485,11 +542,11 @@ func validateEnabledRules(cfg WorkspaceConfig) error { }) continue } - if !prof.TierMap.IsConfigured(cfg.DefaultTier) { + if prof.ExecutionProfileID(cfg.DefaultTier) == "" { missingDefault = append(missingDefault, ValidationDetail{ ProviderID: p, - Field: "tier_map." + string(cfg.DefaultTier), - Message: "default tier has no model mapping", + Field: "execution_profile_ids." + string(cfg.DefaultTier), + Message: "default tier has no execution profile mapping", }) } } @@ -503,7 +560,7 @@ func validateEnabledRules(cfg WorkspaceConfig) error { if len(missingDefault) > 0 { return &ValidationError{ Field: "provider_profiles", - Message: "providers missing default-tier model mapping", + Message: "providers missing default-tier execution profile mapping", Details: missingDefault, } } diff --git a/apps/backend/internal/office/routing/types_test.go b/apps/backend/internal/office/routing/types_test.go index baa704fad7..42a2b80ef9 100644 --- a/apps/backend/internal/office/routing/types_test.go +++ b/apps/backend/internal/office/routing/types_test.go @@ -13,15 +13,15 @@ var testKnown = []routing.ProviderID{ "claude-acp", "codex-acp", "opencode-acp", "copilot-acp", "amp-acp", } -func TestProviderProfileMarshalJSON_OmitsEmptyTierProfileIDs(t *testing.T) { +func TestProviderProfileMarshalJSON_WritesCanonicalExecutionProfileIDs(t *testing.T) { raw, err := json.Marshal(routing.ProviderProfile{ TierMap: routing.TierMap{Balanced: "sonnet"}, }) if err != nil { t.Fatalf("marshal empty tier profile ids: %v", err) } - if strings.Contains(string(raw), "tier_profile_ids") { - t.Fatalf("empty tier_profile_ids should be omitted, got %s", raw) + if strings.Contains(string(raw), "execution_profile_ids") { + t.Fatalf("empty execution_profile_ids should be omitted, got %s", raw) } raw, err = json.Marshal(routing.ProviderProfile{ @@ -33,8 +33,51 @@ func TestProviderProfileMarshalJSON_OmitsEmptyTierProfileIDs(t *testing.T) { if err != nil { t.Fatalf("marshal tier profile ids: %v", err) } - if !strings.Contains(string(raw), `"tier_profile_ids":{"balanced":"profile-balanced"}`) { - t.Fatalf("non-empty tier_profile_ids should be present, got %s", raw) + if !strings.Contains(string(raw), `"execution_profile_ids":{"balanced":"profile-balanced"}`) { + t.Fatalf("canonical execution_profile_ids should be present, got %s", raw) + } + if strings.Contains(string(raw), "tier_profile_ids") { + t.Fatalf("legacy tier_profile_ids should not be written, got %s", raw) + } +} + +func TestProviderProfileUnmarshalJSON_PrefersCanonicalExecutionProfileIDs(t *testing.T) { + var profile routing.ProviderProfile + err := json.Unmarshal([]byte(`{ + "tier_map":{"balanced":"sonnet"}, + "execution_profile_ids":{"balanced":"canonical-profile"}, + "tier_profile_ids":{"balanced":"legacy-profile"} + }`), &profile) + if err != nil { + t.Fatalf("unmarshal provider profile: %v", err) + } + if got := profile.ExecutionProfileID(routing.TierBalanced); got != "canonical-profile" { + t.Fatalf("balanced profile = %q, want canonical-profile", got) + } +} + +func TestProviderProfileUnmarshalJSON_DecodesLegacyTierProfileIDs(t *testing.T) { + var profile routing.ProviderProfile + err := json.Unmarshal([]byte(`{ + "tier_map":{"balanced":"sonnet"}, + "tier_profile_ids":{"balanced":"legacy-profile"} + }`), &profile) + if err != nil { + t.Fatalf("unmarshal legacy provider profile: %v", err) + } + if got := profile.ExecutionProfileID(routing.TierBalanced); got != "legacy-profile" { + t.Fatalf("balanced profile = %q, want legacy-profile", got) + } + + raw, err := json.Marshal(profile) + if err != nil { + t.Fatalf("marshal migrated provider profile: %v", err) + } + if !strings.Contains(string(raw), `"execution_profile_ids":{"balanced":"legacy-profile"}`) { + t.Fatalf("legacy mapping was not rewritten canonically: %s", raw) + } + if strings.Contains(string(raw), "tier_profile_ids") { + t.Fatalf("legacy key survived rewrite: %s", raw) } } @@ -58,7 +101,10 @@ func TestValidateWorkspaceConfig(t *testing.T) { DefaultTier: routing.TierBalanced, ProviderOrder: []routing.ProviderID{"claude-acp"}, ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{ - "claude-acp": {TierMap: routing.TierMap{Balanced: "sonnet"}}, + "claude-acp": { + ExecutionProfileIDs: routing.ExecutionProfileIDs{Balanced: "claude-sonnet"}, + TierMap: routing.TierMap{Balanced: "sonnet"}, + }, }, }, }, @@ -120,7 +166,10 @@ func TestValidateWorkspaceConfig(t *testing.T) { DefaultTier: routing.TierBalanced, ProviderOrder: []routing.ProviderID{"claude-acp", "codex-acp"}, ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{ - "claude-acp": {TierMap: routing.TierMap{Balanced: "sonnet"}}, + "claude-acp": { + ExecutionProfileIDs: routing.ExecutionProfileIDs{Balanced: "claude-sonnet"}, + TierMap: routing.TierMap{Balanced: "sonnet"}, + }, }, }, wantErr: "no profile", @@ -132,8 +181,14 @@ func TestValidateWorkspaceConfig(t *testing.T) { DefaultTier: routing.TierFrontier, ProviderOrder: []routing.ProviderID{"claude-acp", "codex-acp"}, ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{ - "claude-acp": {TierMap: routing.TierMap{Frontier: "opus"}}, - "codex-acp": {TierMap: routing.TierMap{Balanced: "gpt-5.4"}}, + "claude-acp": { + ExecutionProfileIDs: routing.ExecutionProfileIDs{Frontier: "claude-opus"}, + TierMap: routing.TierMap{Frontier: "opus"}, + }, + "codex-acp": { + ExecutionProfileIDs: routing.ExecutionProfileIDs{Balanced: "codex-balanced"}, + TierMap: routing.TierMap{Balanced: "gpt-5.4"}, + }, }, }, wantErr: "default-tier", @@ -247,8 +302,16 @@ func TestValidateAgentOverridesAgainstWorkspace(t *testing.T) { DefaultTier: routing.TierBalanced, ProviderOrder: []routing.ProviderID{"claude-acp", "codex-acp"}, ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{ - "claude-acp": {TierMap: routing.TierMap{Frontier: "opus", Balanced: "sonnet"}}, - "codex-acp": {TierMap: routing.TierMap{Balanced: "gpt-5"}}, + "claude-acp": { + ExecutionProfileIDs: routing.ExecutionProfileIDs{ + Frontier: "claude-opus", Balanced: "claude-sonnet", + }, + TierMap: routing.TierMap{Frontier: "opus", Balanced: "sonnet"}, + }, + "codex-acp": { + ExecutionProfileIDs: routing.ExecutionProfileIDs{Balanced: "codex-balanced"}, + TierMap: routing.TierMap{Balanced: "gpt-5"}, + }, }, } balancedOnly := &routing.WorkspaceConfig{ @@ -256,8 +319,14 @@ func TestValidateAgentOverridesAgainstWorkspace(t *testing.T) { DefaultTier: routing.TierBalanced, ProviderOrder: []routing.ProviderID{"claude-acp", "codex-acp"}, ProviderProfiles: map[routing.ProviderID]routing.ProviderProfile{ - "claude-acp": {TierMap: routing.TierMap{Balanced: "sonnet"}}, - "codex-acp": {TierMap: routing.TierMap{Balanced: "gpt-5"}}, + "claude-acp": { + ExecutionProfileIDs: routing.ExecutionProfileIDs{Balanced: "claude-sonnet"}, + TierMap: routing.TierMap{Balanced: "sonnet"}, + }, + "codex-acp": { + ExecutionProfileIDs: routing.ExecutionProfileIDs{Balanced: "codex-balanced"}, + TierMap: routing.TierMap{Balanced: "gpt-5"}, + }, }, } diff --git a/apps/backend/internal/office/scheduler/dispatch_routing.go b/apps/backend/internal/office/scheduler/dispatch_routing.go index bef2fc221b..ece963e679 100644 --- a/apps/backend/internal/office/scheduler/dispatch_routing.go +++ b/apps/backend/internal/office/scheduler/dispatch_routing.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "go.uber.org/zap" @@ -35,6 +36,8 @@ const ( // blocked_provider_action_required so a human notices. const MaxAttemptsPerRun = 20 +const providerFallbackPromptMarker = "[Kandev provider fallback]" + // DispatchWithRouting attempts the configured providers in order. Three // outcomes are possible: // - launched=true, parked=false → run is in flight; caller skips legacy. @@ -77,7 +80,7 @@ func (ss *SchedulerService) DispatchWithRouting( if err != nil { return false, false, fmt.Errorf("dispatch: resolve: %w", err) } - if !res.Enabled { + if !res.Enabled && len(res.Candidates) == 0 { return false, false, nil } if err := ss.persistRoutingDecision(ctx, run, res); err != nil { @@ -87,7 +90,7 @@ func (ss *SchedulerService) DispatchWithRouting( _, _, perr := ss.parkRunBlocked(ctx, run, agent.WorkspaceID, res) return false, true, perr } - launched, _, terr := ss.tryCandidates(ctx, run, agent, res, launch) + launched, _, terr := ss.tryCandidates(ctx, run, agent, res, launch, prior) if terr != nil { return false, false, terr } @@ -236,17 +239,20 @@ func skipOutcome(reason string) models.RouteAttemptOutcome { func (ss *SchedulerService) tryCandidates( ctx context.Context, run *models.Run, agent *models.AgentInstance, res *routing.Resolution, - launch LaunchContext, + launch LaunchContext, prior []models.RouteAttempt, ) (bool, *routing.BlockReason, error) { taskID := extractRunTaskID(run.Payload) var prev *routing.Candidate for i := range res.Candidates { candidate := res.Candidates[i] + candidateLaunch := continuationLaunchContext( + launch, prior, run.RouteCycleBaselineSeq, candidate, + ) seq, err := ss.recordAttemptStart(ctx, run, candidate, res.RequestedTier) if err != nil { return false, nil, err } - launchErr := ss.launchCandidate(ctx, taskID, agent.ID, candidate, launch) + launchErr := ss.launchCandidate(ctx, taskID, agent.ID, candidate, candidateLaunch) if launchErr == nil { if prev != nil { // We walked past at least one prior candidate — that's @@ -277,6 +283,15 @@ func (ss *SchedulerService) tryCandidates( if fatal { return false, nil, launchErr } + prior = append(prior, models.RouteAttempt{ + RunID: run.ID, + Seq: seq, + ExecutionProfileID: candidate.ExecutionProfileID, + ProviderID: string(candidate.ProviderID), + Model: candidate.Model, + Tier: string(res.RequestedTier), + Outcome: RouteAttemptOutcomeFailedProviderUnavail, + }) // Remember this candidate so the next loop iteration can record // the fallback hop on success. c := candidate @@ -285,6 +300,41 @@ func (ss *SchedulerService) tryCandidates( return ss.exhaustedCandidates(ctx, run, agent, res.RequestedTier) } +func continuationLaunchContext( + launch LaunchContext, prior []models.RouteAttempt, baseline int, + candidate routing.Candidate, +) LaunchContext { + previous, ok := latestFailedExecutionProfile(prior, baseline) + if !ok || previous.ExecutionProfileID == "" || + previous.ExecutionProfileID == candidate.ExecutionProfileID || + strings.Contains(launch.Prompt, providerFallbackPromptMarker) { + return launch + } + launch.Prompt += fmt.Sprintf("\n\n%s\n"+ + "This is a fresh provider-native session replacing execution profile %q (%s). "+ + "Its provider chat and resume token are not available here. Before continuing, "+ + "inspect the durable task description, comments/messages, task status, current "+ + "run state, and the current worktree with git status, git diff, and recent git log. "+ + "Continue the same task from that durable state; do not redo completed work unless "+ + "the repository evidence requires it.", + providerFallbackPromptMarker, previous.ExecutionProfileID, previous.ProviderID) + return launch +} + +func latestFailedExecutionProfile( + prior []models.RouteAttempt, baseline int, +) (models.RouteAttempt, bool) { + for i := len(prior) - 1; i >= 0; i-- { + if prior[i].Seq <= baseline { + break + } + if prior[i].Outcome == RouteAttemptOutcomeFailedProviderUnavail { + return prior[i], true + } + } + return models.RouteAttempt{}, false +} + // recordAttemptStart increments the attempt sequence and appends the // in-flight attempt row with outcome=launched. func (ss *SchedulerService) recordAttemptStart( @@ -297,13 +347,14 @@ func (ss *SchedulerService) recordAttemptStart( } run.CurrentRouteAttemptSeq = seq attempt := models.RouteAttempt{ - RunID: run.ID, - Seq: seq, - ProviderID: string(candidate.ProviderID), - Model: candidate.Model, - Tier: string(tier), - Outcome: RouteAttemptOutcomeLaunched, - StartedAt: time.Now().UTC(), + RunID: run.ID, + Seq: seq, + ExecutionProfileID: candidate.ExecutionProfileID, + ProviderID: string(candidate.ProviderID), + Model: candidate.Model, + Tier: string(tier), + Outcome: RouteAttemptOutcomeLaunched, + StartedAt: time.Now().UTC(), } if err := ss.repo.AppendRouteAttempt(ctx, &attempt); err != nil { return 0, err @@ -343,12 +394,13 @@ func (ss *SchedulerService) launchCandidate( }) } return ss.taskStarter.StartTaskWithRoute(ctx, taskID, agentID, launch, RouteOverride{ - ProviderID: string(candidate.ProviderID), - Model: candidate.Model, - Tier: string(candidate.Tier), - Mode: candidate.Mode, - Flags: candidate.Flags, - Env: candidate.Env, + ExecutionProfileID: candidate.ExecutionProfileID, + ProviderID: string(candidate.ProviderID), + Model: candidate.Model, + Tier: string(candidate.Tier), + Mode: candidate.Mode, + Flags: candidate.Flags, + Env: candidate.Env, }) } @@ -359,8 +411,8 @@ func (ss *SchedulerService) handleLaunchSuccess( ctx context.Context, run *models.Run, agent *models.AgentInstance, candidate routing.Candidate, ) (bool, *routing.BlockReason, error) { - if err := ss.repo.SetRunResolvedProvider(ctx, - run.ID, string(candidate.ProviderID), candidate.Model); err != nil { + if err := ss.repo.SetRunResolvedRoute(ctx, + run.ID, candidate.ExecutionProfileID, string(candidate.ProviderID), candidate.Model); err != nil { return false, nil, err } ss.markHealthScopes(ctx, agent.WorkspaceID, candidate) diff --git a/apps/backend/internal/office/scheduler/dispatch_routing_test.go b/apps/backend/internal/office/scheduler/dispatch_routing_test.go index 79cf093036..aa45321830 100644 --- a/apps/backend/internal/office/scheduler/dispatch_routing_test.go +++ b/apps/backend/internal/office/scheduler/dispatch_routing_test.go @@ -3,6 +3,7 @@ package scheduler_test import ( "context" "fmt" + "strings" "sync" "testing" "time" @@ -81,6 +82,7 @@ func newTestRepoSched(t *testing.T) *officesqlite.Repository { if err != nil { t.Fatalf("open sqlite: %v", err) } + db.SetMaxOpenConns(1) t.Cleanup(func() { _ = db.Close() }) if _, _, err := settingsstore.Provide(db, db, nil); err != nil { t.Fatalf("settings store: %v", err) @@ -119,7 +121,10 @@ func seedRoutingConfig(t *testing.T, repo *officesqlite.Repository, providers [] for _, p := range providers { profiles[p] = routing.ProviderProfile{ TierMap: routing.TierMap{Balanced: string(p) + "-bal"}, - Mode: "default", + ExecutionProfileIDs: routing.ExecutionProfileIDs{ + Balanced: string(p) + "-profile", + }, + Mode: "default", } } cfg := &routing.WorkspaceConfig{ @@ -180,6 +185,34 @@ func TestDispatch_RoutingDisabled_FallsThrough(t *testing.T) { } } +func TestDispatch_RoutingDisabledLaunchesFirstTierProfileOnly(t *testing.T) { + repo := newTestRepoSched(t) + seedRoutingConfig(t, repo, []routing.ProviderID{"claude-acp", "codex-acp"}) + cfg, err := repo.GetWorkspaceRouting(context.Background(), testWorkspaceID) + if err != nil { + t.Fatalf("get routing: %v", err) + } + cfg.Enabled = false + if err := repo.UpsertWorkspaceRouting(context.Background(), testWorkspaceID, cfg); err != nil { + t.Fatalf("disable routing: %v", err) + } + starter := newFakeTaskStarter() + ss := buildScheduler(t, repo, starter) + run := seedRun(t, repo, `{"task_id":"t-1"}`) + launched, parked, err := ss.DispatchWithRouting( + context.Background(), run, makeAgent(), scheduler.LaunchContext{}, + ) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if !launched || parked || starter.callCount() != 1 { + t.Fatalf("launched=%v parked=%v calls=%d", launched, parked, starter.callCount()) + } + if got := starter.lastCall().route.ExecutionProfileID; got != "claude-acp-profile" { + t.Fatalf("execution profile = %q, want claude-acp-profile", got) + } +} + func TestDispatch_ForwardsLaunchContextToStarter(t *testing.T) { repo := newTestRepoSched(t) seedRoutingConfig(t, repo, []routing.ProviderID{"claude-acp"}) @@ -229,6 +262,9 @@ func TestDispatch_FirstProviderSucceeds(t *testing.T) { if got := starter.lastCall().route.ProviderID; got != "claude-acp" { t.Errorf("expected first provider claude-acp; got %s", got) } + if got := starter.lastCall().route.ExecutionProfileID; got != "claude-acp-profile" { + t.Errorf("execution profile = %q", got) + } attempts, _ := repo.ListRouteAttempts(context.Background(), run.ID) if len(attempts) != 1 { t.Fatalf("expected 1 attempt; got %d", len(attempts)) @@ -251,7 +287,11 @@ func TestDispatch_WakeReasonTierPolicy(t *testing.T) { profiles := map[routing.ProviderID]routing.ProviderProfile{ "claude-acp": { TierMap: routing.TierMap{Balanced: "sonnet", Economy: "haiku"}, - Mode: "default", + ExecutionProfileIDs: routing.ExecutionProfileIDs{ + Balanced: "claude-sonnet-profile", + Economy: "claude-haiku-profile", + }, + Mode: "default", }, } cfg := &routing.WorkspaceConfig{ @@ -354,6 +394,9 @@ func TestDispatch_FirstQuotaSecondSucceeds(t *testing.T) { if attempts[1].Outcome != scheduler.RouteAttemptOutcomeLaunched { t.Errorf("second outcome = %q, want launched", attempts[1].Outcome) } + if prompt := starter.lastCall().launch.Prompt; !strings.Contains(prompt, "[Kandev provider fallback]") { + t.Fatalf("fallback launch missing continuation prompt: %q", prompt) + } } func TestDispatch_AllAutoRetryableParksRun(t *testing.T) { @@ -393,13 +436,14 @@ func TestDispatch_ExcludesProvidersFromPriorAttempts(t *testing.T) { t.Fatalf("seed seq bump: %v", err) } if err := repo.AppendRouteAttempt(context.Background(), &officemodels.RouteAttempt{ - RunID: run.ID, - Seq: 1, - ProviderID: "claude-acp", - Model: "claude-acp-bal", - Tier: "balanced", - Outcome: scheduler.RouteAttemptOutcomeFailedProviderUnavail, - StartedAt: time.Now().UTC(), + RunID: run.ID, + Seq: 1, + ExecutionProfileID: "claude-acp-profile", + ProviderID: "claude-acp", + Model: "claude-acp-bal", + Tier: "balanced", + Outcome: scheduler.RouteAttemptOutcomeFailedProviderUnavail, + StartedAt: time.Now().UTC(), }); err != nil { t.Fatalf("seed prior attempt: %v", err) } @@ -532,13 +576,14 @@ func TestDispatch_LiftedRunIgnoresPriorCycleExclusions(t *testing.T) { t.Fatalf("seed seq bump: %v", err) } if err := repo.AppendRouteAttempt(context.Background(), &officemodels.RouteAttempt{ - RunID: run.ID, - Seq: seq, - ProviderID: p, - Model: p + "-bal", - Tier: "balanced", - Outcome: scheduler.RouteAttemptOutcomeFailedProviderUnavail, - StartedAt: time.Now().UTC(), + RunID: run.ID, + Seq: seq, + ExecutionProfileID: p + "-profile", + ProviderID: p, + Model: p + "-bal", + Tier: "balanced", + Outcome: scheduler.RouteAttemptOutcomeFailedProviderUnavail, + StartedAt: time.Now().UTC(), }); err != nil { t.Fatalf("seed prior attempt: %v", err) } @@ -571,6 +616,9 @@ func TestDispatch_LiftedRunIgnoresPriorCycleExclusions(t *testing.T) { if got := starter.lastCall().route.ProviderID; got != "claude-acp" { t.Errorf("expected claude-acp (cycle 2 fresh start); got %s", got) } + if prompt := starter.lastCall().launch.Prompt; strings.Contains(prompt, "[Kandev provider fallback]") { + t.Fatalf("fresh route cycle must not inject continuation prompt: %q", prompt) + } } // TestDispatch_PostStartFallbackKeepsExclusions pins the within-cycle @@ -590,13 +638,14 @@ func TestDispatch_PostStartFallbackKeepsExclusions(t *testing.T) { t.Fatalf("seed seq bump: %v", err) } if err := repo.AppendRouteAttempt(context.Background(), &officemodels.RouteAttempt{ - RunID: run.ID, - Seq: 1, - ProviderID: "claude-acp", - Model: "claude-acp-bal", - Tier: "balanced", - Outcome: scheduler.RouteAttemptOutcomeFailedProviderUnavail, - StartedAt: time.Now().UTC(), + RunID: run.ID, + Seq: 1, + ExecutionProfileID: "claude-acp-profile", + ProviderID: "claude-acp", + Model: "claude-acp-bal", + Tier: "balanced", + Outcome: scheduler.RouteAttemptOutcomeFailedProviderUnavail, + StartedAt: time.Now().UTC(), }); err != nil { t.Fatalf("seed prior attempt: %v", err) } @@ -619,6 +668,16 @@ func TestDispatch_PostStartFallbackKeepsExclusions(t *testing.T) { if got := starter.lastCall().route.ProviderID; got != "codex-acp" { t.Errorf("expected codex-acp (claude excluded within-cycle); got %s", got) } + prompt := starter.lastCall().launch.Prompt + for _, want := range []string{ + "[Kandev provider fallback]", "fresh provider-native session", + "task description", "comments/messages", "task status", "current run state", + "git status", "git diff", "git log", + } { + if !strings.Contains(prompt, want) { + t.Errorf("continuation prompt missing %q: %s", want, prompt) + } + } } func TestLiftParkedRuns_ClearsExpired(t *testing.T) { diff --git a/apps/backend/internal/office/scheduler/routing_lifecycle.go b/apps/backend/internal/office/scheduler/routing_lifecycle.go index c1bb3f17ff..e672783ff7 100644 --- a/apps/backend/internal/office/scheduler/routing_lifecycle.go +++ b/apps/backend/internal/office/scheduler/routing_lifecycle.go @@ -61,9 +61,10 @@ func (ss *SchedulerService) inflightCandidate( for _, a := range attempts { if a.Seq == run.CurrentRouteAttemptSeq { return routing.Candidate{ - ProviderID: routing.ProviderID(a.ProviderID), - Model: a.Model, - Tier: routing.Tier(a.Tier), + ExecutionProfileID: a.ExecutionProfileID, + ProviderID: routing.ProviderID(a.ProviderID), + Model: a.Model, + Tier: routing.Tier(a.Tier), }, nil } } diff --git a/apps/backend/internal/office/scheduler/run.go b/apps/backend/internal/office/scheduler/run.go index b791b72924..03df134440 100644 --- a/apps/backend/internal/office/scheduler/run.go +++ b/apps/backend/internal/office/scheduler/run.go @@ -38,12 +38,13 @@ var ErrRoutingNotSupported = errors.New("routing not supported by task starter") // preset-by-name. Per-provider permission overrides would require // re-modelling that surface and are deferred. type RouteOverride struct { - ProviderID string - Model string - Tier string - Mode string - Flags []string - Env map[string]string + ExecutionProfileID string + ProviderID string + Model string + Tier string + Mode string + Flags []string + Env map[string]string } // LaunchContext is an alias for service.LaunchContext so dispatch diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index bffe9261f4..73481b96e0 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -264,27 +264,30 @@ type AgentProfileInfo struct { // LaunchAgentRequest contains parameters for launching an agent type LaunchAgentRequest struct { - TaskID string - WorkspaceID string // Kandev workspace ID — used to build scratch dir for repo-less tasks - SessionID string - TaskEnvironmentID string // Env owning this session (shared across sessions in the same task) - TaskTitle string // Human-readable task title for semantic worktree naming - AgentProfileID string - RepositoryURL string - Branch string - TaskDescription string // Task description to send via ACP prompt - Attachments []v1.MessageAttachment // Attachments for the initial prompt (images/files) - Priority string - Metadata map[string]interface{} - Env map[string]string - ACPSessionID string // ACP session ID to resume, if available - ModelOverride string // If set, use this model instead of the profile's model - ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime - ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) - PreviousExecutionID string // Previous execution ID for runtime reconnect - McpMode string // MCP tool mode: "task" (default), "config", or "office" - IsEphemeral bool // Ephemeral task (quick chat) — enables fallback workspace creation - WorkspacePath string // Optional host folder for repo-less tasks (overrides scratch fallback) + TaskID string + WorkspaceID string // Kandev workspace ID — used to build scratch dir for repo-less tasks + SessionID string + TaskEnvironmentID string // Env owning this session (shared across sessions in the same task) + TaskTitle string // Human-readable task title for semantic worktree naming + AgentProfileID string + // OfficeAgentProfileID is the stable Office identity. AgentProfileID stays + // the concrete execution profile inside the executor for compatibility. + OfficeAgentProfileID string + RepositoryURL string + Branch string + TaskDescription string // Task description to send via ACP prompt + Attachments []v1.MessageAttachment // Attachments for the initial prompt (images/files) + Priority string + Metadata map[string]interface{} + Env map[string]string + ACPSessionID string // ACP session ID to resume, if available + ModelOverride string // If set, use this model instead of the profile's model + ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime + ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) + PreviousExecutionID string // Previous execution ID for runtime reconnect + McpMode string // MCP tool mode: "task" (default), "config", or "office" + IsEphemeral bool // Ephemeral task (quick chat) — enables fallback workspace creation + WorkspacePath string // Optional host folder for repo-less tasks (overrides scratch fallback) // IsPassthrough is the session's mode snapshot (TaskSession.IsPassthrough) // at session-creation time. Forwarded to the lifecycle manager so @@ -383,14 +386,15 @@ const McpModeOffice = "office" // LaunchOptions contains optional parameters for LaunchPreparedSession. type LaunchOptions struct { - AgentProfileID string - ExecutorID string - Prompt string - WorkflowStepID string - StartAgent bool - McpMode string // MCP tool mode: empty task default, McpModeConfig, or McpModeOffice - Attachments []v1.MessageAttachment - Env map[string]string + AgentProfileID string + OfficeAgentProfileID string + ExecutorID string + Prompt string + WorkflowStepID string + StartAgent bool + McpMode string // MCP tool mode: empty task default, McpModeConfig, or McpModeOffice + Attachments []v1.MessageAttachment + Env map[string]string // RouteOverride carries a provider-routing override resolved by the // office scheduler. When nil, launch behavior is identical to today. RouteOverride *RouteOverride @@ -399,12 +403,13 @@ type LaunchOptions struct { // RouteOverride is the orchestrator-side mirror of routing.Candidate // fields that need to flow into the lifecycle launch. type RouteOverride struct { - ProviderID string - Model string - Tier string - Mode string - Flags []string - Env map[string]string + ExecutionProfileID string + ProviderID string + Model string + Tier string + Mode string + Flags []string + Env map[string]string } // LaunchContext is the orchestrator-side mirror of the Office launch diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_test.go b/apps/backend/internal/orchestrator/executor/executor_environment_test.go index abd8a6e797..214cf3d9b2 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_test.go @@ -327,6 +327,40 @@ func TestBuildResumeRequest_ReusesTaskEnvironmentRuntimeMetadata(t *testing.T) { } } +func TestBuildResumeRequest_UsesExecutionProfileAndKeepsOfficeIdentity(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + task := &v1.Task{ID: "task-1", WorkspaceID: "workspace-1", Title: "Task 1"} + session := &models.TaskSession{ + ID: "session-1", + TaskID: task.ID, + AgentProfileID: "office-cto", + ExecutionProfileID: "claude-opus", + ExecutorID: models.ExecutorIDLocal, + State: models.TaskSessionStateWaitingForInput, + } + repo.executorsRunning[session.ID] = &models.ExecutorRunning{ + SessionID: session.ID, + TaskID: task.ID, + ExecutionProfileID: "claude-opus", + ResumeToken: "claude-session", + } + + req, _, _, _, _, err := exec.buildResumeRequest(context.Background(), task, session, true) + if err != nil { + t.Fatalf("buildResumeRequest: %v", err) + } + if req.AgentProfileID != "claude-opus" { + t.Fatalf("AgentProfileID = %q, want concrete execution profile", req.AgentProfileID) + } + if req.OfficeAgentProfileID != "office-cto" { + t.Fatalf("OfficeAgentProfileID = %q, want stable Office identity", req.OfficeAgentProfileID) + } + if req.ACPSessionID != "claude-session" { + t.Fatalf("ACPSessionID = %q, want matching profile token", req.ACPSessionID) + } +} + func TestReuseExistingEnvironment_SandboxReuse(t *testing.T) { e := newEnvTestExecutor(t) req := &LaunchAgentRequest{TaskID: "task-1"} diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 87a257dfb3..60ad32167d 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -523,6 +523,22 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses return nil, fmt.Errorf("session does not belong to task") } + running, _ := e.repo.GetExecutorRunningBySessionID(ctx, sessionID) + if running != nil && running.ExecutionProfileID != "" && + running.ExecutionProfileID != agentProfileID { + if running.AgentExecutionID != "" { + if err := e.agentManager.StopAgentWithReason( + ctx, running.AgentExecutionID, "execution profile changed", true, + ); err != nil && !errors.Is(err, lifecycle.ErrExecutionNotFound) { + return nil, fmt.Errorf("stop previous execution profile: %w", err) + } + } + if err := e.repo.DeleteExecutorRunningBySessionID(ctx, sessionID); err != nil { + return nil, fmt.Errorf("clear previous execution profile: %w", err) + } + running = nil + } + // Inject session handover context if there are previous sessions for this task. prompt = e.injectHandoverIfNeeded(ctx, task.ID, sessionID, prompt) @@ -585,6 +601,10 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses if err != nil { return nil, err } + req.OfficeAgentProfileID = opts.OfficeAgentProfileID + if req.OfficeAgentProfileID == "" && session.AgentProfileID != "" { + req.OfficeAgentProfileID = session.AgentProfileID + } mergeEnv(req, opts.Env) if opts.RouteOverride != nil { req.RouteOverride = opts.RouteOverride @@ -604,12 +624,12 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses // Unlike ResumeSession we do NOT clear req.TaskDescription — wakeups // deliver the new comment / event as the prompt. if startAgent { - if running, _ := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); running != nil && running.ResumeToken != "" { - req.ACPSessionID = running.ResumeToken + if token := resumeTokenForExecutionProfile(running, agentProfileID); token != "" { + req.ACPSessionID = token e.logger.Info("resuming ACP session via stored resume token", zap.String("task_id", task.ID), zap.String("session_id", sessionID), - zap.String("acp_session_id", running.ResumeToken)) + zap.String("acp_session_id", token)) } } @@ -652,6 +672,14 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses return e.finalizeLaunch(ctx, task, session, agentProfileID, sessionID, primaryRepo, resp, startAgent, execCfg) } +func resumeTokenForExecutionProfile(running *models.ExecutorRunning, profileID string) string { + if running == nil || profileID == "" || + (running.ExecutionProfileID != "" && running.ExecutionProfileID != profileID) { + return "" + } + return running.ResumeToken +} + // handleLaunchFailure marks the session and task as FAILED and returns the original error. func (e *Executor) handleLaunchFailure(ctx context.Context, taskID, sessionID string, launchErr error) error { // Detach from caller context so failure bookkeeping completes even if the diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 5e76d0d7da..dc4ba74edc 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -256,7 +256,8 @@ type mockRepository struct { // Optional hook to inject behavior into GetTaskSession (e.g. simulate a // transient DB error); if nil, the default map lookup is used. - getTaskSessionFunc func(ctx context.Context, id string) (*models.TaskSession, error) + getTaskSessionFunc func(ctx context.Context, id string) (*models.TaskSession, error) + createTaskSessionFunc func(ctx context.Context, session *models.TaskSession) error // Track calls for verification createTaskSessionCalls []*models.TaskSession @@ -308,10 +309,15 @@ func (m *mockRepository) GetRepository(ctx context.Context, id string) (*models. func (m *mockRepository) CreateTaskSession(ctx context.Context, session *models.TaskSession) error { m.mu.Lock() - defer m.mu.Unlock() m.createTaskSessionCalls = append(m.createTaskSessionCalls, session) - m.sessions[session.ID] = session - return nil + fn := m.createTaskSessionFunc + if fn == nil { + m.sessions[session.ID] = session + m.mu.Unlock() + return nil + } + m.mu.Unlock() + return fn(ctx, session) } func (m *mockRepository) GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) { diff --git a/apps/backend/internal/orchestrator/executor/executor_office.go b/apps/backend/internal/orchestrator/executor/executor_office.go index 87f3f5616e..9b825d9433 100644 --- a/apps/backend/internal/orchestrator/executor/executor_office.go +++ b/apps/backend/internal/orchestrator/executor/executor_office.go @@ -42,6 +42,9 @@ func (e *Executor) EnsureSessionForAgent( return nil, fmt.Errorf("lookup (task,agent) session: %w", err) } if existing != nil { + if err := e.rebindOfficeSessionExecutionProfile(ctx, existing, agentProfileID); err != nil { + return nil, err + } reused, decision := e.tryReuseExistingSession(ctx, existing) if decision == reuseDecisionTerminal { // Fall through to create a new row below. @@ -55,6 +58,9 @@ func (e *Executor) EnsureSessionForAgent( // Lost the race against a concurrent caller. Re-read and reuse. raced, lookupErr := e.repo.GetTaskSessionByTaskAndAgent(ctx, task.ID, agentInstanceID) if lookupErr == nil && raced != nil { + if rebindErr := e.rebindOfficeSessionExecutionProfile(ctx, raced, agentProfileID); rebindErr != nil { + return nil, rebindErr + } reused, _ := e.tryReuseExistingSession(ctx, raced) if reused != nil { return reused, nil @@ -64,6 +70,22 @@ func (e *Executor) EnsureSessionForAgent( return created, err } +func (e *Executor) rebindOfficeSessionExecutionProfile( + ctx context.Context, session *models.TaskSession, executionProfileID string, +) error { + if session == nil || executionProfileID == "" || session.ExecutionProfileID == executionProfileID { + return nil + } + snapshot, isPassthrough := e.resolveAgentProfileSnapshot(ctx, executionProfileID) + session.ExecutionProfileID = executionProfileID + session.AgentProfileSnapshot = snapshot + session.IsPassthrough = isPassthrough + if err := e.repo.UpdateTaskSession(ctx, session); err != nil { + return fmt.Errorf("update office execution profile: %w", err) + } + return nil +} + // reuseDecision describes what tryReuseExistingSession did with an existing // row. terminal => caller must create a fresh session; reused => the row was // kept (state may have been flipped from IDLE → RUNNING). @@ -124,9 +146,8 @@ func (e *Executor) createOfficeSession( agentProfileSnapshot, isPassthrough := e.resolveAgentProfileSnapshot(ctx, agentProfileID) now := time.Now().UTC() - // agentInstanceID and agentProfileID collapse to the same value after - // ADR 0005 (the office agent IS the profile). Prefer the explicit - // agent-id parameter when present; fall back to the profile id. + // Office sessions are owned by the stable agent identity while their + // concrete execution profile may change between runs. sessionAgentProfileID := agentInstanceID if sessionAgentProfileID == "" { sessionAgentProfileID = agentProfileID @@ -135,6 +156,7 @@ func (e *Executor) createOfficeSession( ID: uuid.New().String(), TaskID: task.ID, AgentProfileID: sessionAgentProfileID, + ExecutionProfileID: agentProfileID, RepositoryID: repositoryID, BaseBranch: baseBranch, State: models.TaskSessionStateCreated, diff --git a/apps/backend/internal/orchestrator/executor/executor_office_test.go b/apps/backend/internal/orchestrator/executor/executor_office_test.go index e3c1fc4f42..51c3d393c7 100644 --- a/apps/backend/internal/orchestrator/executor/executor_office_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_office_test.go @@ -2,10 +2,12 @@ package executor import ( "context" + "fmt" "testing" "time" "github.com/kandev/kandev/internal/task/models" + taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -34,6 +36,9 @@ func TestEnsureSessionForAgent_CreatesWhenMissing(t *testing.T) { if got.AgentProfileID != "agent-1" { t.Errorf("agent_profile_id: got %q want agent-1", got.AgentProfileID) } + if got.ExecutionProfileID != "profile-1" { + t.Errorf("execution_profile_id: got %q want profile-1", got.ExecutionProfileID) + } if got.State != models.TaskSessionStateCreated { t.Errorf("state: got %q want CREATED", got.State) } @@ -42,6 +47,68 @@ func TestEnsureSessionForAgent_CreatesWhenMissing(t *testing.T) { } } +func TestEnsureSessionForAgent_RebindsExecutionProfileOnReuse(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + existing := &models.TaskSession{ + ID: "sess-existing", + TaskID: "task-office", + AgentProfileID: "agent-1", + ExecutionProfileID: "codex-profile", + State: models.TaskSessionStateIdle, + StartedAt: time.Now().UTC(), + } + repo.sessions[existing.ID] = existing + + got, err := exec.EnsureSessionForAgent( + context.Background(), officeTestTask(), "agent-1", "claude-profile", "exec-1", "", + ) + if err != nil { + t.Fatalf("EnsureSessionForAgent: %v", err) + } + if got.AgentProfileID != "agent-1" { + t.Fatalf("office identity changed: %q", got.AgentProfileID) + } + if got.ExecutionProfileID != "claude-profile" { + t.Fatalf("execution profile = %q, want claude-profile", got.ExecutionProfileID) + } +} + +func TestEnsureSessionForAgent_RebindsExecutionProfileAfterCreateRace(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + winner := &models.TaskSession{ + ID: "sess-winner", + TaskID: "task-office", + AgentProfileID: "agent-1", + ExecutionProfileID: "codex-profile", + State: models.TaskSessionStateIdle, + StartedAt: time.Now().UTC(), + } + repo.createTaskSessionFunc = func(_ context.Context, _ *models.TaskSession) error { + repo.mu.Lock() + repo.sessions[winner.ID] = winner + repo.mu.Unlock() + return fmt.Errorf("%w: concurrent insert", taskrepo.ErrOfficeSessionRaceConflict) + } + + got, err := exec.EnsureSessionForAgent( + context.Background(), officeTestTask(), "agent-1", "claude-profile", "exec-1", "", + ) + if err != nil { + t.Fatalf("EnsureSessionForAgent: %v", err) + } + if got.ID != winner.ID { + t.Fatalf("session = %q, want race winner %q", got.ID, winner.ID) + } + if got.AgentProfileID != "agent-1" { + t.Fatalf("office identity changed: %q", got.AgentProfileID) + } + if got.ExecutionProfileID != "claude-profile" { + t.Fatalf("execution profile = %q, want claude-profile", got.ExecutionProfileID) + } +} + // TestEnsureSessionForAgent_ReusesIdleAndFlipsRunning covers the canonical // reuse path: the second run for the same (task, agent) reuses the existing // row and flips IDLE → RUNNING. No new row is inserted. diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index fea3a2ca87..df0257fcdf 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -521,16 +521,21 @@ func (e *Executor) validateAndLockResume(ctx context.Context, session *models.Ta // repository details, worktree settings, and ACP resume token. // Returns the request, repository ID, executor config, existing ExecutorRunning record (may be nil), and error. func (e *Executor) buildResumeRequest(ctx context.Context, task *v1.Task, session *models.TaskSession, startAgent bool) (*LaunchAgentRequest, string, executorConfig, *models.TaskEnvironment, *models.ExecutorRunning, error) { + executionProfileID := session.ExecutionProfileID + if executionProfileID == "" { + executionProfileID = session.AgentProfileID + } req := &LaunchAgentRequest{ - TaskID: task.ID, - SessionID: session.ID, - TaskTitle: task.Title, - AgentProfileID: session.AgentProfileID, - TaskDescription: task.Description, - Priority: task.Priority, - IsEphemeral: task.IsEphemeral, - IsPassthrough: session.IsPassthrough, - TaskEnvironmentID: session.TaskEnvironmentID, + TaskID: task.ID, + SessionID: session.ID, + TaskTitle: task.Title, + AgentProfileID: executionProfileID, + OfficeAgentProfileID: session.AgentProfileID, + TaskDescription: task.Description, + Priority: task.Priority, + IsEphemeral: task.IsEphemeral, + IsPassthrough: session.IsPassthrough, + TaskEnvironmentID: session.TaskEnvironmentID, } metadata := map[string]interface{}{} @@ -648,8 +653,8 @@ func (e *Executor) applyRunningRecordToResumeRequest(ctx context.Context, req *L } } - if running.ResumeToken != "" && startAgent { - req.ACPSessionID = running.ResumeToken + if token := resumeTokenForExecutionProfile(running, req.AgentProfileID); token != "" && startAgent { + req.ACPSessionID = token // Clear TaskDescription so the agent doesn't receive an automatic prompt on resume. // The session context is restored via ACP session/load; sending a prompt here would // cause the agent to start working immediately instead of waiting for user input. diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 4078b36332..6317acc9a3 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -10,12 +10,61 @@ import ( "testing" "time" + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) // Tests +func TestResumeTokenForExecutionProfile(t *testing.T) { + tests := []struct { + name string + running *models.ExecutorRunning + profileID string + want string + }{ + { + name: "same profile resumes", + running: &models.ExecutorRunning{ + ExecutionProfileID: "claude-profile", + ResumeToken: "claude-session", + }, + profileID: "claude-profile", + want: "claude-session", + }, + { + name: "cross provider token suppressed", + running: &models.ExecutorRunning{ + ExecutionProfileID: "codex-profile", + ResumeToken: "codex-session", + }, + profileID: "claude-profile", + }, + { + name: "legacy unbound token resumes", + running: &models.ExecutorRunning{ResumeToken: "unknown-session"}, + profileID: "claude-profile", + want: "unknown-session", + }, + { + name: "nil running", + profileID: "claude-profile", + }, + { + name: "empty requested profile", + running: &models.ExecutorRunning{ResumeToken: "session"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resumeTokenForExecutionProfile(tt.running, tt.profileID); got != tt.want { + t.Fatalf("resume token = %q, want %q", got, tt.want) + } + }) + } +} + func TestPrepareSession_Success(t *testing.T) { repo := newMockRepository() agentManager := &mockAgentManager{} @@ -906,12 +955,13 @@ func TestLaunchPreparedSession_FullPath_CarriesResumeToken(t *testing.T) { // HasExecutorRunningRow is true → fast path is tried; the live execution // is gone (office IDLE) → ErrStaleExecution → fall through to full path. repo.executorsRunning[session.ID] = &models.ExecutorRunning{ - ID: session.ID, - SessionID: session.ID, - TaskID: session.TaskID, - AgentExecutionID: "stale-exec-id", - ResumeToken: "acp-session-abc", - Status: "ready", + ID: session.ID, + SessionID: session.ID, + TaskID: session.TaskID, + AgentExecutionID: "stale-exec-id", + ExecutionProfileID: "profile-123", + ResumeToken: "acp-session-abc", + Status: "ready", } var capturedReq *LaunchAgentRequest @@ -923,12 +973,13 @@ func TestLaunchPreparedSession_FullPath_CarriesResumeToken(t *testing.T) { launchAgentFunc: func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { capturedReq = req repo.executorsRunning[req.SessionID] = &models.ExecutorRunning{ - ID: req.SessionID, - SessionID: req.SessionID, - TaskID: req.TaskID, - AgentExecutionID: "new-exec-id", - ResumeToken: "acp-session-abc", - Status: "starting", + ID: req.SessionID, + SessionID: req.SessionID, + TaskID: req.TaskID, + AgentExecutionID: "new-exec-id", + ExecutionProfileID: req.AgentProfileID, + ResumeToken: "acp-session-abc", + Status: "starting", } return &LaunchAgentResponse{ AgentExecutionID: "new-exec-id", @@ -967,6 +1018,41 @@ func TestLaunchPreparedSession_FullPath_CarriesResumeToken(t *testing.T) { } } +func TestLaunchPreparedSession_ProfileSwitchIgnoresMissingStaleExecution(t *testing.T) { + repo := newMockRepository() + session := &models.TaskSession{ + ID: "session-123", TaskID: "task-123", AgentProfileID: "office-cto", + State: models.TaskSessionStateCreated, StartedAt: time.Now(), UpdatedAt: time.Now(), + } + repo.sessions[session.ID] = session + repo.executorsRunning[session.ID] = &models.ExecutorRunning{ + ID: session.ID, SessionID: session.ID, TaskID: session.TaskID, + AgentExecutionID: "stale-codex-exec", ExecutionProfileID: "codex-profile", + } + launched := false + agentManager := &mockAgentManager{ + stopAgentFunc: func(context.Context, string, bool) error { + return fmt.Errorf("stale execution: %w", lifecycle.ErrExecutionNotFound) + }, + launchAgentFunc: func(context.Context, *LaunchAgentRequest) (*LaunchAgentResponse, error) { + launched = true + return &LaunchAgentResponse{AgentExecutionID: "claude-exec"}, nil + }, + } + exec := newTestExecutor(t, agentManager, repo) + task := &v1.Task{ID: session.TaskID, WorkspaceID: "workspace-123", Title: "Test Task"} + + _, err := exec.LaunchPreparedSession(context.Background(), task, session.ID, LaunchOptions{ + AgentProfileID: "claude-profile", OfficeAgentProfileID: "office-cto", StartAgent: true, + }) + if err != nil { + t.Fatalf("LaunchPreparedSession: %v", err) + } + if !launched { + t.Fatal("expected profile switch to continue with a fresh launch") + } +} + // TestLaunchPreparedSession_FullPath_WorkspaceOnly_DoesNotResume verifies that // when startAgent is false (workspace-only prep) we do NOT carry the resume // token forward, mirroring the gating in ResumeSession. diff --git a/apps/backend/internal/orchestrator/session_ensure.go b/apps/backend/internal/orchestrator/session_ensure.go index de4f47fa8e..1ff498e797 100644 --- a/apps/backend/internal/orchestrator/session_ensure.go +++ b/apps/backend/internal/orchestrator/session_ensure.go @@ -221,9 +221,9 @@ func (s *Service) tryEnsureExecution(ctx context.Context, sessionID string) { } } -// resolveTaskAgentProfile applies the 4-step resolution chain on the backend: +// resolveTaskAgentProfile applies the 5-step resolution chain on the backend: // 1) task.metadata.agent_profile_id, 2) workflow step override, -// 3) workflow default, 4) workspace default. Returns the resolved profile id +// 3) workflow default, 4) Office task assignee, 5) workspace default. Returns the resolved profile id // (or "" when none resolve) along with the workflow step it loaded (or nil). // Returning the step lets callers reuse it (e.g. to gate auto-start) without a // second DB lookup. @@ -237,6 +237,9 @@ func (s *Service) resolveTaskAgentProfile(ctx context.Context, task *models.Task return id, step } } + if task.AssigneeAgentProfileID != "" { + return task.AssigneeAgentProfileID, step + } ws, err := s.repo.GetWorkspace(ctx, task.WorkspaceID) if err == nil && ws != nil && ws.DefaultAgentProfileID != nil && *ws.DefaultAgentProfileID != "" { return *ws.DefaultAgentProfileID, step diff --git a/apps/backend/internal/orchestrator/session_ensure_test.go b/apps/backend/internal/orchestrator/session_ensure_test.go index 1566b9a67c..32cf6bac26 100644 --- a/apps/backend/internal/orchestrator/session_ensure_test.go +++ b/apps/backend/internal/orchestrator/session_ensure_test.go @@ -452,6 +452,24 @@ func TestResolveTaskAgentProfile_StepThenWorkflowThenWorkspace(t *testing.T) { } }) + t.Run("office assignee before workspace default", func(t *testing.T) { + repo := setupTestRepo(t) + ws := &models.Workspace{ + ID: "ws-office", Name: "Office", DefaultAgentProfileID: strPtr("ws-profile"), + CreatedAt: now, UpdatedAt: now, + } + if err := repo.CreateWorkspace(ctx, ws); err != nil { + t.Fatalf("create workspace: %v", err) + } + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + task := &models.Task{ + ID: "t-office", WorkspaceID: "ws-office", AssigneeAgentProfileID: "office-agent", + } + if got, _ := svc.resolveTaskAgentProfile(ctx, task); got != "office-agent" { + t.Errorf("expected office-agent, got %q", got) + } + }) + t.Run("returns empty when nothing resolves", func(t *testing.T) { repo := setupTestRepo(t) ws := &models.Workspace{ID: "ws-y", Name: "Y", CreatedAt: now, UpdatedAt: now} diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 24f27b6834..c1e0818905 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -513,12 +513,8 @@ func (s *Service) StartTaskWithEnv(ctx context.Context, taskID string, agentProf return s.startTask(ctx, taskID, agentProfileID, executorID, executorProfileID, priority, prompt, workflowStepID, planMode, autoStart, attachments, env, nil) } -// StartTaskWithRoute launches a task with a fully resolved provider -// override resolved by the office routing dispatcher. Workspace, executor -// selection, instruction files, system prompt, and ACP session settings -// are inherited from the base AgentProfile referenced by agentProfileID; -// only the provider-scoped fields (agent_id, model, mode, flags, env, -// permissions) are overridden via route. +// StartTaskWithRoute launches a stable Office identity through a complete +// concrete execution profile selected by the routing dispatcher. // // launch carries the Office-built launch context (prompt, env, workflow // step, attachments, plan-mode flag) so routed launches behave @@ -526,45 +522,19 @@ func (s *Service) StartTaskWithEnv(ctx context.Context, taskID string, agentProf // selection. Without launch, routed runs would fall back to // task.Description and drop role framing / AGENTS.md / wake context. // -// Env merging: launch.Env carries the office-built env vars (token, -// KANDEV_*); route.Env carries provider-scoped overrides. The route -// env wins on key collisions because per-provider env is the more -// specific authority. -// // Office-routed launches use autoStart=false: the user kicked off the // task; the office layer only chose the provider. func (s *Service) StartTaskWithRoute( ctx context.Context, taskID, agentProfileID string, launch executor.LaunchContext, route executor.RouteOverride, ) error { - merged := mergeRouteEnv(launch.Env, route.Env) _, err := s.startTask(ctx, taskID, agentProfileID, launch.ExecutorID, launch.ExecutorProfileID, launch.Priority, launch.Prompt, launch.WorkflowStepID, launch.PlanMode, false, - launch.Attachments, merged, &route) + launch.Attachments, launch.Env, &route) return err } -// mergeRouteEnv combines the office-built launch env with the -// per-provider route env. Route entries win on key collisions. -func mergeRouteEnv(launchEnv, routeEnv map[string]string) map[string]string { - if len(launchEnv) == 0 && len(routeEnv) == 0 { - return nil - } - // The size hint is unused intentionally: CodeQL flags - // `len(a)+len(b)` as a potential overflow for the make() capacity - // argument. Map literals re-grow themselves; the hint was an - // optimization, not a correctness requirement. - out := make(map[string]string) - for k, v := range launchEnv { - out[k] = v - } - for k, v := range routeEnv { - out[k] = v - } - return out -} - //nolint:cyclop,funlen // launch path threads many orthogonal concerns (workflow-step / agent-profile / office-task / config-mode / route / system-prompt wrapping); splitting it would require shared mutable state across helpers func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, priority string, prompt string, workflowStepID string, planMode, autoStart bool, attachments []v1.MessageAttachment, env map[string]string, route *executor.RouteOverride) (*executor.TaskExecution, error) { s.logger.Debug("manually starting task", @@ -603,12 +573,20 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s s.moveTaskToWorkflowStep(ctx, taskID, workflowStepID) + officeAgentProfileID := "" + if isOfficeTask { + officeAgentProfileID = agentProfileID + } + // Resolve the workflow step's agent profile override. // The frontend may pass the workspace default profile, but the step may // require a different agent (e.g., Codex on "In Progress", Auggie on "Review"). callerProfileID := agentProfileID agentProfileID = s.resolveEffectiveAgentProfile(ctx, taskID, workflowStepID, agentProfileID) overrideApplied := agentProfileID != callerProfileID + if route != nil && route.ExecutionProfileID != "" { + agentProfileID = route.ExecutionProfileID + } // Fetch the task from the repository to get complete task info task, err := s.scheduler.GetTask(ctx, taskID) @@ -704,15 +682,16 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s s.rememberTurnPrompt(sessionID, prompt, "", planMode, attachments) execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, executor.LaunchOptions{ - AgentProfileID: agentProfileID, - ExecutorID: executorID, - Prompt: effectivePrompt, - WorkflowStepID: workflowStepID, - StartAgent: true, - McpMode: mcpMode, - Attachments: attachments, - Env: env, - RouteOverride: route, + AgentProfileID: agentProfileID, + OfficeAgentProfileID: officeAgentProfileID, + ExecutorID: executorID, + Prompt: effectivePrompt, + WorkflowStepID: workflowStepID, + StartAgent: true, + McpMode: mcpMode, + Attachments: attachments, + Env: env, + RouteOverride: route, }) if err != nil { return nil, err @@ -778,15 +757,6 @@ func (s *Service) createStartSession( if ensureErr != nil { return "", ensureErr } - // The office/assignee path bypasses StartCreatedSession's override - // mutation: the session is created directly with the assignee - // profile (which falls back to the step's agent_profile_id via the - // runner projection). If that profile differs from the caller's, - // the assignment was workflow-driven, so keep that provenance on - // the session metadata. - if agentProfileID != "" && session.AgentProfileID != "" && session.AgentProfileID != agentProfileID { - s.tagSessionAsWorkflowSwitched(ctx, session.ID) - } return session.ID, nil } return s.executor.PrepareSession(ctx, task, agentProfileID, executorID, executorProfileID, workflowStepID) diff --git a/apps/backend/internal/orchestrator/watcher/watcher.go b/apps/backend/internal/orchestrator/watcher/watcher.go index 3a90b1840a..23fc369c54 100644 --- a/apps/backend/internal/orchestrator/watcher/watcher.go +++ b/apps/backend/internal/orchestrator/watcher/watcher.go @@ -25,13 +25,14 @@ type TaskEventData struct { // AgentEventData contains data from agent events type AgentEventData struct { - TaskID string `json:"task_id"` - SessionID string `json:"session_id"` - AgentExecutionID string `json:"agent_execution_id"` - AgentProfileID string `json:"agent_profile_id"` - ExitCode *int `json:"exit_code,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - PromptGeneration uint64 `json:"prompt_generation,omitempty"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + AgentExecutionID string `json:"agent_execution_id"` + AgentProfileID string `json:"agent_profile_id"` + ExecutionProfileID string `json:"execution_profile_id,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + PromptGeneration uint64 `json:"prompt_generation,omitempty"` } // ACPSessionEventData contains data from ACP session events diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 0f29478f58..977102260a 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -697,10 +697,11 @@ type SessionBranchInfo struct { type TaskSession struct { ID string `json:"id"` TaskID string `json:"task_id"` - Name string `json:"name,omitempty"` // Optional user-supplied label shown on the session tab - AgentExecutionID string `json:"agent_execution_id"` // Docker container/agent execution - ContainerID string `json:"container_id"` // Docker container ID for cleanup - AgentProfileID string `json:"agent_profile_id"` // ID of the agent profile used + Name string `json:"name,omitempty"` // Optional user-supplied label shown on the session tab + AgentExecutionID string `json:"agent_execution_id"` // Docker container/agent execution + ContainerID string `json:"container_id"` // Docker container ID for cleanup + AgentProfileID string `json:"agent_profile_id"` // ID of the agent profile used + ExecutionProfileID string `json:"execution_profile_id"` // Concrete profile used for this execution ExecutorID string `json:"executor_id"` ExecutorProfileID string `json:"executor_profile_id"` EnvironmentID string `json:"environment_id"` @@ -923,19 +924,20 @@ type Executor struct { // ExecutorRunning tracks an active executor instance for a session. type ExecutorRunning struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - TaskID string `json:"task_id"` - ExecutorID string `json:"executor_id"` - Runtime agentruntime.Runtime `json:"runtime,omitempty"` - Status string `json:"status"` - Resumable bool `json:"resumable"` - ResumeToken string `json:"resume_token,omitempty"` - LastMessageUUID string `json:"last_message_uuid,omitempty"` - AgentExecutionID string `json:"agent_execution_id,omitempty"` - ContainerID string `json:"container_id,omitempty"` - AgentctlURL string `json:"agentctl_url,omitempty"` - AgentctlPort int `json:"agentctl_port,omitempty"` + ID string `json:"id"` + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + ExecutionProfileID string `json:"execution_profile_id"` + ExecutorID string `json:"executor_id"` + Runtime agentruntime.Runtime `json:"runtime,omitempty"` + Status string `json:"status"` + Resumable bool `json:"resumable"` + ResumeToken string `json:"resume_token,omitempty"` + LastMessageUUID string `json:"last_message_uuid,omitempty"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` + ContainerID string `json:"container_id,omitempty"` + AgentctlURL string `json:"agentctl_url,omitempty"` + AgentctlPort int `json:"agentctl_port,omitempty"` // PID is SSH-only: the agentctl PID on the *remote* host, used by the SSH // executor's remote-pid stop path. It is 0 for local/standalone rows. PID int `json:"pid,omitempty"` diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 806bc5cce1..713950b27f 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -44,6 +44,8 @@ func (r *Repository) migrateSessionsAddCostColumns() { // runMigrations applies idempotent ALTER TABLE migrations for schema evolution. func (r *Repository) runMigrations() error { + r.migrate.Apply("task_sessions.execution_profile_id", `ALTER TABLE task_sessions ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`) + r.migrate.Apply("executors_running.execution_profile_id", `ALTER TABLE executors_running ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`) r.migrate.Apply("executors_running.last_message_uuid", `ALTER TABLE executors_running ADD COLUMN last_message_uuid TEXT DEFAULT ''`) r.migrate.Apply("executors_running.metadata", `ALTER TABLE executors_running ADD COLUMN metadata TEXT DEFAULT '{}'`) // local_pid holds a host-local liveness handle (the standalone agentctl @@ -335,6 +337,7 @@ func (r *Repository) migrateSessionsRemoveAgentExecutionID() error { id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_profile_id TEXT, + execution_profile_id TEXT NOT NULL DEFAULT '', executor_id TEXT DEFAULT '', executor_profile_id TEXT DEFAULT '', environment_id TEXT DEFAULT '', @@ -361,7 +364,7 @@ func (r *Repository) migrateSessionsRemoveAgentExecutionID() error { FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE )`, `INSERT INTO task_sessions_new SELECT - id, task_id, agent_profile_id, + id, task_id, agent_profile_id, execution_profile_id, executor_id, executor_profile_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, @@ -566,6 +569,7 @@ func (r *Repository) migrateSessionsRemoveWorkflowStepID() error { agent_execution_id TEXT NOT NULL DEFAULT '', container_id TEXT NOT NULL DEFAULT '', agent_profile_id TEXT, + execution_profile_id TEXT NOT NULL DEFAULT '', executor_id TEXT DEFAULT '', executor_profile_id TEXT DEFAULT '', environment_id TEXT DEFAULT '', @@ -589,7 +593,7 @@ func (r *Repository) migrateSessionsRemoveWorkflowStepID() error { FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE )`, `INSERT INTO task_sessions_new SELECT - id, task_id, agent_execution_id, container_id, agent_profile_id, + id, task_id, agent_execution_id, container_id, agent_profile_id, execution_profile_id, executor_id, executor_profile_id, environment_id, repository_id, base_branch, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 3302bbe2b5..8a216fb708 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -167,6 +167,7 @@ const infraSchemaDDL = ` id TEXT PRIMARY KEY, session_id TEXT NOT NULL UNIQUE, task_id TEXT NOT NULL, + execution_profile_id TEXT NOT NULL DEFAULT '', executor_id TEXT NOT NULL, runtime TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'starting', @@ -545,6 +546,7 @@ const sessionWorktreeSchemaDDL = ` agent_execution_id TEXT NOT NULL DEFAULT '', container_id TEXT NOT NULL DEFAULT '', agent_profile_id TEXT, + execution_profile_id TEXT NOT NULL DEFAULT '', executor_id TEXT DEFAULT '', executor_profile_id TEXT DEFAULT '', environment_id TEXT DEFAULT '', diff --git a/apps/backend/internal/task/repository/sqlite/execution_profile_persistence_test.go b/apps/backend/internal/task/repository/sqlite/execution_profile_persistence_test.go new file mode 100644 index 0000000000..c91b536dec --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/execution_profile_persistence_test.go @@ -0,0 +1,180 @@ +package sqlite + +import ( + "context" + "reflect" + "testing" + + "github.com/kandev/kandev/internal/agentruntime" + "github.com/kandev/kandev/internal/task/models" +) + +func TestExecutionProfileIDFieldsUseExpectedJSONTags(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + model interface{} + }{ + {name: "task session", model: models.TaskSession{}}, + {name: "running executor", model: models.ExecutorRunning{}}, + } { + t.Run(tc.name, func(t *testing.T) { + field, ok := reflect.TypeOf(tc.model).FieldByName("ExecutionProfileID") + if !ok { + t.Fatal("ExecutionProfileID field is missing") + } + if got := field.Tag.Get("json"); got != "execution_profile_id" { + t.Fatalf("ExecutionProfileID JSON tag = %q, want execution_profile_id", got) + } + }) + } +} + +func TestExecutionProfileColumnsExistOnFreshSchema(t *testing.T) { + repo := newRepoForSessionTests(t) + + for _, query := range []string{ + `SELECT execution_profile_id FROM task_sessions LIMIT 0`, + `SELECT execution_profile_id FROM executors_running LIMIT 0`, + } { + rows, err := repo.db.Query(query) + if err != nil { + t.Fatalf("fresh schema query %q: %v", query, err) + } + _ = rows.Close() + } +} + +func TestExecutionProfileColumnsReplayOnLegacySchema(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedExecutorRunningCleanupTask(t, repo, "task-legacy-execution-profile") + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-legacy-execution-profile", TaskID: "task-legacy-execution-profile", + State: models.TaskSessionStateWaitingForInput, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "session-legacy-execution-profile", SessionID: "session-legacy-execution-profile", + TaskID: "task-legacy-execution-profile", ExecutorID: "exec-legacy", + Runtime: agentruntime.RuntimeStandalone, Status: models.ExecutorRunningStatusStarting, + ResumeToken: "legacy-token", + }); err != nil { + t.Fatalf("UpsertExecutorRunning: %v", err) + } + + for _, table := range []string{"task_sessions", "executors_running"} { + if _, err := repo.db.Exec(`ALTER TABLE ` + table + ` DROP COLUMN execution_profile_id`); err != nil { + t.Fatalf("drop legacy %s.execution_profile_id: %v", table, err) + } + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations on legacy schema: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("replay runMigrations: %v", err) + } + + var sessionExecutionProfileID string + if err := repo.db.Get(&sessionExecutionProfileID, + `SELECT execution_profile_id FROM task_sessions WHERE id = 'session-legacy-execution-profile'`); err != nil { + t.Fatalf("read migrated task session: %v", err) + } + if sessionExecutionProfileID != "" { + t.Fatalf("legacy session execution_profile_id = %q, want empty default", sessionExecutionProfileID) + } + var executorExecutionProfileID, resumeToken string + if err := repo.db.QueryRow(` + SELECT execution_profile_id, resume_token FROM executors_running + WHERE session_id = 'session-legacy-execution-profile' + `).Scan(&executorExecutionProfileID, &resumeToken); err != nil { + t.Fatalf("read migrated running executor: %v", err) + } + if executorExecutionProfileID != "" { + t.Fatalf("legacy executor execution_profile_id = %q, want empty default", executorExecutionProfileID) + } + if resumeToken != "legacy-token" { + t.Fatalf("migration lost resume token: got %q", resumeToken) + } +} + +func TestExecutionProfileIDsRoundTripThroughRepositories(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedExecutorRunningCleanupTask(t, repo, "task-execution-profile") + + session := &models.TaskSession{ + ID: "session-execution-profile", TaskID: "task-execution-profile", + AgentProfileID: "office-cto", ExecutionProfileID: "codex-gpt-5-6", + State: models.TaskSessionStateRunning, + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + gotSession, err := repo.GetTaskSession(ctx, session.ID) + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + if gotSession.ExecutionProfileID != "codex-gpt-5-6" { + t.Fatalf("execution_profile_id = %q, want codex-gpt-5-6", gotSession.ExecutionProfileID) + } + if gotSession.AgentProfileID != "office-cto" { + t.Fatalf("agent_profile_id = %q, want stable office identity", gotSession.AgentProfileID) + } + + gotSession.ExecutionProfileID = "claude-opus" + if err := repo.UpdateTaskSession(ctx, gotSession); err != nil { + t.Fatalf("UpdateTaskSession: %v", err) + } + listedSessions, err := repo.ListTaskSessions(ctx, session.TaskID) + if err != nil { + t.Fatalf("ListTaskSessions: %v", err) + } + if len(listedSessions) != 1 { + t.Fatalf("ListTaskSessions count = %d, want 1", len(listedSessions)) + } + if listedSessions[0].ExecutionProfileID != "claude-opus" { + t.Fatalf("updated execution_profile_id = %q, want claude-opus", listedSessions[0].ExecutionProfileID) + } + if listedSessions[0].AgentProfileID != "office-cto" { + t.Fatalf("updated agent_profile_id = %q, want stable office identity", listedSessions[0].AgentProfileID) + } + + running := &models.ExecutorRunning{ + ID: session.ID, SessionID: session.ID, TaskID: session.TaskID, + ExecutionProfileID: "codex-gpt-5-6", + ExecutorID: "exec-local", Runtime: agentruntime.RuntimeStandalone, + Status: models.ExecutorRunningStatusRunning, ResumeToken: "codex-token", + } + if err := repo.UpsertExecutorRunning(ctx, running); err != nil { + t.Fatalf("UpsertExecutorRunning: %v", err) + } + gotRunning, err := repo.GetExecutorRunningBySessionID(ctx, session.ID) + if err != nil { + t.Fatalf("GetExecutorRunningBySessionID: %v", err) + } + if gotRunning.ExecutionProfileID != "codex-gpt-5-6" { + t.Fatalf("executor execution_profile_id = %q, want codex-gpt-5-6", gotRunning.ExecutionProfileID) + } + + running.ExecutionProfileID = "claude-opus" + running.ResumeToken = "claude-token" + if err := repo.UpsertExecutorRunning(ctx, running); err != nil { + t.Fatalf("UpsertExecutorRunning update: %v", err) + } + listedRunning, err := repo.ListExecutorsRunningByTaskID(ctx, session.TaskID) + if err != nil { + t.Fatalf("ListExecutorsRunningByTaskID: %v", err) + } + if len(listedRunning) != 1 { + t.Fatalf("ListExecutorsRunningByTaskID count = %d, want 1", len(listedRunning)) + } + if listedRunning[0].ExecutionProfileID != "claude-opus" { + t.Fatalf("updated executor execution_profile_id = %q, want claude-opus", listedRunning[0].ExecutionProfileID) + } + if listedRunning[0].ResumeToken != "claude-token" { + t.Fatalf("updated resume token = %q, want claude-token", listedRunning[0].ResumeToken) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/executor.go b/apps/backend/internal/task/repository/sqlite/executor.go index 9e0cd0a3b7..9b9effc89f 100644 --- a/apps/backend/internal/task/repository/sqlite/executor.go +++ b/apps/backend/internal/task/repository/sqlite/executor.go @@ -163,14 +163,15 @@ func (r *Repository) UpsertExecutorRunning(ctx context.Context, running *models. _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO executors_running ( - id, session_id, task_id, executor_id, runtime, status, resumable, resume_token, + id, session_id, task_id, execution_profile_id, executor_id, runtime, status, resumable, resume_token, last_message_uuid, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, local_pid, worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, metadata, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET id = excluded.id, task_id = excluded.task_id, + execution_profile_id = excluded.execution_profile_id, executor_id = excluded.executor_id, runtime = excluded.runtime, status = excluded.status, @@ -194,6 +195,7 @@ func (r *Repository) UpsertExecutorRunning(ctx context.Context, running *models. running.ID, running.SessionID, running.TaskID, + running.ExecutionProfileID, running.ExecutorID, running.Runtime, running.Status, @@ -220,7 +222,7 @@ func (r *Repository) UpsertExecutorRunning(ctx context.Context, running *models. func (r *Repository) ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error) { rows, err := r.ro.QueryContext(ctx, ` - SELECT id, session_id, task_id, executor_id, runtime, status, resumable, resume_token, + SELECT id, session_id, task_id, execution_profile_id, executor_id, runtime, status, resumable, resume_token, last_message_uuid, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, local_pid, worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, metadata, created_at, updated_at @@ -240,7 +242,7 @@ func (r *Repository) ListExecutorsRunningByTaskID(ctx context.Context, taskID st return nil, fmt.Errorf("task_id is required") } rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` - SELECT id, session_id, task_id, executor_id, runtime, status, resumable, resume_token, + SELECT id, session_id, task_id, execution_profile_id, executor_id, runtime, status, resumable, resume_token, last_message_uuid, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, local_pid, worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, metadata, created_at, updated_at @@ -266,7 +268,7 @@ func (r *Repository) GetExecutorRunningBySessionID(ctx context.Context, sessionI var metadataJSON string err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, session_id, task_id, executor_id, runtime, status, resumable, resume_token, + SELECT id, session_id, task_id, execution_profile_id, executor_id, runtime, status, resumable, resume_token, last_message_uuid, agent_execution_id, container_id, agentctl_url, agentctl_port, pid, local_pid, worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, metadata, created_at, updated_at @@ -276,6 +278,7 @@ func (r *Repository) GetExecutorRunningBySessionID(ctx context.Context, sessionI &running.ID, &running.SessionID, &running.TaskID, + &running.ExecutionProfileID, &running.ExecutorID, &running.Runtime, &running.Status, @@ -328,6 +331,7 @@ func scanExecutorRunningRows(rows *sql.Rows) ([]*models.ExecutorRunning, error) &running.ID, &running.SessionID, &running.TaskID, + &running.ExecutionProfileID, &running.ExecutorID, &running.Runtime, &running.Status, diff --git a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go index b6961e1d0e..16c1378a93 100644 --- a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go +++ b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go @@ -67,6 +67,66 @@ func TestPostgresExecutorRunningLocalPIDMigration(t *testing.T) { } } +func TestPostgresExecutionProfileMigration(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + now := time.Now().UTC() + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), "task-pg-execution-profile", "ws-pg-execution-profile", "Task", now, now); err != nil { + t.Fatalf("seed task: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-pg-execution-profile", TaskID: "task-pg-execution-profile", + AgentProfileID: "office-agent", ExecutionProfileID: "codex-profile", + State: models.TaskSessionStateWaitingForInput, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "session-pg-execution-profile", SessionID: "session-pg-execution-profile", + TaskID: "task-pg-execution-profile", ExecutionProfileID: "codex-profile", + ExecutorID: "exec-pg", Runtime: agentruntime.RuntimeStandalone, + Status: models.ExecutorRunningStatusStarting, ResumeToken: "pg-token", + }); err != nil { + t.Fatalf("UpsertExecutorRunning: %v", err) + } + + for _, table := range []string{"task_sessions", "executors_running"} { + if _, err := db.Exec(`ALTER TABLE ` + table + ` DROP COLUMN execution_profile_id`); err != nil { + t.Fatalf("drop %s.execution_profile_id: %v", table, err) + } + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("runMigrations on legacy postgres schema: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("replay postgres migrations: %v", err) + } + + gotSession, err := repo.GetTaskSession(ctx, "session-pg-execution-profile") + if err != nil { + t.Fatalf("read migrated session: %v", err) + } + if gotSession.ExecutionProfileID != "" || gotSession.AgentProfileID != "office-agent" { + t.Fatalf("migrated session identities = (%q, %q), want (office-agent, empty)", + gotSession.AgentProfileID, gotSession.ExecutionProfileID) + } + gotRunning, err := repo.GetExecutorRunningBySessionID(ctx, "session-pg-execution-profile") + if err != nil { + t.Fatalf("read migrated running executor: %v", err) + } + if gotRunning.ExecutionProfileID != "" || gotRunning.ResumeToken != "pg-token" { + t.Fatalf("migrated executor = (%q, %q), want (empty, pg-token)", + gotRunning.ExecutionProfileID, gotRunning.ResumeToken) + } +} + func TestPostgresSchemaReinitializes(t *testing.T) { db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index fe4fcde5eb..17adb16a8a 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -193,7 +193,7 @@ func (r *Repository) ListTurnsBySession(ctx context.Context, sessionID string) ( // the two column names that used to live here have collapsed into one. const taskSessionSelectCols = `ts.id, ts.task_id, COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''), - ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, + ts.agent_profile_id, ts.execution_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id, ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path, ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, @@ -256,14 +256,14 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task } _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_sessions ( - id, task_id, agent_profile_id, executor_id, executor_profile_id, environment_id, + id, task_id, agent_profile_id, execution_profile_id, executor_id, executor_profile_id, environment_id, repository_id, base_branch, base_commit_sha, workspace_path, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, is_primary, review_status, is_passthrough, task_environment_id, name - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), session.ID, session.TaskID, agentProfileID, - session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, session.WorkspacePath, + session.ExecutionProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, session.WorkspacePath, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, string(metadataJSON), session.StartedAt, session.CompletedAt, session.UpdatedAt, @@ -309,7 +309,7 @@ func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsEr err := row.Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &agentProfileID, - &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, + &session.ExecutionProfileID, &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &session.WorkspacePath, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, @@ -500,13 +500,13 @@ func (r *Repository) updateTaskSession( } result, err := exec.ExecContext(ctx, r.db.Rebind(` UPDATE task_sessions SET - agent_profile_id = ?, executor_id = ?, executor_profile_id = ?, environment_id = ?, + agent_profile_id = ?, execution_profile_id = ?, executor_id = ?, executor_profile_id = ?, environment_id = ?, repository_id = ?, base_branch = ?, base_commit_sha = ?, workspace_path = ?, agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?, state = ?, error_message = ?, completed_at = ?, updated_at = ?, is_primary = ?, review_status = ?, is_passthrough = ?, task_environment_id = ? WHERE id = ? - `), agentProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, + `), agentProfileID, session.ExecutionProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, session.WorkspacePath, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, session.CompletedAt, session.UpdatedAt, @@ -1080,7 +1080,7 @@ func scanTaskSessionRow(rows *sql.Rows) (*models.TaskSession, error) { err := rows.Scan( &session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &agentProfileID, - &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, + &session.ExecutionProfileID, &session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID, &session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &session.WorkspacePath, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 58d575205e..ce48fa0c8d 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -412,6 +412,7 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio TaskEnvironmentID: session.TaskEnvironmentID, WorkspacePath: workspacePath, AgentProfileID: session.AgentProfileID, + ExecutionProfileID: session.ExecutionProfileID, AgentID: agentID, ACPSessionID: acpSessionID, SessionMode: sessionMode, diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index 21c0d790f1..be99b805d2 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -32,11 +32,12 @@ func TestGetWorkspaceInfoForSession_BasicFields(t *testing.T) { now := time.Now().UTC() session := &models.TaskSession{ - ID: "session-1", - TaskID: "task-123", - TaskEnvironmentID: "env-123", - AgentProfileID: "profile-1", - State: models.TaskSessionStateCompleted, + ID: "session-1", + TaskID: "task-123", + TaskEnvironmentID: "env-123", + AgentProfileID: "profile-1", + ExecutionProfileID: "claude-opus", + State: models.TaskSessionStateCompleted, AgentProfileSnapshot: map[string]interface{}{ "agent_name": "auggie", }, @@ -83,6 +84,9 @@ func TestGetWorkspaceInfoForSession_BasicFields(t *testing.T) { if info.AgentProfileID != "profile-1" { t.Errorf("expected AgentProfileID 'profile-1', got %q", info.AgentProfileID) } + if info.ExecutionProfileID != "claude-opus" { + t.Errorf("expected ExecutionProfileID 'claude-opus', got %q", info.ExecutionProfileID) + } if info.AgentID != "auggie" { t.Errorf("expected AgentID 'auggie', got %q", info.AgentID) } diff --git a/apps/web/app/office/agents/[id]/components/agent-config-cli-card.tsx b/apps/web/app/office/agents/[id]/components/agent-config-cli-card.tsx deleted file mode 100644 index 21d57d1999..0000000000 --- a/apps/web/app/office/agents/[id]/components/agent-config-cli-card.tsx +++ /dev/null @@ -1,231 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; -import { Label } from "@kandev/ui/label"; -import { Badge } from "@kandev/ui/badge"; -import { Button } from "@kandev/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { useAppStore } from "@/components/state-provider"; -import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles"; -import { CliProfileEditor } from "@/components/agent/cli-profile-editor"; -import type { AgentProfile } from "@/lib/types/agent-profile"; -import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; - -type Props = { - agentProfileId: string; - currentAgent: AgentProfile; - onAgentProfileChange: (v: string) => void; -}; - -/** - * Inline CLI configuration card. Shows the picker for an existing kanban - * profile + a "Create new" / "Edit linked profile" inline editor that - * obviates the deep-link to /settings/agents. - */ -export function AgentConfigCliCard({ agentProfileId, currentAgent, onAgentProfileChange }: Props) { - const healthy = useHealthyAgentProfiles(agentProfileId); - const settingsAgents = useAppStore((s) => s.settingsAgents.items); - const setAgentProfiles = useAppStore((s) => s.setAgentProfiles); - const agentProfilesState = useAppStore((s) => s.agentProfiles.items); - - const linkedProfile = useMemo( - () => findProfile(settingsAgents, agentProfileId) ?? currentAgent, - [settingsAgents, agentProfileId, currentAgent], - ); - - const [editorMode, setEditorMode] = useState<"closed" | "create" | "edit">("closed"); - const currentOption = useMemo(() => toCurrentAgentOption(currentAgent), [currentAgent]); - const selected = healthy.find((p) => p.id === agentProfileId) ?? currentOption; - const pickerOptions = useMemo(() => { - if (healthy.some((p) => p.id === currentOption.id)) return healthy; - return [currentOption, ...healthy]; - }, [currentOption, healthy]); - - return ( - - - CLI Configuration -

- Pick the CLI client + model + mode + flags this agent uses, or create a new profile - inline. -

-
- -
- - -
- - {selected && editorMode !== "edit" && ( - setEditorMode("edit")} - onCreateNew={() => setEditorMode("create")} - /> - )} - {!selected && editorMode !== "create" && ( - setEditorMode("create")} /> - )} - - {editorMode === "edit" && linkedProfile && ( - setEditorMode("closed")} - onSaved={(saved) => { - optimisticUpsert(setAgentProfiles, agentProfilesState, settingsAgents, saved); - onAgentProfileChange(saved.id); - setEditorMode("closed"); - }} - /> - )} - {editorMode === "create" && ( - setEditorMode("closed")} - onSaved={(saved) => { - optimisticUpsert(setAgentProfiles, agentProfilesState, settingsAgents, saved); - onAgentProfileChange(saved.id); - setEditorMode("closed"); - }} - /> - )} -
-
- ); -} - -function toCurrentAgentOption(profile: AgentProfile): AgentProfileOption { - const agentName = profile.agentDisplayName || profile.agentId || profile.name; - return { - id: profile.id, - label: `${agentName} • ${profile.name}`, - agent_id: profile.agentId || profile.id, - agent_name: agentName, - cli_passthrough: profile.cliPassthrough ?? false, - }; -} - -function findProfile( - agents: { id: string; profiles: AgentProfile[] }[], - profileId: string, -): AgentProfile | undefined { - for (const agent of agents) { - const found = agent.profiles.find((p) => p.id === profileId); - if (found) return found; - } - return undefined; -} - -function optimisticUpsert( - setAgentProfiles: (next: AgentProfileOption[]) => void, - current: AgentProfileOption[], - agents: { id: string; name: string }[], - saved: AgentProfile, -) { - const stub = agents.find((a) => a.id === saved.agentId) ?? { - id: saved.agentId ?? "", - name: saved.agentId ?? "", - }; - const option = toAgentProfileOption(stub, saved); - setAgentProfiles([...current.filter((p) => p.id !== option.id), option]); -} - -function ProfileSummary({ - option, - onEdit, - onCreateNew, -}: { - option: AgentProfileOption; - onEdit: () => void; - onCreateNew: () => void; -}) { - return ( -
-
- {option.agent_name} - {option.cli_passthrough ? CLI passthrough : null} -
-
- - -
-
- ); -} - -function UnassignedHint({ onCreate }: { onCreate: () => void }) { - return ( -
-

- No CLI profile selected. The agent will not be able to launch sessions until a profile is - picked. -

- -
- ); -} - -function InlineEditor({ - mode, - profile, - onClose, - onSaved, -}: { - mode: "create" | "edit"; - profile?: AgentProfile; - onClose: () => void; - onSaved: (saved: AgentProfile) => void; -}) { - return ( -
- -
- ); -} diff --git a/apps/web/app/office/agents/[id]/components/agent-configuration-tab.test.tsx b/apps/web/app/office/agents/[id]/components/agent-configuration-tab.test.tsx index 1bb329fd57..0dee1662c6 100644 --- a/apps/web/app/office/agents/[id]/components/agent-configuration-tab.test.tsx +++ b/apps/web/app/office/agents/[id]/components/agent-configuration-tab.test.tsx @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { StateProvider } from "@/components/state-provider"; +import { updateAgentProfile } from "@/lib/api/domains/office-api"; import type { AgentProfile } from "@/lib/state/slices/office/types"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; import { defaultOfficeState } from "@/lib/state/slices/office/office-slice"; @@ -51,7 +52,12 @@ const PROFILE_OPTION = { }; describe("AgentConfigurationTab", () => { - it("renders the CLI configuration card with the linked profile summary", () => { + it("reconciles the form with the canonical profile returned by the backend", async () => { + vi.mocked(updateAgentProfile).mockResolvedValueOnce({ + ...baseAgent, + agentProfileId: toAgentProfileId(baseAgent.id), + name: "Canonical CEO", + }); render( { , ); - expect(screen.getByText("CLI Configuration")).toBeTruthy(); - // Linked profile is surfaced with the CLI client badge. - expect(screen.getByText(CLAUDE_AGENT_ID)).toBeTruthy(); - }); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Local edit" } }); + fireEvent.click(screen.getByRole("button", { name: "Save Configuration" })); - it("uses the office agent row as the CLI profile when no legacy profile link exists", () => { - const orphan = { - ...baseAgent, - agentProfileId: undefined, - agentId: CLAUDE_AGENT_ID, - agentDisplayName: "Claude", - }; - render( - - - , - ); - - expect(screen.queryByText(/no cli profile selected/i)).toBeNull(); - expect(screen.getByText("Claude")).toBeTruthy(); + await waitFor(() => { + expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Canonical CEO"); + }); }); it("shows create-agent capability for CEO agents", () => { diff --git a/apps/web/app/office/agents/[id]/components/agent-configuration-tab.tsx b/apps/web/app/office/agents/[id]/components/agent-configuration-tab.tsx index 895ed58678..81d1164d99 100644 --- a/apps/web/app/office/agents/[id]/components/agent-configuration-tab.tsx +++ b/apps/web/app/office/agents/[id]/components/agent-configuration-tab.tsx @@ -11,8 +11,6 @@ import { toast } from "sonner"; import { useAppStore } from "@/components/state-provider"; import { updateAgentProfile } from "@/lib/api/domains/office-api"; import type { AgentProfile, AgentRole } from "@/lib/state/slices/office/types"; -import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; -import { AgentConfigCliCard } from "./agent-config-cli-card"; import { AgentRoutingCard } from "./agent-routing-card"; type AgentConfigurationTabProps = { @@ -52,7 +50,6 @@ const CAPABILITY_LABELS: Record = { type FormState = { name: string; role: AgentRole; - agentProfileId: string; budgetMonthlyCents: number; maxConcurrentSessions: number; executorType: string; @@ -62,7 +59,6 @@ function initialForm(agent: AgentProfile): FormState { return { name: agent.name, role: agent.role, - agentProfileId: agent.agentProfileId || agent.id, budgetMonthlyCents: agent.budgetMonthlyCents, maxConcurrentSessions: agent.maxConcurrentSessions, executorType: agent.executorPreference?.type ?? "", @@ -98,13 +94,13 @@ export function AgentConfigurationTab({ agent }: AgentConfigurationTabProps) { const update: Partial = { name: form.name, role: form.role, - agentProfileId: form.agentProfileId ? toAgentProfileId(form.agentProfileId) : undefined, budgetMonthlyCents: form.budgetMonthlyCents, maxConcurrentSessions: form.maxConcurrentSessions, executorPreference: form.executorType ? { type: form.executorType } : undefined, }; - await updateAgentProfile(agent.id, update); - updateStore(agent.id, update); + const saved = await updateAgentProfile(agent.id, update); + updateStore(agent.id, saved); + setForm(initialForm(saved)); setDirty(false); toast.success("Agent configuration updated"); } catch (err) { @@ -125,11 +121,6 @@ export function AgentConfigurationTab({ agent }: AgentConfigurationTabProps) { onRoleChange={(v) => patch({ role: v })} /> - patch({ agentProfileId: v })} - /> ; + return null; } const handleSave = async () => { @@ -132,23 +132,10 @@ function tierMissingMappingWarning( for (const providerId of order) { const profile = cfg.provider_profiles?.[providerId]; if (!profile) continue; - if (profile.tier_map?.[tier]) return null; + const executionProfileIDs = profile.execution_profile_ids ?? profile.tier_profile_ids; + if (executionProfileIDs?.[tier]) return null; } - return `No provider in the effective order has the ${tier} tier mapped. Configure a model for ${tier} on at least one provider in Workspace → Provider routing.`; -} - -function DisabledCard() { - return ( - - - Provider routing -

- Provider routing is off for this workspace. Enable it in Workspace → Provider routing to - override the agent's tier or fallback chain. -

-
-
- ); + return `No provider in the effective order has the ${tier} tier mapped. Select an execution profile for ${tier} on at least one provider in Workspace → Provider routing.`; } function Header() { @@ -156,8 +143,7 @@ function Header() { Provider routing

- Override the workspace's tier or fallback order for this agent. Inheriting keeps the - agent in step with workspace settings. + Override the workspace tier or provider order for this agent.

); diff --git a/apps/web/app/office/components/routing/route-panel.tsx b/apps/web/app/office/components/routing/route-panel.tsx index 6352f85a9a..77c618cde4 100644 --- a/apps/web/app/office/components/routing/route-panel.tsx +++ b/apps/web/app/office/components/routing/route-panel.tsx @@ -117,6 +117,9 @@ function DebugBlock({ attempt }: { attempt: RouteAttempt }) { return (
{attempt.error_confidence && } + {attempt.execution_profile_id && ( + + )} {attempt.adapter_phase && } {attempt.classifier_rule && } {typeof attempt.exit_code === "number" && } diff --git a/apps/web/app/office/workspace/routing/components/provider-tier-mapping.test.ts b/apps/web/app/office/workspace/routing/components/provider-tier-mapping.test.ts new file mode 100644 index 0000000000..ff490453b6 --- /dev/null +++ b/apps/web/app/office/workspace/routing/components/provider-tier-mapping.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import type { ExecutionProfileSummary, ProviderProfile } from "@/lib/state/slices/office/types"; +import { applyExecutionProfileSelection, profilesForProvider } from "./provider-tier-mapping"; + +const PROFILES: ExecutionProfileSummary[] = [ + { id: "codex-work", name: "Codex Work", provider_id: "codex-acp", model: "gpt-5.6" }, + { id: "claude-opus", name: "Claude Opus", provider_id: "claude-acp", model: "opus" }, +]; + +describe("provider tier profile selection", () => { + it("filters the catalogue by logical provider", () => { + expect(profilesForProvider(PROFILES, "claude-acp")).toEqual([PROFILES[1]]); + }); + + it("stores the execution profile and derives the model snapshot", () => { + const current: ProviderProfile = { + tier_map: { balanced: "stale-model" }, + tier_profile_ids: { frontier: "legacy-frontier" }, + mode: "legacy-mode", + flags: ["--legacy"], + env: { LEGACY: "1" }, + }; + expect(applyExecutionProfileSelection(current, PROFILES, "balanced", "claude-opus")).toEqual({ + tier_map: { balanced: "opus" }, + execution_profile_ids: { + frontier: "legacy-frontier", + balanced: "claude-opus", + }, + tier_profile_ids: undefined, + mode: undefined, + flags: undefined, + env: undefined, + }); + }); +}); diff --git a/apps/web/app/office/workspace/routing/components/provider-tier-mapping.tsx b/apps/web/app/office/workspace/routing/components/provider-tier-mapping.tsx index b6f60625cd..ae7b82466d 100644 --- a/apps/web/app/office/workspace/routing/components/provider-tier-mapping.tsx +++ b/apps/web/app/office/workspace/routing/components/provider-tier-mapping.tsx @@ -1,15 +1,18 @@ "use client"; -import { useState } from "react"; -import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; +import { useId } from "react"; import { Badge } from "@kandev/ui/badge"; -import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; -import { Button } from "@kandev/ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible"; -import type { ProviderProfile, Tier, TierMap } from "@/lib/state/slices/office/types"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import type { + ExecutionProfileSummary, + ProviderProfile, + Tier, + TierMap, +} from "@/lib/state/slices/office/types"; import { providerLabel } from "./provider-order-editor"; +const UNMAPPED = "__unmapped__"; const TIERS: Array<{ key: keyof TierMap; label: Tier }> = [ { key: "frontier", label: "frontier" }, { key: "balanced", label: "balanced" }, @@ -19,111 +22,102 @@ const TIERS: Array<{ key: keyof TierMap; label: Tier }> = [ type Props = { providerId: string; profile: ProviderProfile; + executionProfiles: ExecutionProfileSummary[]; defaultTier: Tier; onChange: (next: ProviderProfile) => void; disabled?: boolean; }; -export function ProviderTierMapping({ - providerId, - profile, - defaultTier, - onChange, - disabled, -}: Props) { - const setTier = (key: keyof TierMap, value: string) => - onChange({ - ...profile, - tier_map: { ...profile.tier_map, [key]: value }, - tier_profile_ids: { ...(profile.tier_profile_ids ?? {}), [key]: undefined }, - }); +export function ProviderTierMapping(props: Props) { + const fieldsetId = useId(); + const available = profilesForProvider(props.executionProfiles, props.providerId); + const selectedIDs = props.profile.execution_profile_ids ?? props.profile.tier_profile_ids ?? {}; + + const setTier = (tier: Tier, profileId: string) => { + props.onChange(applyExecutionProfileSelection(props.profile, available, tier, profileId)); + }; return (
-
-

{providerLabel(providerId)}

- {providerId} +
+

{providerLabel(props.providerId)}

+ {props.providerId}
- {TIERS.map(({ key, label }) => ( -
-
- - {label === defaultTier && !profile.tier_map[key] && ( - - Required - - )} + {TIERS.map(({ key, label }) => { + const value = selectedIDs[label] ?? UNMAPPED; + const selectId = `${fieldsetId}-${label}`; + return ( +
+
+ + {label === props.defaultTier && value === UNMAPPED && ( + + Required + + )} +
+
- setTier(key, e.target.value)} - placeholder={`${label} model id`} - disabled={disabled} - data-testid={`tier-input-${providerId}-${label}`} - /> -
- ))} + ); + })}
- + {available.length === 0 && ( +

+ No active execution profiles are available for this provider. +

+ )}
); } -function AdvancedSection({ - profile, - onChange, - disabled, -}: { - profile: ProviderProfile; - onChange: (next: ProviderProfile) => void; - disabled?: boolean; -}) { - const [open, setOpen] = useState(false); - return ( - - - - - -
- - onChange({ ...profile, mode: e.target.value })} - placeholder="default" - disabled={disabled} - className="mt-1" - /> -
-
- - - onChange({ - ...profile, - flags: e.target.value.trim() === "" ? undefined : e.target.value.split(/\s+/), - }) - } - placeholder="--space-separated --flags" - disabled={disabled} - className="mt-1" - /> -
-
-
- ); +export function profilesForProvider( + profiles: ExecutionProfileSummary[], + providerId: string, +): ExecutionProfileSummary[] { + return profiles.filter((item) => item.provider_id === providerId); +} + +export function applyExecutionProfileSelection( + profile: ProviderProfile, + available: ExecutionProfileSummary[], + tier: Tier, + profileId: string, +): ProviderProfile { + const selected = available.find((item) => item.id === profileId); + const selectedIDs = profile.execution_profile_ids ?? profile.tier_profile_ids ?? {}; + return { + ...profile, + tier_map: { ...profile.tier_map, [tier]: selected?.model }, + execution_profile_ids: { ...selectedIDs, [tier]: selected?.id }, + tier_profile_ids: undefined, + // Concrete execution profiles own CLI mode, flags, and environment. + // Clear conflicting legacy workspace-level copies on explicit selection. + mode: undefined, + flags: undefined, + env: undefined, + }; } diff --git a/apps/web/app/office/workspace/routing/components/routing-enable-card.tsx b/apps/web/app/office/workspace/routing/components/routing-enable-card.tsx index ef2647eb71..f011d3baeb 100644 --- a/apps/web/app/office/workspace/routing/components/routing-enable-card.tsx +++ b/apps/web/app/office/workspace/routing/components/routing-enable-card.tsx @@ -13,10 +13,10 @@ export function RoutingEnableCard({ enabled, onChange, disabled }: Props) {
-

Provider routing

+

Automatic provider fallback

- Route Office agents across multiple CLI providers with tier-based fallback. Disabled by - default; enabling does not affect existing kanban tasks. + When enabled, provider limits can move a run to the next configured execution profile. + When disabled, Office uses only the first provider for the selected tier.

void; knownProviders: string[]; + executionProfiles: ExecutionProfileSummary[]; saving: boolean; dirty: boolean; fieldErrors: string[]; @@ -113,6 +116,7 @@ function PageBody({ draft, setDraft, knownProviders, + executionProfiles, saving, dirty, fieldErrors, @@ -135,7 +139,7 @@ function PageBody({ const setProfile = (p: string, prof: ProviderProfile) => setDraft({ ...draft, provider_profiles: { ...draft.provider_profiles, [p]: prof } }); - const sectionsDisabled = !draft.enabled; + const sectionsDisabled = saving; return (
@@ -149,37 +153,34 @@ function PageBody({ - {draft.enabled && ( - <> - - - - {draft.provider_order.map((pid) => ( - setProfile(pid, prof)} - disabled={saving} - /> - ))} - - )} + + + + {draft.provider_order.map((pid) => ( + setProfile(pid, prof)} + disabled={saving} + /> + ))} diff --git a/apps/web/e2e/helpers/office-api-client.ts b/apps/web/e2e/helpers/office-api-client.ts index 420d50e319..0b2f00557c 100644 --- a/apps/web/e2e/helpers/office-api-client.ts +++ b/apps/web/e2e/helpers/office-api-client.ts @@ -270,10 +270,20 @@ export class OfficeApiClient { default_tier: "frontier" | "balanced" | "economy"; provider_profiles: Record< string, - { tier_map: { frontier?: string; balanced?: string; economy?: string }; mode?: string } + { + tier_map: { frontier?: string; balanced?: string; economy?: string }; + execution_profile_ids?: { frontier?: string; balanced?: string; economy?: string }; + } >; }; known_providers: string[]; + execution_profiles: Array<{ + id: string; + name: string; + provider_id: string; + model: string; + mode?: string; + }>; }> { return this.request("GET", `/workspaces/${wsId}/routing`); } @@ -291,7 +301,10 @@ export class OfficeApiClient { default_tier: "frontier" | "balanced" | "economy"; provider_profiles: Record< string, - { tier_map: { frontier?: string; balanced?: string; economy?: string }; mode?: string } + { + tier_map: { frontier?: string; balanced?: string; economy?: string }; + execution_profile_ids?: { frontier?: string; balanced?: string; economy?: string }; + } >; }, ): Promise> { @@ -333,6 +346,7 @@ export class OfficeApiClient { attempts: Array<{ seq: number; provider_id: string; + execution_profile_id: string; model: string; tier: string; outcome: string; diff --git a/apps/web/e2e/helpers/office-routing.ts b/apps/web/e2e/helpers/office-routing.ts new file mode 100644 index 0000000000..daec1122c1 --- /dev/null +++ b/apps/web/e2e/helpers/office-routing.ts @@ -0,0 +1,58 @@ +import type { ApiClient } from "./api-client"; +import type { OfficeApiClient } from "./office-api-client"; + +const PROFILE_WAIT_MS = 20_000; + +export async function balancedExecutionProfileRouting( + apiClient: ApiClient, + officeApi: OfficeApiClient, + workspaceId: string, + providerOrder: string[], +) { + const deadline = Date.now() + PROFILE_WAIT_MS; + while (Date.now() < deadline) { + const agents = await apiClient.listAgents(); + const profiles = await Promise.all( + providerOrder.map(async (providerId) => { + const agent = agents.agents.find((item) => item.name === providerId); + if (!agent) return undefined; + const existing = agent.profiles.find((profile) => profile.model !== ""); + if (existing) return { id: existing.id, model: existing.model }; + const model = `e2e-${providerId}-model`; + const created = await apiClient.createAgentProfile(agent.id, `E2E ${providerId}`, { + model, + }); + return { id: created.id, model }; + }), + ); + if (profiles.some((profile) => profile === undefined)) { + await new Promise((resolve) => setTimeout(resolve, 250)); + continue; + } + const routing = await officeApi.getRouting(workspaceId); + const selected = profiles.map((profile, index) => + routing.execution_profiles.find( + (candidate) => + candidate.provider_id === providerOrder[index] && candidate.id === profile!.id, + ), + ); + if (selected.every((profile) => profile !== undefined)) { + return { + enabled: true, + provider_order: providerOrder, + default_tier: "balanced" as const, + provider_profiles: Object.fromEntries( + selected.map((profile, index) => [ + providerOrder[index], + { + tier_map: { balanced: profile!.model }, + execution_profile_ids: { balanced: profile!.id }, + }, + ]), + ), + }; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`execution profiles did not become available for ${providerOrder.join(", ")}`); +} diff --git a/apps/web/e2e/tests/office/mobile-provider-routing-profiles.spec.ts b/apps/web/e2e/tests/office/mobile-provider-routing-profiles.spec.ts new file mode 100644 index 0000000000..5772c6f49f --- /dev/null +++ b/apps/web/e2e/tests/office/mobile-provider-routing-profiles.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "../../fixtures/office-fixture"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { balancedExecutionProfileRouting } from "../../helpers/office-routing"; + +test.describe("Office execution profile routing on mobile", () => { + test("keeps the first-provider profile selector usable with routing disabled", async ({ + testPage, + backend, + apiClient, + officeApi, + officeSeed, + }) => { + await backend.restart({ KANDEV_MOCK_PROVIDERS: "claude-acp" }); + const configured = await balancedExecutionProfileRouting( + apiClient, + officeApi, + officeSeed.workspaceId, + ["claude-acp"], + ); + await officeApi.updateRouting(officeSeed.workspaceId, { ...configured, enabled: false }); + + const routing = await officeApi.getRouting(officeSeed.workspaceId); + expect(routing.config.enabled).toBe(false); + + await testPage.goto("/office/workspace/routing"); + await expect( + testPage.getByRole("heading", { name: "Provider routing", exact: true }), + ).toBeVisible(); + await assertNoDocumentHorizontalOverflow(testPage, "provider routing editor"); + + const profileSelector = testPage.locator('[data-testid^="tier-profile-"]').first(); + await expect(profileSelector).toBeVisible(); + await profileSelector.click(); + await expect(testPage.getByRole("option").first()).toBeVisible(); + await assertNoDocumentHorizontalOverflow(testPage, "provider routing profile selector"); + }); +}); diff --git a/apps/web/e2e/tests/office/office-routing-agent-override.spec.ts b/apps/web/e2e/tests/office/office-routing-agent-override.spec.ts index c3fdcd34f8..bcc730bd60 100644 --- a/apps/web/e2e/tests/office/office-routing-agent-override.spec.ts +++ b/apps/web/e2e/tests/office/office-routing-agent-override.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "../../fixtures/office-fixture"; import type { APIRequestContext } from "@playwright/test"; +import { balancedExecutionProfileRouting } from "../../helpers/office-routing"; /** * Phase 7 spec #5 — agent-level provider override. @@ -55,6 +56,7 @@ test.describe("Office provider routing — agent override", () => { test("single-provider override never falls back outside the list", async ({ backend, + apiClient, request, officeApi, officeSeed, @@ -64,15 +66,13 @@ test.describe("Office provider routing — agent override", () => { KANDEV_PROVIDER_FAILURES: "claude-acp:quota_limited", }); - await officeApi.updateRouting(officeSeed.workspaceId, { - enabled: true, - provider_order: ["claude-acp", "codex-acp"], - default_tier: "balanced", - provider_profiles: { - "claude-acp": { tier_map: { balanced: "sonnet" }, mode: "default" }, - "codex-acp": { tier_map: { balanced: "gpt-5" }, mode: "default" }, - }, - }); + await officeApi.updateRouting( + officeSeed.workspaceId, + await balancedExecutionProfileRouting(apiClient, officeApi, officeSeed.workspaceId, [ + "claude-acp", + "codex-acp", + ]), + ); await patchAgentOverrides(request, backend.baseUrl, officeSeed.agentId, { provider_order_source: "override", diff --git a/apps/web/e2e/tests/office/office-routing-blocked.spec.ts b/apps/web/e2e/tests/office/office-routing-blocked.spec.ts index 0644c59a07..b8d873d7ea 100644 --- a/apps/web/e2e/tests/office/office-routing-blocked.spec.ts +++ b/apps/web/e2e/tests/office/office-routing-blocked.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../../fixtures/office-fixture"; +import { balancedExecutionProfileRouting } from "../../helpers/office-routing"; /** * Phase 7 spec #3 — every provider blocked on user-actionable code. @@ -30,6 +31,7 @@ test.describe("Office provider routing — blocked", () => { test("user-action codes on every provider park run as blocked", async ({ backend, + apiClient, officeApi, officeSeed, }) => { @@ -39,15 +41,13 @@ test.describe("Office provider routing — blocked", () => { KANDEV_PROVIDER_FAILURES: "claude-acp:auth_required,codex-acp:missing_credentials", }); - await officeApi.updateRouting(officeSeed.workspaceId, { - enabled: true, - provider_order: ["claude-acp", "codex-acp"], - default_tier: "balanced", - provider_profiles: { - "claude-acp": { tier_map: { balanced: "sonnet" }, mode: "default" }, - "codex-acp": { tier_map: { balanced: "gpt-5" }, mode: "default" }, - }, - }); + await officeApi.updateRouting( + officeSeed.workspaceId, + await balancedExecutionProfileRouting(apiClient, officeApi, officeSeed.workspaceId, [ + "claude-acp", + "codex-acp", + ]), + ); const task = (await officeApi.createTask(officeSeed.workspaceId, "Blocked test", { workflow_id: officeSeed.workflowId, diff --git a/apps/web/e2e/tests/office/office-routing-fallback.spec.ts b/apps/web/e2e/tests/office/office-routing-fallback.spec.ts index 667e1bc89f..a778e8fefc 100644 --- a/apps/web/e2e/tests/office/office-routing-fallback.spec.ts +++ b/apps/web/e2e/tests/office/office-routing-fallback.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../../fixtures/office-fixture"; +import { balancedExecutionProfileRouting } from "../../helpers/office-routing"; /** * Phase 7 spec #2 — provider fallback under quota_limited. @@ -36,22 +37,25 @@ test.describe("Office provider routing — fallback", () => { await backend.restart(); }); - test("claude-acp quota fails over to codex-acp", async ({ backend, officeApi, officeSeed }) => { + test("claude-acp quota fails over to codex-acp", async ({ + backend, + apiClient, + officeApi, + officeSeed, + }) => { test.setTimeout(90_000); await backend.restart({ KANDEV_MOCK_PROVIDERS: "claude-acp,codex-acp,opencode-acp", KANDEV_PROVIDER_FAILURES: "claude-acp:quota_limited", }); - await officeApi.updateRouting(officeSeed.workspaceId, { - enabled: true, - provider_order: ["claude-acp", "codex-acp"], - default_tier: "balanced", - provider_profiles: { - "claude-acp": { tier_map: { balanced: "sonnet" }, mode: "default" }, - "codex-acp": { tier_map: { balanced: "gpt-5" }, mode: "default" }, - }, - }); + const routingConfig = await balancedExecutionProfileRouting( + apiClient, + officeApi, + officeSeed.workspaceId, + ["claude-acp", "codex-acp"], + ); + await officeApi.updateRouting(officeSeed.workspaceId, routingConfig); const task = (await officeApi.createTask(officeSeed.workspaceId, "Fallback test", { workflow_id: officeSeed.workflowId, @@ -70,7 +74,12 @@ test.describe("Office provider routing — fallback", () => { // walk to complete first — 40s rides that out without affecting // the happy path (resolves in <5s in isolation). const deadline = Date.now() + 40_000; - let attempts: Array<{ provider_id: string; outcome: string; error_code?: string }> = []; + let attempts: Array<{ + provider_id: string; + execution_profile_id: string; + outcome: string; + error_code?: string; + }> = []; while (Date.now() < deadline) { const runs = (await officeApi.listRuns(officeSeed.workspaceId)) as { runs?: Array<{ id: string; task_id?: string }>; @@ -86,9 +95,16 @@ test.describe("Office provider routing — fallback", () => { expect(attempts.length).toBeGreaterThanOrEqual(2); expect(attempts[0].provider_id).toBe("claude-acp"); + expect(attempts[0].execution_profile_id).toBe( + routingConfig.provider_profiles["claude-acp"].execution_profile_ids.balanced, + ); expect(attempts[0].outcome).toBe("failed_provider_unavailable"); expect(attempts[0].error_code).toBe("quota_limited"); expect(attempts[1].provider_id).toBe("codex-acp"); + expect(attempts[1].execution_profile_id).toBe( + routingConfig.provider_profiles["codex-acp"].execution_profile_ids.balanced, + ); + expect(attempts[1].execution_profile_id).not.toBe(attempts[0].execution_profile_id); expect(attempts[1].outcome).toBe("launched"); const health = await officeApi.listRoutingHealth(officeSeed.workspaceId); diff --git a/apps/web/e2e/tests/office/office-routing-recovery.spec.ts b/apps/web/e2e/tests/office/office-routing-recovery.spec.ts index 0de48d21a0..bb91441a06 100644 --- a/apps/web/e2e/tests/office/office-routing-recovery.spec.ts +++ b/apps/web/e2e/tests/office/office-routing-recovery.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../../fixtures/office-fixture"; +import { balancedExecutionProfileRouting } from "../../helpers/office-routing"; /** * Phase 7 spec #4 — recovery from degraded state. @@ -30,6 +31,7 @@ test.describe("Office provider routing — recovery", () => { test("clearing the injection + retry returns provider to healthy", async ({ backend, + apiClient, officeApi, officeSeed, }) => { @@ -38,15 +40,13 @@ test.describe("Office provider routing — recovery", () => { KANDEV_MOCK_PROVIDERS: "claude-acp,codex-acp,opencode-acp", KANDEV_PROVIDER_FAILURES: "claude-acp:quota_limited", }); - await officeApi.updateRouting(officeSeed.workspaceId, { - enabled: true, - provider_order: ["claude-acp", "codex-acp"], - default_tier: "balanced", - provider_profiles: { - "claude-acp": { tier_map: { balanced: "sonnet" }, mode: "default" }, - "codex-acp": { tier_map: { balanced: "gpt-5" }, mode: "default" }, - }, - }); + await officeApi.updateRouting( + officeSeed.workspaceId, + await balancedExecutionProfileRouting(apiClient, officeApi, officeSeed.workspaceId, [ + "claude-acp", + "codex-acp", + ]), + ); const degradeTask = (await officeApi.createTask(officeSeed.workspaceId, "Degrade trigger", { workflow_id: officeSeed.workflowId, })) as { id?: string }; diff --git a/apps/web/hooks/domains/office/use-workspace-routing.test.tsx b/apps/web/hooks/domains/office/use-workspace-routing.test.tsx index 8805895732..fb318683aa 100644 --- a/apps/web/hooks/domains/office/use-workspace-routing.test.tsx +++ b/apps/web/hooks/domains/office/use-workspace-routing.test.tsx @@ -16,12 +16,13 @@ vi.mock("@/lib/api/domains/office-extended-api", () => ({ const setKnownProviders = vi.fn(); const setWorkspaceRouting = vi.fn(); +let routingByWorkspace: Record = {}; vi.mock("@/components/state-provider", () => ({ useAppStore: (sel: (state: unknown) => unknown) => sel({ office: { - routing: { byWorkspace: {}, knownProviders: [] }, + routing: { byWorkspace: routingByWorkspace, knownProviders: [] }, }, setKnownProviders, setWorkspaceRouting, @@ -32,6 +33,7 @@ describe("useWorkspaceRouting", () => { beforeEach(() => { setKnownProviders.mockReset(); setWorkspaceRouting.mockReset(); + routingByWorkspace = {}; mocks.getWorkspaceRouting.mockReset(); mocks.getWorkspaceRouting.mockResolvedValue({ config: { @@ -59,4 +61,27 @@ describe("useWorkspaceRouting", () => { unmount(); spy.mockRestore(); }); + + it("fetches execution profiles even when routing config is cached", async () => { + routingByWorkspace = { "ws-1": { enabled: false } }; + renderHook(() => useWorkspaceRouting("ws-1")); + await waitFor(() => expect(mocks.getWorkspaceRouting).toHaveBeenCalledWith("ws-1")); + }); + + it("discards an in-flight response after the workspace changes", async () => { + let resolveFirst!: (value: unknown) => void; + let resolveSecond!: (value: unknown) => void; + mocks.getWorkspaceRouting + .mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve))) + .mockReturnValueOnce(new Promise((resolve) => (resolveSecond = resolve))); + const { result, rerender } = renderHook( + ({ workspace }: { workspace: string }) => useWorkspaceRouting(workspace), + { initialProps: { workspace: "ws-1" } }, + ); + rerender({ workspace: "ws-2" }); + resolveFirst({ execution_profiles: [{ id: "stale" }] }); + resolveSecond({ execution_profiles: [{ id: "current" }] }); + + await waitFor(() => expect(result.current.executionProfiles).toEqual([{ id: "current" }])); + }); }); diff --git a/apps/web/hooks/domains/office/use-workspace-routing.ts b/apps/web/hooks/domains/office/use-workspace-routing.ts index 6b29eb6196..6e6cbea5b0 100644 --- a/apps/web/hooks/domains/office/use-workspace-routing.ts +++ b/apps/web/hooks/domains/office/use-workspace-routing.ts @@ -1,17 +1,18 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useAppStore } from "@/components/state-provider"; import { getWorkspaceRouting, retryProvider, updateWorkspaceRouting, } from "@/lib/api/domains/office-extended-api"; -import type { WorkspaceRouting } from "@/lib/state/slices/office/types"; +import type { ExecutionProfileSummary, WorkspaceRouting } from "@/lib/state/slices/office/types"; export type UseWorkspaceRoutingResult = { config: WorkspaceRouting | undefined; knownProviders: string[]; + executionProfiles: ExecutionProfileSummary[]; isLoading: boolean; error: string | null; refresh: () => Promise; @@ -27,28 +28,42 @@ export function useWorkspaceRouting(workspaceName: string | null): UseWorkspaceR const setWorkspaceRouting = useAppStore((s) => s.setWorkspaceRouting); const setKnownProviders = useAppStore((s) => s.setKnownProviders); const [isLoading, setIsLoading] = useState(false); + const [executionProfiles, setExecutionProfiles] = useState([]); + const [profilesWorkspace, setProfilesWorkspace] = useState(null); const [error, setError] = useState(null); + const requestVersion = useRef(0); const refresh = useCallback(async () => { if (!workspaceName) return; + const version = ++requestVersion.current; setIsLoading(true); setError(null); try { const res = await getWorkspaceRouting(workspaceName); + if (version !== requestVersion.current) return; if (res.config) setWorkspaceRouting(workspaceName, res.config); if (Array.isArray(res.known_providers)) setKnownProviders(res.known_providers); + setExecutionProfiles(Array.isArray(res.execution_profiles) ? res.execution_profiles : []); + setProfilesWorkspace(workspaceName); } catch (e) { + if (version !== requestVersion.current) return; setError(e instanceof Error ? e.message : "Failed to load routing config"); } finally { - setIsLoading(false); + if (version === requestVersion.current) setIsLoading(false); } }, [workspaceName, setWorkspaceRouting, setKnownProviders]); useEffect(() => { - if (!workspaceName) return; - if (config !== undefined) return; + if (!workspaceName) { + requestVersion.current += 1; + setExecutionProfiles([]); + setProfilesWorkspace(null); + setIsLoading(false); + return; + } + if (profilesWorkspace === workspaceName) return; void refresh(); - }, [workspaceName, config, refresh]); + }, [workspaceName, profilesWorkspace, refresh]); const update = useCallback( async (cfg: WorkspaceRouting) => { @@ -67,5 +82,5 @@ export function useWorkspaceRouting(workspaceName: string | null): UseWorkspaceR [workspaceName], ); - return { config, knownProviders, isLoading, error, refresh, update, retry }; + return { config, knownProviders, executionProfiles, isLoading, error, refresh, update, retry }; } diff --git a/apps/web/lib/api/domains/office-routing-api.ts b/apps/web/lib/api/domains/office-routing-api.ts index 07b74a523c..7b0c0b6443 100644 --- a/apps/web/lib/api/domains/office-routing-api.ts +++ b/apps/web/lib/api/domains/office-routing-api.ts @@ -3,6 +3,7 @@ import type { AgentRouteData, AgentRoutePreview, AgentRoutingOverrides, + ExecutionProfileSummary, ProviderHealth, WorkspaceRouting, } from "@/lib/state/slices/office/types"; @@ -12,6 +13,7 @@ const BASE = "/api/v1/office"; export type RoutingConfigResponse = { config: WorkspaceRouting | null; known_providers: string[]; + execution_profiles: ExecutionProfileSummary[]; }; export function getWorkspaceRouting(workspaceId: string, options?: ApiRequestOptions) { diff --git a/apps/web/lib/state/slices/office/types.ts b/apps/web/lib/state/slices/office/types.ts index 18bb5f81f3..1f58d603cb 100644 --- a/apps/web/lib/state/slices/office/types.ts +++ b/apps/web/lib/state/slices/office/types.ts @@ -505,12 +505,23 @@ export type TierMap = { export type ProviderProfile = { tier_map: TierMap; + execution_profile_ids?: Partial>; + /** @deprecated Accepted only while reading pre-migration routing payloads. */ tier_profile_ids?: Partial>; mode?: string; flags?: string[]; env?: Record; }; +export type ExecutionProfileSummary = { + id: string; + name: string; + provider_id: string; + model: string; + mode?: string; + workspace_id?: string; +}; + // Wake reasons the workspace can map onto specific tiers. v1 keeps the // surface narrow — only the three reasons that historically used the // cheap-profile shortcut. Adding more requires both a backend constant @@ -564,6 +575,7 @@ export type RouteAttemptOutcome = export type RouteAttempt = { seq: number; + execution_profile_id?: string; provider_id: string; model?: string; tier: Tier | ""; @@ -580,6 +592,7 @@ export type RouteAttempt = { }; export type ProviderModelPair = { + execution_profile_id?: string; provider_id: string; model: string; tier: Tier | ""; @@ -594,11 +607,13 @@ export type AgentRoutePreview = { // effective provider order, even when that provider is currently // skipped (degraded / missing mapping). primary_provider_id?: string; + primary_execution_profile_id?: string; primary_model?: string; // current_* reflects the candidate the next launch would actually // pick; equal to primary when not degraded. Empty when every // candidate is skipped. current_provider_id?: string; + current_execution_profile_id?: string; current_model?: string; fallback_chain: ProviderModelPair[]; missing: string[]; diff --git a/docs/decisions/2026-07-15-office-agent-execution-profile-routing.md b/docs/decisions/2026-07-15-office-agent-execution-profile-routing.md new file mode 100644 index 0000000000..21677b7694 --- /dev/null +++ b/docs/decisions/2026-07-15-office-agent-execution-profile-routing.md @@ -0,0 +1,99 @@ +# ADR-2026-07-15-office-agent-execution-profile-routing: Separate Office identity from routed execution profiles + +**Status:** proposed +**Date:** 2026-07-15 +**Area:** backend, frontend + +## Context + +ADR 0005 unified shallow CLI profiles and rich Office agents into one `agent_profiles` table. That removed duplicate identity rows and made Office agents usable by the shared task and workflow model. It also left one row carrying two responsibilities: + +1. Durable Office identity: name, role, hierarchy, instructions, skills, Office permissions, budget, status, and history. +2. Concrete execution configuration: CLI/provider registration, account environment, model, mode, ACP config options, CLI flags and permission behavior, passthrough mode, and MCP configuration. + +Provider routing currently changes a subset of responsibility (2) with model/mode/flags/environment overlays while launching the rich Office row. That is not sufficient for cross-provider fallback. A Claude profile and a Codex profile may use different credentials, CLI flags, permission behavior, config options, passthrough mode, and MCP configuration. Applying provider/model overlays to a Codex-authored base row can launch Claude with an invalid mixture. + +The same ambiguity also makes provider changes modify or copy fields on the Office row even though the user's intent is to keep a stable role and only change how that role executes. + +## Decision + +Keep the unified physical `agent_profiles` table from ADR 0005, but recognize two logical roles and carry both IDs through Office launches: + +- `agent_profile_id` is the stable Office identity for Office tasks. It owns role metadata, hierarchy, instructions, skills, Office permissions, budget, status, executor preference, task/run ownership, costs, and Office capabilities. +- `execution_profile_id` is the concrete profile selected for one launch. It owns the CLI/provider, credentials and environment, model, mode, ACP config options, CLI flags and CLI permission behavior, passthrough mode, and `agent_profile_mcp_configs` entry. + +Non-Office launches remain compatible by treating a missing distinct execution ID as `execution_profile_id = agent_profile_id`. + +### Routing references profiles + +Workspace provider-tier mappings store execution profile IDs as authoritative references. Provider and model are derived snapshots for validation, preview, health scope, and audit. Routing does not construct a launch by combining a base Office profile with provider-level mode/flags/environment overlays. + +The canonical JSON field is `execution_profile_ids`. The decoder accepts legacy `tier_profile_ids` during migration. Legacy model-only mappings are converted only when an active profile match is unique; otherwise settings show a missing/ambiguous mapping that requires user selection. + +An execution profile reference must: + +- exist and not be deleted; +- be a shallow runtime profile rather than a rich Office identity; +- be global or belong to the same workspace; +- carry a launchable CLI configuration; and +- resolve to the logical provider that owns the mapping. + +Profile deletion remains blocked while a routing mapping references it. + +### Tier selection is always active + +The workspace `enabled` flag controls automatic fallback, not whether Office uses tier profiles. + +- When `enabled=false`, resolution computes the effective tier and launches only the first provider's mapped execution profile. It does not health-filter or try another provider. +- When `enabled=true`, resolution walks the ordered healthy candidates and uses the existing degradation, retry, and parking policy. + +An invalid first mapping is an actionable configuration error. Office does not silently launch the workspace default profile. + +### Runtime and persistence boundary + +Task assignment and Office ownership continue to use the stable `agent_profile_id`. The concrete `execution_profile_id` is persisted on route attempts, runs, task sessions, and `executors_running`. + +Lifecycle resolution uses `execution_profile_id` for process configuration and `agent_profile_id` for Office materialization and authorization. Kandev-owned identity environment such as `KANDEV_AGENT_PROFILE_ID` cannot be overridden by an execution profile; an optional `KANDEV_EXECUTION_PROFILE_ID` exposes the selected runtime profile for diagnostics. + +Persisting the execution profile on `executors_running` binds provider-native resume state to the profile that created it. If fallback selects another profile, Kandev clears or ignores the prior ACP/session token, refreshes the execution snapshot, and starts a fresh provider-native session. + +### Cross-provider continuation + +Post-start fallback reuses the same task, run, task environment, and worktree. It reapplies the stable Office agent's instructions and skills and adds a continuation instruction telling the replacement agent to inspect task description, comments/messages, status, prior run state, and current git state before continuing. + +Provider-native chat state is not portable and is not transferred. Durable task state and repository state are the handoff contract. + +## Consequences + +### Positive + +- A custom Office role keeps its instructions, skills, permissions, budget, and history while switching providers. +- Codex-to-Claude fallback uses the complete Claude profile, including its credentials, flags, config options, permission behavior, and MCP servers. +- Route history and restart recovery identify the exact execution profile used. +- Existing task/workflow references remain stable because ADR 0005's unified table and Office identity IDs are preserved. + +### Costs + +- Launch APIs and persistence must carry two IDs explicitly. +- Routing validation now depends on loading referenced execution profiles. +- Session recovery must reject resume tokens when the execution profile changes. +- The routing UI must select profiles rather than accept model-only mappings. +- Existing copy-on-select behavior for Office agent configuration is superseded and must be removed or migrated without reverting unrelated fixes. + +## Alternatives considered + +### Reintroduce `office_agent_instances` + +Rejected. It creates a clean table boundary but reverses ADR 0005, duplicates identity across task/workflow systems, and requires broad foreign-key and API migration. Two explicit IDs provide the required runtime boundary without restoring the old table split. + +### Continue applying route overlays to the Office row + +Rejected. Provider/model/mode/flags/environment overlays cannot represent complete CLI profiles and can leak provider-specific credentials, config, permissions, passthrough behavior, or MCP configuration across a fallback. + +### Copy the selected execution profile into every Office agent + +Rejected. Copies drift, make provider switching mutate identity rows, and cannot represent an ordered fallback chain without repeatedly rewriting the Office agent. + +## Relationship to prior decisions + +This decision amends ADR 0005. The physical table unification and stable Office identity remain. The statement that one row is the complete launch source no longer applies to routed Office execution: a rich row is the logical identity, and a separately referenced profile is the concrete runtime source. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index d0b683a74a..7e36e909c7 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -49,3 +49,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0041 | [Backend-owned portable user settings](0041-backend-owned-portable-user-settings.md) | accepted | backend, frontend | 2026-07-15 | | 0042 | [Project shell output and fetch it on demand](0042-project-shell-output-and-fetch-on-demand.md) | accepted | backend, frontend, protocol | 2026-07-16 | | 0043 | [Plugins read/write kandev data via capability-gated Host gRPC RPCs](0043-plugin-host-data-api.md) | accepted | backend, protocol | 2026-07-17 | +| 2026-07-15-office-agent-execution-profile-routing | [Separate Office identity from routed execution profiles](2026-07-15-office-agent-execution-profile-routing.md) | proposed | backend, frontend | 2026-07-15 | diff --git a/docs/plans/office-execution-profile-routing/plan.md b/docs/plans/office-execution-profile-routing/plan.md new file mode 100644 index 0000000000..813b20fbca --- /dev/null +++ b/docs/plans/office-execution-profile-routing/plan.md @@ -0,0 +1,76 @@ +--- +spec: docs/specs/office/routing.md +decision: docs/decisions/2026-07-15-office-agent-execution-profile-routing.md +created: 2026-07-15 +status: completed +--- + +# Implementation Plan: Office Execution Profile Routing + +## Overview + +Preserve the rich Office `agent_profile_id` as the durable role identity while resolving a complete `execution_profile_id` for every Office launch. Make provider-tier mappings reference profiles, persist the concrete selection through sessions and executors, and make cross-provider fallback start a fresh provider-native session in the same task/worktree with an explicit durable-state continuation prompt. + +## Contract + +- Office identity owns role metadata, hierarchy, instructions, skills, Office permissions, budgets, status, executor preference, task/run ownership, costs, and capabilities. +- Execution profile owns provider CLI/account, model, mode, ACP config options, CLI flags and permission behavior, environment, passthrough, and MCP configuration. +- Tier profile resolution is always active. `enabled=false` selects only the first candidate; `enabled=true` permits health-aware fallback. +- Cross-profile fallback never reuses the previous profile's ACP/session token. +- Non-Office launches retain `execution_profile_id = agent_profile_id` compatibility. + +## Data And API + +- Make `execution_profile_ids` authoritative in `office_workspace_routing.provider_profiles`; decode legacy `tier_profile_ids` and migrate unambiguous model-only mappings. +- Add concrete execution profile summaries to routing APIs and replace raw tier model/config editing with profile selection. +- Add `execution_profile_id` to `task_sessions`, `executors_running`, `runs`, and `office_run_route_attempts` through replayable SQLite/Postgres migrations. +- Include the execution profile in route previews, run details, route attempts, and diagnostics. + +## Runtime + +- Carry stable Office ID and concrete execution ID from resolver through scheduler, orchestrator, executor, and lifecycle. +- Resolve process config, profile environment, CLI arguments, config options, passthrough, and MCP from the execution profile. +- Resolve skills, instructions, Office JWT/capabilities, budgets, and ownership from the Office identity. +- On profile change, clean up the previous process, suppress its resume token, retain task/worktree state, and launch with a continuation instruction. + +## Migration + +- Accept and rewrite legacy `tier_profile_ids` as `execution_profile_ids`. +- Match legacy model-only mappings to an active profile only when provider/model/mode identify exactly one profile. +- Surface missing or ambiguous profile mappings as actionable settings errors. +- Seed absent legacy workspace routing from existing Office configuration so upgrades do not silently change providers. +- Reconcile and supersede the current copy-on-select changes; preserve unrelated restart-name and session-assignee fixes. + +## Implementation Waves + +Wave 1: +- [x] [task-01-routing-profile-contract](task-01-routing-profile-contract.md) + +Wave 2: +- [x] [task-02-resolution-and-routing-persistence](task-02-resolution-and-routing-persistence.md) +- [x] [task-03-dual-id-session-plumbing](task-03-dual-id-session-plumbing.md) + +Wave 3: +- [x] [task-04-lifecycle-profile-ownership](task-04-lifecycle-profile-ownership.md) + +Wave 4: +- [x] [task-05-cross-provider-continuation](task-05-cross-provider-continuation.md) + +Wave 5: +- [x] [task-06-frontend-migration-and-qa](task-06-frontend-migration-and-qa.md) + +## Verification + +Run formatting before checks: + +```bash +make -C apps/backend fmt +make -C apps/backend test +make -C apps/backend lint +cd apps/web && pnpm run typecheck +cd apps && pnpm --filter @kandev/web test +cd apps && pnpm --filter @kandev/web lint +cd apps/web && pnpm e2e --grep "Office routing" +``` + +Focused tests run within each task before the full commands. Container-backed Codex-to-Claude fallback E2E runs when the existing Office E2E fixture can inject classified provider limits; otherwise a lifecycle integration test must cover the real profile/config/token boundary and the browser E2E covers configuration and telemetry. diff --git a/docs/plans/office-execution-profile-routing/task-01-routing-profile-contract.md b/docs/plans/office-execution-profile-routing/task-01-routing-profile-contract.md new file mode 100644 index 0000000000..3578cf18dd --- /dev/null +++ b/docs/plans/office-execution-profile-routing/task-01-routing-profile-contract.md @@ -0,0 +1,23 @@ +--- +id: "01-routing-profile-contract" +title: "Execution-profile routing contract" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/office/routing.md" +--- + +# Task 01: Execution-profile routing contract + +**Acceptance:** routing JSON stores authoritative execution profile IDs; legacy keys decode safely; validation rejects deleted, cross-workspace, unlaunchable, or wrong-provider profiles; routing GET returns selectable profile summaries; deletion guards use the authoritative references. + +**TDD cases:** canonical/legacy JSON round-trip; unique legacy provider/model migration; missing and ambiguous migration; provider-key mismatch; global and same-workspace compatibility; referenced-profile deletion. + +**Likely files:** `apps/backend/internal/office/routing/types.go`, routing validators/profile lookup helpers, `apps/backend/internal/office/repository/sqlite/workspace_routing.go`, `apps/backend/internal/office/dashboard/handler_routing.go`, routing DTOs, settings deletion guards, and focused tests. + +**Verification:** focused routing/repository/dashboard/settings Go tests, then `make -C apps/backend test` for affected packages. + +**Dependencies:** None. + +**Output contract:** summarize schema/API compatibility, migration outcomes, tests, files, and remaining risks. diff --git a/docs/plans/office-execution-profile-routing/task-02-resolution-and-routing-persistence.md b/docs/plans/office-execution-profile-routing/task-02-resolution-and-routing-persistence.md new file mode 100644 index 0000000000..efc64fbbac --- /dev/null +++ b/docs/plans/office-execution-profile-routing/task-02-resolution-and-routing-persistence.md @@ -0,0 +1,23 @@ +--- +id: "02-resolution-and-routing-persistence" +title: "Resolver and route persistence" +status: done +wave: 2 +depends_on: ["01-routing-profile-contract"] +plan: "plan.md" +spec: "../../specs/office/routing.md" +--- + +# Task 02: Resolver and route persistence + +**Acceptance:** each candidate carries `execution_profile_id`; provider/model are derived snapshots; disabled routing selects exactly the first effective-tier profile without fallback; enabled routing preserves ordered health-aware fallback; runs and route attempts persist and expose the execution profile. + +**TDD cases:** wake-reason and agent tier precedence; disabled single-candidate behavior; enabled fallback order; excluded/degraded candidates; missing first profile; run/attempt round-trip and restart recovery. + +**Likely files:** `apps/backend/internal/office/routing/resolver.go`, `apps/backend/internal/office/scheduler/dispatch_routing.go`, `apps/backend/internal/office/models/models.go`, `apps/backend/internal/office/repository/sqlite/{base.go,base_migrations.go,run_routing.go,route_attempts.go}`, dashboard route/run DTOs, and tests. + +**Verification:** focused routing/scheduler/office repository tests; SQLite and Postgres replay migration tests where supported. + +**Dependencies:** Task 01. + +**Output contract:** report candidate semantics, persisted fields, migrations, tests, and any compatibility fallback used. diff --git a/docs/plans/office-execution-profile-routing/task-03-dual-id-session-plumbing.md b/docs/plans/office-execution-profile-routing/task-03-dual-id-session-plumbing.md new file mode 100644 index 0000000000..2da52b513d --- /dev/null +++ b/docs/plans/office-execution-profile-routing/task-03-dual-id-session-plumbing.md @@ -0,0 +1,23 @@ +--- +id: "03-dual-id-session-plumbing" +title: "Dual-ID session plumbing" +status: done +wave: 2 +depends_on: ["01-routing-profile-contract"] +plan: "plan.md" +spec: "../../specs/office/routing.md" +--- + +# Task 03: Dual-ID session plumbing + +**Acceptance:** scheduler, orchestrator, executor, and lifecycle launch contracts carry both IDs explicitly; task/session ownership remains keyed by Office identity; session and `executors_running` snapshots persist the execution profile; non-Office launch behavior remains compatible. + +**TDD cases:** Office identity differs from execution profile; direct non-Office launch aliases IDs; session ensure/reuse keeps Office ownership; executor snapshot round-trip; restart recovers the concrete profile; cross-workspace execution profile rejected before launch. + +**Likely files:** Office scheduler starter interfaces, `apps/backend/internal/backendapp/{main.go,adapters.go}`, orchestrator task operations, orchestrator executor request plumbing, task models/API DTOs, task SQLite session/executor schema and migrations, runtime launch types, and tests. + +**Verification:** focused scheduler/backendapp/orchestrator/task repository/runtime contract tests plus migration replay coverage. + +**Dependencies:** Task 01. Can run in parallel with Task 02 after the candidate contract is fixed. + +**Output contract:** report the two-ID call chain, persistence changes, compatibility behavior, tests, and unresolved call sites. diff --git a/docs/plans/office-execution-profile-routing/task-04-lifecycle-profile-ownership.md b/docs/plans/office-execution-profile-routing/task-04-lifecycle-profile-ownership.md new file mode 100644 index 0000000000..d58a98dae2 --- /dev/null +++ b/docs/plans/office-execution-profile-routing/task-04-lifecycle-profile-ownership.md @@ -0,0 +1,23 @@ +--- +id: "04-lifecycle-profile-ownership" +title: "Lifecycle profile ownership" +status: done +wave: 3 +depends_on: ["02-resolution-and-routing-persistence", "03-dual-id-session-plumbing"] +plan: "plan.md" +spec: "../../specs/office/routing.md" +--- + +# Task 04: Lifecycle profile ownership + +**Acceptance:** runtime uses the execution profile for CLI/provider, credentials/env, model, mode, config options, CLI flags and permissions, passthrough, and MCP; it uses the Office identity for instructions, skills, Office JWT/capabilities, budgets, ownership, and executor preference; Kandev-owned identity env cannot be overridden. + +**TDD cases:** Codex identity with Claude execution profile launches Claude config only; execution-profile MCP/config options/passthrough/permissions apply; Office skills/instructions and capability claims remain; environment precedence protects `KANDEV_*`; non-Office launch remains unchanged. + +**Likely files:** lifecycle launch/profile/environment/passthrough/MCP/skill deployment paths, executor Office preparation, Office runtime token/capability code, and integration tests around the assembled launch request and command. + +**Verification:** focused lifecycle and executor tests, including real profile resolution through the settings repository. + +**Dependencies:** Tasks 02 and 03. + +**Output contract:** enumerate every field by owner, tests proving no cross-profile leakage, files changed, and residual runtime risks. diff --git a/docs/plans/office-execution-profile-routing/task-05-cross-provider-continuation.md b/docs/plans/office-execution-profile-routing/task-05-cross-provider-continuation.md new file mode 100644 index 0000000000..3902bbce55 --- /dev/null +++ b/docs/plans/office-execution-profile-routing/task-05-cross-provider-continuation.md @@ -0,0 +1,23 @@ +--- +id: "05-cross-provider-continuation" +title: "Cross-provider continuation" +status: done +wave: 4 +depends_on: ["04-lifecycle-profile-ownership"] +plan: "plan.md" +spec: "../../specs/office/routing.md" +--- + +# Task 05: Cross-provider continuation + +**Acceptance:** a classified post-start provider limit can requeue the same run on the next execution profile; profile changes clean up the old process and never reuse its native resume token; the task environment/worktree and Office identity remain stable; the replacement receives an explicit inspect-and-continue instruction. + +**TDD cases:** Codex limit falls back to Claude Opus; Claude receives no Codex ACP token or config; same-profile recovery may resume; worktree/session ownership remains stable; ambiguous post-start failures do not fallback; exhausted routes park as before; continuation prompt references durable task/comments/status/git state without claiming chat transfer. + +**Likely files:** Office scheduler routing lifecycle/error handling, orchestrator/lifecycle resume logic, `executors_running` recovery, Office prompt builder, route telemetry, and integration tests. + +**Verification:** focused scheduler/orchestrator/lifecycle tests including process cleanup and resume-token guards. + +**Dependencies:** Task 04. + +**Output contract:** report fallback state transitions, token guard evidence, continuation behavior, tests, and provider-specific limitations. diff --git a/docs/plans/office-execution-profile-routing/task-06-frontend-migration-and-qa.md b/docs/plans/office-execution-profile-routing/task-06-frontend-migration-and-qa.md new file mode 100644 index 0000000000..46e3cee73b --- /dev/null +++ b/docs/plans/office-execution-profile-routing/task-06-frontend-migration-and-qa.md @@ -0,0 +1,23 @@ +--- +id: "06-frontend-migration-and-qa" +title: "Frontend, migration cleanup, and QA" +status: done +wave: 5 +depends_on: ["05-cross-provider-continuation"] +plan: "plan.md" +spec: "../../specs/office/routing.md" +--- + +# Task 06: Frontend, migration cleanup, and QA + +**Acceptance:** routing settings select concrete profiles per provider/tier and show profile/provider/model/account summaries; agent surfaces retain stable Office identity and expose resolved execution profile/fallback; onboarding stores authoritative profile IDs; copy-on-select behavior is removed or migrated; desktop and mobile layouts remain usable; all repository verification passes. + +**TDD/E2E cases:** profile picker filtering and invalid mapping states; disabled first-profile preview; enabled Codex-to-Claude route preview and telemetry; onboarding round-trip across restart; custom CTO identity unchanged after provider switch; mobile routing editor; no regression in direct Office task session selection or restart name preservation. + +**Likely files:** Office routing types/store/hooks, routing page and provider-tier mapping, onboarding tier selectors, agent routing card/route strip, run detail attempts, the current dirty agent configuration handler/service/component changes, frontend unit tests, and Office Playwright specs. + +**Verification:** `make -C apps/backend fmt`, backend test/lint, web typecheck/unit/lint, targeted desktop and mobile Playwright Office routing flows, and container-backed fallback E2E when supported. + +**Dependencies:** Task 05. + +**Output contract:** summarize user-visible changes, migration cleanup, all verification commands/results, files, screenshots/E2E evidence, and remaining limitations. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index 4f61ee2e57..bc2d6f38a7 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -250,7 +250,7 @@ "id": "office-mode", "audiences": ["user", "operator"], "stability": "experimental", - "docs": ["coordination", "feature-status"], + "docs": ["coordination", "feature-status", "office-provider-routing"], "sources": ["apps/backend/internal/office/routes.go", "apps/web/src/office-routes.tsx"], "tests": ["apps/backend/internal/office/onboarding/service_test.go", "apps/web/src/office-routes.test.ts"] }, diff --git a/docs/public/index.md b/docs/public/index.md index 447658dbcd..56e2b1d235 100644 --- a/docs/public/index.md +++ b/docs/public/index.md @@ -44,7 +44,7 @@ These pages describe the current source tree and its development conventions: The regular Kanban workbench, task sessions, review surfaces, and task-scoped Kandev MCP are the supported product path. Some capabilities still depend on a particular agent, executor, provider, platform, credential, or install channel; those differences are called out on their topic pages. -**Office is separate, disabled in the production runtime profile, feature-flagged, and still in progress.** Its source, tests, specifications, and internal plans do not make persistent teams, routines, budgets, or coordinator-led autonomy a supported production contract. See [Feature status](feature-status.md). +**Office is separate, disabled in the production runtime profile, feature-flagged, and still in progress.** Its source, tests, specifications, and internal plans do not make persistent teams, routines, budgets, or coordinator-led autonomy a supported production contract. See [Feature status](feature-status.md) for that boundary and [Office provider routing](office-provider-routing.md) for the feature-flagged routing surface. ## Match docs to the running version diff --git a/docs/public/meta.json b/docs/public/meta.json index 4460ca5062..03116c9244 100644 --- a/docs/public/meta.json +++ b/docs/public/meta.json @@ -41,6 +41,8 @@ "extending-kandev", "add-agent-cli", "release-process", + "---Office---", + "office-provider-routing", "---Protocol Reference---", "websocket-api" ] diff --git a/docs/public/office-provider-routing.md b/docs/public/office-provider-routing.md new file mode 100644 index 0000000000..bb1ef22586 --- /dev/null +++ b/docs/public/office-provider-routing.md @@ -0,0 +1,37 @@ +--- +title: "Office Provider Routing" +description: "Route persistent Office agent identities through interchangeable provider execution profiles." +--- + +# Office Provider Routing + +Office provider routing separates an agent's persistent role from the CLI profile used to run it. This lets a CTO, reviewer, or other custom Office agent keep the same instructions, skills, permissions, budget, task ownership, and history while Kandev selects a Codex, Claude Code, or another configured execution profile for each launch. + +Office mode is currently feature-flagged. The routing settings appear under **Office > Routing** when Office is enabled. + +## Configure Routes + +1. Create the concrete agent profiles you want to launch. Each profile contains one provider CLI, account environment, model, mode, flags, permission behavior, passthrough setting, and MCP configuration. +2. Open **Office > Routing**. +3. Add providers in the order Kandev should consider them. +4. For each provider, select an existing runtime execution profile for the Frontier, Balanced, and Economy tiers you want that provider to serve. Rich Office agent identities are not valid routing targets. +5. Choose the workspace default tier and save. +6. Optionally override the tier or provider order on an individual Office agent. + +The selectors store execution-profile IDs. Model names shown in the editor are derived from those profiles; entering a model name alone is not enough to define a launch. + +## Fallback Behavior + +**Automatic provider fallback off** still uses the first configured execution profile for the requested tier. Kandev does not health-filter the route or try later providers. + +**Automatic provider fallback on** evaluates configured profiles in order. A provider that reports a recognized authentication, quota, rate-limit, outage, subscription, or model-availability failure can be degraded and skipped for later launches. Kandev then tries the next healthy configured profile. Agent-level provider-order overrides constrain this list; an agent pinned to Claude does not fall back to Codex. + +If no route is available, Kandev keeps the task assigned to the same Office agent and waits for provider capacity or user action. Retryable limits are retried automatically after their retry time. Credential and configuration failures remain blocked until corrected or retried manually. + +## What A Provider Switch Preserves + +Kandev preserves the Office agent ID, task, run, task environment, worktree, instructions, and skills. The new execution profile supplies the provider CLI, account, model, mode, flags, environment, permission behavior, passthrough setting, and MCP configuration. + +A provider-native session token is never reused by a different execution profile. Cross-provider fallback starts a fresh provider session and tells the new agent to inspect the durable task conversation, run state, and repository state before continuing. Provider chat history itself is not transferred. + +For example, a custom CTO can normally run through a Codex GPT profile. When that route reaches its usage limit, an enabled fallback chain can launch a Claude Code Opus profile while the CTO's role, five assigned skills, instructions, task, and current worktree remain unchanged. diff --git a/docs/specs/office/agents.md b/docs/specs/office/agents.md index 39aa5dd7aa..88c2256e84 100644 --- a/docs/specs/office/agents.md +++ b/docs/specs/office/agents.md @@ -8,17 +8,17 @@ owner: cfl ## Why -Kandev has agent profiles (configuration templates: model, CLI flags, mode) but no concept of a persistent, stateful agent entity. Without agent instances, there is no hierarchy, no delegation, no budget tracking per agent, no autonomous coordination, and no operational visibility into how an agent runs over time. +Kandev has execution profiles (configuration templates for a concrete CLI, account, model, flags, environment, and MCP setup) but also needs persistent, stateful Office agents. Without a stable Office identity, switching a provider also risks switching or copying the agent's role, instructions, skills, permissions, budget, and history. -Office introduces agent instances: long-lived entities that reference an agent profile for execution config but carry their own identity, role, permissions, skills, instructions, and runtime state. They run inside a narrow capability-scoped runtime, call kandev through a structured CLI rather than raw curl, and expose per-agent dashboards with run history, costs, and per-run detail pages. +Office therefore treats a workspace-scoped rich `agent_profiles` row as a stable Office identity and selects a separate concrete execution profile for each launch. Both row types remain in the unified table established by ADR 0005, but they have different responsibilities. Office agents run inside a narrow capability-scoped runtime, call kandev through a structured CLI rather than raw curl, and expose per-agent dashboards with run history, costs, and per-run detail pages. ## What ### Agent instances -- An agent instance is a persistent entity distinct from `AgentProfile`. -- `AgentProfile` remains unchanged: it describes how to launch a specific agent CLI (model, flags, mode). -- An agent instance references a profile via `agent_profile_id` and adds: +- An Office agent is a persistent `agent_profiles` row scoped by `workspace_id`; its row ID is the logical `agent_profile_id` referenced by assignments, instructions, skills, budgets, permissions, and Office history. +- A launch separately resolves an `execution_profile_id` from the agent's effective tier and provider order. The referenced execution profile owns the concrete CLI runtime configuration. +- The Office identity owns: - **Name**: human-readable label ("CEO", "Frontend Worker", "QA Bot"). - **Role**: `ceo`, `worker`, `specialist`, `assistant`, or `reviewer`. Determines default permissions and UI treatment. - **Status**: `idle`, `working`, `paused`, `stopped`, plus transitional `pending_approval`. @@ -29,7 +29,9 @@ Office introduces agent instances: long-lived entities that reference an agent p - **Icon**: avatar for UI display. - **Executor preference**: optional executor override for this agent. - **Channels**: optional external messaging channels (Telegram, Slack). -- Multiple instances can share the same profile (e.g. three "Claude Sonnet" workers with different skills and budgets). +- Multiple Office agents can use the same execution profile without sharing Office instructions, skills, permissions, budgets, or history. +- Changing an agent's tier or provider order changes future execution-profile resolution without changing the Office identity. +- Provider routing and failover are specified in [routing](./routing.md). ### Hierarchy @@ -103,9 +105,17 @@ office_agent_runtime Runtime state must survive restarts (a budget-paused agent stays paused). Not user-editable, not exported. On startup, the reconciliation service merges filesystem config with this DB state: missing runtime rows are created with `status=idle`; orphaned rows (no YAML) are deleted. -### Agent profiles +### Office identities and execution profiles -Stay in the existing `agent_profiles` DB table. Referenced by `agent_profile_id` from the agent YAML. Managed via the existing settings UI (`/settings/agents/`). +Office identities and execution profiles stay in the existing `agent_profiles` DB table, as required by ADR 0005, but launch resolution does not treat them as the same logical object: + +- A workspace-scoped rich row is the Office identity. Its `agent_profile_id` remains stable across provider changes and is used for hierarchy, instructions, skills, Office permissions, budgets, costs, and task/run ownership. +- A concrete execution profile is selected per launch and recorded as `execution_profile_id`. It owns the registered CLI/provider, credentials and environment, model, mode, ACP config options, CLI flags, CLI permission behavior, passthrough mode, and MCP configuration. +- Global shallow profiles are the normal execution-profile choices. Existing same-workspace rows may remain selectable for upgrade compatibility when they carry a valid CLI configuration. +- An execution profile from another workspace cannot be selected. +- Non-Office launches remain compatible: when no distinct execution profile is supplied, runtime treats `execution_profile_id = agent_profile_id`. + +The CLI-shaped columns on rich Office rows remain in the schema for compatibility with ADR 0005 and existing data, but they are not authoritative for routed Office launches. New Office configuration changes select tiers and provider order instead of copying CLI runtime fields into the identity row. ### Instructions @@ -503,7 +513,8 @@ Skill list (name, description, source type, which agents use each skill), inline What survives a kandev backend restart: -- **Agent identity and configuration** persist in `agent_profiles` (office rows: `workspace_id != '' AND deleted_at IS NULL`). Name, role, icon, `reports_to`, permissions JSON, budget cents, `max_concurrent_sessions`, `cooldown_sec`, `desired_skills`, `skill_ids`, `executor_preference`, and `failure_threshold` are all durable. The same row is the canonical "agent profile" — there is no longer a separate agent-profile/agent-instance split (ADR 0005 Wave G). +- **Agent identity** persists in `agent_profiles` (Office rows: `workspace_id != '' AND deleted_at IS NULL`). Name, role, icon, `reports_to`, Office permissions, budget, concurrency/cooldown settings, skills, instructions, executor preference, and failure policy are durable on the stable identity. Startup profile reconciliation must not replace an Office agent's name with a generated model label. +- **Execution configuration** is referenced, not copied, for Office launches. The resolved `execution_profile_id` supplies the provider CLI, account environment, model, mode, ACP config options, CLI flags and permissions, passthrough behavior, and MCP configuration. Route attempts and sessions retain that ID for audit and restart recovery. - **Runtime status** persists in `office_agent_runtime` (PK `agent_id`). On restart a `paused` agent stays `paused`, `pause_reason` (e.g. `"budget"`) is preserved, and `last_run_finished_at` is retained so the cooldown guard works across restarts. - **Reconciliation at startup**: `infra.Reconciler.ReconcileAll` (called once during boot) drops `office_agent_runtime` rows whose `agent_id` no longer exists in `agent_profiles`, deletes `office_channels` and `office_budget_policies` rows that reference removed agents/projects, and seeds default routine triggers for routines without one. Reconciliation is best-effort: any sub-step that errors is logged but does not block boot. - **Hire requests** persist as `pending_approval` agent rows plus an approval entry in the inbox. A restart mid-hire leaves the approval visible; the user can still approve or reject and the same activation/deletion paths run. @@ -528,6 +539,12 @@ There are no TTLs on agent rows, runtime rows, instructions, skills, run history - **GIVEN** a workspace with no agent instances, **WHEN** the user creates a CEO instance (selecting a profile, role=ceo), **THEN** the instance appears in the agents list with status `idle` and the sidebar shows it under "Agents". +- **GIVEN** an existing CLI profile with a custom mode, permission behavior, flags, config options, environment variables, and MCP setup, **WHEN** routing selects it for an Office launch, **THEN** that complete execution profile is used while the Office name, role, instructions, skills, permissions, budget, and history remain unchanged. + +- **GIVEN** the same Office agent later resolves from a Codex execution profile to a Claude execution profile, **WHEN** the new route launches, **THEN** the task and Office identity remain stable, Claude receives its own complete runtime configuration, and no Codex-native resume token is sent to Claude. + +- **GIVEN** an Office task assigned directly to an Office agent and no existing task session, **WHEN** the task session is prepared, **THEN** the assignee's stable `agent_profile_id` is used before the workspace default so the session can start. + - **GIVEN** a running CEO instance, **WHEN** the CEO determines a task requires a frontend specialist and no suitable worker exists, **THEN** the CEO submits a hire request for a new worker with appropriate skills, and the request appears in the user's inbox as a pending approval. - **GIVEN** a pending hire approval, **WHEN** the user approves it, **THEN** the new agent activates (status=idle), appears in the org tree under the CEO, and the CEO receives a wakeup notification. diff --git a/docs/specs/office/overview.md b/docs/specs/office/overview.md index 10cacd06d6..9522dbf28d 100644 --- a/docs/specs/office/overview.md +++ b/docs/specs/office/overview.md @@ -91,7 +91,7 @@ Why DB-first: - Cloud-ready: shared PostgreSQL for team/SaaS works without filesystem coordination. - Atomic operations (budget checks, wakeup claims, approval flows) stay transactional. - No fsnotify / reload / cache-invalidation complexity. -- Agent profiles continue to live in the existing kandev DB tables, referenced by ID. +- Office agents are workspace-scoped rich rows in the existing `agent_profiles` table and are referenced by their stable canonical row ID. Concrete launches separately record the routed `execution_profile_id`. Filesystem layout: @@ -131,7 +131,7 @@ Filesystem layout: **Skill injection**: skills for agent sessions are written into the agent's worktree (CWD) before each session. Skill content can come from the DB (inline skills created via UI), the filesystem (imported from GitHub/skills.sh), or bundled (shipped with the kandev binary). All routes inject into the agent-specific skill path under the worktree. See [office agents](./agents.md#skill-injection). -**Agent profiles** (model, CLI flags, MCP servers) stay in the existing kandev `agent_profiles` DB table. Office agent instances reference profiles by ID. Filesystem export format: +**Office identities and execution profiles** share the existing kandev `agent_profiles` DB table but have separate responsibilities. The workspace-scoped rich row owns Office identity and metadata; provider routing references a concrete execution profile for each launch instead of copying runtime configuration into the Office row. Filesystem export format: ```yaml # agents/ceo.yml @@ -167,9 +167,9 @@ When a user opens `/office`, the backend checks both DB and filesystem state via **On "Create & Launch"** the following are created in a single transaction: 1. Office workspace: `kandev.yml` on filesystem + DB row in `workspaces` + system office workflow (7 steps). -2. CEO agent instance with `role=ceo`, full permissions, linked profile, bundled skills (kandev-protocol, memory). +2. CEO Office identity with `role=ceo`, full Office permissions, and bundled skills (kandev-protocol, memory). 3. Agent runtime row `status=idle` in `office_agent_runtime`. -4. Workspace provider routing seed: disabled by default, default tier persisted from the coordinator tier selector, Frontier / Balanced / Economy mapped from the selected source profiles, and profile IDs recorded as tier metadata so profile deletion is blocked while a tier still references them. +4. Workspace provider routing seed: automatic fallback disabled by default, default tier persisted from the coordinator tier selector, and Frontier / Balanced / Economy mapped to authoritative execution profile IDs so launches use the complete selected CLI configuration and profile deletion is blocked while referenced. 5. First task if not skipped: assigned to the CEO, `status=todo`; a `task_assigned` wakeup is enqueued so the scheduler picks it up. The task brief tells the CEO to create the required projects, including one per repository, and propose follow-up tasks for human approval before creating them. 6. Onboarding state marked completed in the DB. diff --git a/docs/specs/office/routing.md b/docs/specs/office/routing.md index 43bbc23593..9300b8cde8 100644 --- a/docs/specs/office/routing.md +++ b/docs/specs/office/routing.md @@ -6,18 +6,19 @@ owner: cfl # Office Provider Routing -Provider routing lets a workspace map abstract model tiers (Frontier / Balanced / Economy) to concrete CLI providers, configure an ordered fallback chain, and degrade providers automatically when they hit auth / quota / rate / outage limits. This spec describes the routing contract: tier mapping, fallback chain, provider health checks, the degraded state lifecycle, and the wake-reason tier policy. +Provider routing lets a workspace map abstract model tiers (Frontier / Balanced / Economy) to concrete execution profiles, configure an ordered provider fallback chain, and degrade routes automatically when they hit auth / quota / rate / outage limits. An execution profile is a complete launch configuration, not a model-only overlay. This spec describes the routing contract: Office identity preservation, execution-profile selection, fallback, provider health, degraded-state lifecycle, and wake-reason tier policy. ## Why -Office agents need a predictable way to choose between CLI providers and model strengths without manually cloning every agent profile for every provider. Users also need controlled fallback when a provider hits subscription or rate limits, while keeping important agents pinned to a specific provider when desired. +Office agents need a predictable way to choose between CLI providers, accounts, and model strengths without cloning their role configuration. Users also need controlled fallback when a provider hits subscription or rate limits while preserving the Office agent's instructions, skills, permissions, budget, task, and worktree. ## What ### Enablement and tiering -- Provider routing is an advanced workspace setting and is disabled by default. -- When provider routing is disabled, Office uses the concrete CLI profile chosen for the agent exactly as it does today. +- Provider routing is an advanced workspace setting and automatic fallback is disabled by default. +- Office always resolves an execution profile from the agent's effective tier, even when automatic fallback is disabled. +- When routing is disabled, Office selects only the first configured provider in the effective provider order for that tier. It does not health-filter, try a later provider, or silently use a workspace default profile. - Every Office agent has an effective model tier even when routing is disabled. - Workspace settings provide the default model tier, initially `balanced`. - Agents inherit the workspace default tier unless the user sets an agent-specific override. @@ -25,19 +26,26 @@ Office agents need a predictable way to choose between CLI providers and model s ### Provider order and tier mapping - Workspace settings can define a global provider order, for example `claude -> codex -> opencode`. -- Workspace settings can map each provider to three model tiers: `frontier`, `balanced`, and `economy`. +- Workspace settings map each provider and tier to an existing execution profile. The selected profile supplies the provider CLI, account environment, model, mode, ACP config options, CLI flags and permission behavior, passthrough mode, and MCP configuration. - Tier labels are user-facing as Frontier, Balanced, and Economy. - Example tier mappings include Claude `frontier=opus`, `balanced=sonnet`, `economy=haiku`, and Codex `frontier=gpt-5.5 high`, `balanced=gpt-5.4`, `economy=gpt-5.3 mini`. - Each Office agent can inherit workspace routing or override it. - Agent overrides can choose a model tier and provider order independently; an override order with only `claude` never falls back to `codex` or `opencode`. -- Users configure provider tier mappings explicitly; the UI does not silently apply recommended presets. +- Users configure provider tier profiles explicitly; the UI does not silently apply recommended presets or accept a raw model string as a complete route. - Onboarding lets the user choose one concrete CLI profile for the coordinator and one source profile for each workspace tier: Frontier, Balanced, and Economy. Office expands those source profiles into provider/model tier mappings so enabling routing later does not require editing every agent. +- Persisted routing keys use the provider's logical registry name (for example `claude-acp`), resolved from the source profile's parent agent. Database UUIDs are never valid provider IDs. - The tier profile selectors are profile-family choices, not direct agent assignments. When the CEO creates a worker, QA, or specialist agent and assigns a tier, the agent inherits the workspace tier family selected during onboarding or later routing settings. -- Source profile IDs used to seed tier mappings are recorded as routing metadata. Deleting a profile that is still referenced by a workspace tier returns an in-use error until the mapping is changed; manually editing a tier model in routing settings clears the source-profile association for that tier. +- Execution profile IDs are the authoritative tier mappings. Provider and model labels shown by routing are derived snapshots from the referenced profile. +- Deleting a profile that is still referenced by a workspace tier returns an in-use error until the mapping is changed. +- A referenced profile must exist, be active, belong to the same workspace or be global, carry a launchable CLI configuration, and resolve to the logical provider whose routing entry contains it. ### Run resolution -- Run launch resolves a logical Office agent to an effective provider/model only when provider routing is enabled. +- Every Office run has two explicit identities: stable `agent_profile_id` for the Office agent and concrete `execution_profile_id` for the selected launch profile. +- Run launch resolves the effective tier in this order: wake-reason policy (agent override > workspace policy) -> agent tier override -> workspace default tier. +- It then resolves the effective provider order and loads the execution profile referenced by each provider's effective-tier mapping. +- `enabled=false` returns only the first configured candidate and disables automatic fallback. `enabled=true` returns the ordered healthy candidates and preserves the existing degradation/parking policy. +- Missing, deleted, cross-workspace, wrong-provider, or ambiguous execution-profile mappings are actionable configuration errors. They never fall through to the workspace default agent profile. - Provider adapters classify launch and runtime failures into normalized provider-routing error codes using structured signals, provider-specific message patterns, and adapter phase context. - Unknown failures in provider-owned phases can fall back as low-confidence `unknown_provider_error` events, with raw evidence preserved for later classifier updates. @@ -48,6 +56,9 @@ Office agents need a predictable way to choose between CLI providers and model s - When a provider route fails again during a retry/probe, the same task immediately tries the next configured provider candidate and the failed provider route stays degraded with an increased backoff. - If every configured provider route is unavailable, the task keeps its logical Office agent assignment and waits for provider capacity or user action instead of being reassigned. - If at least one exhausted provider route is auto-retryable, the scheduler wakes the blocked task automatically at the earliest retry time. +- When fallback selects a different execution profile after work has started, Kandev reuses the task, run, task environment, and worktree but starts a fresh provider-native session. A resume token created by one execution profile is never supplied to another. +- The fallback launch reapplies the Office agent's instructions and skills and adds a continuation instruction telling the new agent to inspect the durable task description, comments/messages, status, run state, and current git worktree before continuing. +- Provider-native chat history is not transferred across providers. Durable task state and repository state are the handoff contract. ### Provider health checks and degraded state @@ -75,7 +86,7 @@ Office agents need a predictable way to choose between CLI providers and model s ## Data model -Provider routing persists three things: the workspace's routing config, per-provider health rows, and per-run routing telemetry. Agent overrides ride on the existing `agent_profiles.settings` JSON blob (no new table). +Provider routing persists the workspace routing config, concrete execution-profile decisions, per-provider health rows, and per-run routing telemetry. Agent overrides ride on the stable Office identity's `agent_profiles.settings` JSON blob (no new identity table). ### `office_workspace_routing` (per workspace) @@ -87,12 +98,39 @@ office_workspace_routing enabled int 0|1 default_tier string frontier|balanced|economy provider_order text JSON array of provider IDs - provider_profiles text JSON map: provider_id -> {tier_map, tier_profile_ids, mode, flags, env} + provider_profiles text JSON map: provider_id -> {execution_profile_ids} tier_per_reason text JSON map: wake_reason -> tier (NOT NULL, "{}" default) updated_at timestamp ``` -`provider_profiles[pid].tier_map` is `{frontier, balanced, economy}` with each value either a model ID or empty string ("skip this provider for this tier"). `provider_profiles[pid].tier_profile_ids` is optional metadata with the source profile ID that authored each tier mapping; routing resolution ignores it, but profile deletion checks use it to prevent orphaned tier/profile associations. `tier_per_reason` keys are restricted to `heartbeat | routine_trigger | budget_alert` (see `routing.AllWakeReasons`); other keys are rejected by `routing.ValidateWorkspaceConfig`. +`provider_profiles[pid].execution_profile_ids` is `{frontier, balanced, economy}` with each value either an active execution profile ID or empty string ("skip this provider for this tier"). It is authoritative at runtime. The backend derives provider/model display snapshots by loading the referenced profile; routing does not persist independent mode, flags, environment, permissions, or MCP overlays. + +For upgrade compatibility the decoder accepts the legacy `tier_profile_ids` key and writes the canonical `execution_profile_ids` key. Legacy `tier_map` entries are migrated only when Kandev can find exactly one active execution profile with the same logical provider and model (and mode when present). Missing or ambiguous matches surface as configuration errors that require profile selection. A workspace with no routing row is seeded from its existing Office configuration so an upgrade does not silently switch providers. + +`tier_per_reason` keys are restricted to `heartbeat | routine_trigger | budget_alert` (see `routing.AllWakeReasons`); other keys are rejected by `routing.ValidateWorkspaceConfig`. + +### Dual identity persistence + +The stable Office identity remains in existing `agent_profile_id` columns. Concrete launch choice is stored separately: + +``` +task_sessions + agent_profile_id string stable Office identity + execution_profile_id string concrete profile for the current execution + +executors_running + agent_profile_id string stable Office identity where already present + execution_profile_id string profile that owns the process and resume token + +runs + agent_profile_id string stable Office identity + resolved_execution_profile_id string concrete profile that launched + +office_run_route_attempts + execution_profile_id string concrete profile tried by this attempt +``` + +`task_sessions.execution_profile_id` and `executors_running.execution_profile_id` are required for restart recovery. A provider-native resume token is valid only when the stored execution profile matches the profile selected for the next launch. Existing non-Office sessions may use `execution_profile_id = agent_profile_id`. ### `office_provider_health` (per workspace, per provider, per scope) @@ -126,6 +164,7 @@ runs (routing additions) requested_tier string the tier the resolver consumed (override > default) resolved_provider_id string the provider that actually launched resolved_model string model on the launched provider + resolved_execution_profile_id string concrete profile that supplied the launch configuration current_route_attempt_seq int monotonically bumped by IncrementRouteAttemptSeq route_cycle_baseline_seq int seq floor for the current retry cycle; attempts at or below the baseline are NOT in the exclude-set routing_blocked_status string waiting_for_provider_capacity | blocked_provider_action_required @@ -142,6 +181,7 @@ office_run_route_attempts run_id string PK part, FK -> runs.id seq int PK part provider_id string + execution_profile_id string model string tier string outcome string succeeded|failed|skipped @@ -190,7 +230,7 @@ type ResolveOptions struct { } type Resolution struct { - Enabled bool // false short-circuits to legacy launch + FallbackEnabled bool // false returns only the first configured candidate RequestedTier Tier ProviderOrder []ProviderID Candidates []Candidate // ordered, exclude-set-filtered @@ -199,7 +239,7 @@ type Resolution struct { } ``` -`Candidate` carries `(ProviderID, Model, Tier, Mode, Flags, Env)` — self-describing so the launch path never reaches back into routing config. +`Candidate` carries `(ExecutionProfileID, ProviderID, Model, Tier)`. `ProviderID` and `Model` are audit/display snapshots derived from the selected execution profile. The runtime resolves the complete launch configuration from `ExecutionProfileID`; routing never combines a base profile with mode/flags/env overlays from another provider. `routingerr.ProviderProber` (`internal/agent/runtime/routingerr/probe.go`) is the optional cheap-availability check: @@ -221,7 +261,7 @@ Probers must complete in under five seconds and MUST NOT start an agent session. | Method | Path | Body | Response | |---|---|---|---| -| GET | `/workspaces/:wsId/routing` | – | `{config: WorkspaceConfig, known_providers: [ProviderID]}` | +| GET | `/workspaces/:wsId/routing` | – | `{config: WorkspaceConfig, known_providers: [ProviderID], execution_profiles: [ExecutionProfileSummary]}` | | PUT | `/workspaces/:wsId/routing` | `WorkspaceConfig` | `204` on success, `400` with `{error, field, details[]}` on validation failure | | POST | `/workspaces/:wsId/routing/retry` | `{provider_id}` | `{status: "probed"\|"retrying", retry_at?}` | | GET | `/workspaces/:wsId/routing/health` | – | `{health: [ProviderHealth]}` (non-healthy rows only) | @@ -230,7 +270,7 @@ Probers must complete in under five seconds and MUST NOT start an agent session. The per-agent override blob is written via the existing `PATCH /agents/:id` endpoint (mounted in `internal/office/agents`): the request body's `settings` JSON round-trips the `"routing"` key. Validation runs `routing.ValidateAgentOverridesAgainstWorkspace`, which adds a cross-check that the overridden tier is mapped on at least one provider in the effective order so save-time errors surface immediately instead of at the next launch. -`PUT /workspaces/:wsId/routing` enabling routing requires (a) non-empty `provider_order`, (b) every provider in the order has a registered `ProviderProfile`, (c) every provider in the order maps `DefaultTier` to a model. Disabling routing requires no fields. Material config changes (provider order, default tier, provider profiles) trigger `ClearAllParkedRoutingForWorkspace`, which re-queues every parked run in the workspace. +`PUT /workspaces/:wsId/routing` always validates the first provider's execution profile for every effective tier that can launch. Enabling automatic fallback additionally validates every provider candidate in the order. Each referenced profile must exist, be active, be a shallow runtime profile rather than a rich Office identity, be global or same-workspace, and resolve to the provider key that contains it. Material config changes (provider order, default tier, execution profile mappings) trigger `ClearAllParkedRoutingForWorkspace`, which re-queues every parked run in the workspace. ### WS event types (forwarded to clients by the office WS broadcaster) @@ -281,19 +321,23 @@ Routing is a workspace-admin surface; agents themselves do not call any routing - **Workspace routing config (`office_workspace_routing`)**: durable. Survives restart. When the row is missing, `GetWorkspaceRouting` synthesises the spec defaults in-memory and the caller may upsert later. - **Provider health (`office_provider_health`)**: durable. `state`, `retry_at`, `backoff_step`, `last_failure`, `last_success`, and the sanitized `raw_excerpt` survive restart, so a degraded provider stays degraded until a successful probe / launch / user retry, even across crashes. Healthy rows are not persisted (they are pruned to the healthy state, and `ListProviderHealth` filters them out at the SQL layer). -- **Run routing decision**: `runs.logical_provider_order`, `requested_tier`, `resolved_provider_id`, `resolved_model`, `current_route_attempt_seq`, `route_cycle_baseline_seq`, `routing_blocked_status`, `earliest_retry_at`, `scheduled_retry_at` are durable. A parked run still re-dispatches after a restart because `scheduled_retry_at` re-enters the scheduler's eligibility filter. -- **Route attempts (`office_run_route_attempts`)**: durable. Every attempt row is written before the next candidate is tried, so post-start fallback reasoning (the exclude-set is `seq > route_cycle_baseline_seq`) survives restart. +- **Run routing decision**: `runs.logical_provider_order`, `requested_tier`, `resolved_execution_profile_id`, `resolved_provider_id`, `resolved_model`, `current_route_attempt_seq`, `route_cycle_baseline_seq`, `routing_blocked_status`, `earliest_retry_at`, `scheduled_retry_at` are durable. A parked run still re-dispatches after a restart because `scheduled_retry_at` re-enters the scheduler's eligibility filter. +- **Route attempts (`office_run_route_attempts`)**: durable. Every attempt records `execution_profile_id` before the next candidate is tried, so post-start fallback reasoning and the exact CLI profile used survive restart. +- **Session/executor binding**: `task_sessions.execution_profile_id` and `executors_running.execution_profile_id` bind process state and provider-native resume tokens to the concrete profile that created them. A profile change clears or ignores incompatible resume state. - **Agent overrides**: durable via `agent_profiles.settings` JSON. Round-trips other settings keys untouched. - **Backoff jitter**: deterministic-ish (±25% via the process-default rand source); not seeded across restarts. The base `backoff_step` is durable, so the schedule rung resumes from where it left off, but the exact jitter offset is reset. - **Prober registry (`routingerr.RegisterProber`)**: process-local, in-memory only. Probers must re-register on every boot via package-init or DI wiring. There is no on-disk record of which providers have probers. - **Provider order / tier-mapping changes that materially alter routing decisions trigger `ClearAllParkedRoutingForWorkspace`**: parked runs are re-queued and resolution runs fresh against the new config. False-positive clears (changes that could not have affected the block reason) are harmless because runs simply re-park with the latest verdict. -- **Workspace flips enabled `true -> false`**: `ClearAllParkedRoutingForWorkspace` re-queues every parked run so they unstick via the legacy non-routing dispatch path on the next scheduler tick. +- **Workspace flips enabled `true -> false`**: `ClearAllParkedRoutingForWorkspace` re-queues every parked run. The next dispatch still resolves the effective tier but tries only the first provider's execution profile. See also: [`office/runtime.md`](runtime.md) for how the runtime classifies and surfaces individual errors, and the office scheduler (`internal/office/scheduler/`) for the dispatcher that consumes `Resolver.Resolve`. ## Scenarios -- **GIVEN** provider routing is disabled, **WHEN** the coordinator agent launches, **THEN** it uses the concrete profile copied during onboarding with its selected model and mode. +- **GIVEN** automatic provider routing is disabled, **WHEN** the coordinator agent launches, **THEN** it resolves the effective tier and launches only the first provider's referenced execution profile without trying a fallback. +- **GIVEN** a custom CTO Office agent has five skills and custom instructions and its Frontier order is `codex -> claude`, with Codex GPT-5.6 and Claude Opus execution profiles, **WHEN** Codex is healthy, **THEN** the CTO launches through the full Codex profile while retaining its Office identity configuration. +- **GIVEN** that Codex execution hits a classified five-hour usage limit after starting work, **WHEN** routing falls back to Claude, **THEN** Kandev starts a fresh Claude-native session in the same task and worktree using the full Claude profile, reapplies the CTO instructions and skills, and tells Claude to inspect durable task and git state before continuing. +- **GIVEN** that cross-provider fallback, **WHEN** the Claude process starts, **THEN** it does not receive the Codex ACP resume token or Codex-specific environment, flags, permissions, config options, passthrough setting, or MCP configuration. - **GIVEN** workspace routing is enabled with `claude -> codex -> opencode` and tier `balanced`, **WHEN** an agent without overrides launches, **THEN** it first tries the Claude balanced model and may fall back through the remaining providers on provider-limit errors. - **GIVEN** the CEO agent overrides provider order to `claude` and tier `frontier`, **WHEN** Claude is rate-limited, **THEN** the CEO run does not try Codex or OpenCode and follows the normal failure/escalation path. - **GIVEN** a worker agent inherits workspace routing, **WHEN** the workspace default tier changes from `frontier` to `balanced`, **THEN** future worker runs use the balanced tier without editing the worker. @@ -310,7 +354,8 @@ See also: [`office/runtime.md`](runtime.md) for how the runtime classifies and s - **GIVEN** all configured providers require user action, **WHEN** a task exhausts the route list, **THEN** the task is blocked until the user reconnects, configures a provider, fixes model mappings, or manually retries. - **GIVEN** exhausted provider routes include both auto-retryable and user-actionable failures, **WHEN** the task is blocked, **THEN** the UI shows the earliest automatic retry and the user actions needed for the blocked routes. - **GIVEN** a provider is unavailable for a workspace, **WHEN** the provider health state changes, **THEN** the dashboard and inbox show an actionable issue listing affected agents and routes. -- **GIVEN** onboarding creates a new workspace, **WHEN** setup completes, **THEN** provider routing is disabled but the selected Frontier / Balanced / Economy source profiles are expanded into the workspace routing seed. +- **GIVEN** onboarding creates a new workspace, **WHEN** setup completes, **THEN** automatic fallback is disabled but the selected Frontier / Balanced / Economy execution profile IDs are stored in the workspace routing seed. +- **GIVEN** those execution profiles reference provider rows by database UUID, **WHEN** onboarding writes the routing seed, **THEN** provider order is keyed by each provider's logical registry name and the execution profile IDs remain the authoritative tier mappings. - **GIVEN** an agent profile is referenced by a workspace tier, **WHEN** the user tries to delete that profile, **THEN** deletion returns an in-use error until the tier mapping is changed. - **GIVEN** routing is enabled and the workspace's `tier_per_reason.heartbeat = economy`, **WHEN** a heartbeat run launches, **THEN** the resolver picks the Economy tier model regardless of the agent's default tier. - **GIVEN** the security agent overrides wake-reason tiers with `heartbeat = frontier`, **WHEN** its heartbeat fires, **THEN** it uses Frontier even though the workspace policy says Economy. @@ -318,9 +363,9 @@ See also: [`office/runtime.md`](runtime.md) for how the runtime classifies and s ## Out of scope -- Requiring every agent to be edited before provider routing can be enabled. +- Creating a new Office identity table or reversing ADR 0005's physical table unification. - Routing non-Office kanban sessions. -- Changing onboarding to require provider-routing setup. +- Transferring provider-native conversation history between different CLIs. - Shipping recommended provider/model tier presets. - Cost optimization beyond user-selected tiers and provider order. - Per-wake-reason policy for reasons outside `{heartbeat, routine_trigger, budget_alert}` in v1. Future work could extend the set.