diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index af87269c0..2e04ae104 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -2579,6 +2579,30 @@ func providerNeedsInlineSystemPrompt(provider string) bool { } } +// gateResumeToReusedWorkdir clears the task's prior session unless the task +// runs in the exact workdir the session was recorded against, and reports +// whether that workdir was reused. CLI backends key their session stores to +// the cwd (Claude Code looks sessions up under ~/.claude/projects//), +// so a session id from a different workdir can never resolve: the CLI exits +// within a second and the run fails before doing any work — permanently, +// because the failed run records no session and the next claim serves the +// same stale pointer again. This fires whenever the prior workdir no longer +// exists (GC'd after the issue went done, daemon reinstall, manual cleanup) +// and execenv.Reuse fell back to a fresh Prepare (GitHub #3854). +func gateResumeToReusedWorkdir(task *Task, taskCtx *execenv.TaskContextForEnv, envWorkDir string, taskLog *slog.Logger) bool { + reused := task.PriorWorkDir != "" && envWorkDir == task.PriorWorkDir + if !reused && task.PriorSessionID != "" { + taskLog.Info("dropping prior session: workdir not reused, per-cwd session cannot resolve", + "session_id", task.PriorSessionID, + "prior_workdir", task.PriorWorkDir, + "workdir", envWorkDir, + ) + task.PriorSessionID = "" + taskCtx.PriorSessionResumed = false + } + return reused +} + func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot int, taskLog *slog.Logger) (TaskResult, error) { // Refuse to spawn an agent without a workspace. An empty workspace_id // here would make MULTICA_WORKSPACE_ID empty in the agent env, and the @@ -2721,6 +2745,8 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i defer d.unmarkActiveEnvRoot(env.RootDir) } + reused := gateResumeToReusedWorkdir(&task, &taskCtx, env.WorkDir, taskLog) + // Inject runtime-specific config (meta skill) so the agent discovers .agent_context/. runtimeBrief, err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx) if err != nil { @@ -2853,7 +2879,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i return TaskResult{}, fmt.Errorf("create agent backend: %w", err) } - reused := task.PriorWorkDir != "" && env.WorkDir == task.PriorWorkDir taskLog.Info("starting agent", "provider", provider, "workdir", env.WorkDir, diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go index de1d462de..fa748e5f9 100644 --- a/server/internal/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -18,6 +18,7 @@ import ( "testing" "time" + "github.com/multica-ai/multica/server/internal/daemon/execenv" "github.com/multica-ai/multica/server/internal/daemon/repocache" "github.com/multica-ai/multica/server/pkg/agent" ) @@ -896,6 +897,71 @@ func newRepoReadyTestDaemon(t *testing.T, handler http.HandlerFunc) *Daemon { return d } +func TestGateResumeToReusedWorkdir(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sessionID string + priorDir string + envDir string + wantSession string + wantReused bool + }{ + { + name: "same workdir keeps session", + sessionID: "sess-1", + priorDir: "/ws/task-a/workdir", + envDir: "/ws/task-a/workdir", + wantSession: "sess-1", + wantReused: true, + }, + { + name: "fresh workdir drops session", + sessionID: "sess-1", + priorDir: "/ws/task-a/workdir", + envDir: "/ws/task-b/workdir", + wantSession: "", + wantReused: false, + }, + { + name: "session without recorded workdir drops session", + sessionID: "sess-1", + priorDir: "", + envDir: "/ws/task-b/workdir", + wantSession: "", + wantReused: false, + }, + { + name: "no prior session is a no-op", + sessionID: "", + priorDir: "/ws/task-a/workdir", + envDir: "/ws/task-b/workdir", + wantSession: "", + wantReused: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + task := Task{PriorSessionID: tt.sessionID, PriorWorkDir: tt.priorDir} + taskCtx := execenv.TaskContextForEnv{PriorSessionResumed: tt.sessionID != ""} + + reused := gateResumeToReusedWorkdir(&task, &taskCtx, tt.envDir, slog.Default()) + + if reused != tt.wantReused { + t.Fatalf("reused = %v, want %v", reused, tt.wantReused) + } + if task.PriorSessionID != tt.wantSession { + t.Fatalf("PriorSessionID = %q, want %q", task.PriorSessionID, tt.wantSession) + } + if taskCtx.PriorSessionResumed != (tt.wantSession != "") { + t.Fatalf("PriorSessionResumed = %v, want %v", taskCtx.PriorSessionResumed, tt.wantSession != "") + } + }) + } +} + func TestExecuteAndDrain_ResumeFailureFallback(t *testing.T) { t.Parallel()