Files
kandev/apps/backend/internal/orchestrator/session_launch.go
T
João Salavisa 744aabee96 fix(mcp): keep spawner attribution on a spawned session's first turn (#2047)
* fix(mcp): keep spawner attribution on a spawned session's first turn

A session started through spawn_session_kandev never learned who spawned
it, so it had no way to report back. The MCP handler did prepend a
<kandev-system> attribution block, but the first-turn canonicalizer
(sysprompt.canonicalizeKandevContext) drops every system block it cannot
attribute to server state — and the spawn block was not on the trusted
list, so it was deleted before the agent ever saw it. Spawns are always a
first turn, so this happened every time.

Generate the block at the launch site instead, from server-resolved
identity, and whitelist that exact content for the injector — the same
pattern entity references already use:

- config/prompts/spawned-session.md holds the attribution template.
- sysprompt.SpawnedSessionContext builds it; sysprompt.StripTags (lifted
  out of the handlers' stripSystemTag) keeps embedded IDs from closing
  the block early.
- LaunchSessionRequest.SpawnOrigin carries the spawner identity and is
  kept off the wire (json:"-") so a WS client cannot forge a trusted
  system block.
- startTask prepends the block and passes the identical content as
  trusted content to both the task and Office injectors. Its trailing
  env/route parameters move into a startTaskOptions struct rather than
  growing the positional list again.
- The MCP handler now only fills SpawnOrigin (including the spawner's
  session name) and passes the prompt through untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(mcp): derive spawn attribution from the verified spawner session

Addresses both PR review findings.

CodeRabbit: spawnOrigin copied sender_task_id / sender_session_id straight
out of the request, so an unknown or cross-task sender_session_id became
trusted launch attribution — the spawned agent would be told to report to
a session that never asked for it. Resolve the spawner session once, up
front, require it to belong to the claimed sender task, and derive the
origin (and the inherited agent profile) from that row. An unverifiable
claim now yields no attribution instead of a forged one. This also drops
a duplicate session lookup.

Codex: passthrough profiles skip the Kandev MCP wrap, so the new
attribution never reached a spawned passthrough session even though it
gets MCP configured and can reply. Give that path plain-text attribution
(no <kandev-system> markup, which would show up raw in the TTY the user
is watching). The wrap branch moves into applyLaunchPromptContext, which
keeps startTask under the nesting limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 22:51:40 +01:00

375 lines
14 KiB
Go

package orchestrator
import (
"context"
"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"
)
// 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"`
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"`
// 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:"-"`
}
// 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"`
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
}
// 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.
if err := s.authorizeTask(ctx, req.TaskID); err != nil {
return nil, err
}
if err := s.authorizeSession(ctx, req.SessionID); err != nil {
return nil, 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)
}
}
// 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 {
return !req.AutoStart && !req.DeferredStart && s.isPassthroughProfile(ctx, req.AgentProfileID)
}
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, 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) {
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{SpawnOrigin: req.SpawnOrigin},
)
if err != nil {
return nil, err
}
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.ResumeTaskSession(ctx, req.TaskID, req.SessionID)
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) 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.
if err := s.authorizeSession(ctx, sessionID); err != nil {
return nil, err
}
if err := s.authorizeTask(ctx, taskID); err != nil {
return nil, err
}
switch action {
case "fresh_start":
s.clearResumeToken(ctx, sessionID)
case "resume":
// no-op — relaunch with existing resume token
default:
return nil, fmt.Errorf("invalid recovery action: %s", action)
}
resp, err := s.LaunchSession(ctx, &LaunchSessionRequest{
TaskID: taskID,
SessionID: sessionID,
Intent: IntentResume,
})
if err != nil {
return nil, normalizeRecoverSessionError(err)
}
return resp, nil
}
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
}
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 {
resp := &LaunchSessionResponse{
Success: true,
TaskID: taskID,
SessionID: exec.SessionID,
AgentExecutionID: exec.AgentExecutionID,
State: string(exec.SessionState),
}
if exec.WorktreePath != "" {
resp.WorktreePath = &exec.WorktreePath
}
if exec.WorktreeBranch != "" {
resp.WorktreeBranch = &exec.WorktreeBranch
}
return resp
}