3d2a04e776
* 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>
169 lines
6.2 KiB
Go
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)
|
|
}
|