From ce2b263ea5e003f372a6919c4ade2386b64b5b81 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Sun, 29 Mar 2026 18:40:29 +0800 Subject: [PATCH] feat(daemon): reuse workdir across tasks on same agent+issue pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously each task created a fresh workdir via execenv.Prepare(), even when resuming work on the same (agent, issue). This caused the agent's session context to be out of sync with a blank code state. Now the server returns prior_work_dir in the claim response, and the daemon tries execenv.Reuse() first — which wraps the existing directory, detects git worktree state, and refreshes context files. Falls back to Prepare() if the prior workdir no longer exists. Workdirs are no longer cleaned up after task completion so they remain available for reuse. --- server/internal/daemon/daemon.go | 37 ++++++++++++++--------- server/internal/daemon/execenv/execenv.go | 30 ++++++++++++++++++ server/internal/daemon/types.go | 1 + server/internal/handler/agent.go | 1 + server/internal/handler/daemon.go | 3 ++ 5 files changed, 57 insertions(+), 15 deletions(-) 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)