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 (
-
- Pick the CLI client + model + mode + flags this agent uses, or create a new profile
- inline.
-
- No CLI profile selected. The agent will not be able to launch sessions until a profile is - picked. -
- -- Provider routing is off for this workspace. Enable it in Workspace → Provider routing to - override the agent's tier or fallback chain. -
-- 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.
{providerLabel(providerId)}
- {providerId} +{providerLabel(props.providerId)}
+ {props.providerId}+ No active execution profiles are available for this provider. +
+ )}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.