Files
kandev/apps/backend/internal/agent/lifecycle/manager_profile.go
T
João Salavisa 3d2a04e776 feat(orchestrator): deferred task move with hand-off prompt + boot/turn-end event split (#743)
* feat(orchestrator): reuse existing same-profile session on workflow step switch

When a workflow step transition needs a different agent profile, look up
an existing session on the same task that already uses the target profile
and promote it back to primary instead of creating a fresh one. Reuses
COMPLETED and FAILED sessions (CANCELLED stays excluded — explicit user
stops shouldn't be auto-revived).

Backend (event_handlers_workflow.go):
  - findReusableSessionForProfile: most recently updated session matching
    the target profile, skipping CANCELLED.
  - reuseSessionForStep: promotes the session to primary; if terminal,
    revives via reviveReusedSession.
  - reviveReusedSession: distinguishes "previously launched" (has
    executors_running record -> flip to WAITING_FOR_INPUT, lazy-resume via
    ResumeSession with stored token) from "never launched" (no record ->
    flip to CREATED so autoStartStepPrompt routes through
    StartCreatedSession for a full fresh launch). Clears stale
    ErrorMessage/CompletedAt so the UI stops surfacing prior failures.
  - createNewSessionForStep + completeAndStopSession: shared helpers.

Backend (task_operations.go):
  - StartCreatedSession no longer calls processOnTurnStartViaEngine.
    User-initiated messages already fire on_turn_start in
    dispatchPromptAsync. Workflow-driven auto-start prompts arrived
    because the workflow just transitioned us into this step via
    on_turn_complete; firing on_turn_start again cascades the workflow
    back out and the redirect-to-active-session logic delivers the prompt
    to the wrong session's agent.

Frontend (web):
  - task.updated handler follows focus to the new primary when the user
    is viewing the task and was sitting on the previous primary.
  - pinnedSessionId tracks user-initiated session selection
    (setActiveSession sets it; setActiveSessionAuto leaves it alone).
    The terminal-handoff branch in maybeAdoptSessionOnTransition skips
    replacement when the terminating session matches the pin, so manually
    clicking a session no longer yanks focus back to the workflow primary
    each time the pinned session terminates.

Tests:
  - TestSwitchSessionForStep_ReusesExistingProfileSession (never-launched).
  - TestSwitchSessionForStep_ReusesPreviouslyLaunchedSession (launched).
  - TestSwitchSessionForStep_ReusesFailedSession (FAILED reuse + cleanup).
  - tasks.test.ts: focus-follow behavior across 4 scenarios.
  - agent-session.test.ts: pinnedSessionId guard plus migration to
    setActiveSessionAuto for WS-driven adoption.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(orchestrator): deferred task move with hand-off prompt + boot/turn-end event split

Adds move_task_kandev MCP tool that lets a workflow agent (e.g. QA at "Review")
move a task to another step with a prompt for the receiving agent. The move is
queued as a PendingMove and applied on the calling agent's turn-end so on_enter
processing doesn't race the still-running turn.

Also splits agent.ready into agent.boot_ready + agent.ready at the lifecycle
layer. Previously both signals shared one event and the orchestrator
disambiguated them via a resumeInProgressSessions flag — which had a race
when the boot signal arrived before persistResumeState wrote state=STARTING,
causing the boot to fire on_turn_complete against the just-revived step and
ping-pong the task forward through the workflow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR #743 review feedback (Greptile + CodeRabbit)

Backend:
- isTransientPromptError no longer treats ErrExecutionNotFound as transient,
  preventing executeQueuedMessage from infinite-requeueing on permanent
  missing-execution errors. autoStartStepPrompt's explicit errors.Is check
  before the transient branch keeps the recovery path working (Greptile P2 #1)
- applyPendingMove drains the orphaned hand-off prompt on ApplyTransition
  failure so it can't be misdelivered to the source step on a future turn
  (Greptile P2 #2)
- handleMoveTask returns errors instead of panicking when messageQueue is
  nil; queueMoveTaskPrompt returns an error so the deferred path fails fast
  if the queue can't accept the prompt (CodeRabbit CR-2)
- handleMoveTask rejects when no primary session exists and a hand-off prompt
  is required, instead of silently dropping it (CodeRabbit CR-3)
- autoStartStepPrompt preserves attachments from queued messages (was: silently
  dropped); attachment-only messages are no longer skipped; the fallback path
  also forwards attachments (CodeRabbit CR-5)
- applyPendingMove reinserts the pending move on early failure paths (config
  missing, task/step load errors) so a future turn-end retries naturally
  (CodeRabbit CR-6)
- registerKanbanTools split into 3 helpers (list/profile/task) to satisfy
  backend function-size lint limits (CodeRabbit CR-1)

Web:
- maybeAdoptSessionOnTransition's auto-adopt path also checks pinnedSessionId
  to handle the reverse-event-ordering race (old COMPLETED arrives before new
  STARTING) — pinned sessions are no longer yanked off the user. Regression
  test added (CodeRabbit CR-7)

Tests:
- mockAgentManager records (executionID, prompt) pairs; new helper
  capturedPromptsForExecution actually filters by exec ID instead of
  returning all captured prompts (CodeRabbit CR-4)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): respect pinned session in primary-follow-focus path

When the user clicks a session, setActiveSession sets pinnedSessionId. If a
workflow profile switch then updates the task's primarySessionId while the
user happens to still be on the previous primary, the focus-follow block in
tasks.ts was calling setActiveSessionAuto(taskId, newPrimary) and silently
overriding the explicit pin. Add pinnedSessionId !== previousPrimary as a
fifth condition. Regression test in tasks.test.ts covers exactly this
sequence.

Addresses Greptile P1 on PR #743.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address CodeRabbit review on PR #743 (3 new comments)

- handleMoveTask idle path now rolls back the queued hand-off prompt when
  taskSvc.MoveTask fails, so a "you were moved..." message can't be delivered
  on a future turn for a transition that didn't actually happen. Added
  TakeQueued to the MessageQueuer interface to support this.
- lookupSession now returns (*models.TaskSession, error) so callers can
  distinguish a backend lookup failure from a legitimate "no primary session"
  state. handleMoveTask maps the error case to ErrorCodeInternalError instead
  of collapsing it into ErrorCodeValidation.
- queueMoveTaskPrompt returns errors instead of log-and-continue (signature
  change already shipped); existing tests updated to reflect the new contract.
- Refactored TestPendingMove_ReviewToInProgress_OneTransitionOnly: the
  ~280-line monolithic test is now split into a pendingMoveScenario builder
  + assertion helpers, each well under the 80-line / 50-statement lint limit.

All 14 review threads resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: relax move_task_kandev prompt requirement; fix CI lint

CI surfaced two issues after rebasing onto main:

- nestif lint warning on autoStartStepPrompt's queued-message merge block.
  Extracted the take-and-merge logic into takeAndMergeHandoffMessage helper,
  flattening the nested conditionals.
- E2E test settings/config-management 'agent can move a task to a different
  step' failed because my CR-3 fix made the move_task_kandev prompt parameter
  required. That broke admin/config-mode moves where there's no agent to
  hand off to. Made prompt optional: when supplied AND the source session
  is mid-turn, the deferred-move flow runs (queue prompt + record
  PendingMove); otherwise just move the task. Refactored handleMoveTask
  into deferMoveTaskWithPrompt and applyMoveTaskImmediate to keep each
  function under the 80-line lint limit.

Tool descriptions in mcp/server/server.go and mcp/server/config_handlers.go
updated to reflect prompt's now-optional status. moveTaskHandler in
config_handlers.go forwards prompt only when set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(mcp): treat 'no primary session' as a not-found, not an internal error

GetPrimarySessionByTaskID returns a plain error string ("no primary session
found for task: <id>") when the repo has no rows. My CR-B refactor was
treating ALL lookup errors as ErrorCodeInternalError, which broke the E2E
test 'agent can move a task to a different step' (config-mode move on a
freshly-created task that has no agent yet).

Match the not-found message and return (nil, nil); only real backend
failures bubble up as InternalError. Distinguishing properly with
errors.Is would require an exported sentinel that doesn't exist; matching
the noRowsErr substring is the pragmatic fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 18:17:36 +01:00

169 lines
6.2 KiB
Go

package lifecycle
import (
"context"
"errors"
"fmt"
"strings"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/executor"
"github.com/kandev/kandev/internal/agent/mcpconfig"
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
)
// ResolveAgentProfile resolves an agent profile ID to profile information.
// This is exposed for external callers (like the orchestrator executor) to get profile info.
// The profile's model is guaranteed to be non-empty as it's validated at creation time.
func (m *Manager) ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) {
if m.profileResolver == nil {
return nil, fmt.Errorf("profile resolver not configured")
}
return m.profileResolver.ResolveProfile(ctx, profileID)
}
// getAgentConfigForExecution retrieves the agent configuration for an execution.
// The execution must have AgentCommand set (which includes the agent type).
func (m *Manager) getAgentConfigForExecution(execution *AgentExecution) (agents.Agent, error) {
if execution.AgentProfileID == "" {
return nil, fmt.Errorf("execution %s has no agent profile ID", execution.ID)
}
if m.profileResolver == nil {
return nil, fmt.Errorf("profile resolver not configured")
}
profileInfo, err := m.profileResolver.ResolveProfile(context.Background(), execution.AgentProfileID)
if err != nil {
return nil, fmt.Errorf("failed to resolve profile: %w", err)
}
agentTypeName := profileInfo.AgentName
agentConfig, ok := m.registry.Get(agentTypeName)
if !ok {
return nil, fmt.Errorf("agent type not found: %s", agentTypeName)
}
return agentConfig, nil
}
// resolveMcpServers centralizes MCP resolution for a session:
// - loads per-agent MCP config,
// - applies executor-scoped transport rules, allow/deny lists, URL rewrites, and env injection,
// - converts to ACP stdio server definitions used during session initialization.
func (m *Manager) resolveMcpServers(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent) ([]agentctltypes.McpServer, error) {
if execution == nil {
return nil, nil
}
return m.resolveMcpServersWithParams(ctx, execution.AgentProfileID, execution.Metadata, agentConfig)
}
// applyExecutorMcpPolicy reads executor metadata and applies any executor-scoped MCP policy
// override on top of the given default policy. It returns the updated policy and executor ID.
func (m *Manager) applyExecutorMcpPolicy(profileID string, executorID string, metadata map[string]interface{}, policy mcpconfig.Policy) (mcpconfig.Policy, string, error) {
if metadata == nil {
return policy, executorID, nil
}
if value, ok := metadata["executor_id"].(string); ok {
executorID = value
}
value, ok := metadata["executor_mcp_policy"]
if !ok {
return policy, executorID, nil
}
updated, policyWarnings, err := mcpconfig.ApplyExecutorPolicy(policy, value)
if err != nil {
return policy, executorID, fmt.Errorf("invalid executor MCP policy: %w", err)
}
policy = updated
for _, warning := range policyWarnings {
m.logger.Warn("mcp policy warning",
zap.String("profile_id", profileID),
zap.String("executor_id", executorID),
zap.String("warning", warning))
}
return policy, executorID, nil
}
// resolveMcpServersWithParams resolves MCP servers with explicit parameters.
// This is used by Launch() before the execution object exists.
func (m *Manager) resolveMcpServersWithParams(ctx context.Context, profileID string, metadata map[string]interface{}, agentConfig agents.Agent) ([]agentctltypes.McpServer, error) {
if m.mcpProvider == nil || agentConfig == nil {
return nil, nil
}
profileID = strings.TrimSpace(profileID)
if profileID == "" {
return nil, nil
}
config, err := m.mcpProvider.GetConfigByProfileID(ctx, profileID)
if err != nil {
if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) || errors.Is(err, mcpconfig.ErrAgentProfileNotFound) {
return nil, nil
}
m.logger.Warn("failed to load MCP config",
zap.String("profile_id", profileID),
zap.Error(err))
return nil, err
}
if config == nil || !config.Enabled {
return nil, nil
}
// Get default runtime for MCP policy (used before execution exists)
defaultRT, _ := m.getDefaultExecutorBackend()
policy := mcpconfig.DefaultPolicyForRuntime(runtimeName(defaultRT))
executorID := ""
policy, executorID, err = m.applyExecutorMcpPolicy(profileID, executorID, metadata, policy)
if err != nil {
return nil, err
}
resolved, warnings, err := mcpconfig.Resolve(config, policy)
if err != nil {
return nil, err
}
for _, warning := range warnings {
m.logger.Warn("mcp config warning",
zap.String("profile_id", profileID),
zap.String("executor_id", executorID),
zap.String("warning", warning))
}
return mcpconfig.ToACPServers(resolved), nil
}
func runtimeName(rt ExecutorBackend) executor.Name {
if rt == nil {
return executor.NameUnknown
}
return rt.Name()
}
// resolveProfileModelAndMode resolves the model and mode configured on an agent profile.
// Returns empty strings if the profile cannot be resolved.
func (m *Manager) resolveProfileModelAndMode(ctx context.Context, profileID string) (string, string) {
if profileID == "" || m.profileResolver == nil {
return "", ""
}
info, err := m.profileResolver.ResolveProfile(ctx, profileID)
if err != nil || info == nil {
return "", ""
}
return info.Model, info.Mode
}
// initializeACPSession delegates to SessionManager for full ACP session initialization and prompting.
// We pass MarkBootReady (not MarkReady) for the no-prompt branches: dispatchInitialPrompt only
// invokes the callback when there's no taskDescription/attachments to send, which is a *boot*
// signal (the agent has never run a turn). When there IS a prompt, the callback is unused and
// MarkReady fires later from handleCompleteEvent — that path is the true turn-end.
func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, attachments []MessageAttachment, mcpServers []agentctltypes.McpServer) error {
profileModel, profileMode := m.resolveProfileModelAndMode(ctx, execution.AgentProfileID)
return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, attachments, mcpServers, m.MarkBootReady, profileModel, profileMode)
}