diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 22b7aecb..18282eab 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -600,26 +600,33 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR AgentInstructions: instructions, AgentSkills: convertSkillsForEnv(skills), } - env, err := execenv.Prepare(execenv.PrepareParams{ - WorkspacesRoot: d.cfg.WorkspacesRoot, - TaskID: task.ID, - AgentName: agentName, - Provider: provider, - Task: taskCtx, - }, d.logger) - if err != nil { - return TaskResult{}, fmt.Errorf("prepare execution environment: %w", err) + + // Try to reuse the workdir from a previous task on the same (agent, issue) pair. + var env *execenv.Environment + if task.PriorWorkDir != "" { + env = execenv.Reuse(task.PriorWorkDir, provider, taskCtx, d.logger) + } + if env == nil { + var err error + env, err = execenv.Prepare(execenv.PrepareParams{ + WorkspacesRoot: d.cfg.WorkspacesRoot, + TaskID: task.ID, + AgentName: agentName, + Provider: provider, + Task: taskCtx, + }, d.logger) + if err != nil { + return TaskResult{}, fmt.Errorf("prepare execution environment: %w", err) + } } // Inject runtime-specific config (meta skill) so the agent discovers .agent_context/. if err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx); err != nil { d.logger.Warn("execenv: inject runtime config failed (non-fatal)", "error", err) } - defer func() { - if cleanupErr := env.Cleanup(!d.cfg.KeepEnvAfterTask); cleanupErr != nil { - d.logger.Warn("cleanup env failed", "task_id", task.ID, "error", cleanupErr) - } - }() + // NOTE: No cleanup — workdir is preserved for reuse by future tasks on + // the same (agent, issue) pair. The work_dir path is stored in DB on + // task completion and passed back via PriorWorkDir on the next claim. prompt := BuildPrompt(task) @@ -643,7 +650,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR return TaskResult{}, fmt.Errorf("create agent backend: %w", err) } - d.logger.Info("starting agent", "provider", provider, "task_id", task.ID, "workdir", env.WorkDir, "branch", env.BranchName, "env_type", env.Type, "model", entry.Model, "timeout", d.cfg.AgentTimeout.String(), "resume_session", task.PriorSessionID) + d.logger.Info("starting agent", "provider", provider, "task_id", task.ID, "workdir", env.WorkDir, "reused", task.PriorWorkDir != "" && env.WorkDir == task.PriorWorkDir, "branch", env.BranchName, "env_type", env.Type, "model", entry.Model, "timeout", d.cfg.AgentTimeout.String(), "resume_session", task.PriorSessionID) session, err := backend.Execute(ctx, prompt, agent.ExecOptions{ Cwd: env.WorkDir, diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index 98a16e34..0d2d100f 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -147,6 +147,36 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) { return env, nil } +// Reuse wraps an existing workdir into an Environment and refreshes context files. +// Returns nil if the workdir does not exist (caller should fall back to Prepare). +func Reuse(workDir, provider string, task TaskContextForEnv, logger *slog.Logger) *Environment { + if _, err := os.Stat(workDir); err != nil { + return nil + } + + env := &Environment{ + RootDir: filepath.Dir(workDir), + WorkDir: workDir, + Type: WorkspaceTypeDirectory, + logger: logger, + } + + // Detect if this is a git worktree. + if gitRoot, ok := detectGitRepo(workDir); ok { + env.Type = WorkspaceTypeGitWorktree + env.BranchName = getDefaultBranch(workDir) + env.gitRoot = gitRoot + } + + // Refresh context files (issue_context.md, skills). + if err := writeContextFiles(workDir, provider, task); err != nil { + logger.Warn("execenv: refresh context files failed", "error", err) + } + + logger.Info("execenv: reusing env", "workdir", workDir, "type", env.Type, "branch", env.BranchName) + return env +} + // 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. diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index bc6fcb75..7c985bfb 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -23,6 +23,7 @@ type Task struct { IssueID string `json:"issue_id"` Agent *AgentData `json:"agent,omitempty"` PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue + PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue } // AgentData holds agent details returned by the claim endpoint. diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index 20434c37..4fd796d8 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -96,6 +96,7 @@ type AgentTaskResponse struct { Agent *TaskAgentData `json:"agent,omitempty"` CreatedAt string `json:"created_at"` PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue + PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue } // TaskAgentData holds agent info included in claim responses so the daemon diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index a00d998f..b36fddcd 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -224,6 +224,9 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { IssueID: task.IssueID, }); err == nil && prior.SessionID.Valid { resp.PriorSessionID = prior.SessionID.String + if prior.WorkDir.Valid { + resp.PriorWorkDir = prior.WorkDir.String + } } slog.Info("task claimed by runtime", "task_id", uuidToString(task.ID), "runtime_id", runtimeID, "agent_id", uuidToString(task.AgentID), "prior_session", resp.PriorSessionID)