fix: recover Dynamic workflow completion after preflight failure (#3929)
This commit is contained in:
@@ -13,3 +13,6 @@ type RepositoryPreparationError = lifecycle.RepositoryPreparationError
|
||||
// ErrCancelEscalated reports that cancellation released local admission after
|
||||
// the provider failed to acknowledge the cancellation within its bound.
|
||||
var ErrCancelEscalated = lifecycle.ErrCancelEscalated
|
||||
|
||||
// ErrVirtualProfile marks a logical routing profile that has no concrete execution profile.
|
||||
var ErrVirtualProfile = lifecycle.ErrVirtualProfile
|
||||
|
||||
@@ -4063,6 +4063,9 @@ func (s *Service) sessionHasUnauthorizedExactModelDrift(
|
||||
}
|
||||
profile, err := s.agentManager.ResolveAgentProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
if errors.Is(err, agentruntime.ErrVirtualProfile) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("resolve exact workflow profile %q: %w", profileID, err)
|
||||
}
|
||||
if profile == nil {
|
||||
@@ -7699,6 +7702,53 @@ func (s *Service) applyEngineTransitionWithMode(
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recoverCompletedTurnAfterWorkflowPreflightFailure(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
session *models.TaskSession,
|
||||
result engine.HandleResult,
|
||||
) {
|
||||
if session == nil || !result.Transitioned || s.turnService == nil ||
|
||||
!workflowProfileSwitchGuardIsHeld(ctx, session.ID) {
|
||||
return
|
||||
}
|
||||
task, err := s.repo.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
s.logger.Warn("could not verify workflow source after completion preflight failure",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", session.ID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if task == nil || task.WorkflowStepID != result.FromStepID {
|
||||
return
|
||||
}
|
||||
currentSession, err := s.repo.GetTaskSession(ctx, session.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("could not reload session after completion preflight failure",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", session.ID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if currentSession == nil || currentSession.TaskID != taskID ||
|
||||
currentSession.State != models.TaskSessionStateRunning {
|
||||
return
|
||||
}
|
||||
activeTurnID, err := s.peekActiveTurnID(ctx, currentSession.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("could not check active turn after completion preflight failure; skipping recovery",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", currentSession.ID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if activeTurnID != "" {
|
||||
return
|
||||
}
|
||||
s.setSessionWaitingForInput(ctx, taskID, currentSession.ID, currentSession)
|
||||
}
|
||||
|
||||
// applyEngineTransitionWithCommitMode applies the shared lifecycle around a
|
||||
// transition commit. The default path commits through ApplyTransition. The
|
||||
// quorum decision path supplies a CAS commit so lifecycle hooks cannot be
|
||||
@@ -7733,6 +7783,9 @@ func (s *Service) applyEngineTransitionWithCommitMode(
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("step_id", result.ToStepID),
|
||||
zap.Error(err))
|
||||
if trigger == engine.TriggerOnTurnComplete {
|
||||
s.recoverCompletedTurnAfterWorkflowPreflightFailure(ctx, taskID, session, result)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
+558
@@ -0,0 +1,558 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
agentsettingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
|
||||
agentsettingsstore "github.com/kandev/kandev/internal/agent/settings/store"
|
||||
commonlogger "github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
"github.com/kandev/kandev/internal/orchestrator/watcher"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/workflow/engine"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
)
|
||||
|
||||
type storeBackedWorkflowAgentManager struct {
|
||||
*mockAgentManager
|
||||
profiles *lifecycle.StoreProfileResolver
|
||||
}
|
||||
|
||||
func (m *storeBackedWorkflowAgentManager) ResolveAgentProfile(
|
||||
ctx context.Context,
|
||||
profileID string,
|
||||
) (*executor.AgentProfileInfo, error) {
|
||||
profile, err := m.profiles.ResolveProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &executor.AgentProfileInfo{
|
||||
ProfileID: profile.ProfileID,
|
||||
ProfileName: profile.ProfileName,
|
||||
AgentID: profile.AgentID,
|
||||
AgentName: profile.AgentName,
|
||||
Model: profile.Model,
|
||||
RequireExactModel: profile.RequireExactModel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newStoreBackedDynamicProfileResolver(t *testing.T, profileID string) *lifecycle.StoreProfileResolver {
|
||||
t.Helper()
|
||||
db, err := sqlx.Open("sqlite3", ":memory:")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, db.Close()) })
|
||||
repo, cleanup, err := agentsettingsstore.Provide(db, db, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, cleanup()) })
|
||||
ctx := context.Background()
|
||||
require.NoError(t, repo.CreateAgent(ctx, &agentsettingsmodels.Agent{ID: "dynamic", Name: "dynamic"}))
|
||||
require.NoError(t, repo.CreateAgentProfile(ctx, &agentsettingsmodels.AgentProfile{
|
||||
ID: profileID, AgentID: "dynamic", Name: "Dynamic profile", Enabled: true,
|
||||
}))
|
||||
return lifecycle.NewStoreProfileResolver(repo, nil)
|
||||
}
|
||||
|
||||
func useStoreBackedProfileResolver(fixture *profileSwitchFixture, resolver *lifecycle.StoreProfileResolver) {
|
||||
fixture.svc.agentManager = &storeBackedWorkflowAgentManager{
|
||||
mockAgentManager: fixture.agentMgr,
|
||||
profiles: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.14
|
||||
func TestWorkflowCompletionRecoveryLogsActiveTurnLookupFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
core, logs := observer.New(zapcore.WarnLevel)
|
||||
logger, err := commonlogger.NewFromZap(zap.New(core))
|
||||
require.NoError(t, err)
|
||||
fixture.svc.logger = logger
|
||||
fixture.svc.turnService = &failingActiveTurnLookup{
|
||||
TurnService: &repoTurnService{repo: fixture.repo},
|
||||
err: errors.New("turn store unavailable"),
|
||||
}
|
||||
|
||||
fixture.svc.recoverCompletedTurnAfterWorkflowPreflightFailure(
|
||||
withWorkflowProfileSwitchGuardHeld(ctx, fixture.current.ID, ""),
|
||||
"t1",
|
||||
fixture.current,
|
||||
engine.HandleResult{Transitioned: true, FromStepID: "step-a", ToStepID: "step-b"},
|
||||
)
|
||||
|
||||
session, err := fixture.repo.GetTaskSession(ctx, fixture.current.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, models.TaskSessionStateRunning, session.State)
|
||||
warnings := logs.FilterMessage("could not check active turn after completion preflight failure; skipping recovery").All()
|
||||
require.Len(t, warnings, 1)
|
||||
require.Equal(t, "t1", warnings[0].ContextMap()["task_id"])
|
||||
require.Equal(t, fixture.current.ID, warnings[0].ContextMap()["session_id"])
|
||||
require.Equal(t, "turn store unavailable", warnings[0].ContextMap()["error"])
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.1
|
||||
// @covers AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.5
|
||||
// @covers AC-AGENTS-DYNAMIC-AGENT-ROUTING-001.1
|
||||
// @covers AC-AGENTS-DYNAMIC-AGENT-ROUTING-001.3
|
||||
func TestWorkflowDynamicProfileReuse(t *testing.T) {
|
||||
const dynamicProfileID = "profile-dynamic"
|
||||
ctx := context.Background()
|
||||
|
||||
for _, startPolicy := range []models.WorkflowProfileSessionStartPolicy{
|
||||
"", models.WorkflowProfileSessionStartPolicyReuse,
|
||||
} {
|
||||
name := string(startPolicy)
|
||||
if name == "" {
|
||||
name = "default"
|
||||
}
|
||||
t.Run("current session/"+name, func(t *testing.T) {
|
||||
fixture := newProfileSwitchFixture(t, startPolicy, models.WorkflowProfileSessionEndPolicyPark)
|
||||
fixture.current.AgentProfileID = dynamicProfileID
|
||||
fixture.current.ExecutionProfileID = "profile-concrete"
|
||||
fixture.current.RouteGeneration = 7
|
||||
require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current))
|
||||
useStoreBackedProfileResolver(fixture, newStoreBackedDynamicProfileResolver(t, dynamicProfileID))
|
||||
|
||||
err := fixture.svc.preflightWorkflowStepCredentials(ctx, "t1", fixture.current, &wfmodels.WorkflowStep{
|
||||
ID: "step-b", WorkflowID: "wf1", AgentProfileID: dynamicProfileID,
|
||||
ProfileSessionStartPolicy: startPolicy,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
persisted, err := fixture.repo.GetTaskSession(ctx, fixture.current.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, dynamicProfileID, persisted.AgentProfileID)
|
||||
require.Equal(t, "profile-concrete", persisted.ExecutionProfileID)
|
||||
require.Equal(t, int64(7), persisted.RouteGeneration)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("parked reusable session", func(t *testing.T) {
|
||||
fixture := newProfileSwitchFixture(
|
||||
t,
|
||||
models.WorkflowProfileSessionStartPolicyReuse,
|
||||
models.WorkflowProfileSessionEndPolicyPark,
|
||||
)
|
||||
parked := &models.TaskSession{
|
||||
ID: "session-dynamic", TaskID: "t1", AgentProfileID: dynamicProfileID,
|
||||
ExecutorID: "exec-local", ExecutorProfileID: "ep1", TaskEnvironmentID: "env-1",
|
||||
State: models.TaskSessionStateWaitingForInput, StartedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
require.NoError(t, fixture.repo.CreateTaskSession(ctx, parked))
|
||||
useStoreBackedProfileResolver(fixture, newStoreBackedDynamicProfileResolver(t, dynamicProfileID))
|
||||
target := &wfmodels.WorkflowStep{ID: "step-b", WorkflowID: "wf1", AgentProfileID: dynamicProfileID}
|
||||
source := &wfmodels.WorkflowStep{ID: "step-a", WorkflowID: "wf1"}
|
||||
|
||||
policy, selected, err := fixture.svc.exactModelWorkflowStartPolicy(
|
||||
ctx,
|
||||
"t1",
|
||||
fixture.current.ID,
|
||||
target,
|
||||
source,
|
||||
dynamicProfileID,
|
||||
models.WorkflowProfileSessionStartPolicyReuse,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, models.WorkflowProfileSessionStartPolicyReuse, policy)
|
||||
require.Equal(t, parked.ID, selected.ID)
|
||||
})
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.5
|
||||
func TestWorkflowDynamicCompletionAdvances(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, "", models.WorkflowProfileSessionEndPolicyPark)
|
||||
const dynamicProfileID = "profile-dynamic"
|
||||
fixture.current.AgentProfileID = dynamicProfileID
|
||||
require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current))
|
||||
useStoreBackedProfileResolver(fixture, newStoreBackedDynamicProfileResolver(t, dynamicProfileID))
|
||||
fixture.svc.SetWorkflowStepGetter(fixture.stepGetter)
|
||||
fixture.stepGetter.steps["step-a"] = &wfmodels.WorkflowStep{
|
||||
ID: "step-a", WorkflowID: "wf1", Name: "Work", Position: 0,
|
||||
Events: wfmodels.StepEvents{OnTurnComplete: []wfmodels.OnTurnCompleteAction{{Type: wfmodels.OnTurnCompleteMoveToNext}}},
|
||||
}
|
||||
fixture.stepGetter.steps["step-b"] = &wfmodels.WorkflowStep{
|
||||
ID: "step-b", WorkflowID: "wf1", Name: "Review", Position: 1,
|
||||
AgentProfileID: dynamicProfileID,
|
||||
ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse,
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, fixture.repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: "turn-a", TaskID: "t1", TaskSessionID: fixture.current.ID,
|
||||
StartedAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
}))
|
||||
fixture.svc.turnService = &repoTurnService{repo: fixture.repo}
|
||||
events := &mockEventBus{}
|
||||
fixture.svc.eventBus = events
|
||||
|
||||
fixture.svc.handleAgentReady(ctx, watcher.AgentEventData{
|
||||
TaskID: "t1", SessionID: fixture.current.ID, AgentExecutionID: "execution-a",
|
||||
})
|
||||
fixture.svc.handleCompleteStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
|
||||
TaskID: "t1", SessionID: fixture.current.ID,
|
||||
Data: &lifecycle.AgentStreamEventData{Type: agentEventComplete},
|
||||
})
|
||||
|
||||
task, err := fixture.repo.GetTask(ctx, "t1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "step-b", task.WorkflowStepID)
|
||||
sessions, err := fixture.repo.ListTaskSessions(ctx, "t1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sessions, 1)
|
||||
require.Equal(t, fixture.current.ID, sessions[0].ID)
|
||||
require.Equal(t, dynamicProfileID, sessions[0].AgentProfileID)
|
||||
require.Equal(t, models.TaskSessionStateWaitingForInput, sessions[0].State)
|
||||
turn, err := fixture.repo.GetTurn(ctx, "turn-a")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, turn.CompletedAt)
|
||||
require.Empty(t, fixture.agentMgr.capturedPrompts, "a destination without auto_start must not launch a prompt")
|
||||
require.NotEmpty(t, events.published())
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.11
|
||||
// @covers AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.14
|
||||
func TestWorkflowProfileLookupFailureRecoversCompletedSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyNew, models.WorkflowProfileSessionEndPolicyPark)
|
||||
fixture.svc.SetWorkflowStepGetter(fixture.stepGetter)
|
||||
fixture.stepGetter.steps["step-b"] = &wfmodels.WorkflowStep{
|
||||
ID: "step-b", WorkflowID: "wf1", Name: "Review", Position: 1,
|
||||
}
|
||||
const missingProfileID = "missing-profile"
|
||||
fixture.current.AgentProfileID = missingProfileID
|
||||
require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current))
|
||||
useStoreBackedProfileResolver(fixture, newStoreBackedDynamicProfileResolver(t, "profile-dynamic"))
|
||||
fixture.stepGetter.steps["step-b"].AgentProfileID = missingProfileID
|
||||
fixture.stepGetter.steps["step-b"].ProfileSessionStartPolicy = models.WorkflowProfileSessionStartPolicyReuse
|
||||
fixture.svc.turnService = &repoTurnService{repo: fixture.repo}
|
||||
eventBus := &mockEventBus{}
|
||||
fixture.svc.eventBus = eventBus
|
||||
commitCalls := 0
|
||||
|
||||
applied := fixture.svc.applyEngineTransitionWithCommitMode(
|
||||
withWorkflowProfileSwitchGuardHeld(ctx, fixture.current.ID, ""),
|
||||
"t1",
|
||||
fixture.current,
|
||||
engine.HandleResult{Transitioned: true, FromStepID: "step-a", ToStepID: "step-b"},
|
||||
engine.TriggerOnTurnComplete,
|
||||
"Test",
|
||||
transitionLifecycleWithOnEnter,
|
||||
func(context.Context) (bool, error) {
|
||||
commitCalls++
|
||||
return true, nil
|
||||
},
|
||||
)
|
||||
|
||||
require.False(t, applied)
|
||||
require.Zero(t, commitCalls, "rejected completion must not commit the transition")
|
||||
task, err := fixture.repo.GetTask(ctx, "t1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "step-a", task.WorkflowStepID)
|
||||
session, err := fixture.repo.GetTaskSession(ctx, fixture.current.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, models.TaskSessionStateWaitingForInput, session.State)
|
||||
require.True(t, session.IsPrimary)
|
||||
require.Empty(t, fixture.agentMgr.capturedPrompts)
|
||||
stateEvents := eventBus.published()
|
||||
require.Len(t, stateEvents, 1)
|
||||
require.Equal(t, events.TaskSessionStateChanged, stateEvents[0].Subject)
|
||||
payload, ok := stateEvents[0].Event.Data.(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "t1", payload["task_id"])
|
||||
require.Equal(t, fixture.current.ID, payload["session_id"])
|
||||
require.Equal(t, string(models.TaskSessionStateRunning), payload["old_state"])
|
||||
require.Equal(t, string(models.TaskSessionStateWaitingForInput), payload["new_state"])
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.14
|
||||
func TestWorkflowCredentialPreflightFailureDispatchesFollowUp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
configureDestinationOnlyCredentialPreflightFailure(t, fixture)
|
||||
fixture.svc.turnService = &repoTurnService{repo: fixture.repo}
|
||||
fixture.agentMgr.promptDone = make(chan struct{})
|
||||
eventBus := &mockEventBus{}
|
||||
fixture.svc.eventBus = eventBus
|
||||
startedAt := time.Now().UTC()
|
||||
require.NoError(t, fixture.repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: "turn-completed", TaskID: "t1", TaskSessionID: fixture.current.ID,
|
||||
StartedAt: startedAt, CreatedAt: startedAt, UpdatedAt: startedAt,
|
||||
}))
|
||||
|
||||
fixture.svc.handleAgentReady(ctx, watcher.AgentEventData{
|
||||
TaskID: "t1", SessionID: fixture.current.ID, AgentExecutionID: "execution-a",
|
||||
})
|
||||
fixture.svc.handleCompleteStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
|
||||
TaskID: "t1", SessionID: fixture.current.ID, ExecutionID: "execution-a",
|
||||
Data: &lifecycle.AgentStreamEventData{Type: agentEventComplete, TurnID: "turn-completed"},
|
||||
})
|
||||
|
||||
assertRejectedCompletionStayedOnSource(t, fixture, "turn-completed")
|
||||
assertSessionReadinessPublished(t, eventBus, fixture.current.ID)
|
||||
require.Empty(t, fixture.agentMgr.capturedPrompts, "preflight rejection must not launch the destination")
|
||||
fixture.agentMgr.isAgentRunning = true
|
||||
require.NoError(t, fixture.svc.QueueUserPrompt(ctx, "t1", fixture.current.ID, "follow up", "", false, nil, nil, false))
|
||||
select {
|
||||
case <-fixture.agentMgr.promptDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("follow-up prompt was admitted but not dispatched")
|
||||
}
|
||||
|
||||
fixture.agentMgr.mu.Lock()
|
||||
capturedPrompts := append([]string(nil), fixture.agentMgr.capturedPrompts...)
|
||||
capturedCalls := append([]promptCall(nil), fixture.agentMgr.capturedPromptCalls...)
|
||||
fixture.agentMgr.mu.Unlock()
|
||||
require.Equal(t, []string{"follow up"}, capturedPrompts)
|
||||
require.Len(t, capturedCalls, 1)
|
||||
require.Equal(t, "execution-a", capturedCalls[0].ExecutionID)
|
||||
turns, err := fixture.repo.ListTurnsBySession(ctx, fixture.current.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, turns, 2, "the follow-up must start exactly one new turn")
|
||||
activeTurn, err := fixture.svc.turnService.GetActiveTurn(ctx, fixture.current.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, activeTurn)
|
||||
require.NotEqual(t, "turn-completed", activeTurn.ID)
|
||||
require.Zero(t, fixture.svc.messageQueue.GetStatus(ctx, fixture.current.ID).Count, "follow-up queue entry must be consumed")
|
||||
}
|
||||
|
||||
func configureDestinationOnlyCredentialPreflightFailure(t *testing.T, fixture *profileSwitchFixture) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
fixture.svc.SetWorkflowStepGetter(fixture.stepGetter)
|
||||
fixture.stepGetter.steps["step-a"] = &wfmodels.WorkflowStep{
|
||||
ID: "step-a", WorkflowID: "wf1", Name: "Work", Position: 0,
|
||||
Events: wfmodels.StepEvents{OnTurnComplete: []wfmodels.OnTurnCompleteAction{{Type: wfmodels.OnTurnCompleteMoveToNext}}},
|
||||
}
|
||||
fixture.stepGetter.steps["step-b"] = &wfmodels.WorkflowStep{
|
||||
ID: "step-b", WorkflowID: "wf1", Name: "Review", Position: 1,
|
||||
AgentProfileID: "profile-b", ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse,
|
||||
}
|
||||
require.NoError(t, fixture.repo.CreateExecutor(ctx, &models.Executor{
|
||||
ID: "exec-ssh", Name: "SSH", Type: models.ExecutorTypeSSH, Status: models.ExecutorStatusActive,
|
||||
}))
|
||||
require.NoError(t, fixture.repo.CreateExecutorProfile(ctx, &models.ExecutorProfile{
|
||||
ID: "ep-source-token", ExecutorID: "exec-ssh", Name: "Source GitHub token",
|
||||
Config: map[string]string{"remote_auth_secrets": `{"gh_cli_env":"test-token"}`},
|
||||
}))
|
||||
require.NoError(t, fixture.repo.CreateExecutorProfile(ctx, &models.ExecutorProfile{
|
||||
ID: "ep-destination", ExecutorID: "exec-ssh", Name: "Destination without GitHub token",
|
||||
}))
|
||||
fixture.current.ExecutorID = "exec-ssh"
|
||||
fixture.current.ExecutorProfileID = "ep-source-token"
|
||||
require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current))
|
||||
require.NoError(t, fixture.repo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: "session-destination", TaskID: "t1", AgentProfileID: "profile-b",
|
||||
ExecutorID: "exec-ssh", ExecutorProfileID: "ep-destination", TaskEnvironmentID: "env-1",
|
||||
State: models.TaskSessionStateWaitingForInput, StartedAt: time.Now().UTC(), IsPrimary: false,
|
||||
}))
|
||||
fixture.svc.executor.SetGitHubCredentialBroker(
|
||||
fakeSwitchSessionCredentialIssuer{}, "https://kandev.example/api/v1/github/credentials/resolve",
|
||||
)
|
||||
require.NoError(t, fixture.repo.CreateRepository(ctx, &models.Repository{
|
||||
ID: "repo-invalid-github", WorkspaceID: "ws1", Name: "Invalid GitHub remote",
|
||||
SourceType: "local", Provider: "github", RemoteURL: "https://forge.example/acme/widgets.git",
|
||||
}))
|
||||
require.NoError(t, fixture.repo.CreateTaskRepository(ctx, &models.TaskRepository{
|
||||
ID: "task-repo-invalid-github", TaskID: "t1", RepositoryID: "repo-invalid-github",
|
||||
}))
|
||||
}
|
||||
|
||||
func assertRejectedCompletionStayedOnSource(t *testing.T, fixture *profileSwitchFixture, completedTurnID string) {
|
||||
t.Helper()
|
||||
task, err := fixture.repo.GetTask(context.Background(), "t1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "step-a", task.WorkflowStepID)
|
||||
source, err := fixture.repo.GetTaskSession(context.Background(), fixture.current.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, models.TaskSessionStateWaitingForInput, source.State)
|
||||
require.True(t, source.IsPrimary)
|
||||
require.Equal(t, "profile-a", source.AgentProfileID)
|
||||
destination, err := fixture.repo.GetTaskSession(context.Background(), "session-destination")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, models.TaskSessionStateWaitingForInput, destination.State)
|
||||
require.False(t, destination.IsPrimary)
|
||||
turn, err := fixture.repo.GetTurn(context.Background(), completedTurnID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, turn.CompletedAt, "READY must complete the real active turn")
|
||||
}
|
||||
|
||||
func assertSessionReadinessPublished(t *testing.T, eventBus *mockEventBus, sessionID string) {
|
||||
t.Helper()
|
||||
for _, published := range eventBus.published() {
|
||||
if published.Subject != events.TaskSessionStateChanged {
|
||||
continue
|
||||
}
|
||||
payload, ok := published.Event.Data.(map[string]interface{})
|
||||
if ok && payload["session_id"] == sessionID &&
|
||||
payload["new_state"] == string(models.TaskSessionStateWaitingForInput) {
|
||||
return
|
||||
}
|
||||
}
|
||||
require.Fail(t, "READY recovery must publish WAITING_FOR_INPUT for the original session")
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.14
|
||||
func TestWorkflowPreflightFailurePreservesLiveTurn(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
trigger engine.Trigger
|
||||
mode transitionLifecycleMode
|
||||
target bool
|
||||
}{
|
||||
{name: "manual move", trigger: engine.Trigger("manual"), mode: transitionLifecycleWithOnEnter, target: true},
|
||||
{name: "turn start", trigger: engine.TriggerOnTurnStart, mode: transitionLifecycleOnTurnStart, target: true},
|
||||
{name: "guarded decision", trigger: engine.TriggerOnTurnComplete, mode: transitionLifecycleGuardedDecision, target: true},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyNew, models.WorkflowProfileSessionEndPolicyPark)
|
||||
fixture.stepGetter.steps["step-b"] = &wfmodels.WorkflowStep{ID: "step-b", WorkflowID: "wf1", AgentProfileID: "profile-b"}
|
||||
fixture.svc.executor.SetGitHubCredentialBroker(
|
||||
fakeSwitchSessionCredentialIssuer{}, "https://kandev.example/api/v1/github/credentials/resolve",
|
||||
)
|
||||
require.NoError(t, fixture.repo.CreateRepository(ctx, &models.Repository{
|
||||
ID: "repo1", WorkspaceID: "ws1", Name: "widgets", SourceType: "local",
|
||||
Provider: "acme-forge", RemoteURL: "https://forge.example/acme/widgets.git",
|
||||
}))
|
||||
require.NoError(t, fixture.repo.CreateTaskRepository(ctx, &models.TaskRepository{
|
||||
ID: "taskrepo1", TaskID: "t1", RepositoryID: "repo1",
|
||||
}))
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, fixture.repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: "active-turn", TaskID: "t1", TaskSessionID: fixture.current.ID,
|
||||
StartedAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
}))
|
||||
fixture.svc.turnService = &repoTurnService{repo: fixture.repo}
|
||||
result := engine.HandleResult{Transitioned: true, FromStepID: "step-a", ToStepID: "step-b"}
|
||||
if !testCase.target {
|
||||
result.ToStepID = "missing-step"
|
||||
}
|
||||
|
||||
fixture.svc.applyEngineTransitionWithCommitMode(
|
||||
withWorkflowProfileSwitchGuardHeld(ctx, fixture.current.ID, ""),
|
||||
"t1", fixture.current, result, testCase.trigger, "Test", testCase.mode,
|
||||
func(context.Context) (bool, error) { return true, nil },
|
||||
)
|
||||
|
||||
session, err := fixture.repo.GetTaskSession(ctx, fixture.current.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, models.TaskSessionStateRunning, session.State)
|
||||
task, err := fixture.repo.GetTask(ctx, "t1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "step-a", task.WorkflowStepID)
|
||||
activeTurn, err := (&repoTurnService{repo: fixture.repo}).GetActiveTurn(ctx, fixture.current.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "active-turn", activeTurn.ID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.14
|
||||
func TestWorkflowCompletionRecoveryPreservesNewerTurn(t *testing.T) {
|
||||
for _, state := range []models.TaskSessionState{
|
||||
models.TaskSessionStateWaitingForInput,
|
||||
models.TaskSessionStateCompleted,
|
||||
} {
|
||||
t.Run(string(state), func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyNew, models.WorkflowProfileSessionEndPolicyPark)
|
||||
fixture.svc.SetWorkflowStepGetter(fixture.stepGetter)
|
||||
fixture.stepGetter.steps["step-b"] = &wfmodels.WorkflowStep{
|
||||
ID: "step-b", WorkflowID: "wf1", AgentProfileID: "profile-b",
|
||||
}
|
||||
fixture.svc.executor.SetGitHubCredentialBroker(
|
||||
fakeSwitchSessionCredentialIssuer{}, "https://kandev.example/api/v1/github/credentials/resolve",
|
||||
)
|
||||
require.NoError(t, fixture.repo.CreateRepository(ctx, &models.Repository{
|
||||
ID: "repo1", WorkspaceID: "ws1", Name: "widgets", SourceType: "local",
|
||||
Provider: "acme-forge", RemoteURL: "https://forge.example/acme/widgets.git",
|
||||
}))
|
||||
require.NoError(t, fixture.repo.CreateTaskRepository(ctx, &models.TaskRepository{
|
||||
ID: "taskrepo1", TaskID: "t1", RepositoryID: "repo1",
|
||||
}))
|
||||
fixture.svc.turnService = &repoTurnService{repo: fixture.repo}
|
||||
snapshot := *fixture.current
|
||||
require.NoError(t, fixture.repo.UpdateTaskSessionState(ctx, fixture.current.ID, state, ""))
|
||||
if state == models.TaskSessionStateWaitingForInput {
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, fixture.repo.CreateTurn(ctx, &models.Turn{
|
||||
ID: "newer-turn", TaskID: "t1", TaskSessionID: fixture.current.ID,
|
||||
StartedAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
}))
|
||||
require.NoError(t, fixture.repo.UpdateTaskSessionState(ctx, fixture.current.ID, models.TaskSessionStateRunning, ""))
|
||||
}
|
||||
|
||||
fixture.svc.applyEngineTransitionWithCommitMode(
|
||||
withWorkflowProfileSwitchGuardHeld(ctx, fixture.current.ID, ""),
|
||||
"t1", &snapshot,
|
||||
engine.HandleResult{Transitioned: true, FromStepID: "step-a", ToStepID: "step-b"},
|
||||
engine.TriggerOnTurnComplete, "Test", transitionLifecycleWithOnEnter,
|
||||
func(context.Context) (bool, error) { return true, nil },
|
||||
)
|
||||
|
||||
persisted, err := fixture.repo.GetTaskSession(ctx, fixture.current.ID)
|
||||
require.NoError(t, err)
|
||||
if state == models.TaskSessionStateWaitingForInput {
|
||||
require.Equal(t, models.TaskSessionStateRunning, persisted.State)
|
||||
activeTurn, err := (&repoTurnService{repo: fixture.repo}).GetActiveTurn(ctx, fixture.current.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "newer-turn", activeTurn.ID)
|
||||
return
|
||||
}
|
||||
require.Equal(t, models.TaskSessionStateCompleted, persisted.State)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("duplicate completed turn publishes readiness once", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyNew, models.WorkflowProfileSessionEndPolicyPark)
|
||||
fixture.svc.SetWorkflowStepGetter(fixture.stepGetter)
|
||||
fixture.stepGetter.steps["step-b"] = &wfmodels.WorkflowStep{
|
||||
ID: "step-b", WorkflowID: "wf1", AgentProfileID: "profile-b",
|
||||
}
|
||||
fixture.svc.executor.SetGitHubCredentialBroker(
|
||||
fakeSwitchSessionCredentialIssuer{}, "https://kandev.example/api/v1/github/credentials/resolve",
|
||||
)
|
||||
require.NoError(t, fixture.repo.CreateRepository(ctx, &models.Repository{
|
||||
ID: "repo1", WorkspaceID: "ws1", Name: "widgets", SourceType: "local",
|
||||
Provider: "acme-forge", RemoteURL: "https://forge.example/acme/widgets.git",
|
||||
}))
|
||||
require.NoError(t, fixture.repo.CreateTaskRepository(ctx, &models.TaskRepository{
|
||||
ID: "taskrepo1", TaskID: "t1", RepositoryID: "repo1",
|
||||
}))
|
||||
fixture.svc.turnService = &repoTurnService{repo: fixture.repo}
|
||||
eventBus := &mockEventBus{}
|
||||
fixture.svc.eventBus = eventBus
|
||||
result := engine.HandleResult{Transitioned: true, FromStepID: "step-a", ToStepID: "step-b"}
|
||||
apply := func() bool {
|
||||
return fixture.svc.applyEngineTransitionWithCommitMode(
|
||||
withWorkflowProfileSwitchGuardHeld(ctx, fixture.current.ID, ""),
|
||||
"t1", fixture.current, result, engine.TriggerOnTurnComplete, "Test",
|
||||
transitionLifecycleWithOnEnter, func(context.Context) (bool, error) { return true, nil },
|
||||
)
|
||||
}
|
||||
|
||||
require.False(t, apply())
|
||||
require.False(t, apply())
|
||||
persisted, err := fixture.repo.GetTaskSession(ctx, fixture.current.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, models.TaskSessionStateWaitingForInput, persisted.State)
|
||||
task, err := fixture.repo.GetTask(ctx, "t1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "step-a", task.WorkflowStepID)
|
||||
require.Len(t, eventBus.published(), 1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
async function createDynamicProfile(
|
||||
apiClient: InstanceType<typeof import("../../helpers/api-client").ApiClient>,
|
||||
candidateProfileId: string,
|
||||
) {
|
||||
const response = await apiClient.rawRequest("POST", "/api/v1/agents/dynamic/profiles", {
|
||||
name: "Dynamic workflow completion",
|
||||
dynamic: {
|
||||
version: 1,
|
||||
candidates: [
|
||||
{
|
||||
position: 0,
|
||||
execution_profile_id: candidateProfileId,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Dynamic profile creation failed (${response.status}): ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as { id: string };
|
||||
}
|
||||
|
||||
async function waitForAgentMarker(
|
||||
apiClient: InstanceType<typeof import("../../helpers/api-client").ApiClient>,
|
||||
sessionId: string,
|
||||
marker: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { messages } = await apiClient.listSessionMessages(sessionId);
|
||||
return messages.some(
|
||||
(message) => message.author_type === "agent" && message.content.includes(marker),
|
||||
);
|
||||
},
|
||||
{ timeout: 45_000, message: `session ${sessionId} did not receive ${marker}` },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test.describe("Dynamic workflow completion", () => {
|
||||
test("reuses the logical session after completion and reload", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const releaseFeature = await backend.useEnv({
|
||||
KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING: "true",
|
||||
});
|
||||
let dynamicProfileId = "";
|
||||
|
||||
try {
|
||||
const dynamicProfile = await createDynamicProfile(apiClient, seedData.agentProfileId);
|
||||
dynamicProfileId = dynamicProfile.id;
|
||||
const workflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Dynamic completion reuse",
|
||||
);
|
||||
const source = await apiClient.createWorkflowStep(workflow.id, "Implement", 0, {
|
||||
is_start_step: true,
|
||||
agent_profile_id: dynamicProfile.id,
|
||||
profile_session_start_policy: "reuse",
|
||||
profile_session_end_policy: "park",
|
||||
events: { on_turn_complete: [{ type: "move_to_next" }] },
|
||||
});
|
||||
const review = await apiClient.createWorkflowStep(workflow.id, "Review", 1, {
|
||||
agent_profile_id: dynamicProfile.id,
|
||||
profile_session_start_policy: "reuse",
|
||||
profile_session_end_policy: "park",
|
||||
});
|
||||
const firstMarker = "dynamic-completion-first-turn";
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Dynamic completion reuse task",
|
||||
dynamicProfile.id,
|
||||
{
|
||||
description: `e2e:message("${firstMarker}")`,
|
||||
workflow_id: workflow.id,
|
||||
workflow_step_id: source.id,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
let sessionId = "";
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
const session = sessions.find(
|
||||
(candidate) => candidate.agent_profile_id === dynamicProfile.id,
|
||||
);
|
||||
sessionId = session?.id ?? "";
|
||||
return session?.state === "WAITING_FOR_INPUT";
|
||||
},
|
||||
{ timeout: 45_000, message: "Dynamic workflow session did not become ready" },
|
||||
)
|
||||
.toBe(true);
|
||||
await waitForAgentMarker(apiClient, sessionId, firstMarker);
|
||||
await expect
|
||||
.poll(() => apiClient.getTask(task.id).then((current) => current.workflow_step_id), {
|
||||
timeout: 30_000,
|
||||
message: "completed turn did not advance to Review",
|
||||
})
|
||||
.toBe(review.id);
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step");
|
||||
await session.sendMessage('e2e:message("dynamic-completion-second-turn")');
|
||||
await waitForAgentMarker(apiClient, sessionId, "dynamic-completion-second-turn");
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
const completedSession = sessions.find((candidate) => candidate.id === sessionId);
|
||||
return completedSession
|
||||
? {
|
||||
id: completedSession.id,
|
||||
agent_profile_id: completedSession.agent_profile_id,
|
||||
is_primary: completedSession.is_primary,
|
||||
state: completedSession.state,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
{
|
||||
timeout: 45_000,
|
||||
message: "completed second turn did not settle on the reused session",
|
||||
},
|
||||
)
|
||||
.toEqual({
|
||||
id: sessionId,
|
||||
agent_profile_id: dynamicProfile.id,
|
||||
is_primary: true,
|
||||
state: "WAITING_FOR_INPUT",
|
||||
});
|
||||
|
||||
const finalSessions = (await apiClient.listTaskSessions(task.id)).sessions;
|
||||
expect(finalSessions).toHaveLength(1);
|
||||
expect(finalSessions[0]).toMatchObject({
|
||||
id: sessionId,
|
||||
agent_profile_id: dynamicProfile.id,
|
||||
is_primary: true,
|
||||
state: "WAITING_FOR_INPUT",
|
||||
});
|
||||
expect((await apiClient.getTask(task.id)).workflow_step_id).toBe(review.id);
|
||||
await expect(session.activeChat()).toContainText("dynamic-completion-second-turn");
|
||||
} finally {
|
||||
try {
|
||||
if (dynamicProfileId) await apiClient.deleteAgentProfile(dynamicProfileId, true);
|
||||
} finally {
|
||||
await releaseFeature();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
created: 2026-09-25
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001
|
||||
- REQ-AGENTS-DYNAMIC-AGENT-ROUTING-001
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/workflow-profile-readiness.md
|
||||
- ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md
|
||||
- ../../specs/agents/system-design/dynamic-agent-routing-01.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation plan: Dynamic workflow completion
|
||||
|
||||
## Overview
|
||||
|
||||
Restore workflow advancement for Dynamic profiles and promptability after rejected completion transitions.
|
||||
One work order owns the regression tests, correction, and browser evidence. Implementation is complete.
|
||||
|
||||
## Evidence and root cause
|
||||
|
||||
The supplied bundle records build `25271b6e7`, macOS arm64, on 2026-09-25.
|
||||
Task `d6b9d34e-4129-40fe-8528-90dec2caa281` uses session `48bfdbf8-689e-4523-93bd-f7fa20981f69`.
|
||||
Four successful turns encounter the same rejection at 10:12:42, 10:54:10, 11:00:43, and 11:40:20 (UTC+01:00).
|
||||
The error ends with `agent profile belongs to a virtual execution family`.
|
||||
|
||||
At 11:40:13, Cursor emits the reported preparation thought. It returns `end_turn` at 11:40:17.
|
||||
READY completes the turn at 11:40:20, but the destination preflight rejects the Dynamic profile.
|
||||
The complete-stream path then defers readiness to an already-handled READY event.
|
||||
The backend retains `RUNNING` with no active turn and rejects follow-up prompts.
|
||||
Cancellation restores readiness. One captured occurrence persists for approximately 27 minutes.
|
||||
|
||||
`sessionHasUnauthorizedExactModelDrift` resolves a logical Dynamic profile through the concrete lifecycle resolver.
|
||||
The resolver correctly rejects it. The completion preflight failure path then returns without restoring session readiness.
|
||||
Both paths exist in the reporter commit and investigated checkout `3aa3233c78`.
|
||||
The bundle contains partial retained browser history. Backend and ACP evidence establish this sequence without a live reproduction.
|
||||
The archive is task-local evidence, not an implementation prerequisite. Do not copy private transcript content into tests.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Existing reuse criteria 001.1 and 001.5 for Dynamic logical profiles.
|
||||
- Existing recoverability criterion 001.11 and the precise readiness outcome in new criterion 001.14.
|
||||
- Current-session and parked-session checks, completion error settlement, and follow-up acceptance.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Provider routing policies, model fallback, new flags, schema changes, and stall timers.
|
||||
- UI layout, copy, controls, Office behavior, and broad transition refactoring.
|
||||
- Changing concrete exact-model enforcement or permitting virtual CLI launches.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Follow [profile readiness](../../specs/tasks/system-design/workflow-profile-readiness.md).
|
||||
Handle only wrapped `agent/runtime.ErrVirtualProfile` in the shared exact-model drift helper; the runtime facade re-exports the lifecycle sentinel.
|
||||
Keep ordinary lookup errors fatal and retain concrete exact-model checks.
|
||||
At rejected completion preflight, settle the owned completed source through existing state publication.
|
||||
Do not apply that settlement to live manual moves, turn-start transitions, or guarded decision calls.
|
||||
|
||||
Use `newProfileSwitchFixture` and real session rows for orchestration assertions.
|
||||
Use the real settings-backed lifecycle resolver in one case to prevent permissive mocks from hiding the bug.
|
||||
Retain session guards, route identities, transition markers, managed credential checks, and existing queue behavior.
|
||||
No public documentation change is needed: this repair restores documented session reuse and recovery.
|
||||
|
||||
## Tests
|
||||
|
||||
Planned tests belong in `apps/backend/internal/orchestrator/event_handlers_workflow_dynamic_completion_test.go`.
|
||||
|
||||
| Test | Evidence | Criteria |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| `TestWorkflowDynamicProfileReuse` | Default/explicit reuse, current/parked sessions, real virtual resolver, no route reset | 001.1, 001.5; Dynamic 001.1, 001.3, 001.7 |
|
||||
| `TestWorkflowDynamicCompletionAdvances` | READY completes one turn, advances once, preserves logical session, reaches waiting or next legitimate turn | 001.5 |
|
||||
| `TestWorkflowProfileLookupFailureRecoversCompletedSession` | Profile lookup failure preserves the source and publishes waiting | 001.11, 001.14 |
|
||||
| `TestWorkflowCredentialPreflightFailureDispatchesFollowUp` | READY/complete-stream rejection preserves the source and dispatches one follow-up prompt and turn | 001.14 |
|
||||
| `TestWorkflowPreflightFailurePreservesLiveTurn` | Manual, turn-start, and guarded-decision failures cannot settle active work | 001.14 |
|
||||
| `TestWorkflowCompletionRecoveryPreservesNewerTurn` | Duplicate/delayed completion cannot settle a newer turn or reopen a terminal session | 001.14 |
|
||||
|
||||
Run existing profile-policy tests as controls for concrete exact-model mismatch, missing models, lookup errors, and same-profile `new`.
|
||||
Tests must assert persisted state, session identity, event payloads, and prompt recipients, not only helper calls.
|
||||
|
||||
## E2E tests
|
||||
|
||||
Add `apps/web/e2e/tests/workflow/dynamic-workflow-completion.spec.ts`, project `chromium`.
|
||||
Use the isolated backend and `backend.useEnv` to enable the existing Dynamic flag for this test only.
|
||||
Restore the environment in `finally`, as the Dynamic settings tests do.
|
||||
Seed one Dynamic profile with a mock concrete candidate and consecutive steps using that profile with `reuse`.
|
||||
Let the first turn complete and advance automatically to a step without automatic launch.
|
||||
Assert the same primary session, destination step, completed response, and ready composer.
|
||||
Send a second unique prompt through the composer and assert one accepted turn and its response without cancellation.
|
||||
Reload before the follow-up in one case to prove persisted readiness. This covers 001.5 and Dynamic profile compatibility.
|
||||
Failure recovery criterion 001.14 uses backend integration evidence through state publication and prompt admission.
|
||||
Do not inject browser store state to simulate successful backend settlement.
|
||||
|
||||
This repair changes shared backend state only. Desktop and phone consume the same session events.
|
||||
Mobile parity review requires no new layout or touch behavior. No ASCII preview is needed.
|
||||
Existing mobile workflow targeting coverage remains applicable. The new backend event assertions cover the recovery state for both clients.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Restore Dynamic workflow completion](task-01-restore-completion.md) (done)
|
||||
|
||||
## Related delivery records
|
||||
|
||||
`workflow-session-targeting` and `workflow-session-focus` record completed recipient and presentation work.
|
||||
`workflow-same-profile-new-session` records the completed `new` policy repair.
|
||||
`queued-session-ownership` remains in progress and owns inspection/admission behavior.
|
||||
This package does not reopen their tasks or claim their results as new evidence.
|
||||
It adds the missing Dynamic completion cases and retains their recipient, queue, and stop-intent contracts.
|
||||
|
||||
## Verification results
|
||||
|
||||
Implementation and design checks on 2026-09-25 passed:
|
||||
|
||||
- Red regressions reproduced Dynamic virtual-profile rejection and the stranded `RUNNING` session before the production fix.
|
||||
- `(cd apps/backend && go test ./internal/orchestrator -run '^TestWorkflow(Dynamic|ProfileLookupFailure|CredentialPreflightFailure|Completion|PreflightFailure)' -count=1)`
|
||||
- `(cd apps/backend && go test -race ./internal/orchestrator -run 'TestWorkflow(Dynamic|ProfileLookupFailure|CredentialPreflightFailure|Completion|PreflightFailure)|TestPrepareWorkflowStepSession|TestPreflightWorkflowStepCredentials|TestExactModelWorkflowStartPolicy|TestHandleAgentReady|TestHandleCompleteStreamEvent' -count=1)`
|
||||
- `(cd apps/backend && go test ./internal/agent/runtime/lifecycle -run 'Test.*Profile' -count=1)`
|
||||
- `make -C apps/backend lint` (0 issues)
|
||||
- `(cd apps/web && pnpm e2e:run --project chromium tests/workflow/dynamic-workflow-completion.spec.ts tests/workflow/workflow-session-targeting.spec.ts)` (6 tests passed, including both specs)
|
||||
- `(cd apps/web && pnpm exec eslint --max-warnings 0 e2e/tests/workflow/dynamic-workflow-completion.spec.ts)`
|
||||
- `python3 scripts/list-docs.py validate`
|
||||
- `python3 scripts/lint-spec-files.py --all`
|
||||
- `git diff --check`
|
||||
- `pnpm exec prettier --check` on the new E2E test and formatted plan/spec files.
|
||||
- `pnpm exec eslint --max-warnings 0` on the new E2E test.
|
||||
- Managed E2E build completed for the backend and Vite web assets.
|
||||
- Frontend dependency installation was unnecessary because `apps/node_modules` was present.
|
||||
|
||||
PR fixup verification on 2026-09-25 also passed: targeted orchestrator race tests, changed-code `golangci-lint` (0 issues), and the managed Chromium workflow scenarios with retries disabled (6 passed). The local PR documentation coverage evaluator reported `covered` after the work order linked the Dynamic routing design. Documentation validation and specification lint passed.
|
||||
|
||||
The new browser regression cleans up its Dynamic profile while the feature flag is enabled.
|
||||
|
||||
## Risks
|
||||
|
||||
- Ignoring all resolver errors can reuse deleted or unavailable profiles.
|
||||
- Settling every failed transition can interrupt a live manual-move source or a newer turn.
|
||||
- A helper-only test can miss the READY/complete-stream ordering and prompt admission failure.
|
||||
- Concurrent queue changes can affect the guard boundary. Preserve existing ownership tests.
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
id: "01-restore-completion"
|
||||
title: "Restore Dynamic workflow completion"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001
|
||||
- REQ-AGENTS-DYNAMIC-AGENT-ROUTING-001
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.1
|
||||
- AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.5
|
||||
- AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.11
|
||||
- AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.14
|
||||
- AC-AGENTS-DYNAMIC-AGENT-ROUTING-001.1
|
||||
- AC-AGENTS-DYNAMIC-AGENT-ROUTING-001.3
|
||||
- AC-AGENTS-DYNAMIC-AGENT-ROUTING-001.7
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/workflow-profile-readiness.md
|
||||
- ../../specs/tasks/system-design/workflow-profile-session-lifecycle.md
|
||||
- ../../specs/agents/system-design/dynamic-agent-routing-01.md
|
||||
---
|
||||
|
||||
# Task 01: Restore Dynamic workflow completion
|
||||
|
||||
## Summary
|
||||
|
||||
A Dynamic logical profile must survive workflow reuse validation.
|
||||
A rejected completion preflight must leave the owned completed conversation ready for another prompt.
|
||||
|
||||
## In scope
|
||||
|
||||
- Implement the profile validation and completion recovery sections of the design.
|
||||
- Add all five planned Go tests and the browser scenario from the plan.
|
||||
- Preserve concrete exact-model checks and existing cancellation, queue, and stale-event guards.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Provider selection, fallback policy, migrations, flag rollout, UI composition, and broad refactoring.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Default and explicit Dynamic reuse pass preflight and preparation without resolving a virtual CLI or replacing logical identity.
|
||||
2. Rejected completion preflight preserves the workflow source, publishes readiness, and accepts a follow-up without cancelling or repeating entry actions.
|
||||
3. Concrete model checks, active manual moves, newer turns, terminal sessions, and duplicate completion retain their existing safety behavior.
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
1. Mark this order `in_progress`.
|
||||
2. Add the planned Go and browser regressions before production changes.
|
||||
3. Run the focused tests. Record expected failures for virtual resolution and stranded `RUNNING` state.
|
||||
4. Handle only wrapped `ErrVirtualProfile` as no logical exact-model drift.
|
||||
5. Restore readiness only for the owned completed-turn preflight failure path.
|
||||
6. Run all commands below. Record results and synchronize the plan.
|
||||
|
||||
Use typed error matching. Do not infer profile kind from names or swallow unrelated errors.
|
||||
At least one regression must use the real settings store and concrete resolver.
|
||||
Test READY followed by complete-stream delivery, as observed in the report.
|
||||
Assert no destination launch or committed transition after preflight rejection.
|
||||
For race cases, coordinate channels or existing hooks instead of elapsed sleeps.
|
||||
|
||||
## Verification
|
||||
|
||||
Run each command from the repository root. Install frontend dependencies once if this worktree lacks them.
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm install --frozen-lockfile)
|
||||
(cd apps/backend && go test ./internal/orchestrator -run '^TestWorkflow(Dynamic|ProfileLookupFailure|CredentialPreflightFailure|Completion|PreflightFailure)' -count=1)
|
||||
(cd apps/backend && go test -race ./internal/orchestrator -run 'TestWorkflow(Dynamic|ProfileLookupFailure|CredentialPreflightFailure|Completion|PreflightFailure)|TestPrepareWorkflowStepSession|TestPreflightWorkflowStepCredentials|TestExactModelWorkflowStartPolicy|TestHandleAgentReady|TestHandleCompleteStreamEvent' -count=1)
|
||||
(cd apps/backend && go test ./internal/agent/runtime/lifecycle -run 'Test.*Profile' -count=1)
|
||||
make -C apps/backend lint
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/workflow/dynamic-workflow-completion.spec.ts tests/workflow/workflow-session-targeting.spec.ts)
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Use the managed E2E runner for fresh builds and cleanup. Confirm both spec files have discovered tests.
|
||||
The browser scenario uses the existing mock candidate and real backend lifecycle, without external provider credentials.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/orchestrator/event_handlers_workflow.go`
|
||||
- `apps/backend/internal/agent/runtime/errors.go` (runtime facade error alias)
|
||||
- `apps/backend/internal/orchestrator/event_handlers_workflow_dynamic_completion_test.go` (new)
|
||||
- `apps/web/e2e/tests/workflow/dynamic-workflow-completion.spec.ts` (new)
|
||||
- `docs/plans/dynamic-workflow-completion/plan.md`
|
||||
- This work order
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. Execute this order sequentially in the primary session.
|
||||
|
||||
## Risks
|
||||
|
||||
The shared transition helper has callers that still own active turns.
|
||||
Do not broaden readiness settlement beyond a completed turn or weaken the concrete resolver.
|
||||
Avoid recursive acquisition of the session guard in the READY path.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirements](../../specs/tasks/requirements/workflow-profile-session-lifecycle.md), criteria 001.1, 001.5, 001.11, and 001.14.
|
||||
- [Profile readiness design](../../specs/tasks/system-design/workflow-profile-readiness.md).
|
||||
- [Dynamic requirements](../../specs/agents/requirements/dynamic-agent-routing.md).
|
||||
- `event_handlers_workflow_profile_session_policy_test.go` and `event_handlers_workflow_profile_session_policy_resolution_test.go`.
|
||||
- `event_handlers_agent.go`: `handleAgentReady` and its ownership guards.
|
||||
- `event_handlers_streaming.go`: waiting-state publication and complete-stream processing.
|
||||
- `internal/agent/runtime/lifecycle/profile_resolver.go`: typed virtual-profile rejection.
|
||||
- E2E patterns: Dynamic settings profile creation and workflow session targeting.
|
||||
- [Plan evidence and browser scenario](plan.md).
|
||||
|
||||
## Results
|
||||
|
||||
Completed on 2026-09-25. The regression suite reproduced the virtual-profile preflight failure and the stranded `RUNNING` session before the production change. After the fix, targeted orchestrator tests, the race-enabled control suite, lifecycle resolver tests, backend lint, and all six selected Chromium tests passed. The managed E2E run built the backend and Vite assets. Both selected specs were discovered and executed. Spec validation, spec lint, formatting, and diff checks passed. Frontend dependencies were already installed, so the frozen install step was skipped.
|
||||
|
||||
Review follow-up completed on 2026-09-25. The profile-lookup recovery check is now separate from a credential-preflight integration regression that delivers `handleAgentReady` followed by complete-stream, verifies the completed turn and unchanged primary source session, then sends a follow-up through queue admission and confirms exactly one prompt and new turn with no stranded queue entry. The destination-only credential rejection uses a parked target with a profile lacking the source session's valid credential. The browser scenario now polls the exact persisted session identity, primary flag, and `WAITING_FOR_INPUT` state after the second response marker. The runtime facade re-exports `ErrVirtualProfile` so the orchestrator respects the lifecycle import boundary. Focused orchestrator tests and race-enabled recovery coverage passed, architecture lint passed, backend lint passed, and the managed Chromium run passed all six selected tests after rebuilding the backend and web assets. The E2E file's ESLint and Prettier checks passed.
|
||||
|
||||
PR fixup completed on 2026-09-25. Active-turn lookup errors now emit a warning with task/session identifiers while recovery remains fail-closed; a regression covers the warning and retained `RUNNING` state. The browser scenario reloads the task before its follow-up, proving persisted readiness. The plan and work order now link the Dynamic routing system design so the delivery package covers every referenced requirement. Validation passed:
|
||||
|
||||
- `(cd apps/backend && go test ./internal/orchestrator -run '^TestWorkflowCompletionRecoveryLogsActiveTurnLookupFailure$' -count=1)`
|
||||
- `(cd apps/backend && go test -race ./internal/orchestrator -run 'TestWorkflow(Dynamic|ProfileLookupFailure|CredentialPreflightFailure|Completion|PreflightFailure)|TestWorkflowCompletionRecoveryLogsActiveTurnLookupFailure|TestPrepareWorkflowStepSession|TestPreflightWorkflowStepCredentials|TestExactModelWorkflowStartPolicy|TestHandleAgentReady|TestHandleCompleteStreamEvent' -count=1)`
|
||||
- `(cd apps/backend && golangci-lint run ./... --new-from-rev=3aa3233c7833c8f3c034083e10d76999af3a010d --timeout=5m)` (0 issues)
|
||||
- `(cd apps/web && pnpm e2e:run --project chromium tests/workflow/dynamic-workflow-completion.spec.ts tests/workflow/workflow-session-targeting.spec.ts -- --retries=0)` (6 passed)
|
||||
- `(cd apps/web && pnpm exec eslint --max-warnings 0 e2e/tests/workflow/dynamic-workflow-completion.spec.ts)`
|
||||
- `(cd apps/web && pnpm exec prettier --check e2e/tests/workflow/dynamic-workflow-completion.spec.ts)`
|
||||
- `python3 scripts/list-docs.py validate`
|
||||
- `python3 scripts/lint-spec-files.py --all`
|
||||
- Local `pr-docs.cjs` coverage evaluation reported `covered`.
|
||||
- `git diff --check`
|
||||
@@ -107,6 +107,8 @@ starts and ends, so repeated stages use the intended context.
|
||||
drawer shall have one scroll region, safe-area spacing, keyboard-safe
|
||||
navigation, and no document-level horizontal overflow.
|
||||
|
||||
- **AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.14:** If destination validation rejects an automatic transition after a turn completes, the current conversation shall accept another message without cancellation. The workflow step and primary session shall remain unchanged. This recovery shall not stop a newer turn or reopen a terminal session. Desktop and phone shall receive the recovered session state through the existing update channel.
|
||||
|
||||
### REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-002: Explicit workflow session recipients
|
||||
|
||||
**Intent:** Return a later step's prompt to the intended conversation without
|
||||
@@ -186,6 +188,8 @@ requiring its profile to be known when the workflow is authored.
|
||||
|
||||
## Implementation plans
|
||||
|
||||
- [Dynamic workflow completion repair](../../../plans/dynamic-workflow-completion/plan.md)
|
||||
|
||||
- [Manual workflow session focus](../../../plans/workflow-session-focus/plan.md)
|
||||
|
||||
## Example
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
status: draft
|
||||
system: tasks
|
||||
requirements:
|
||||
- REQ-TASKS-WORKFLOW-PROFILE-SESSIONS-001
|
||||
---
|
||||
|
||||
# Workflow profile readiness
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
The task system owns workflow session reuse and readiness after a completed turn.
|
||||
This design extends [session lifecycle](workflow-profile-session-lifecycle.md).
|
||||
The agent conductor retains provider selection, candidate validation, and concrete model enforcement.
|
||||
The [agent requirements](../../agents/requirements/dynamic-agent-routing.md) own that contract.
|
||||
This design preserves the existing Dynamic routing decision. It adds no routing authority.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Criteria | Design section |
|
||||
| --------------------------------------------------- | ------------------- |
|
||||
| AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.1, 001.5 | Profile validation |
|
||||
| AC-TASKS-WORKFLOW-PROFILE-SESSIONS-001.11, 001.14 | Completion recovery |
|
||||
| AC-AGENTS-DYNAMIC-AGENT-ROUTING-001.1, 001.3, 001.7 | Profile validation |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
`event_handlers_workflow.go` owns profile reuse checks and transition preflight.
|
||||
`handleAgentReady` owns the completed-turn boundary under existing session guards.
|
||||
`setSessionWaitingForInput` owns state persistence and publication.
|
||||
The concrete lifecycle resolver rejects virtual profiles through `ErrVirtualProfile`, re-exported by the `agent/runtime` facade for higher-level callers.
|
||||
The Dynamic conductor owns concrete execution identities and launch policy.
|
||||
|
||||
## Data and contracts
|
||||
|
||||
Logical profile identity remains on the task session. Concrete execution identity remains on the existing route and runtime records.
|
||||
A Dynamic logical profile has no concrete model for comparison with a provider runtime snapshot.
|
||||
A wrapped `ErrVirtualProfile` distinguishes that case from missing profiles and repository errors.
|
||||
No new API, event, database field, or flag is required.
|
||||
|
||||
## Profile validation
|
||||
|
||||
In `sessionHasUnauthorizedExactModelDrift`, handle the typed virtual-profile result as no logical exact-model drift.
|
||||
Use `errors.Is` rather than text matching. Keep every other resolver error fatal to the transition.
|
||||
Keep the concrete resolver rejection intact. Do not make virtual profiles launchable.
|
||||
|
||||
The shared drift helper serves preflight, current-session reuse, and parked-session selection.
|
||||
All these paths must use the same distinction. Concrete profiles retain existing exact-model checks, including unknown persisted models.
|
||||
The change must not select a candidate, reset a route, or compare unrelated provider models.
|
||||
Missing and disabled candidates retain their existing conductor validation and recovery behavior.
|
||||
|
||||
## Completion recovery
|
||||
|
||||
A failed destination preflight must leave a completed source conversation promptable.
|
||||
For `on_turn_complete`, settle the still-owned completed session through the existing waiting-state helper before returning the rejection.
|
||||
Use the current lifecycle guard and execution/turn ownership checks. Preserve the original rejection for diagnostics and caller error handling.
|
||||
|
||||
The shared transition helper also handles manual moves, turn start, and guarded decisions.
|
||||
Do not settle these callers merely because their preflight fails. In particular, a manual move can fail during a live turn.
|
||||
A newer turn, replaced execution, or terminal session must retain its state.
|
||||
Do not infer cancellation from guard ownership.
|
||||
|
||||
The transition must not commit, retire the source, create a destination prompt, or consume a successful-transition marker after failed preflight.
|
||||
The already-completed turn remains complete. Duplicate READY or complete events must not repeat entry actions or disturb a later turn.
|
||||
Complete-stream handling must not depend on a second READY event that will never arrive.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
The existing error channel reports the validation failure. The source remains primary on its current workflow step.
|
||||
The existing session update makes the composer ready on desktop and phone. Reload reads the same persisted state.
|
||||
A later message starts a normal turn. No cancellation, runtime restart, or synthetic completion is required.
|
||||
Do not add a timer or weaken admission checks for genuinely active turns.
|
||||
|
||||
## Persistence and security
|
||||
|
||||
Use existing conditional state writes and transition serialization. No migration or archive rewrite is required.
|
||||
Keep managed Git credential checks and profile lookup failures intact.
|
||||
Diagnostics contain identifiers and sanitized errors, never prompts or credentials.
|
||||
|
||||
## Verification design
|
||||
|
||||
Use a real settings store and lifecycle resolver in at least one regression to prove the virtual-profile result.
|
||||
Cover default and explicit reuse, current and parked Dynamic sessions, and concrete exact-model controls.
|
||||
At READY, cover successful Dynamic transition and rejected preflight with persisted source recovery and an accepted follow-up prompt.
|
||||
Cover duplicate completion and a newer turn, plus manual and turn-start rejection during active work.
|
||||
A browser scenario completes a Dynamic-profile turn, enters the next step, and sends another message without cancellation.
|
||||
Backend failure-path tests assert the existing state event and persisted readiness.
|
||||
|
||||
## Observability
|
||||
|
||||
Retain the preflight rejection log and session state event. No new metric is required.
|
||||
Evidence must distinguish a completed turn from an active execution before reporting a stalled session.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [Dynamic profile routing](../../../decisions/2026-08-13-dynamic-agent-profile-routing.md)
|
||||
|
||||
## Implementation plans
|
||||
|
||||
- [Dynamic workflow completion](../../../plans/dynamic-workflow-completion/plan.md)
|
||||
@@ -132,6 +132,8 @@ of the transition.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
[Profile readiness](workflow-profile-readiness.md) defines Dynamic-profile validation and completed-turn recovery after preflight rejection.
|
||||
|
||||
Destination preparation and credential validation fail before source mutation.
|
||||
The current session remains primary and recoverable.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user