Merge pull request #171 from multica-ai/forrestchang/issue-resume-qa

feat(daemon): reuse workdir across tasks on same agent+issue
This commit is contained in:
Jiayuan Zhang 2026-03-29 18:42:11 +08:00 committed by GitHub
commit 3b58dff375
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 57 additions and 15 deletions

View file

@ -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,

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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)