Add an `instructions` text field to the agent model, allowing users to define each agent's role, expertise, and working style. Instructions are injected into CLAUDE.md as an "Agent Identity" section so the agent knows who it is on every task execution. - Migration 021: add instructions column to agent table - Backend: create/update/get agent handlers support instructions - ClaimTask response includes instructions for daemon injection - execenv: inject instructions into CLAUDE.md meta-skill - Frontend: add Instructions tab to agent detail panel
177 lines
6.1 KiB
Go
177 lines
6.1 KiB
Go
// Package execenv manages isolated per-task execution environments for the daemon.
|
|
// Each task gets its own directory with a git worktree (for code tasks) or plain
|
|
// directory (for non-code tasks), plus injected context files.
|
|
package execenv
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// WorkspaceType indicates how the working directory was set up.
|
|
type WorkspaceType string
|
|
|
|
const (
|
|
WorkspaceTypeGitWorktree WorkspaceType = "git_worktree"
|
|
WorkspaceTypeDirectory WorkspaceType = "directory"
|
|
)
|
|
|
|
// PrepareParams holds all inputs needed to set up an execution environment.
|
|
type PrepareParams struct {
|
|
WorkspacesRoot string // base path for all envs (e.g., ~/multica_workspaces)
|
|
RepoPath string // source git repo path (for worktree creation), provided per-task by server
|
|
TaskID string // task UUID — used for directory name
|
|
AgentName string // for git branch naming only
|
|
Provider string // agent provider ("claude", "codex") — determines skill injection paths
|
|
Task TaskContextForEnv // context data for writing files
|
|
}
|
|
|
|
// TaskContextForEnv is the subset of task context used for writing context files.
|
|
type TaskContextForEnv struct {
|
|
IssueID string
|
|
AgentName string
|
|
AgentInstructions string // agent identity/persona instructions, injected into CLAUDE.md
|
|
AgentSkills []SkillContextForEnv
|
|
}
|
|
|
|
// SkillContextForEnv represents a skill to be written into the execution environment.
|
|
type SkillContextForEnv struct {
|
|
Name string
|
|
Content string
|
|
Files []SkillFileContextForEnv
|
|
}
|
|
|
|
// SkillFileContextForEnv represents a supporting file within a skill.
|
|
type SkillFileContextForEnv struct {
|
|
Path string
|
|
Content string
|
|
}
|
|
|
|
// Environment represents a prepared, isolated execution environment.
|
|
type Environment struct {
|
|
// RootDir is the top-level env directory ({workspacesRoot}/{task_id_short}/).
|
|
RootDir string
|
|
// WorkDir is the directory to pass as Cwd to the agent ({RootDir}/workdir/).
|
|
WorkDir string
|
|
// Type indicates git_worktree or directory.
|
|
Type WorkspaceType
|
|
// BranchName is the git branch name (empty for directory type).
|
|
BranchName string
|
|
// CodexHome is the path to the per-task CODEX_HOME directory (set only for codex provider).
|
|
CodexHome string
|
|
|
|
gitRoot string // source repo root (for cleanup)
|
|
logger *slog.Logger // for cleanup logging
|
|
}
|
|
|
|
// Prepare creates an isolated execution environment for a task.
|
|
func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
|
|
if params.WorkspacesRoot == "" {
|
|
return nil, fmt.Errorf("execenv: workspaces root is required")
|
|
}
|
|
if params.TaskID == "" {
|
|
return nil, fmt.Errorf("execenv: task ID is required")
|
|
}
|
|
|
|
envRoot := filepath.Join(params.WorkspacesRoot, shortID(params.TaskID))
|
|
|
|
// Remove existing env if present (defensive — task IDs are unique).
|
|
if _, err := os.Stat(envRoot); err == nil {
|
|
if err := os.RemoveAll(envRoot); err != nil {
|
|
return nil, fmt.Errorf("execenv: remove existing env: %w", err)
|
|
}
|
|
}
|
|
|
|
// Create directory tree.
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
for _, dir := range []string{workDir, filepath.Join(envRoot, "output"), filepath.Join(envRoot, "logs")} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("execenv: create directory %s: %w", dir, err)
|
|
}
|
|
}
|
|
|
|
env := &Environment{
|
|
RootDir: envRoot,
|
|
WorkDir: workDir,
|
|
Type: WorkspaceTypeDirectory,
|
|
logger: logger,
|
|
}
|
|
|
|
// Detect git repo and set up worktree if available.
|
|
if params.RepoPath != "" {
|
|
if gitRoot, ok := detectGitRepo(params.RepoPath); ok {
|
|
branchName := fmt.Sprintf("agent/%s/%s", sanitizeName(params.AgentName), shortID(params.TaskID))
|
|
|
|
// Get the default branch as base ref.
|
|
baseRef := getDefaultBranch(gitRoot)
|
|
|
|
if err := setupGitWorktree(gitRoot, workDir, branchName, baseRef); err != nil {
|
|
logger.Warn("execenv: git worktree setup failed, falling back to directory mode", "error", err)
|
|
} else {
|
|
env.Type = WorkspaceTypeGitWorktree
|
|
env.BranchName = branchName
|
|
env.gitRoot = gitRoot
|
|
|
|
// Exclude injected directories from git tracking.
|
|
for _, pattern := range []string{".agent_context", ".claude", "CLAUDE.md", "AGENTS.md"} {
|
|
if err := excludeFromGit(workDir, pattern); err != nil {
|
|
logger.Warn("execenv: failed to exclude from git", "pattern", pattern, "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write context files into workdir (skills go to provider-native paths).
|
|
if err := writeContextFiles(workDir, params.Provider, params.Task); err != nil {
|
|
return nil, fmt.Errorf("execenv: write context files: %w", err)
|
|
}
|
|
|
|
// For Codex, set up a per-task CODEX_HOME seeded from ~/.codex/ with skills.
|
|
if params.Provider == "codex" {
|
|
codexHome := filepath.Join(envRoot, "codex-home")
|
|
if err := prepareCodexHome(codexHome, logger); err != nil {
|
|
return nil, fmt.Errorf("execenv: prepare codex-home: %w", err)
|
|
}
|
|
if len(params.Task.AgentSkills) > 0 {
|
|
if err := writeSkillFiles(filepath.Join(codexHome, "skills"), params.Task.AgentSkills); err != nil {
|
|
return nil, fmt.Errorf("execenv: write codex skills: %w", err)
|
|
}
|
|
}
|
|
env.CodexHome = codexHome
|
|
}
|
|
|
|
logger.Info("execenv: prepared env", "root", envRoot, "type", env.Type, "branch", env.BranchName)
|
|
return env, nil
|
|
}
|
|
|
|
// Cleanup tears down the execution environment.
|
|
// If removeAll is true, the entire env root is deleted. Otherwise, workdir is
|
|
// removed but output/ and logs/ are preserved for debugging.
|
|
func (env *Environment) Cleanup(removeAll bool) error {
|
|
if env == nil {
|
|
return nil
|
|
}
|
|
|
|
// Remove git worktree first (must happen before directory deletion).
|
|
if env.Type == WorkspaceTypeGitWorktree && env.gitRoot != "" {
|
|
removeGitWorktree(env.gitRoot, env.WorkDir, env.BranchName, env.logger)
|
|
}
|
|
|
|
if removeAll {
|
|
if err := os.RemoveAll(env.RootDir); err != nil {
|
|
env.logger.Warn("execenv: cleanup removeAll failed", "error", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Partial cleanup: remove workdir, keep output/ and logs/.
|
|
if err := os.RemoveAll(env.WorkDir); err != nil {
|
|
env.logger.Warn("execenv: cleanup workdir failed", "error", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|