Files
kandev/apps/backend/internal/orchestrator/session_launch.go
T
João Salavisa 481359ddb2 feat(auth): organization units, tenancy, roles and scopes for team access (#2919)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:40:43 +00:00

503 lines
19 KiB
Go

package orchestrator
import (
"context"
"errors"
"fmt"
"strings"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/task/models"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// sessionTerminalErrText mirrors lifecycle.ErrSessionTerminal's message. It is
// duplicated as a string rather than imported so this higher-level orchestrator
// file does not take a direct dependency on internal/agent/runtime/lifecycle
// (ARCH-RUNTIME-IMPORT): the launch failures reach here only as wrapped-error
// strings or a stringified persisted session error, so a string match is both
// sufficient and required (see IsBenignLaunchTeardownErr).
const sessionTerminalErrText = "session is terminal"
// SessionIntent represents the type of session operation requested.
type SessionIntent string
const (
IntentPrepare SessionIntent = "prepare" // Create session, optionally launch workspace, NO agent
IntentStart SessionIntent = "start" // Create session + launch agent (new session)
IntentStartCreated SessionIntent = "start_created" // Start agent on existing CREATED session
IntentResume SessionIntent = "resume" // Restart stopped session with resume token
IntentWorkflowStep SessionIntent = "workflow_step" // Start session with workflow step prompt config
IntentRestoreWorkspace SessionIntent = "restore_workspace" // Restore workspace access for terminal-state session
)
// LaunchSessionRequest is the unified request for session.launch.
type LaunchSessionRequest struct {
TaskID string `json:"task_id"`
Intent SessionIntent `json:"intent,omitempty"`
SessionID string `json:"session_id,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
// ProfileExplicit marks a non-empty profile selected through an explicit
// selector-backed choice. It bypasses workflow-step profile resolution for
// IntentStart only; IntentStartCreated keeps its existing profile resolution
// behavior.
ProfileExplicit bool `json:"profile_explicit,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
ExecutorProfileID string `json:"executor_profile_id,omitempty"`
Prompt string `json:"prompt,omitempty"`
PlanMode bool `json:"plan_mode,omitempty"`
WorkflowStepID string `json:"workflow_step_id,omitempty"`
Priority string `json:"priority,omitempty"`
LaunchWorkspace bool `json:"launch_workspace,omitempty"`
SkipMessageRecord bool `json:"skip_message_record,omitempty"`
AutoStart bool `json:"auto_start,omitempty"`
// NoAgentLaunch marks a prepare request that must NEVER be upgraded into an
// agent launch, even for passthrough profiles (whose prepare would normally
// be eagerly upgraded so the PTY exists). It backs the session.ensure
// auto_start=false override used by the prevent-auto-start-on-open
// preference: the session is created workspace-only (CREATED) and the
// Start agent button launches it later. It is an internal server-side flag
// set from EnsureSessionOptions, kept off the wire protocol (`json:"-"`).
NoAgentLaunch bool `json:"-"`
// DeferredStart marks a prepare whose caller will follow up with an explicit
// IntentStartCreated that carries the prompt (the two-phase create flow:
// cheap sync prepare + async start). It suppresses the passthrough
// launchPrepare→launchStart upgrade so the eager launch doesn't spawn a
// promptless PTY and pre-empt the prompt-bearing start. It is an internal
// server-side coordination flag set by the deferred-start handlers, so it is
// kept off the wire protocol (`json:"-"`) — a client must not be able to
// suppress the upgrade and strand a passthrough session without a PTY.
DeferredStart bool `json:"-"`
Attachments []v1.MessageAttachment `json:"attachments,omitempty"`
// SpawnOrigin identifies the agent session that requested this launch via
// spawn_session_kandev, so the new session's first turn can carry spawner
// attribution and reply instructions. Like DeferredStart it is kept off the
// wire protocol (`json:"-"`): the launch site turns it into a *trusted*
// <kandev-system> block that survives first-turn canonicalization, so a WS
// client must not be able to forge one and fabricate server authority.
SpawnOrigin *SpawnOrigin `json:"-"`
// AllowBranchReplacement is set only by RecoverSession for the explicit
// resume_new_branch action. Clients cannot grant this permission directly.
AllowBranchReplacement bool `json:"-"`
}
// SpawnOrigin describes the agent session that spawned a new sibling session.
// The identifiers are resolved server-side by the MCP layer from the calling
// agent's own session, never read from the tool arguments.
type SpawnOrigin struct {
TaskID string
SessionID string
SessionName string
}
// LaunchSessionResponse is the unified response for session.launch.
type LaunchSessionResponse struct {
Success bool `json:"success"`
TaskID string `json:"task_id"`
SessionID string `json:"session_id,omitempty"`
AgentExecutionID string `json:"agent_execution_id,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
State string `json:"state"`
WorktreePath *string `json:"worktree_path,omitempty"`
WorktreeBranch *string `json:"worktree_branch,omitempty"`
}
// ResolveIntent infers the session intent from request fields when Intent is empty.
func ResolveIntent(req *LaunchSessionRequest) SessionIntent {
if req.Intent != "" {
return req.Intent
}
if req.SessionID != "" && req.WorkflowStepID != "" {
return IntentWorkflowStep
}
if req.SessionID != "" && req.Prompt == "" && req.AgentProfileID == "" {
return IntentResume
}
if req.SessionID != "" {
return IntentStartCreated
}
if req.LaunchWorkspace && req.Prompt == "" {
return IntentPrepare
}
return IntentStart
}
// IsBenignLaunchTeardownErr reports whether a session-launch failure is an
// expected graceful-shutdown teardown race rather than a genuine fault. During
// shutdown the root context is cancelled and terminal sessions reject launches,
// so an in-flight session.launch fails predictably; those should log WARN
// without a stack trace, not ERROR.
//
// Two shapes reach the launch handler on shutdown:
// - restore_workspace wraps lifecycle.ErrSessionTerminal with %w and the
// cancelled root context surfaces context.Canceled, so errors.Is matches
// the context sentinel.
// - resume stringifies a persisted session error (task_operations.go uses %s
// on sess.ErrorMessage), destroying any sentinel, so a bounded string
// fallback for "context canceled"/"session is terminal" is required. The
// terminal-session text is matched as a string (not errors.Is) to avoid a
// higher-level import of the runtime/lifecycle seam.
func IsBenignLaunchTeardownErr(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) {
return true
}
msg := err.Error()
return strings.Contains(msg, context.Canceled.Error()) ||
strings.Contains(msg, sessionTerminalErrText)
}
// LaunchSession is the unified entry point for all session operations.
func (s *Service) LaunchSession(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) {
// Every intent funnels through here. SessionID is empty when creating, so
// that case is carried by the task check alone.
// Launching a session starts an agent turn: session.prompt.
if err := s.authorizeTaskPrompt(ctx, req.TaskID); err != nil {
return nil, err
}
// Existing-session launches must also prove that the supplied session and
// task belong together; independent reach checks do not establish that
// binding when a caller can access more than one task.
if err := s.authorizeTaskSessionPair(ctx, req.TaskID, req.SessionID); err != nil {
return nil, err
}
if err := s.claimLaunchAttachments(ctx, req); err != nil {
return nil, fmt.Errorf("claim launch attachments: %w", err)
}
intent := ResolveIntent(req)
req.Prompt = strings.TrimSpace(req.Prompt)
switch intent {
case IntentPrepare:
return s.launchPrepare(ctx, req)
case IntentStart:
return s.launchStart(ctx, req)
case IntentStartCreated:
return s.launchStartCreated(ctx, req)
case IntentResume:
return s.launchResume(ctx, req)
case IntentWorkflowStep:
return s.launchWorkflowStep(ctx, req)
case IntentRestoreWorkspace:
return s.launchRestoreWorkspace(ctx, req)
default:
return nil, fmt.Errorf("unknown intent: %s", intent)
}
}
func (s *Service) claimLaunchAttachments(ctx context.Context, req *LaunchSessionRequest) error {
hasDescriptor := false
for _, attachment := range req.Attachments {
if attachment.AttachmentID != "" {
hasDescriptor = true
break
}
}
if !hasDescriptor {
return nil
}
if s.launchAttachmentClaimer == nil {
return errors.New("launch attachment claimer is not configured")
}
return s.launchAttachmentClaimer.ClaimMessageAttachments(
ctx,
req.TaskID,
req.SessionID,
req.Attachments,
)
}
// launchPrepare creates a session entry without launching the agent.
// Passthrough profiles can't be "prepared" without a running PTY — the terminal
// has nothing to attach to until the agent process exists. Upgrade those calls
// to a full start so the PTY is ready by the time the user sees the terminal.
//
// AutoStart=true means we arrived here from launchStart's blocked-auto-start
// downgrade path; skipping the upgrade in that case avoids a launchStart ↔
// launchPrepare bounce.
//
// DeferredStart=true means a prompt-bearing IntentStartCreated will follow this
// prepare (the two-phase create flow); skipping the upgrade there leaves the
// session CREATED so that follow-up start launches the passthrough agent WITH
// the prompt — eagerly launching here would spawn a promptless PTY and the
// later start would be rejected against the now-running session.
func (s *Service) launchPrepare(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) {
if s.shouldUpgradePassthroughPrepare(ctx, req) {
return s.launchStart(ctx, req)
}
sessionID, err := s.PrepareTaskSession(
ctx, req.TaskID, req.AgentProfileID, req.ExecutorID,
req.ExecutorProfileID, req.WorkflowStepID, req.LaunchWorkspace,
)
if err != nil {
return nil, err
}
return &LaunchSessionResponse{
Success: true,
TaskID: req.TaskID,
SessionID: sessionID,
State: string(models.TaskSessionStateCreated),
}, nil
}
// shouldUpgradePassthroughPrepare reports whether a prepare request for a
// passthrough profile should be eagerly upgraded to a full launch so a PTY
// exists for the terminal to attach to. It is the single decision point for the
// upgrade documented on launchPrepare: only genuine prepare-only callers (no
// imminent prompt-bearing start) get the eager launch. See launchPrepare for
// why AutoStart and DeferredStart each suppress it.
func (s *Service) shouldUpgradePassthroughPrepare(ctx context.Context, req *LaunchSessionRequest) bool {
if req.NoAgentLaunch {
return false
}
return !req.AutoStart && !req.DeferredStart && s.isPassthroughProfile(ctx, req.AgentProfileID)
}
// isPassthroughProfile reports whether the agent profile is a CLI
// passthrough provider.
func (s *Service) isPassthroughProfile(ctx context.Context, profileID string) bool {
if profileID == "" || s.agentManager == nil {
return false
}
info, err := s.agentManager.ResolveAgentProfile(ctx, profileID)
if err != nil || info == nil {
return false
}
return info.CLIPassthrough
}
// launchStart creates a new session and launches the agent.
// If the request is an auto-start and the task's current workflow step does not
// have auto_start_agent, or the task has unresolved dependencies, the request
// is downgraded to a prepare (workspace-only, no agent) to prevent unwanted
// auto-starts from the frontend's useAutoStartSession hook.
func (s *Service) launchStart(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) {
if req.AutoStart && (s.shouldBlockAutoStart(ctx, req) ||
s.dependencyBlocksAutoStart(ctx, req.TaskID, "session.launch")) {
req.LaunchWorkspace = true
return s.launchPrepare(ctx, req)
}
execution, err := s.startTask(
ctx, req.TaskID, req.AgentProfileID, req.ExecutorID,
req.ExecutorProfileID, req.Priority, req.Prompt,
req.WorkflowStepID, req.PlanMode, req.AutoStart, req.Attachments,
startTaskOptions{ProfileExplicit: req.ProfileExplicit, SpawnOrigin: req.SpawnOrigin},
)
if err != nil {
return nil, err
}
if execution == nil {
// The automatic terminal-PR gate intentionally skips session creation.
// Return a successful no-op response so session.ensure and WS callers do
// not dereference a nil execution while the task-owned error card remains
// the recovery surface.
return &LaunchSessionResponse{
Success: true,
TaskID: req.TaskID,
}, nil
}
return executionToLaunchResponse(req.TaskID, execution), nil
}
// shouldBlockAutoStart checks whether the task's workflow step allows auto-starting
// the agent. Returns true when the step exists but does not have auto_start_agent
// in its on_enter events. Tasks without a workflow step are never blocked.
func (s *Service) shouldBlockAutoStart(ctx context.Context, req *LaunchSessionRequest) bool {
if s.workflowStepGetter == nil {
return false
}
task, err := s.repo.GetTask(ctx, req.TaskID)
if err != nil || task.WorkflowStepID == "" {
return false
}
step, err := s.workflowStepGetter.GetStep(ctx, task.WorkflowStepID)
if err != nil || step == nil {
return false
}
if step.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent) {
return false
}
s.logger.Info("auto-start downgraded to prepare: step lacks auto_start_agent",
zap.String("task_id", req.TaskID),
zap.String("workflow_step_id", task.WorkflowStepID),
zap.String("step_name", step.Name))
return true
}
// launchStartCreated starts agent execution on an existing CREATED session.
func (s *Service) launchStartCreated(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) {
execution, err := s.StartCreatedSession(
ctx, req.TaskID, req.SessionID, req.AgentProfileID,
req.Prompt, req.SkipMessageRecord, req.PlanMode, req.AutoStart, req.Attachments, nil,
)
if err != nil {
return nil, err
}
return executionToLaunchResponse(req.TaskID, execution), nil
}
// launchResume resumes a stopped session.
func (s *Service) launchResume(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) {
execution, err := s.ResumeTaskSessionWithOptions(ctx, req.TaskID, req.SessionID, executor.ResumeOptions{
AllowBranchReplacement: req.AllowBranchReplacement,
})
if err != nil {
return nil, err
}
return executionToLaunchResponse(req.TaskID, execution), nil
}
// launchWorkflowStep starts a session with workflow step prompt configuration.
func (s *Service) launchWorkflowStep(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) {
err := s.StartSessionForWorkflowStep(ctx, req.TaskID, req.SessionID, req.WorkflowStepID)
if err != nil {
return nil, err
}
return &LaunchSessionResponse{
Success: true,
TaskID: req.TaskID,
SessionID: req.SessionID,
State: string(v1.TaskSessionStateRunning),
}, nil
}
// launchRestoreWorkspace restores workspace access for a terminal-state session (COMPLETED, FAILED, CANCELLED).
// It creates a lightweight agentctl execution so the frontend can browse files, open terminals, and view git status.
func (s *Service) launchRestoreWorkspace(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) {
if req.SessionID == "" {
return nil, fmt.Errorf("session_id is required for workspace restore")
}
session, err := s.repo.GetTaskSession(ctx, req.SessionID)
if err != nil {
return nil, fmt.Errorf("session not found: %w", err)
}
if session.TaskID != req.TaskID {
return nil, fmt.Errorf("session does not belong to task")
}
if err := s.agentManager.EnsureWorkspaceExecutionForSession(ctx, req.TaskID, req.SessionID); err != nil {
return nil, fmt.Errorf("failed to restore workspace: %w", err)
}
resp := &LaunchSessionResponse{
Success: true,
TaskID: req.TaskID,
SessionID: req.SessionID,
State: string(session.State),
}
if len(session.Worktrees) > 0 {
wt := session.Worktrees[0]
if wt.WorktreePath != "" {
resp.WorktreePath = &wt.WorktreePath
}
if wt.WorktreeBranch != "" {
resp.WorktreeBranch = &wt.WorktreeBranch
}
}
return resp, nil
}
// RecoverSession handles user-initiated recovery after an agent CLI failure.
// action is "resume" (retry with existing ACP session), "resume_new_branch"
// (retry after replacing a confirmed missing branch), or "fresh_start" (clear
// token, start fresh).
func (s *Service) RecoverSession(ctx context.Context, taskID, sessionID, action string) (*LaunchSessionResponse, error) {
// Guard before the switch: "fresh_start" clears the resume token, so an
// unauthorized call would mutate the session even if the launch failed.
// Recovering a session resumes an agent turn: session.prompt.
if err := s.authorizeSessionPrompt(ctx, sessionID); err != nil {
return nil, err
}
if err := s.authorizeTask(ctx, taskID); err != nil {
return nil, err
}
if action == "runtime_retry" {
if s.wasResumeAttempt(ctx, sessionID) {
action = "resume"
} else {
action = "fresh_start"
}
}
switch action {
case "fresh_start":
if err := s.clearResumeToken(ctx, sessionID); err != nil {
return nil, fmt.Errorf("failed to clear resume token for fresh start: %w", err)
}
case "resume":
// no-op — relaunch with existing resume token
case "resume_new_branch":
// The launch carries the explicit permission. It is not persisted on the
// session and cannot be inferred from a previous failed attempt.
default:
return nil, fmt.Errorf("invalid recovery action: %s", action)
}
resp, err := s.LaunchSession(ctx, &LaunchSessionRequest{
TaskID: taskID,
SessionID: sessionID,
Intent: IntentResume,
AllowBranchReplacement: action == "resume_new_branch",
})
if err != nil {
return nil, normalizeRecoverSessionError(err)
}
return resp, nil
}
// normalizeRecoverSessionError maps a missing-profile resume failure to a
// user-actionable message.
func normalizeRecoverSessionError(err error) error {
if err == nil {
return nil
}
if isMissingProfileResumeError(err) {
return fmt.Errorf("the agent profile used by this session was deleted; start a new session and choose an available agent profile: %w", err)
}
return err
}
// isMissingProfileResumeError reports whether the error indicates the
// session's agent profile no longer exists.
func isMissingProfileResumeError(err error) bool {
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "failed to resolve agent profile") ||
strings.Contains(msg, "agent profile not found")
}
// executionToLaunchResponse converts a TaskExecution to a LaunchSessionResponse.
func executionToLaunchResponse(taskID string, exec *executor.TaskExecution) *LaunchSessionResponse {
if exec == nil {
return &LaunchSessionResponse{
Success: true,
TaskID: taskID,
}
}
resp := &LaunchSessionResponse{
Success: true,
TaskID: taskID,
SessionID: exec.SessionID,
AgentExecutionID: exec.AgentExecutionID,
AgentProfileID: exec.AgentProfileID,
State: string(exec.SessionState),
}
if exec.WorktreePath != "" {
resp.WorktreePath = &exec.WorktreePath
}
if exec.WorktreeBranch != "" {
resp.WorktreeBranch = &exec.WorktreeBranch
}
return resp
}