package backendapp import ( "context" "errors" "fmt" "sync" "time" "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/registry" runtimeapi "github.com/kandev/kandev/internal/agent/runtime" "github.com/kandev/kandev/internal/agent/runtime/agentctl" runtimeenv "github.com/kandev/kandev/internal/agent/runtime/environment" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/clarification" "github.com/kandev/kandev/internal/common/logger" githubsvc "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/pkg/api/v1" ) // taskGetterRepo is the minimal interface needed by the scheduler adapter. type taskGetterRepo interface { GetTask(ctx context.Context, id string) (*models.Task, error) } // taskRepositoryAdapter adapts the task repository for the orchestrator's scheduler type taskRepositoryAdapter struct { repo taskGetterRepo svc *taskservice.Service } // GetTask retrieves a task by ID and converts it to API type func (a *taskRepositoryAdapter) GetTask(ctx context.Context, taskID string) (*v1.Task, error) { task, err := a.repo.GetTask(ctx, taskID) if err != nil { return nil, err } return task.ToAPI(), nil } // UpdateTaskState updates task state via the service func (a *taskRepositoryAdapter) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error { _, err := a.svc.UpdateTaskState(ctx, taskID, state) return err } // UpdateTaskStateIfCurrentIn transitions task state via the service when the // current state is in allowed. func (a *taskRepositoryAdapter) UpdateTaskStateIfCurrentIn( ctx context.Context, taskID string, state v1.TaskState, allowed []v1.TaskState, ) (bool, error) { return a.svc.UpdateTaskStateIfCurrentIn(ctx, taskID, state, allowed) } // UpdateTaskStateIfNotArchived is UpdateTaskStateIfCurrentIn without the // prior-state constraint (see scheduler.TaskRepository doc). func (a *taskRepositoryAdapter) UpdateTaskStateIfNotArchived( ctx context.Context, taskID string, state v1.TaskState, ) (bool, error) { return a.svc.UpdateTaskStateIfNotArchived(ctx, taskID, state) } func (a *taskRepositoryAdapter) UpdateTaskStateIfSessionState( ctx context.Context, taskID, sessionID string, expectedSessionState models.TaskSessionState, state v1.TaskState, ) (bool, error) { return a.svc.UpdateTaskStateIfSessionState(ctx, taskID, sessionID, expectedSessionState, state) } func (a *taskRepositoryAdapter) UpdateTaskStateIfPrimarySessionState( ctx context.Context, taskID, sessionID string, expectedSessionState models.TaskSessionState, state v1.TaskState, ) (bool, error) { return a.svc.UpdateTaskStateIfPrimarySessionState( ctx, taskID, sessionID, expectedSessionState, state, ) } // lifecycleAdapter adapts the lifecycle manager as an AgentManagerClient type lifecycleAdapter struct { mgr *lifecycle.Manager registry *registry.Registry logger *logger.Logger } var _ interface { OwnsPromptGeneration(sessionID, executionID string, generation uint64) bool GetPromptGenerationForSession(ctx context.Context, sessionID string) (uint64, error) } = (*lifecycleAdapter)(nil) // newLifecycleAdapter creates a new lifecycle adapter func newLifecycleAdapter(mgr *lifecycle.Manager, reg *registry.Registry, log *logger.Logger) *lifecycleAdapter { return &lifecycleAdapter{ mgr: mgr, registry: reg, logger: log.WithFields(zap.String("component", "lifecycle_adapter")), } } // LaunchAgent creates a new agentctl instance for a task. // Agent subprocess is NOT started - call StartAgentProcess() explicitly. func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { // WorkspacePath wins when set (repo-less task with picked folder); otherwise // fall back to RepositoryURL (legacy: this carries a local filesystem path // for the workspace). Empty is also valid — lifecycle manager creates a // scratch workspace. workspacePath := req.WorkspacePath if workspacePath == "" { workspacePath = req.RepositoryURL } officeProfileID := req.OfficeAgentProfileID if officeProfileID == "" { officeProfileID = req.AgentProfileID } launchReq := buildLifecycleLaunchRequest(req, workspacePath, officeProfileID) // Create the agentctl execution (does NOT start agent process) execution, err := a.mgr.Launch(ctx, launchReq) if err != nil { return nil, err } // Extract worktree info from metadata if available metadata := execution.MetadataSnapshot() var worktreeID, worktreePath, worktreeBranch string if metadata != nil { if id, ok := metadata["worktree_id"].(string); ok { worktreeID = id } if path, ok := metadata["worktree_path"].(string); ok { worktreePath = path } if branch, ok := metadata["worktree_branch"].(string); ok { worktreeBranch = branch } } // Surface per-repo worktree results from the prepare step so the orchestrator // can persist N TaskEnvironmentRepo / TaskSessionWorktree rows when multi-repo. var worktrees []executor.RepoWorktreeResult if execution.PrepareResult != nil && len(execution.PrepareResult.Worktrees) > 0 { worktrees = make([]executor.RepoWorktreeResult, 0, len(execution.PrepareResult.Worktrees)) for _, w := range execution.PrepareResult.Worktrees { worktrees = append(worktrees, executor.RepoWorktreeResult{ RepositoryID: w.RepositoryID, BranchSlug: w.BranchSlug, WorktreeID: w.WorktreeID, WorktreeBranch: w.WorktreeBranch, WorktreePath: w.WorktreePath, MainRepoGitDir: w.MainRepoGitDir, ErrorMessage: w.ErrorMessage, }) } } return &executor.LaunchAgentResponse{ AgentExecutionID: execution.ID, ContainerID: execution.ContainerID, Status: execution.Status, WorktreeID: worktreeID, WorktreePath: worktreePath, WorktreeBranch: worktreeBranch, WorkspacePath: execution.WorkspacePath, Metadata: metadata, PrepareResult: execution.PrepareResult, Worktrees: worktrees, }, nil } func buildLifecycleLaunchRequest( req *executor.LaunchAgentRequest, workspacePath, officeProfileID string, ) *lifecycle.LaunchRequest { launchReq := &lifecycle.LaunchRequest{ TaskID: req.TaskID, WorkspaceID: req.WorkspaceID, SessionID: req.SessionID, TaskEnvironmentID: req.TaskEnvironmentID, TaskTitle: req.TaskTitle, AgentProfileID: officeProfileID, ExecutionProfileID: req.AgentProfileID, StartAgent: req.StartAgent, WorkspacePath: workspacePath, TaskDescription: req.TaskDescription, Attachments: convertToLifecycleAttachments(req.Attachments), Env: req.Env, ApprovedSecretEnvKeys: append([]string(nil), req.ApprovedSecretEnvKeys...), EnvironmentDefinitions: append([]runtimeenv.Definition(nil), req.EnvironmentDefinitions...), EnvironmentResolutionRequired: req.EnvironmentResolutionRequired, ACPSessionID: req.ACPSessionID, Metadata: req.Metadata, ModelOverride: req.ModelOverride, ExecutorType: req.ExecutorType, ExecutorConfig: req.ExecutorConfig, PreviousExecutionID: req.PreviousExecutionID, McpMode: req.McpMode, McpProviders: req.McpProviders, McpProfile: req.McpProfile, IsEphemeral: req.IsEphemeral, IsPassthrough: req.IsPassthrough, SetupScript: req.SetupScript, CopyFiles: req.CopyFiles, UseWorktree: req.UseWorktree, WorktreeID: req.WorktreeID, RepositoryID: req.RepositoryID, RepositoryPath: req.RepositoryPath, BaseBranch: req.BaseBranch, DefaultBranch: req.DefaultBranch, CheckoutBranch: req.CheckoutBranch, PRNumber: req.PRNumber, RemoteContribution: req.RemoteContribution, WorktreeBranchPrefix: req.WorktreeBranchPrefix, WorktreeBranchTemplate: req.WorktreeBranchTemplate, WorktreeBranchTicket: req.WorktreeBranchTicket, PullBeforeWorktree: req.PullBeforeWorktree, RemoteSyncHandled: req.RemoteSyncHandled, TaskDirName: req.TaskDirName, RepoName: req.RepoName, BranchSlug: req.BranchSlug, BranchIdentitySlug: req.BranchIdentitySlug, } launchReq.WorkspaceFolders = lifecycleWorkspaceFolders(req.WorkspaceFolders) launchReq.RouteOverride = lifecycleRouteOverride(req.RouteOverride) launchReq.Repositories = lifecycleRepoLaunchSpecs(req.Repositories) return launchReq } func lifecycleWorkspaceFolders(folders []executor.WorkspaceFolderSpec) []lifecycle.WorkspaceFolderSpec { if len(folders) == 0 { return nil } result := make([]lifecycle.WorkspaceFolderSpec, 0, len(folders)) for _, f := range folders { result = append(result, lifecycle.WorkspaceFolderSpec{Name: f.Name, LocalPath: f.LocalPath}) } return result } func lifecycleRouteOverride(override *executor.RouteOverride) *lifecycle.RouteOverride { if override == nil { return nil } return &lifecycle.RouteOverride{ ExecutionProfileID: override.ExecutionProfileID, ProviderID: override.ProviderID, Model: override.Model, Tier: override.Tier, Mode: override.Mode, Flags: override.Flags, Env: override.Env, } } func lifecycleRepoLaunchSpecs(repos []executor.RepoSpec) []lifecycle.RepoLaunchSpec { if len(repos) == 0 { return nil } specs := make([]lifecycle.RepoLaunchSpec, 0, len(repos)) for _, r := range repos { specs = append(specs, lifecycle.RepoLaunchSpec{ RepositoryID: r.RepositoryID, RepositoryPath: r.RepositoryPath, RepositoryURL: r.RepositoryURL, RepoName: r.RepoName, BaseBranch: r.BaseBranch, DefaultBranch: r.DefaultBranch, CheckoutBranch: r.CheckoutBranch, PRNumber: r.PRNumber, RemoteContribution: r.RemoteContribution, WorktreeID: r.WorktreeID, WorktreeBranchPrefix: r.WorktreeBranchPrefix, WorktreeBranchTemplate: r.WorktreeBranchTemplate, WorktreeBranchTicket: r.WorktreeBranchTicket, PullBeforeWorktree: r.PullBeforeWorktree, RemoteSyncHandled: r.RemoteSyncHandled, RepoSetupScript: r.RepoSetupScript, RepoCleanupScript: r.RepoCleanupScript, CopyFiles: r.CopyFiles, BranchSlug: r.BranchSlug, BranchIdentitySlug: r.BranchIdentitySlug, }) } return specs } // convertToLifecycleAttachments converts v1.MessageAttachment to lifecycle.MessageAttachment. func convertToLifecycleAttachments(attachments []v1.MessageAttachment) []lifecycle.MessageAttachment { if len(attachments) == 0 { return nil } result := make([]lifecycle.MessageAttachment, len(attachments)) for i, att := range attachments { result[i] = lifecycle.MessageAttachment{ AttachmentID: att.AttachmentID, Type: att.Type, Data: att.Data, MimeType: att.MimeType, Name: att.Name, SizeBytes: att.SizeBytes, DeliveryMode: att.DeliveryMode, } } return result } // SetExecutionDescription updates the task description in an existing execution's metadata. func (a *lifecycleAdapter) SetExecutionDescription(ctx context.Context, agentExecutionID string, description string) error { return a.mgr.SetExecutionDescription(ctx, agentExecutionID, description) } // RequiresCloneURL implements executor.ExecutorTypeCapabilities by delegating to // the lifecycle manager. Without this, executor types like local_docker and // sprites can't tell the orchestrator they need a clone URL. func (a *lifecycleAdapter) RequiresCloneURL(executorType string) bool { return a.mgr.RequiresCloneURL(executorType) } // ShouldApplyPreferredShell implements executor.ExecutorTypeCapabilities. func (a *lifecycleAdapter) ShouldApplyPreferredShell(executorType string) bool { return a.mgr.ShouldApplyPreferredShell(executorType) } // SetExecutionEnv updates per-run env vars in an existing execution. func (a *lifecycleAdapter) SetExecutionEnv(ctx context.Context, agentExecutionID string, env map[string]string) error { return a.mgr.SetExecutionEnv(ctx, agentExecutionID, env) } // SetMcpMode changes the MCP tool mode on an existing execution's agentctl instance. func (a *lifecycleAdapter) SetMcpMode(ctx context.Context, executionID string, mode string) error { return a.mgr.SetMcpMode(ctx, executionID, mode) } // StartAgentProcess starts the agent subprocess for an instance. // The command is built internally based on the instance's agent profile. func (a *lifecycleAdapter) StartAgentProcess(ctx context.Context, agentInstanceID string) error { return a.mgr.StartAgentProcess(ctx, agentInstanceID) } func (a *lifecycleAdapter) IsAgentCommandConfigured(agentInstanceID string) bool { return a.mgr.IsAgentCommandConfigured(agentInstanceID) } // StopAgent stops a running agent func (a *lifecycleAdapter) StopAgent(ctx context.Context, agentInstanceID string, force bool) error { return a.mgr.StopAgent(ctx, agentInstanceID, force) } // StopAgentWithReason stops a running agent and propagates the stop reason to runtime teardown. // The lifecycle-tier not-found sentinel is normalized to the public runtime // sentinel at this seam so orchestrator consumers depend only on // runtimeapi.ErrNotFound and never import runtime/lifecycle. func (a *lifecycleAdapter) StopAgentWithReason(ctx context.Context, agentInstanceID string, reason string, force bool) error { return normalizeRuntimeStopError(a.mgr.StopAgentWithReason(ctx, agentInstanceID, reason, force)) } // normalizeRuntimeStopError maps the backend lifecycle not-found sentinel onto // the public runtime not-found sentinel while preserving the original error for // diagnostics. Unrelated errors (and nil) pass through unchanged so a transient // runtime failure is never mistaken for an absent runtime. func normalizeRuntimeStopError(err error) error { if err == nil { return nil } if errors.Is(err, lifecycle.ErrExecutionNotFound) { return errors.Join(runtimeapi.ErrNotFound, err) } return err } // RowLiveness classifies the liveness of the OS process backing an // executors_running row using the runtime-aware host-local probe. It is the // orchestrator's window into the platform-split liveness check (kept in the // lifecycle package) used by startup reconciliation // (#1597 runtime-aware liveness). A local check never runs against a // remote/SSH row — such rows return Unknown. func (a *lifecycleAdapter) RowLiveness(row *models.ExecutorRunning) models.ProcessLiveness { return lifecycle.RowProcessLiveness(row) } // GetAgentStatus returns the status of an agent execution func (a *lifecycleAdapter) GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentExecution, error) { execution, found := a.mgr.GetExecution(agentInstanceID) if !found { return nil, fmt.Errorf("agent execution %q not found", agentInstanceID) } containerID := execution.ContainerID now := time.Now() result := &v1.AgentExecution{ ID: execution.ID, TaskID: execution.TaskID, AgentProfileID: execution.AgentProfileID, ContainerID: &containerID, Status: execution.Status, StartedAt: &execution.StartedAt, StoppedAt: execution.FinishedAt, CreatedAt: execution.StartedAt, UpdatedAt: now, } if execution.ExitCode != nil { result.ExitCode = execution.ExitCode } if execution.ErrorMessage != "" { result.ErrorMessage = &execution.ErrorMessage } return result, nil } // ListAgentTypes returns available agent types func (a *lifecycleAdapter) ListAgentTypes(ctx context.Context) ([]*v1.AgentType, error) { configs := a.registry.List() result := make([]*v1.AgentType, 0, len(configs)) for _, cfg := range configs { result = append(result, registry.ToAPIType(cfg)) } return result, nil } func (a *lifecycleAdapter) WasSessionInitialized(executionID string) bool { return a.mgr.WasSessionInitialized(executionID) } func (a *lifecycleAdapter) OwnsPromptGeneration(sessionID, executionID string, generation uint64) bool { return a.mgr.OwnsPromptGeneration(sessionID, executionID, generation) } func (a *lifecycleAdapter) GetPromptGenerationForSession(ctx context.Context, sessionID string) (uint64, error) { return a.mgr.GetPromptGenerationForSession(ctx, sessionID) } func (a *lifecycleAdapter) GetSessionAuthMethods(sessionID string) []streams.AuthMethodInfo { return a.mgr.GetSessionAuthMethods(sessionID) } // PromptAgent sends a follow-up prompt to a running agent // Attachments (images) are passed to the agent if provided func (a *lifecycleAdapter) PromptAgent(ctx context.Context, agentInstanceID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool) (*executor.PromptResult, error) { result, err := a.mgr.PromptAgent(ctx, agentInstanceID, prompt, attachments, dispatchOnly) if err != nil { return nil, err } return &executor.PromptResult{ StopReason: result.StopReason, AgentMessage: result.AgentMessage, }, nil } func (a *lifecycleAdapter) PromptAgentWithDispatchCallback(ctx context.Context, agentInstanceID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func()) (*executor.PromptResult, error) { result, err := a.mgr.PromptAgentWithDispatchCallback(ctx, agentInstanceID, prompt, attachments, dispatchOnly, onDispatched) if err != nil { return nil, err } return &executor.PromptResult{ StopReason: result.StopReason, AgentMessage: result.AgentMessage, }, nil } // Compile-time guard for the steer capability. The executor selects the steer // path by asserting the agent manager to its unexported // steerAgentWithDispatchCallback interface (internal/orchestrator/executor); that // assertion fails silently at runtime if this method's signature drifts, // disabling every production steer. Pin the exact shape here so drift is a build // error, not a runtime regression. var _ interface { SteerAgentWithDispatchCallback(context.Context, string, string, []v1.MessageAttachment, bool, func()) (*executor.PromptResult, error) } = (*lifecycleAdapter)(nil) // SteerAgentWithDispatchCallback forwards a mid-turn steer to the lifecycle // manager. Without it the executor's steer path type-assertion fails and every // eligible steer returns ErrPromptDispatchCallbackUnsupported, so the production // agent-manager client must satisfy the steerAgentWithDispatchCallback capability // exactly as it does the ordinary dispatch-callback prompt. func (a *lifecycleAdapter) SteerAgentWithDispatchCallback(ctx context.Context, agentInstanceID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func()) (*executor.PromptResult, error) { result, err := a.mgr.SteerAgentWithDispatchCallback(ctx, agentInstanceID, prompt, attachments, dispatchOnly, onDispatched) if err != nil { return nil, err } return &executor.PromptResult{ StopReason: result.StopReason, AgentMessage: result.AgentMessage, }, nil } // CancelAgent interrupts the current agent turn without terminating the process. func (a *lifecycleAdapter) CancelAgent(ctx context.Context, sessionID string) error { return a.mgr.CancelAgentBySessionID(ctx, sessionID) } // RestartAgentProcess stops the agent subprocess and starts a fresh one with a new ACP session. func (a *lifecycleAdapter) RestartAgentProcess(ctx context.Context, agentExecutionID string) error { return a.mgr.RestartAgentProcess(ctx, agentExecutionID) } // ResetAgentContext resets the agent's context using the fastest available strategy. func (a *lifecycleAdapter) ResetAgentContext(ctx context.Context, agentExecutionID string) error { return a.mgr.ResetAgentContext(ctx, agentExecutionID) } // SetSessionModelBySessionID attempts an in-place model switch via ACP session/set_model. func (a *lifecycleAdapter) SetSessionModelBySessionID(ctx context.Context, sessionID, modelID string) error { return a.mgr.SetSessionModelBySessionID(ctx, sessionID, modelID) } // SetSessionConfigOptionBySessionID applies an ACP dynamic session option. // Workflow conditional configuration uses this optional seam so older agent // manager test doubles and non-ACP providers can fail closed without widening // the executor launch contract. func (a *lifecycleAdapter) SetSessionConfigOptionBySessionID(ctx context.Context, sessionID, configID, value string) error { return a.mgr.SetSessionConfigOptionBySessionID(ctx, sessionID, configID, value) } // SetSessionModeBySessionID applies a session permission mode via ACP session/set_mode. func (a *lifecycleAdapter) SetSessionModeBySessionID(ctx context.Context, sessionID, modeID string) error { return a.mgr.SetSessionModeBySessionID(ctx, sessionID, modeID) } // RespondToPermissionBySessionID sends a response to a permission request for a session func (a *lifecycleAdapter) RespondToPermissionBySessionID(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error { return a.mgr.RespondToPermissionBySessionID(sessionID, pendingID, optionID, cancelled) } // IsAgentRunningForSession checks if an agent is actually running for a session // This probes the actual agent (Docker container or standalone process) func (a *lifecycleAdapter) IsAgentRunningForSession(ctx context.Context, sessionID string) bool { return a.mgr.IsAgentRunningForSession(ctx, sessionID) } // IsAgentReadyForPrompt checks if the session can accept a prompt immediately. func (a *lifecycleAdapter) IsAgentReadyForPrompt(ctx context.Context, sessionID string) bool { return a.mgr.IsAgentReadyForPrompt(ctx, sessionID) } func (a *lifecycleAdapter) RecoverAgentPromptStream(ctx context.Context, sessionID string) error { return a.mgr.RecoverAgentPromptStream(ctx, sessionID) } // IsPassthroughSession checks if the given session is running in passthrough (PTY) mode. func (a *lifecycleAdapter) IsPassthroughSession(ctx context.Context, sessionID string) bool { return a.mgr.IsPassthroughSession(ctx, sessionID) } func (a *lifecycleAdapter) WritePassthroughStdin(ctx context.Context, sessionID string, data string) error { return a.mgr.WritePassthroughStdin(ctx, sessionID, data) } // ResolvePassthroughConfig returns the resolved PassthroughConfig for a session's agent. func (a *lifecycleAdapter) ResolvePassthroughConfig(ctx context.Context, sessionID string) (agents.PassthroughConfig, error) { return a.mgr.ResolvePassthroughConfig(ctx, sessionID) } func (a *lifecycleAdapter) MarkPassthroughRunning(sessionID string) error { return a.mgr.MarkPassthroughRunning(sessionID) } func (a *lifecycleAdapter) PollRemoteStatusForRecords(ctx context.Context, records []executor.RemoteStatusPollRequest) { lcRecords := make([]lifecycle.RemoteStatusPollRecord, len(records)) for i, r := range records { lcRecords[i] = lifecycle.RemoteStatusPollRecord{ SessionID: r.SessionID, Runtime: r.Runtime, AgentExecutionID: r.AgentExecutionID, ContainerID: r.ContainerID, Metadata: r.Metadata, } } a.mgr.PollRemoteStatusForRecords(ctx, lcRecords) } func (a *lifecycleAdapter) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { return a.mgr.CleanupStaleExecutionBySessionID(ctx, sessionID) } func (a *lifecycleAdapter) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID, sessionID string) error { _, err := a.mgr.EnsureWorkspaceExecutionForSession(ctx, taskID, sessionID) return err } func (a *lifecycleAdapter) GetExecutionIDForSession(ctx context.Context, sessionID string) (string, error) { return a.mgr.GetExecutionIDForSession(ctx, sessionID) } func (a *lifecycleAdapter) GetRemoteRuntimeStatusBySession(ctx context.Context, sessionID string) (*executor.RemoteRuntimeStatus, error) { status, ok := a.mgr.GetRemoteStatusBySessionID(ctx, sessionID) if !ok || status == nil { return nil, nil } return &executor.RemoteRuntimeStatus{ RuntimeName: status.RuntimeName, RemoteName: status.RemoteName, State: status.State, CreatedAt: status.CreatedAt, LastCheckedAt: status.LastCheckedAt, ErrorMessage: status.ErrorMessage, }, nil } // ResolveAgentProfile resolves an agent profile ID to profile information func (a *lifecycleAdapter) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) { info, err := a.mgr.ResolveAgentProfile(ctx, profileID) if err != nil { return nil, err } return &executor.AgentProfileInfo{ ProfileID: info.ProfileID, ProfileName: info.ProfileName, AgentID: info.AgentID, AgentName: info.AgentName, Model: info.Model, Mode: info.Mode, ConfigOptions: info.ConfigOptions, AutoApprove: info.AutoApprove, DangerouslySkipPermissions: info.DangerouslySkipPermissions, CLIPassthrough: info.CLIPassthrough, SupportsMCP: info.SupportsMCP, }, nil } // GetGitLog retrieves the git log for a session from baseCommit to HEAD. // If targetBranch is provided, uses dynamic merge-base calculation for accurate filtering. func (a *lifecycleAdapter) GetGitLog(ctx context.Context, sessionID, baseCommit string, limit int, targetBranch string) (*client.GitLogResult, error) { execution, ok := a.mgr.GetExecutionBySessionID(sessionID) if !ok { return nil, nil // No execution, not an error } agentClient := execution.GetAgentCtlClient() if agentClient == nil { return nil, nil } return agentClient.GitLog(ctx, baseCommit, limit, targetBranch, "") } // GetCumulativeDiff retrieves the cumulative diff for a session from baseCommit to HEAD. func (a *lifecycleAdapter) GetCumulativeDiff(ctx context.Context, sessionID, baseCommit string) (*client.CumulativeDiffResult, error) { execution, ok := a.mgr.GetExecutionBySessionID(sessionID) if !ok { return nil, nil // No execution, not an error } agentClient := execution.GetAgentCtlClient() if agentClient == nil { return nil, nil } // Orchestrator-side cumulative diffs (snapshot tracking) anchor to the // caller-provided base SHA — no dynamic merge-base recomputation. The // live panel uses the WS-handler path, which passes targetBranch. return agentClient.GetCumulativeDiff(ctx, baseCommit, "") } // GetGitStatus retrieves the current git status for a session. func (a *lifecycleAdapter) GetGitStatus(ctx context.Context, sessionID string) (*client.GitStatusResult, error) { execution, ok := a.mgr.GetExecutionBySessionID(sessionID) if !ok { return nil, nil // No execution, not an error } agentClient := execution.GetAgentCtlClient() if agentClient == nil { return nil, nil } return agentClient.GetGitStatus(ctx) } // GetGitStatusFresh retrieves a fresh (non-cached) git status for a session. func (a *lifecycleAdapter) GetGitStatusFresh(ctx context.Context, sessionID string) (*client.GitStatusResult, error) { execution, ok := a.mgr.GetExecutionBySessionID(sessionID) if !ok { return nil, nil } agentClient := execution.GetAgentCtlClient() if agentClient == nil { return nil, nil } return agentClient.GetGitStatusFresh(ctx) } // WaitForAgentctlReady waits for the agentctl HTTP server to be ready for a session. func (a *lifecycleAdapter) WaitForAgentctlReady(ctx context.Context, sessionID string) error { return a.mgr.WaitForAgentctlReadyForSession(ctx, sessionID) } // orchestratorWrapper wraps orchestrator.Service to implement taskhandlers.OrchestratorService. // The wrapper only adapts ResumeTaskSession which returns *executor.TaskExecution that we don't need. type orchestratorWrapper struct { svc *orchestrator.Service } // PromptTask forwards directly to the orchestrator service. // Attachments (images) are passed through to the agent. func (w *orchestratorWrapper) PromptTask(ctx context.Context, taskID, taskSessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool) (*orchestrator.PromptResult, error) { return w.svc.PromptTask(ctx, taskID, taskSessionID, prompt, model, planMode, attachments, dispatchOnly) } // ResumeTaskSession forwards to the orchestrator service, discarding the TaskExecution result. func (w *orchestratorWrapper) ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error { _, err := w.svc.ResumeTaskSession(ctx, taskID, taskSessionID) return err } // StartCreatedSession forwards to the orchestrator service, discarding the TaskExecution result. func (w *orchestratorWrapper) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode, autoStart bool, attachments []v1.MessageAttachment, references []v1.EntityReference) error { _, err := w.svc.StartCreatedSession(ctx, taskID, sessionID, agentProfileID, prompt, skipMessageRecord, planMode, autoStart, attachments, references) return err } type githubTaskIssueStoreAdapter struct { svc *taskservice.Service } func (a githubTaskIssueStoreAdapter) GetTask(ctx context.Context, taskID string) (*models.Task, error) { task, err := a.svc.GetTask(ctx, taskID) if err != nil { return nil, wrapGitHubTaskIssueStoreError(err) } return task, nil } func (a githubTaskIssueStoreAdapter) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { task, err := a.svc.GetTask(ctx, taskID) if err != nil { return nil, wrapGitHubTaskIssueStoreError(err) } return task.Repositories, nil } func (a githubTaskIssueStoreAdapter) GetRepository(ctx context.Context, repositoryID string) (*models.Repository, error) { return a.svc.GetRepository(ctx, repositoryID) } func (a githubTaskIssueStoreAdapter) UpdateTaskMetadata(ctx context.Context, taskID string, metadata map[string]interface{}) (*models.Task, error) { task, err := a.svc.UpdateTask(ctx, taskID, &taskservice.UpdateTaskRequest{Metadata: metadata}) if err != nil { return nil, wrapGitHubTaskIssueStoreError(err) } return task, nil } func wrapGitHubTaskIssueStoreError(err error) error { if errors.Is(err, taskrepo.ErrTaskNotFound) { return fmt.Errorf("%w: %w", githubsvc.ErrTaskNotFound, err) } return err } // ProcessOnTurnStart forwards to the orchestrator service. func (w *orchestratorWrapper) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error { return w.svc.ProcessOnTurnStart(ctx, taskID, sessionID) } // StepRequiresCompletionSignal forwards to the orchestrator service. func (w *orchestratorWrapper) StepRequiresCompletionSignal(ctx context.Context, taskID string) bool { return w.svc.StepRequiresCompletionSignal(ctx, taskID) } // ForegroundActivity forwards to the orchestrator service (ADR-0049). func (w *orchestratorWrapper) ForegroundActivity(sessionID string) v1.ForegroundActivity { return w.svc.ForegroundActivity(sessionID) } func (w *orchestratorWrapper) SteerEligible(sessionID string, state models.TaskSessionState) bool { return w.svc.SteerEligible(sessionID, state) } func (w *orchestratorWrapper) SteerTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment) (*orchestrator.PromptResult, error) { return w.svc.SteerTask(ctx, taskID, sessionID, prompt, model, planMode, attachments) } // messageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface type messageCreatorAdapter struct { svc *taskservice.Service logger *logger.Logger // sessionModelCache caches the resolved model per session to avoid repeated DB lookups. // Invalidated via InvalidateModelCache when the model changes (e.g., model switch). sessionModelMu sync.RWMutex sessionModelCache map[string]string } // getSessionModel resolves the model from the session's agent profile snapshot. // Results are cached per session ID to avoid repeated DB queries during streaming. func (a *messageCreatorAdapter) getSessionModel(ctx context.Context, sessionID string) string { // Check cache first a.sessionModelMu.RLock() if model, ok := a.sessionModelCache[sessionID]; ok { a.sessionModelMu.RUnlock() return model } a.sessionModelMu.RUnlock() // Cache miss — fetch from DB session, err := a.svc.GetTaskSession(ctx, sessionID) if err != nil || session == nil || session.AgentProfileSnapshot == nil { return "" } model, _ := session.AgentProfileSnapshot["model"].(string) // Store in cache a.sessionModelMu.Lock() if a.sessionModelCache == nil { a.sessionModelCache = make(map[string]string) } a.sessionModelCache[sessionID] = model a.sessionModelMu.Unlock() return model } // InvalidateModelCache clears the cached model for a session so the next message // re-reads it from the DB. Called after model switches. func (a *messageCreatorAdapter) InvalidateModelCache(sessionID string) { a.sessionModelMu.Lock() delete(a.sessionModelCache, sessionID) a.sessionModelMu.Unlock() } // CreateAgentMessage creates a message with author_type="agent" func (a *messageCreatorAdapter) CreateAgentMessage(ctx context.Context, taskID, content, agentSessionID, turnID string) error { var metadata map[string]interface{} if model := a.getSessionModel(ctx, agentSessionID); model != "" { metadata = map[string]interface{}{"model": model} } _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ TaskSessionID: agentSessionID, TaskID: taskID, TurnID: turnID, Content: content, AuthorType: "agent", Metadata: metadata, }) return err } // CreateUserMessage creates a message with author_type="user" func (a *messageCreatorAdapter) CreateUserMessage(ctx context.Context, taskID, content, agentSessionID, turnID string, metadata map[string]interface{}) error { _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ TaskSessionID: agentSessionID, TaskID: taskID, TurnID: turnID, Content: content, AuthorType: "user", Metadata: metadata, }) return err } // CreateToolCallMessage creates a message for a tool call func (a *messageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error { metadata := map[string]interface{}{ "tool_call_id": toolCallID, "title": title, "status": status, } // Add parent tool call ID for subagent nesting (if present) if parentToolCallID != "" { metadata["parent_tool_call_id"] = parentToolCallID } // Add normalized tool data to metadata for frontend consumption if normalized != nil { metadata["normalized"] = normalized } // Determine message type from the normalized tool kind msgType := "tool_call" if normalized != nil { msgType = normalized.Kind().ToMessageType() } _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ TaskSessionID: agentSessionID, TaskID: taskID, TurnID: turnID, Content: title, AuthorType: "agent", Type: msgType, Metadata: metadata, }) return err } // UpdateToolCallMessage updates a tool call message's status. // If the message doesn't exist, it creates it using taskID, turnID, and msgType. func (a *messageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, status, result, agentSessionID, title, turnID, msgType string, normalized *streams.NormalizedPayload) error { return a.svc.UpdateToolCallMessageWithCreate(ctx, agentSessionID, toolCallID, parentToolCallID, status, result, title, normalized, taskID, turnID, msgType) } // CreateSessionMessage creates a message for non-chat session updates (status/progress/error/etc). func (a *messageCreatorAdapter) CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error { _, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ TaskSessionID: agentSessionID, TaskID: taskID, TurnID: turnID, Content: content, AuthorType: "agent", Type: messageType, Metadata: metadata, RequestsInput: requestsInput, }) return err } // CreatePermissionRequestMessage creates a message for a permission request func (a *messageCreatorAdapter) CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, pendingID, toolCallID, title, turnID string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error) { metadata := map[string]interface{}{ "pending_id": pendingID, "tool_call_id": toolCallID, "options": options, "action_type": actionType, "action_details": actionDetails, } msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ TaskSessionID: sessionID, TaskID: taskID, TurnID: turnID, Content: title, AuthorType: "agent", Type: "permission_request", Metadata: metadata, }) if err != nil { return "", err } return msg.ID, nil } // UpdatePermissionMessage updates a permission message's status func (a *messageCreatorAdapter) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID string, status models.PermissionStatus) error { return a.svc.UpdatePermissionMessage(ctx, sessionID, pendingID, status) } // CreateClarificationRequestMessages emits one chat message per question in a // clarification request bundle, all sharing the given pending_id. The frontend // renders them as a stacked group; only the last message sets RequestsInput=true // so the chat scrolls to the bottom of the group when the bundle arrives. // Returns the created message IDs in the same order as the input questions. // // If any individual CreateMessage call fails, every previously created message // in the bundle is deleted so we don't leave a half-rendered group dangling in // the chat. Best-effort: if cleanup itself fails the caller still receives the // original error and the orphan messages stay (logged at warn-level). func (a *messageCreatorAdapter) CreateClarificationRequestMessages(ctx context.Context, taskID, sessionID, pendingID string, questions []clarification.Question, clarificationContext string) ([]string, error) { ids := make([]string, 0, len(questions)) total := len(questions) for i, question := range questions { options := make([]interface{}, len(question.Options)) for j, opt := range question.Options { options[j] = map[string]interface{}{ "option_id": opt.ID, "label": opt.Label, "description": opt.Description, } } questionData := map[string]interface{}{ "id": question.ID, "title": question.Title, "prompt": question.Prompt, "options": options, } metadata := map[string]interface{}{ "pending_id": pendingID, "question_id": question.ID, "question": questionData, "question_index": i, "question_total": total, "context": clarificationContext, "status": "pending", } // Only the last message marks the session as waiting for input so the // chat scrolls to the end of the group when the bundle arrives. requestsInput := i == total-1 msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ TaskSessionID: sessionID, TaskID: taskID, Content: question.Prompt, AuthorType: "agent", Type: "clarification_request", Metadata: metadata, RequestsInput: requestsInput, }) if err != nil { a.rollbackPartialBundle(ctx, ids, pendingID) return nil, err } ids = append(ids, msg.ID) } return ids, nil } // rollbackPartialBundle removes the messages already created when later writes // in a multi-question bundle fail. Best-effort — failures are logged but do // not bubble up because the caller already has a more useful error. func (a *messageCreatorAdapter) rollbackPartialBundle(ctx context.Context, ids []string, pendingID string) { for _, id := range ids { if err := a.svc.DeleteMessage(ctx, id); err != nil { a.logger.Warn("failed to roll back partial clarification bundle message", zap.String("pending_id", pendingID), zap.String("message_id", id), zap.Error(err)) } } } // UpdateClarificationMessage updates a clarification message's status and answer // for a specific (pending_id, question_id) pair within the session. func (a *messageCreatorAdapter) UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, questionID, status string, answer *clarification.Answer) error { return a.svc.UpdateClarificationMessageForQuestion(ctx, sessionID, pendingID, questionID, status, answer) } // CreateAgentMessageStreaming creates a new agent message with a pre-generated ID. // This is used for real-time streaming where content arrives incrementally. func (a *messageCreatorAdapter) CreateAgentMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error { var metadata map[string]interface{} if model := a.getSessionModel(ctx, agentSessionID); model != "" { metadata = map[string]interface{}{"model": model} } _, err := a.svc.CreateMessageWithID(ctx, messageID, &taskservice.CreateMessageRequest{ TaskSessionID: agentSessionID, TaskID: taskID, TurnID: turnID, Content: content, AuthorType: "agent", Metadata: metadata, }) return err } // AppendAgentMessage appends additional content to an existing streaming message. func (a *messageCreatorAdapter) AppendAgentMessage(ctx context.Context, messageID, additionalContent string) error { return a.svc.AppendMessageContent(ctx, messageID, additionalContent) } // CreateThinkingMessageStreaming creates a new thinking message with a pre-generated ID. // This is used for real-time streaming of agent thinking/reasoning content. func (a *messageCreatorAdapter) CreateThinkingMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error { metadata := map[string]interface{}{ "thinking": content, } if model := a.getSessionModel(ctx, agentSessionID); model != "" { metadata["model"] = model } _, err := a.svc.CreateMessageWithID(ctx, messageID, &taskservice.CreateMessageRequest{ TaskSessionID: agentSessionID, TaskID: taskID, TurnID: turnID, Content: "", AuthorType: "agent", Type: "thinking", Metadata: metadata, }) return err } // AppendThinkingMessage appends additional content to an existing streaming thinking message. func (a *messageCreatorAdapter) AppendThinkingMessage(ctx context.Context, messageID, additionalContent string) error { return a.svc.AppendThinkingContent(ctx, messageID, additionalContent) }