From bd899e84fcb40ab25defe20cac9f7bfc453b5e53 Mon Sep 17 00:00:00 2001 From: ayattara Date: Mon, 7 Sep 2026 01:00:09 +0000 Subject: [PATCH] fix(workflow): isolate exact model lane entry --- apps/backend/internal/backendapp/adapters.go | 2 + .../orchestrator/event_handlers_workflow.go | 40 +++++++++++ ...rs_workflow_profile_session_policy_test.go | 66 +++++++++++++++++++ .../orchestrator/executor/executor.go | 2 + 4 files changed, 110 insertions(+) diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index 563fab46e7..d46776054f 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -920,6 +920,8 @@ func (a *lifecycleAdapter) ResolveAgentProfile(ctx context.Context, profileID st AgentName: info.AgentName, Model: info.Model, Mode: info.Mode, + FallbackModel: info.FallbackModel, + AutoFallback: info.AutoFallback, ConfigOptions: info.ConfigOptions, AutoApprove: info.AutoApprove, DangerouslySkipPermissions: info.DangerouslySkipPermissions, diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 59ce3b3920..c6e45d7aab 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -2781,6 +2781,14 @@ func (s *Service) prepareWorkflowStepSession( effectiveProfile := s.resolveStepAgentProfile(ctx, step) if effectiveProfile == "" || effectiveProfile == session.AgentProfileID { if effectiveProfile != "" { + if s.workflowEntryRequiresFreshExactModelSession(ctx, session, step, sourceStep, effectiveProfile) { + endPolicy := s.resolveStepProfileSessionEndPolicy(sourceStep) + newSession, err := s.createNewSessionForStepWithEndPolicy(ctx, taskID, session, effectiveProfile, endPolicy) + if err != nil { + return nil, false, err + } + return newSession, true, nil + } s.tagSessionAsWorkflowSwitched(ctx, session.ID) } if !session.IsPrimary { @@ -2808,6 +2816,38 @@ func (s *Service) prepareWorkflowStepSession( return newSession, true, nil } +// workflowEntryRequiresFreshExactModelSession prevents a workflow lane from +// resuming a parked session whose persisted provider model disagrees with the +// same exact profile's configured model. Explicit session overrides remain +// valid within a lane; this boundary applies only while entering a different +// workflow step and only to profiles that did not authorize a fallback. +func (s *Service) workflowEntryRequiresFreshExactModelSession( + ctx context.Context, + session *models.TaskSession, + step, sourceStep *wfmodels.WorkflowStep, + profileID string, +) bool { + if session == nil || step == nil || sourceStep == nil || sourceStep.ID == step.ID || profileID == "" { + return false + } + profile, err := s.agentManager.ResolveAgentProfile(ctx, profileID) + if err != nil || profile == nil || profile.Model == "" || profile.AutoFallback || profile.FallbackModel != "" { + return false + } + effective, ok := models.LoadEffectiveSessionRuntimeConfig(session) + if !ok || effective.Model == "" || effective.Model == profile.Model { + return false + } + s.logger.Info("creating fresh workflow session for exact model identity", + zap.String("session_id", session.ID), + zap.String("profile_id", profileID), + zap.String("source_step_id", sourceStep.ID), + zap.String("target_step_id", step.ID), + zap.String("configured_model", profile.Model), + zap.String("persisted_model", effective.Model)) + return true +} + func (s *Service) preflightWorkflowStepCredentials( ctx context.Context, taskID string, diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_test.go index f12556260c..8d1c26526b 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_session_policy_test.go @@ -651,6 +651,72 @@ func TestPrepareWorkflowStepSession_QAToPRDoesNotReuseSolRuntime(t *testing.T) { require.Error(t, err, "the destination must not inherit the QA ACP resume identity before launch") } +// TestPrepareWorkflowStepSession_HumanQAToWorkReplacesExactProfileRuntimeOverride +// covers a parked primary whose profile ID is already the Work profile but +// whose prior Human-QA turn persisted a different runtime model. Entering an +// exact Work lane must create a clean session before any auto-start prompt can +// resume that stale ACP identity. +func TestPrepareWorkflowStepSession_HumanQAToWorkReplacesExactProfileRuntimeOverride(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + fixture.agentMgr.resolveProfileInfo = &executor.AgentProfileInfo{ + Model: "gpt-5.6-terra", + } + fixture.current.AgentProfileID = "terra-work-profile" + fixture.current.AgentProfileSnapshot = map[string]interface{}{"model": "gpt-5.6-terra"} + fixture.current.Metadata = map[string]interface{}{ + models.SessionMetaKeyRuntimeConfig: models.SessionRuntimeConfig{Model: "gpt-5.6-luna"}, + } + require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current)) + + workStep := &wfmodels.WorkflowStep{ + ID: "step-work", + WorkflowID: "wf1", + AgentProfileID: "terra-work-profile", + ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse, + } + humanQAStep := &wfmodels.WorkflowStep{ + ID: "step-human-qa", WorkflowID: "wf1", AgentProfileID: "terra-work-profile", + ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyPark, + } + + workSession, switched, err := fixture.svc.prepareWorkflowStepSession(ctx, "t1", fixture.current, workStep, humanQAStep) + require.NoError(t, err) + require.True(t, switched) + require.NotNil(t, workSession) + require.NotEqual(t, fixture.current.ID, workSession.ID) + require.Equal(t, "terra-work-profile", workSession.AgentProfileID) + _, inheritedRuntime := models.LoadSessionRuntimeConfig(workSession.Metadata) + require.False(t, inheritedRuntime, "a Work launch must not inherit Human-QA's Luna runtime model") + + previous, err := fixture.repo.GetTaskSession(ctx, fixture.current.ID) + require.NoError(t, err) + require.False(t, previous.IsPrimary) +} + +func TestPrepareWorkflowStepSession_HumanQAToWorkKeepsAuthorizedFallbackSession(t *testing.T) { + ctx := context.Background() + fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark) + fixture.agentMgr.resolveProfileInfo = &executor.AgentProfileInfo{ + Model: "gpt-5.6-terra", FallbackModel: "gpt-5.6-luna", + } + fixture.current.AgentProfileID = "terra-with-luna-fallback" + fixture.current.Metadata = map[string]interface{}{ + models.SessionMetaKeyRuntimeConfig: models.SessionRuntimeConfig{Model: "gpt-5.6-luna"}, + } + require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current)) + + workStep := &wfmodels.WorkflowStep{ + ID: "step-work", WorkflowID: "wf1", AgentProfileID: "terra-with-luna-fallback", + } + humanQAStep := &wfmodels.WorkflowStep{ID: "step-human-qa", WorkflowID: "wf1"} + + workSession, switched, err := fixture.svc.prepareWorkflowStepSession(ctx, "t1", fixture.current, workStep, humanQAStep) + require.NoError(t, err) + require.False(t, switched) + require.Equal(t, fixture.current.ID, workSession.ID) +} + func TestSwitchSessionForStep_NewOnStartParkOnEndRoundTrip(t *testing.T) { ctx := context.Background() fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyNew, models.WorkflowProfileSessionEndPolicyPark) diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index db2729c4d4..5ca7903de4 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -380,6 +380,8 @@ type AgentProfileInfo struct { AgentName string Model string Mode string + FallbackModel string + AutoFallback bool ConfigOptions map[string]string AutoApprove bool DangerouslySkipPermissions bool