diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 91a6d05888..ac50ad6c53 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -19,6 +19,9 @@ on: - "apps/pnpm-lock.yaml" - ".github/workflows/e2e-tests.yml" - "!**/*.md" + # Manual trigger for verifying CI on draft PRs and ad-hoc reruns. The + # default sharded run uses the same ref the user picks in the dispatch UI. + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -60,6 +63,12 @@ jobs: - name: Build backend and web run: make build-backend build-web + # Linux/amd64 helper binaries (agentctl + mock-agent) bind-mounted into + # containers by the Docker E2E project. Cheap to build (~1s each) so we + # always do it; the regular E2E job ignores them. + - name: Build Docker E2E helper binaries + run: make build-backend-linux-helpers + - name: Upload backend build uses: actions/upload-artifact@v4 with: @@ -130,9 +139,13 @@ jobs: working-directory: apps/web run: tar -xf next-build.tar && rm next-build.tar + # Pinned to chromium + mobile-chrome so the dedicated `e2e-docker` job + # below owns the real-Docker project. Without --project the docker spec + # would run here too, fail seeding (KANDEV_DOCKER_ENABLED is off), and + # spam every shard. - name: Run E2E tests (shard ${{ matrix.shard }}/10) working-directory: apps/web - run: npx playwright test --config e2e/playwright.config.ts --shard=${{ matrix.shard }}/10 + run: npx playwright test --config e2e/playwright.config.ts --project=chromium --project=mobile-chrome --shard=${{ matrix.shard }}/10 - name: Upload blob report if: always() @@ -150,9 +163,93 @@ jobs: path: apps/web/e2e/test-results/ retention-days: 7 + # Real-Docker E2E: exercises the local_docker executor end-to-end. ubuntu-latest + # ships with Docker preinstalled, so no service container is required. The + # spec count is small (currently 1 active + 2 fixme) so we don't shard. + # Cleans up any leftover kandev-managed containers at the end so a leaky + # process doesn't poison the next run on the same self-hosted runner. + e2e-docker: + name: E2E Docker + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Verify Docker is available + run: docker info + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: "9" + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install dependencies + working-directory: apps + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + working-directory: apps/web + run: npx playwright install chromium --with-deps + + - name: Download backend build + uses: actions/download-artifact@v4 + with: + name: e2e-backend-build + path: apps/backend/bin/ + + - name: Make backend binaries executable + run: chmod +x apps/backend/bin/* + + - name: Download web build + uses: actions/download-artifact@v4 + with: + name: e2e-web-build + path: apps/web/ + + - name: Extract web build + working-directory: apps/web + run: tar -xf next-build.tar && rm next-build.tar + + - name: Run Docker E2E tests + working-directory: apps/web + env: + KANDEV_E2E_DOCKER: "1" + run: npx playwright test --config e2e/playwright.config.ts --project=docker + + - name: Cleanup leftover kandev containers + if: always() + run: docker ps -aq --filter label=kandev.managed=true | xargs -r docker rm -f + + # Named with the same `blob-report-*` prefix the merge job globs for + # so the docker results land in the unified HTML report alongside the + # sharded chromium/mobile-chrome runs. + - name: Upload Docker blob report + if: always() + uses: actions/upload-artifact@v4 + with: + name: blob-report-docker + path: apps/web/e2e/blob-report/ + retention-days: 3 + + - name: Upload Docker E2E results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: docker-e2e-test-results + path: apps/web/e2e/test-results/ + retention-days: 7 + e2e-report: name: Merge E2E Reports - needs: e2e + needs: [e2e, e2e-docker] if: always() runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ae7580652..1cbdd16957 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -360,6 +360,17 @@ jobs: go build -o bin/kandev${EXT} ./cmd/kandev go build -o bin/agentctl${EXT} ./cmd/agentctl + # Cross-compile a linux/amd64 agentctl regardless of the host platform. + # Remote executors (local Docker, remote Docker, Sprites) bind-mount this + # binary into the container — without it, Docker sessions fail with + # "agentctl linux binary not found" on Mac/Windows installs and even on + # Linux installs (the resolver looks for the -linux-amd64 suffix). + # CGO is off so this works from any host without extra toolchain setup. + - name: Build linux/amd64 agentctl helper + run: | + cd apps/backend + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 CC= go build -o bin/agentctl-linux-amd64 ./cmd/agentctl + - name: Package bundle run: | rm -rf dist/kandev/bin dist/kandev/web @@ -367,6 +378,7 @@ jobs: EXT=${{ matrix.goos == 'windows' && '.exe' || '' }} cp apps/backend/bin/kandev${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl${EXT} dist/kandev/bin/ + cp apps/backend/bin/agentctl-linux-amd64 dist/kandev/bin/ mkdir -p dist/kandev/web cp -R dist/web/. dist/kandev/web/ # cli/ was already placed by the build-cli job (downloaded above). diff --git a/Dockerfile b/Dockerfile index 6e7ce6bdec..34da1f699a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,8 @@ RUN go mod download COPY apps/backend/ ./ RUN go build -ldflags "-s -w" -o /out/kandev ./cmd/kandev && \ - go build -ldflags "-s -w" -o /out/agentctl ./cmd/agentctl + go build -ldflags "-s -w" -o /out/agentctl ./cmd/agentctl && \ + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w" -o /out/agentctl-linux-amd64 ./cmd/agentctl # --------------------------------------------------------------------------- # Stage 2: Web + CLI builder — build Next.js standalone + CLI @@ -87,8 +88,11 @@ RUN userdel -r node && groupadd -r kandev && useradd -r -g kandev -u 1000 -d /da # /app/apps/web/.next/standalone/web/server.js RUN mkdir -p /app/apps/backend/bin /app/apps/web/.next /data/worktrees -# Copy Go binaries +# Copy Go binaries. The -linux-amd64 variant of agentctl is bind-mounted into +# Docker-executor sandboxes by the lifecycle manager; ship it next to kandev +# so the AgentctlResolver finds it without manual configuration. COPY --from=go-builder /out/kandev /app/apps/backend/bin/kandev +COPY --from=go-builder /out/agentctl-linux-amd64 /app/apps/backend/bin/agentctl-linux-amd64 COPY --from=go-builder /out/agentctl /usr/local/bin/agentctl # Copy Next.js standalone output diff --git a/Makefile b/Makefile index e577b8669e..909168c889 100644 --- a/Makefile +++ b/Makefile @@ -170,6 +170,11 @@ build-backend: @printf "$(CYAN)Building backend...$(RESET)\n" @$(MAKE) -C $(BACKEND_DIR) build +.PHONY: build-backend-linux-helpers +build-backend-linux-helpers: + @printf "$(CYAN)Building linux/amd64 helper binaries (agentctl + mock-agent) for Docker E2E...$(RESET)\n" + @$(MAKE) -C $(BACKEND_DIR) build-agentctl-linux build-mock-agent-linux + .PHONY: acpdbg acpdbg: @$(MAKE) -s -C $(BACKEND_DIR) acpdbg ARGS="$(ARGS)" diff --git a/apps/backend/Makefile b/apps/backend/Makefile index 54dbe60b49..c0c8cd2644 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -25,7 +25,7 @@ BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || powershell -c LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) -.PHONY: all build build-all build-agentctl build-agentctl-linux build-acpdbg acpdbg build-mock-agent build-preview clean run dev start-debug test test-e2e test-sprites-e2e lint fmt vet help +.PHONY: all build build-all build-agentctl build-agentctl-linux build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview clean run dev start-debug test test-e2e test-sprites-e2e lint fmt vet help ## Default target all: build @@ -52,8 +52,11 @@ build-acpdbg: @$(MKDIR) $(BUILD_DIR) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/acpdbg ./cmd/acpdbg -## Build the unified kandev binary -build: build-agentctl build-mock-agent build-acpdbg +## Build the unified kandev binary. Always includes the linux/amd64 agentctl +## variant because remote executors (local Docker, remote Docker, Sprites) +## bind-mount it into containers — without it, the first Docker session on a +## non-Linux host fails with "agentctl linux binary not found". +build: build-agentctl build-agentctl-linux build-mock-agent build-acpdbg @echo "Building $(BINARY_NAME)..." @$(MKDIR) $(BUILD_DIR) CGO_ENABLED=1 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/kandev @@ -75,6 +78,12 @@ build-agentctl-linux: @$(MKDIR) $(BUILD_DIR) GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl-linux-amd64 ./cmd/agentctl +## Build mock-agent for linux/amd64 (used by Docker E2E tests) +build-mock-agent-linux: + @echo "Building mock-agent for linux/amd64..." + @$(MKDIR) $(BUILD_DIR) + GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/mock-agent-linux-amd64 ./cmd/mock-agent + ## Clean build artifacts clean: diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 2eaeb43474..1e93bb7d80 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -203,6 +203,18 @@ func (a *lifecycleAdapter) SetExecutionDescription(ctx context.Context, agentExe return a.mgr.SetExecutionDescription(ctx, agentExecutionID, description) } +// RequiresCloneURL implements executor.ExecutorTypeCapabilities by delegating to +// the lifecycle manager. Without this, executor types like local_docker and +// sprites can't tell the orchestrator they need a clone URL. +func (a *lifecycleAdapter) RequiresCloneURL(executorType string) bool { + return a.mgr.RequiresCloneURL(executorType) +} + +// ShouldApplyPreferredShell implements executor.ExecutorTypeCapabilities. +func (a *lifecycleAdapter) ShouldApplyPreferredShell(executorType string) bool { + return a.mgr.ShouldApplyPreferredShell(executorType) +} + // SetMcpMode changes the MCP tool mode on an existing execution's agentctl instance. func (a *lifecycleAdapter) SetMcpMode(ctx context.Context, executionID string, mode string) error { return a.mgr.SetMcpMode(ctx, executionID, mode) diff --git a/apps/backend/cmd/kandev/agents.go b/apps/backend/cmd/kandev/agents.go index 1594c23aba..13f15b632c 100644 --- a/apps/backend/cmd/kandev/agents.go +++ b/apps/backend/cmd/kandev/agents.go @@ -60,7 +60,7 @@ func provideLifecycleManager( // Register Docker runtime if enabled (client is created lazily on first use) if cfg.Docker.Enabled { - dockerExec := lifecycle.NewDockerExecutor(cfg.Docker, log) + dockerExec := lifecycle.NewDockerExecutor(cfg.Docker, cfg.ResolvedHomeDir(), log) executorRegistry.Register(dockerExec) log.Info("Docker runtime registered (lazy initialization)") } diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 0ca154eb66..b69361925c 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -647,7 +647,7 @@ func registerSecondaryRoutes( p.log.Debug("Registered Slack handlers (HTTP + WebSocket)") } - docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), p.log) + docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), dockerTaskTitleProvider(p.taskRepo, p.log), p.log) p.log.Debug("Registered Docker management handlers (HTTP)") registerHealthRoutes(p) @@ -666,6 +666,21 @@ func registerSecondaryRoutes( registerE2EResetRoutes(p.router, p.taskRepo, p.taskSvc, p.log) } +func dockerTaskTitleProvider(taskRepo *sqliterepo.Repository, log *logger.Logger) docker.TaskTitleProvider { + return func(ctx context.Context, taskID string) (string, bool) { + if taskRepo == nil || taskID == "" { + return "", false + } + task, err := taskRepo.GetTask(ctx, taskID) + if err != nil { + log.Debug("docker container task title lookup failed", + zap.String("task_id", taskID), zap.Error(err)) + return "", false + } + return task.Title, task.Title != "" + } +} + // registerHealthRoutes sets up the system health endpoint with all health checkers. func registerHealthRoutes(p routeParams) { var githubProvider health.GitHubStatusProvider diff --git a/apps/backend/cmd/kandev/worktree.go b/apps/backend/cmd/kandev/worktree.go index d3d1507089..f5faf41013 100644 --- a/apps/backend/cmd/kandev/worktree.go +++ b/apps/backend/cmd/kandev/worktree.go @@ -2,6 +2,12 @@ package main import ( "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/common/config" @@ -67,6 +73,12 @@ func provideWorktreeManager(dbPool *db.Pool, cfg *config.Config, log *logger.Log lifecycleMgr.SetBootMessageService(&bootMsgAdapter{svc: taskSvc}) } taskSvc.SetWorktreeCleanup(manager) + if lifecycleMgr != nil { + taskSvc.SetEnvironmentDestroyer(&environmentDestroyerAdapter{ + lifecycle: lifecycleMgr, + worktrees: manager, + }) + } // Wire script message handler with adapters taskSvcAdapter := &taskServiceAdapter{svc: taskSvc} @@ -86,3 +98,112 @@ func provideWorktreeManager(dbPool *db.Pool, cfg *config.Config, log *logger.Log return manager, recreator, cleanup, nil } + +// environmentDestroyerAdapter implements taskservice.EnvironmentDestroyer by +// delegating to the lifecycle Manager (for containers/sandboxes) and the worktree +// Manager (for worktrees). Branch is preserved on worktree removal so unpushed +// work is never silently dropped. +type environmentDestroyerAdapter struct { + lifecycle *lifecycle.Manager + worktrees *worktree.Manager +} + +func (a *environmentDestroyerAdapter) DestroyContainer(ctx context.Context, containerID string) error { + return a.lifecycle.DestroyContainer(ctx, containerID) +} + +func (a *environmentDestroyerAdapter) DestroySandbox(ctx context.Context, sandboxID, executionID string) error { + return a.lifecycle.DestroySandbox(ctx, sandboxID, executionID) +} + +func (a *environmentDestroyerAdapter) DestroyWorktree(ctx context.Context, worktreeID string) error { + // removeBranch=false: preserve the branch so unpushed work isn't lost. + return a.worktrees.RemoveByID(ctx, worktreeID, false) +} + +func (a *environmentDestroyerAdapter) GetContainerLiveStatus(ctx context.Context, containerID string) (*taskservice.ContainerLiveStatus, error) { + live, err := a.lifecycle.GetContainerLiveStatus(ctx, containerID) + if err != nil || live == nil { + return nil, err + } + out := &taskservice.ContainerLiveStatus{ + ContainerID: live.ContainerID, + State: live.State, + Status: live.Status, + ExitCode: live.ExitCode, + Health: live.Health, + Missing: live.Missing, + } + if live.StartedAt != nil { + out.StartedAt = live.StartedAt.Format(time.RFC3339) + } + if live.FinishedAt != nil { + out.FinishedAt = live.FinishedAt.Format(time.RFC3339) + } + return out, nil +} + +// pushBranchTimeout caps how long we'll wait for `git push` before treating +// the call as hung. Reset is invoked from the request thread; without a bound +// a stalled remote (auth prompt, packet loss, slow proxy) would block the +// entire HTTP handler indefinitely. +const pushBranchTimeout = 30 * time.Second + +func (a *environmentDestroyerAdapter) PushEnvironmentBranch(ctx context.Context, env *models.TaskEnvironment) error { + // For host-side worktrees we can push directly. Container/sandbox workspaces + // would require an active agentctl client — not wired yet, surface a clear + // error so the user knows to push manually. + if env.WorktreePath == "" { + return fmt.Errorf("push-before-reset is not supported for this environment type; please push manually first") + } + pushCtx, cancel := context.WithTimeout(ctx, pushBranchTimeout) + defer cancel() + + branch := strings.TrimSpace(env.WorktreeBranch) + var cmd *exec.Cmd + if branch == "" { + cmd = exec.CommandContext(pushCtx, "git", "push") + } else { + // Prefer the branch's configured upstream remote so this works for + // repos whose primary remote isn't called "origin" (e.g. fork + // workflows with "upstream"/"github"). Fall back to "origin" only + // when no upstream is set, matching the historical behaviour. + remote := detectBranchRemote(pushCtx, env.WorktreePath, branch) + cmd = exec.CommandContext(pushCtx, "git", "push", remote, branch) + } + cmd.Dir = env.WorktreePath + // Disable interactive credential prompts — without this, a missing + // credential helper can hang waiting on stdin even with the timeout above + // (signal delivery is gated behind the prompt read). + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + out, err := cmd.CombinedOutput() + if err != nil { + if errors.Is(pushCtx.Err(), context.DeadlineExceeded) { + return fmt.Errorf("git push timed out after %s in %s (branch %q); push manually and retry", pushBranchTimeout, env.WorktreePath, branch) + } + return fmt.Errorf("git push failed: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +// defaultGitRemote is the conventional default remote name. Used as the +// fallback when a branch has no configured upstream and at the comment level +// when describing historical hard-coded behaviour. +const defaultGitRemote = "origin" + +// detectBranchRemote reads the configured upstream remote for `branch` from +// the worktree's git config. Falls back to defaultGitRemote when no upstream +// is set (matching the historical hard-coded behaviour). +func detectBranchRemote(ctx context.Context, dir, branch string) string { + cmd := exec.CommandContext(ctx, "git", "config", "--get", "branch."+branch+".remote") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return defaultGitRemote + } + remote := strings.TrimSpace(string(out)) + if remote == "" { + return defaultGitRemote + } + return remote +} diff --git a/apps/backend/cmd/kandev/worktree_test.go b/apps/backend/cmd/kandev/worktree_test.go new file mode 100644 index 0000000000..c5cce3da7f --- /dev/null +++ b/apps/backend/cmd/kandev/worktree_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "os" + "os/exec" + "testing" +) + +// TestDetectBranchRemote_ReturnsConfiguredUpstream covers the happy path: a +// branch with an explicit `branch..remote` config returns that remote +// (covers fork-workflow repos whose primary remote is named "upstream", +// "github", etc., where hard-coding "origin" would push to the wrong place). +func TestDetectBranchRemote_ReturnsConfiguredUpstream(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + mustGit(t, dir, "init", "--quiet") + mustGit(t, dir, "commit", "--allow-empty", "-m", "init") + mustGit(t, dir, "checkout", "-b", "feature") + // Mimic what `git push --set-upstream ` would write. + mustGit(t, dir, "config", "branch.feature.remote", "upstream") + + if got := detectBranchRemote(context.Background(), dir, "feature"); got != "upstream" { + t.Errorf("got %q, want upstream", got) + } +} + +func TestDetectBranchRemote_NoUpstreamFallsBackToOrigin(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + mustGit(t, dir, "init", "--quiet") + mustGit(t, dir, "commit", "--allow-empty", "-m", "init") + mustGit(t, dir, "checkout", "-b", "feature") + // No branch.feature.remote config — git config returns non-zero. + + if got := detectBranchRemote(context.Background(), dir, "feature"); got != defaultGitRemote { + t.Errorf("got %q, want %s", got, defaultGitRemote) + } +} + +func TestDetectBranchRemote_NonGitDirFallsBackToOrigin(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + // `git config` in a non-git dir errors out, so detectBranchRemote + // should fall back to the default remote rather than propagate the error. + if got := detectBranchRemote(context.Background(), t.TempDir(), "feature"); got != defaultGitRemote { + t.Errorf("got %q, want %s", got, defaultGitRemote) + } +} + +// mustGit runs `git -C dir ` and fails the test on non-zero exit. +func mustGit(t *testing.T, dir string, args ...string) { + t.Helper() + full := append([]string{"-C", dir}, args...) + cmd := exec.Command("git", full...) + // Start from the parent environment so git keeps HOME/PATH/etc., then: + // - disable system config (/etc/gitconfig) to prevent host policy from + // interfering with the test + // - set an identity inline so `git commit` works on CI runners that + // have no user.email/user.name configured. Both AUTHOR and COMMITTER + // are needed — git fails fast if either is missing. + cmd.Env = append(os.Environ(), + "GIT_CONFIG_NOSYSTEM=1", + "GIT_AUTHOR_NAME=test", + "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", + "GIT_COMMITTER_EMAIL=test@example.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} diff --git a/apps/backend/internal/agent/agents/codex_acp.go b/apps/backend/internal/agent/agents/codex_acp.go index 6e5c4290f0..7a8a268596 100644 --- a/apps/backend/internal/agent/agents/codex_acp.go +++ b/apps/backend/internal/agent/agents/codex_acp.go @@ -88,13 +88,19 @@ func (a *CodexACP) Runtime() *RuntimeConfig { Env: map[string]string{}, Mounts: []MountTemplate{ {Source: "{workspace}", Target: "/workspace"}, - {Source: "{home}/.codex", Target: "/root/.codex"}, }, ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, Protocol: agent.ProtocolACP, SessionConfig: SessionConfig{ NativeSessionResume: true, CanRecover: &canRecover, + // Use the same SessionDirTemplate pattern every other ACP agent + // uses; the docker container manager wires this into a kandev-owned + // per-container session dir, isolated from the host's ~/.codex + // (which carries host-absolute rollout paths in state.db that + // don't resolve inside the container). + SessionDirTemplate: "{home}/.codex", + SessionDirTarget: "/root/.codex", }, } } diff --git a/apps/backend/internal/agent/agents/codex_acp_test.go b/apps/backend/internal/agent/agents/codex_acp_test.go new file mode 100644 index 0000000000..1ea17754f0 --- /dev/null +++ b/apps/backend/internal/agent/agents/codex_acp_test.go @@ -0,0 +1,29 @@ +package agents + +import ( + "strings" + "testing" +) + +func TestCodexACPRuntimeNoLongerBindMountsHostHome(t *testing.T) { + a := NewCodexACP() + rt := a.Runtime() + + for _, m := range rt.Mounts { + if strings.Contains(m.Source, "{home}") { + t.Fatalf("codex Mounts unexpectedly references {home}: %+v", m) + } + } +} + +func TestCodexACPSessionDirTemplate(t *testing.T) { + a := NewCodexACP() + cfg := a.Runtime().SessionConfig + + if cfg.SessionDirTemplate != "{home}/.codex" { + t.Fatalf("SessionDirTemplate = %q, want %q", cfg.SessionDirTemplate, "{home}/.codex") + } + if cfg.SessionDirTarget != "/root/.codex" { + t.Fatalf("SessionDirTarget = %q, want %q", cfg.SessionDirTarget, "/root/.codex") + } +} diff --git a/apps/backend/internal/agent/agents/mock.go b/apps/backend/internal/agent/agents/mock.go index 70ddaf00ca..b4bf74f5e2 100644 --- a/apps/backend/internal/agent/agents/mock.go +++ b/apps/backend/internal/agent/agents/mock.go @@ -86,11 +86,10 @@ func (a *MockAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { } func (a *MockAgent) BuildCommand(opts CommandOptions) Command { - binary := "mock-agent" - if a.binaryPath != "" { - binary = a.binaryPath - } - return Cmd(binary).Build() + // Always resolve via PATH: works on the host (E2E PATH includes the + // kandev bin dir) and inside Docker containers (mock-agent is + // bind-mounted at /usr/local/bin/mock-agent for E2E). + return Cmd("mock-agent").Build() } func (a *MockAgent) Runtime() *RuntimeConfig { @@ -107,7 +106,10 @@ func (a *MockAgent) Runtime() *RuntimeConfig { } } -func (a *MockAgent) RemoteAuth() *RemoteAuth { return nil } +// RemoteAuth returns a non-nil empty spec so the mock agent counts as a +// remote-capable agent (it's runnable inside Docker for E2E tests) but +// requires no credentials. The empty methods list signals "no auth needed". +func (a *MockAgent) RemoteAuth() *RemoteAuth { return &RemoteAuth{} } // InstallScript returns a deterministic short-lived shell script so e2e tests // can exercise the install streaming endpoint without depending on npm. diff --git a/apps/backend/internal/agent/docker/client.go b/apps/backend/internal/agent/docker/client.go index 94b91b65f0..bf772acd3a 100644 --- a/apps/backend/internal/agent/docker/client.go +++ b/apps/backend/internal/agent/docker/client.go @@ -17,6 +17,7 @@ import ( "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" @@ -24,17 +25,26 @@ import ( // ContainerConfig holds configuration for creating a container. type ContainerConfig struct { - Name string - Image string - Cmd []string - Env []string // Environment variables - WorkingDir string - Mounts []MountConfig - NetworkMode string - Memory int64 // Memory limit in bytes - CPUQuota int64 // CPU quota - Labels map[string]string - AutoRemove bool + Name string + Image string + Entrypoint []string // Overrides the image ENTRYPOINT (nil = use image default) + Cmd []string + Env []string // Environment variables + WorkingDir string + Mounts []MountConfig + NetworkMode string + Memory int64 // Memory limit in bytes + CPUQuota int64 // CPU quota + Labels map[string]string + AutoRemove bool + PortBindings []PortBindingConfig +} + +// PortBindingConfig describes a container port to publish on the Docker host. +type PortBindingConfig struct { + ContainerPort int + HostIP string + HostPort string } // MountConfig holds mount configuration. @@ -55,6 +65,7 @@ type ContainerInfo struct { FinishedAt time.Time ExitCode int Health string + Labels map[string]string } // Client wraps the Docker client. @@ -194,19 +205,23 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri } // Container configuration + exposedPorts, portBindings := buildDockerPortBindings(cfg.PortBindings) containerCfg := &container.Config{ - Image: cfg.Image, - Cmd: cfg.Cmd, - Env: cfg.Env, - WorkingDir: cfg.WorkingDir, - Labels: cfg.Labels, + Image: cfg.Image, + Entrypoint: cfg.Entrypoint, + Cmd: cfg.Cmd, + Env: cfg.Env, + WorkingDir: cfg.WorkingDir, + Labels: cfg.Labels, + ExposedPorts: exposedPorts, } // Host configuration hostCfg := &container.HostConfig{ - Mounts: mounts, - NetworkMode: container.NetworkMode(cfg.NetworkMode), - AutoRemove: cfg.AutoRemove, + Mounts: mounts, + NetworkMode: container.NetworkMode(cfg.NetworkMode), + AutoRemove: cfg.AutoRemove, + PortBindings: portBindings, Resources: container.Resources{ Memory: cfg.Memory, CPUQuota: cfg.CPUQuota, @@ -226,6 +241,23 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri return resp.ID, nil } +func buildDockerPortBindings(bindings []PortBindingConfig) (nat.PortSet, nat.PortMap) { + if len(bindings) == 0 { + return nil, nil + } + exposedPorts := nat.PortSet{} + portBindings := nat.PortMap{} + for _, binding := range bindings { + containerPort := nat.Port(fmt.Sprintf("%d/tcp", binding.ContainerPort)) + exposedPorts[containerPort] = struct{}{} + portBindings[containerPort] = append(portBindings[containerPort], nat.PortBinding{ + HostIP: binding.HostIP, + HostPort: binding.HostPort, + }) + } + return exposedPorts, portBindings +} + // StartContainer starts a container. func (c *Client) StartContainer(ctx context.Context, containerID string) error { c.logger.Info("Starting container", zap.String("container_id", containerID)) @@ -314,6 +346,7 @@ func (c *Client) GetContainerInfo(ctx context.Context, containerID string) (*Con State: inspect.State.Status, Status: inspect.State.Status, ExitCode: inspect.State.ExitCode, + Labels: inspect.Config.Labels, } // Parse timestamps @@ -374,6 +407,41 @@ func (c *Client) GetContainerIP(ctx context.Context, containerID string) (string return "", fmt.Errorf("no IP address found for container %s", containerID) } +// GetContainerHostPort returns the Docker host endpoint for a published TCP port. +func (c *Client) GetContainerHostPort(ctx context.Context, containerID string, containerPort int) (string, int, error) { + c.logger.Debug("Getting container host port", + zap.String("container_id", containerID), + zap.Int("container_port", containerPort)) + + inspect, err := c.cli.ContainerInspect(ctx, containerID) + if err != nil { + return "", 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err) + } + if inspect.NetworkSettings == nil || inspect.NetworkSettings.Ports == nil { + return "", 0, fmt.Errorf("container %s has no published ports", containerID) + } + + bindings := inspect.NetworkSettings.Ports[nat.Port(fmt.Sprintf("%d/tcp", containerPort))] + if len(bindings) == 0 { + return "", 0, fmt.Errorf("container %s port %d/tcp is not published", containerID, containerPort) + } + hostPort, err := nat.ParsePort(bindings[0].HostPort) + if err != nil { + return "", 0, fmt.Errorf("invalid host port for container %s port %d/tcp: %w", containerID, containerPort, err) + } + hostIP := normalizeDockerHostIP(bindings[0].HostIP) + return hostIP, hostPort, nil +} + +func normalizeDockerHostIP(hostIP string) string { + switch hostIP { + case "", "0.0.0.0", "::": + return "127.0.0.1" + default: + return hostIP + } +} + // GetContainerLabels returns the labels of a container func (c *Client) GetContainerLabels(ctx context.Context, containerID string) (map[string]string, error) { c.logger.Debug("Getting container labels", zap.String("container_id", containerID)) @@ -478,6 +546,7 @@ func (c *Client) ListContainers(ctx context.Context, labels map[string]string) ( Image: ctr.Image, State: ctr.State, Status: ctr.Status, + Labels: ctr.Labels, } infos = append(infos, info) } @@ -526,9 +595,13 @@ func (c *Client) CreateContainerInteractive(ctx context.Context, cfg ContainerCo }) } - // Container configuration with stdin attached + // Container configuration with stdin attached. Mirror CreateContainer's + // handling of ContainerConfig fields — including Entrypoint — so the same + // config struct produces consistent container behavior on both paths. + exposedPorts, portBindings := buildDockerPortBindings(cfg.PortBindings) containerCfg := &container.Config{ Image: cfg.Image, + Entrypoint: cfg.Entrypoint, Cmd: cfg.Cmd, Env: cfg.Env, WorkingDir: cfg.WorkingDir, @@ -538,14 +611,16 @@ func (c *Client) CreateContainerInteractive(ctx context.Context, cfg ContainerCo AttachStdin: true, AttachStdout: true, AttachStderr: true, + ExposedPorts: exposedPorts, Tty: false, // Important: no TTY for JSON-RPC } // Host configuration hostCfg := &container.HostConfig{ - Mounts: mounts, - NetworkMode: container.NetworkMode(cfg.NetworkMode), - AutoRemove: cfg.AutoRemove, + Mounts: mounts, + NetworkMode: container.NetworkMode(cfg.NetworkMode), + AutoRemove: cfg.AutoRemove, + PortBindings: portBindings, Resources: container.Resources{ Memory: cfg.Memory, CPUQuota: cfg.CPUQuota, diff --git a/apps/backend/internal/agent/docker/client_test.go b/apps/backend/internal/agent/docker/client_test.go new file mode 100644 index 0000000000..d2d86476b9 --- /dev/null +++ b/apps/backend/internal/agent/docker/client_test.go @@ -0,0 +1,76 @@ +package docker + +import ( + "fmt" + "testing" + + "github.com/docker/go-connections/nat" +) + +func TestNormalizeDockerHostIP(t *testing.T) { + cases := []struct { + in string + want string + }{ + {in: "", want: "127.0.0.1"}, + {in: "0.0.0.0", want: "127.0.0.1"}, + {in: "::", want: "127.0.0.1"}, + {in: "127.0.0.1", want: "127.0.0.1"}, + {in: "10.0.0.5", want: "10.0.0.5"}, + {in: "::1", want: "::1"}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + if got := normalizeDockerHostIP(tc.in); got != tc.want { + t.Errorf("normalizeDockerHostIP(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestBuildDockerPortBindings_EmptyReturnsNil(t *testing.T) { + exposed, bindings := buildDockerPortBindings(nil) + if exposed != nil { + t.Errorf("expected nil exposed ports, got %v", exposed) + } + if bindings != nil { + t.Errorf("expected nil bindings, got %v", bindings) + } +} + +func TestBuildDockerPortBindings_AssignsContainerAndHost(t *testing.T) { + in := []PortBindingConfig{ + {ContainerPort: 8080, HostIP: "127.0.0.1", HostPort: "0"}, + {ContainerPort: 9000, HostIP: "0.0.0.0", HostPort: "9001"}, + } + exposed, bindings := buildDockerPortBindings(in) + + if got := len(exposed); got != 2 { + t.Fatalf("exposed ports = %d, want 2", got) + } + for _, b := range in { + key := nat.Port(fmt.Sprintf("%d/tcp", b.ContainerPort)) + if _, ok := exposed[key]; !ok { + t.Errorf("exposed missing %s", key) + } + got := bindings[key] + if len(got) != 1 { + t.Fatalf("bindings[%s] = %d entries, want 1", key, len(got)) + } + if got[0].HostIP != b.HostIP || got[0].HostPort != b.HostPort { + t.Errorf("bindings[%s] = %+v, want host_ip=%q host_port=%q", key, got[0], b.HostIP, b.HostPort) + } + } +} + +func TestBuildDockerPortBindings_DeduplicatesContainerPort(t *testing.T) { + in := []PortBindingConfig{ + {ContainerPort: 7000, HostIP: "127.0.0.1", HostPort: "0"}, + {ContainerPort: 7000, HostIP: "10.0.0.5", HostPort: "7000"}, + } + _, bindings := buildDockerPortBindings(in) + got := bindings[nat.Port("7000/tcp")] + if len(got) != 2 { + t.Fatalf("want both bindings on port 7000, got %d", len(got)) + } +} diff --git a/apps/backend/internal/agent/docker/handlers.go b/apps/backend/internal/agent/docker/handlers.go index c13e4e638c..a169334333 100644 --- a/apps/backend/internal/agent/docker/handlers.go +++ b/apps/backend/internal/agent/docker/handlers.go @@ -3,6 +3,7 @@ package docker import ( "bufio" + "context" "net/http" "time" @@ -20,12 +21,13 @@ type buildImageRequest struct { // containerResponse is the JSON representation of a container in list responses. type containerResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Image string `json:"image"` - State string `json:"state"` - Status string `json:"status"` - StartedAt time.Time `json:"started_at"` + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + State string `json:"state"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + Labels map[string]string `json:"labels,omitempty"` } // stopContainerRequest is the optional JSON body for POST /api/v1/docker/containers/:id/stop. @@ -36,12 +38,15 @@ type stopContainerRequest struct { // ClientProvider lazily resolves the Docker client. Returns nil if Docker is unavailable. type ClientProvider func() *Client +// TaskTitleProvider resolves a task ID to its display title for container listings. +type TaskTitleProvider func(ctx context.Context, taskID string) (string, bool) + // RegisterDockerRoutes registers Docker management HTTP routes on the given router. // The clientProvider lazily resolves the Docker client on each request. -func RegisterDockerRoutes(router *gin.Engine, clientProvider ClientProvider, log *logger.Logger) { +func RegisterDockerRoutes(router *gin.Engine, clientProvider ClientProvider, taskTitleProvider TaskTitleProvider, log *logger.Logger) { api := router.Group("/api/v1/docker") api.POST("/build", handleBuildImage(clientProvider, log)) - api.GET("/containers", handleListContainers(clientProvider, log)) + api.GET("/containers", handleListContainers(clientProvider, taskTitleProvider, log)) api.POST("/containers/:id/stop", handleStopContainer(clientProvider, log)) api.DELETE("/containers/:id", handleRemoveContainer(clientProvider, log)) } @@ -113,7 +118,7 @@ func streamBuildOutput(c *gin.Context, reader interface{ Read([]byte) (int, erro // handleListContainers handles GET /api/v1/docker/containers. // Supports optional query params: image, labels (comma-separated key=value pairs). -func handleListContainers(clientProvider ClientProvider, log *logger.Logger) gin.HandlerFunc { +func handleListContainers(clientProvider ClientProvider, taskTitleProvider TaskTitleProvider, log *logger.Logger) gin.HandlerFunc { return func(c *gin.Context) { dockerClient := requireDocker(c, clientProvider) if dockerClient == nil { @@ -130,22 +135,50 @@ func handleListContainers(clientProvider ClientProvider, log *logger.Logger) gin return } - resp := make([]containerResponse, len(containers)) - for i, ctr := range containers { - resp[i] = containerResponse{ - ID: ctr.ID, - Name: ctr.Name, - Image: ctr.Image, - State: ctr.State, - Status: ctr.Status, - StartedAt: ctr.StartedAt, - } - } + resp := newContainerResponsesWithTaskTitles(c.Request.Context(), containers, taskTitleProvider) c.JSON(http.StatusOK, gin.H{"containers": resp}) } } +func newContainerResponses(containers []ContainerInfo) []containerResponse { + return newContainerResponsesWithTaskTitles(context.Background(), containers, nil) +} + +func newContainerResponsesWithTaskTitles(ctx context.Context, containers []ContainerInfo, taskTitleProvider TaskTitleProvider) []containerResponse { + resp := make([]containerResponse, len(containers)) + for i, ctr := range containers { + resp[i] = containerResponse{ + ID: ctr.ID, + Name: ctr.Name, + Image: ctr.Image, + State: ctr.State, + Status: ctr.Status, + StartedAt: ctr.StartedAt, + Labels: labelsWithTaskTitle(ctx, ctr.Labels, taskTitleProvider), + } + } + return resp +} + +func labelsWithTaskTitle(ctx context.Context, labels map[string]string, taskTitleProvider TaskTitleProvider) map[string]string { + if len(labels) == 0 { + return labels + } + result := make(map[string]string, len(labels)+1) + for key, value := range labels { + result[key] = value + } + if result["kandev.task_title"] != "" || taskTitleProvider == nil { + return result + } + title, ok := taskTitleProvider(ctx, result["kandev.task_id"]) + if ok && title != "" { + result["kandev.task_title"] = title + } + return result +} + // parseLabelsQuery extracts label filters from the "labels" query parameter. // Expected format: "key1=value1,key2=value2". func parseLabelsQuery(c *gin.Context) map[string]string { diff --git a/apps/backend/internal/agent/docker/handlers_test.go b/apps/backend/internal/agent/docker/handlers_test.go new file mode 100644 index 0000000000..48f0d0d121 --- /dev/null +++ b/apps/backend/internal/agent/docker/handlers_test.go @@ -0,0 +1,55 @@ +package docker + +import ( + "context" + "testing" + "time" +) + +func TestContainerResponsesIncludeLabels(t *testing.T) { + containers := []ContainerInfo{{ + ID: "container-1", + Name: "kandev-agent-1", + Image: "kandev/agent:test", + State: "running", + Status: "Up 2 seconds", + StartedAt: time.Unix(100, 0), + Labels: map[string]string{ + "kandev.executor_profile_id": "profile-1", + "kandev.task_id": "task-1", + }, + }} + + got := newContainerResponses(containers) + + if got[0].Labels["kandev.executor_profile_id"] != "profile-1" { + t.Fatalf("labels = %#v, want executor profile label", got[0].Labels) + } + if got[0].Labels["kandev.task_id"] != "task-1" { + t.Fatalf("labels = %#v, want task label", got[0].Labels) + } +} + +func TestContainerResponsesEnrichTaskTitleLabel(t *testing.T) { + containers := []ContainerInfo{{ + ID: "container-1", + Name: "kandev-agent-1", + Image: "kandev/agent:test", + State: "running", + Status: "Up 2 seconds", + Labels: map[string]string{ + "kandev.task_id": "task-1", + }, + }} + + got := newContainerResponsesWithTaskTitles(context.Background(), containers, func(_ context.Context, taskID string) (string, bool) { + return "Readable Task Title", taskID == "task-1" + }) + + if got[0].Labels["kandev.task_title"] != "Readable Task Title" { + t.Fatalf("labels = %#v, want task title label", got[0].Labels) + } + if _, exists := containers[0].Labels["kandev.task_title"]; exists { + t.Fatalf("source labels mutated: %#v", containers[0].Labels) + } +} diff --git a/apps/backend/internal/agent/handlers/shell_handlers.go b/apps/backend/internal/agent/handlers/shell_handlers.go index 13b9a407ca..45fd1ce030 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers.go +++ b/apps/backend/internal/agent/handlers/shell_handlers.go @@ -13,6 +13,7 @@ import ( "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/scripts" + v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" ) @@ -130,10 +131,9 @@ func (h *ShellHandlers) wsShellSubscribe(ctx context.Context, msg *ws.Message) ( return nil, fmt.Errorf("session_id is required") } - // Get or create execution on-demand (survives backend restart) - execution, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, req.SessionID) + execution, err := h.ensureShellExecution(ctx, req.SessionID) if err != nil { - return nil, fmt.Errorf("no agent running for session %s: %w", req.SessionID, err) + return nil, err } // Get buffered output to include in response @@ -164,13 +164,9 @@ func (h *ShellHandlers) wsShellInput(ctx context.Context, msg *ws.Message) (*ws. return nil, fmt.Errorf("session_id is required") } - // Get the agent execution for this session. - // Use GetExecutionBySessionID (not GetOrEnsureExecution): writing input - // requires the workspace stream to already be live, and the 5s wait below - // is too short to absorb a cold-start of agentctl. - execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) - if !ok { - return nil, fmt.Errorf("no agent running for session %s", req.SessionID) + execution, err := h.ensureShellExecution(ctx, req.SessionID) + if err != nil { + return nil, err } // Wait for the workspace stream to be ready with a timeout. @@ -201,6 +197,43 @@ func (h *ShellHandlers) wsShellInput(ctx context.Context, msg *ws.Message) (*ws. }) } +// shellLifecycle is the narrow slice of lifecycle.Manager that +// ensureShellExecution needs. *lifecycle.Manager satisfies it; tests can +// stub it without touching the rest of the manager. +type shellLifecycle interface { + GetOrEnsureExecution(ctx context.Context, sessionID string) (*lifecycle.AgentExecution, error) + CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error +} + +func (h *ShellHandlers) ensureShellExecution(ctx context.Context, sessionID string) (*lifecycle.AgentExecution, error) { + return ensureShellExecution(ctx, h.lifecycleMgr, h.logger, sessionID) +} + +// ensureShellExecution returns a live shell execution for sessionID, recovering +// from a stale (Failed) execution by cleaning it up and re-launching once. +// Extracted as a free function so the recovery branch is directly testable +// without standing up a real lifecycle.Manager. +func ensureShellExecution(ctx context.Context, lifecycleMgr shellLifecycle, log *logger.Logger, sessionID string) (*lifecycle.AgentExecution, error) { + execution, err := lifecycleMgr.GetOrEnsureExecution(ctx, sessionID) + if err != nil { + log.Debug("failed to ensure shell execution", + zap.String("session_id", sessionID), + zap.Error(err)) + return nil, fmt.Errorf("no agent running for session %s", sessionID) + } + if execution.Status != v1.AgentStatusFailed { + return execution, nil + } + if err := lifecycleMgr.CleanupStaleExecutionBySessionID(ctx, sessionID); err != nil { + return nil, fmt.Errorf("cleanup stale execution for session %s: %w", sessionID, err) + } + execution, err = lifecycleMgr.GetOrEnsureExecution(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("recover execution for session %s: %w", sessionID, err) + } + return execution, nil +} + // UserShellListRequest for user_shell.list action. // User shells are env-scoped — sessions in the same task share one shell list. type UserShellListRequest struct { diff --git a/apps/backend/internal/agent/handlers/shell_handlers_test.go b/apps/backend/internal/agent/handlers/shell_handlers_test.go index 93852ae070..7cea233597 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers_test.go +++ b/apps/backend/internal/agent/handlers/shell_handlers_test.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "errors" "strings" "testing" "time" @@ -11,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" + v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -581,3 +583,132 @@ func TestWsUserShellStop_NoInteractiveRunner(t *testing.T) { t.Errorf("expected 'interactive runner not available', got: %v", err) } } + +// stubShellLifecycle satisfies shellLifecycle for ensureShellExecution +// tests so the recovery branch can be driven without a real +// lifecycle.Manager. Each call records its argument and returns the next +// scripted return value. +type stubShellLifecycle struct { + getResults []*lifecycle.AgentExecution + getErrors []error + getCalls []string + cleanupErr error + cleanupCalls []string +} + +func (s *stubShellLifecycle) GetOrEnsureExecution(_ context.Context, sessionID string) (*lifecycle.AgentExecution, error) { + idx := len(s.getCalls) + s.getCalls = append(s.getCalls, sessionID) + if idx >= len(s.getResults) { + return nil, errors.New("stub exhausted") + } + return s.getResults[idx], s.getErrors[idx] +} + +func (s *stubShellLifecycle) CleanupStaleExecutionBySessionID(_ context.Context, sessionID string) error { + s.cleanupCalls = append(s.cleanupCalls, sessionID) + return s.cleanupErr +} + +func TestEnsureShellExecution_HappyPath(t *testing.T) { + stub := &stubShellLifecycle{ + getResults: []*lifecycle.AgentExecution{{ID: "exec-1", Status: v1.AgentStatusRunning}}, + getErrors: []error{nil}, + } + + got, err := ensureShellExecution(context.Background(), stub, newTestLogger(), "session-1") + if err != nil { + t.Fatalf("happy-path returned error: %v", err) + } + if got.ID != "exec-1" { + t.Errorf("got execution %q, want exec-1", got.ID) + } + if len(stub.getCalls) != 1 { + t.Errorf("expected 1 GetOrEnsureExecution call, got %d", len(stub.getCalls)) + } + if len(stub.cleanupCalls) != 0 { + t.Errorf("running execution should not trigger cleanup, got %d calls", len(stub.cleanupCalls)) + } +} + +func TestEnsureShellExecution_RecoversStaleExecution(t *testing.T) { + // First call returns a Failed execution → cleanup → second call returns + // a healthy one. This is the recovery path that survives a crashed + // agent: the user retries shell ops, kandev cleans the stale row, and + // re-launches transparently. + stub := &stubShellLifecycle{ + getResults: []*lifecycle.AgentExecution{ + {ID: "exec-stale", Status: v1.AgentStatusFailed}, + {ID: "exec-fresh", Status: v1.AgentStatusRunning}, + }, + getErrors: []error{nil, nil}, + } + + got, err := ensureShellExecution(context.Background(), stub, newTestLogger(), "session-1") + if err != nil { + t.Fatalf("recovery path returned error: %v", err) + } + if got.ID != "exec-fresh" { + t.Errorf("got %q, want exec-fresh after recovery", got.ID) + } + if len(stub.cleanupCalls) != 1 || stub.cleanupCalls[0] != "session-1" { + t.Errorf("expected exactly one CleanupStaleExecutionBySessionID(session-1), got %v", stub.cleanupCalls) + } + if len(stub.getCalls) != 2 { + t.Errorf("expected 2 GetOrEnsureExecution calls (initial + recovery), got %d", len(stub.getCalls)) + } +} + +func TestEnsureShellExecution_InitialErrorWrapped(t *testing.T) { + stub := &stubShellLifecycle{ + getResults: []*lifecycle.AgentExecution{nil}, + getErrors: []error{errors.New("manager unavailable")}, + } + + _, err := ensureShellExecution(context.Background(), stub, newTestLogger(), "session-1") + if err == nil { + t.Fatal("expected error when initial GetOrEnsureExecution fails") + } + if !strings.Contains(err.Error(), "no agent running for session session-1") { + t.Errorf("error message does not surface session id: %v", err) + } +} + +func TestEnsureShellExecution_CleanupErrorPropagates(t *testing.T) { + stub := &stubShellLifecycle{ + getResults: []*lifecycle.AgentExecution{{ID: "exec-stale", Status: v1.AgentStatusFailed}}, + getErrors: []error{nil}, + cleanupErr: errors.New("cleanup denied"), + } + + _, err := ensureShellExecution(context.Background(), stub, newTestLogger(), "session-1") + if err == nil { + t.Fatal("expected cleanup failure to surface") + } + if !strings.Contains(err.Error(), "cleanup stale execution") || !strings.Contains(err.Error(), "session-1") { + t.Errorf("cleanup error not propagated cleanly: %v", err) + } + // Second GetOrEnsureExecution must NOT fire when cleanup fails: re- + // launching on a stuck stale row would loop forever. + if len(stub.getCalls) != 1 { + t.Errorf("expected exactly 1 GetOrEnsureExecution call when cleanup fails, got %d", len(stub.getCalls)) + } +} + +func TestEnsureShellExecution_RecoveryRelaunchFailureWrapped(t *testing.T) { + stub := &stubShellLifecycle{ + getResults: []*lifecycle.AgentExecution{ + {ID: "exec-stale", Status: v1.AgentStatusFailed}, + nil, + }, + getErrors: []error{nil, errors.New("relaunch failed")}, + } + + _, err := ensureShellExecution(context.Background(), stub, newTestLogger(), "session-1") + if err == nil { + t.Fatal("expected error when recovery re-launch fails") + } + if !strings.Contains(err.Error(), "recover execution") { + t.Errorf("recovery error not labeled clearly: %v", err) + } +} diff --git a/apps/backend/internal/agent/lifecycle/agent_session_seeder.go b/apps/backend/internal/agent/lifecycle/agent_session_seeder.go new file mode 100644 index 0000000000..6336937d0c --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/agent_session_seeder.go @@ -0,0 +1,190 @@ +package lifecycle + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/remoteauth" + "github.com/kandev/kandev/internal/common/logger" +) + +// agentSessionsRootSubdir is the subdirectory under the kandev home where +// per-container agent session dirs live. e.g. ~/.kandev/agent-sessions/. +const agentSessionsRootSubdir = "agent-sessions" + +// authMethodTypeFiles selects RemoteAuthMethod entries that copy local files +// into the remote environment (vs env-var-based auth). +const authMethodTypeFiles = "files" + +// AgentSessionsRoot returns the directory under the kandev home where +// per-container session dirs are created (e.g. ~/.kandev/agent-sessions/). +func AgentSessionsRoot(kandevHomeDir string) string { + if kandevHomeDir == "" { + return "" + } + return filepath.Join(kandevHomeDir, agentSessionsRootSubdir) +} + +// InstanceSessionRoot returns the per-container parent dir that mimics the +// agent's home (e.g. ~/.kandev/agent-sessions//). Files seeded +// from the host land under this path keyed by their RemoteAuth.TargetRelDir, +// matching the tree the bind-mount target expects inside the container. +func InstanceSessionRoot(kandevHomeDir, instanceID string) string { + if kandevHomeDir == "" || instanceID == "" { + return "" + } + return filepath.Join(AgentSessionsRoot(kandevHomeDir), instanceID) +} + +// SessionDirHostPath returns the kandev-managed bind-mount source for one +// agent in one container. It mirrors the structure SessionDirTarget expects +// inside the container: e.g. SessionDirTemplate "{home}/.codex" + target +// "/root/.codex" → host path "/agent-sessions// +// .codex". The leaf dotdir is preserved so the tree on the host matches the +// tree the agent expects, which means UploadCredentialFiles + the +// inside-container view of the bind mount are byte-identical. +func SessionDirHostPath(kandevHomeDir, instanceID, sessionDirTemplate string) string { + if sessionDirTemplate == "" { + return "" + } + root := InstanceSessionRoot(kandevHomeDir, instanceID) + if root == "" { + return "" + } + rel := strings.TrimPrefix(sessionDirTemplate, "{home}/") + return filepath.Join(root, rel) +} + +// localFileUploader implements FileUploader for the host filesystem. Used by +// the docker session seeder to copy auth files from ~/ into the +// kandev-managed per-container session dir. The uploader is bound to a +// session root and refuses any write that resolves outside it: instanceID +// flows into that root from internal callers, but the containment check +// keeps a malformed input from escaping into ~/.ssh, /etc, etc. +type localFileUploader struct { + root string +} + +func (u localFileUploader) WriteFile(_ context.Context, path string, data []byte, mode os.FileMode) error { + cleanPath, err := containedPath(u.root, path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(cleanPath), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(cleanPath), err) + } + return os.WriteFile(cleanPath, data, mode) +} + +// containedPath returns a cleaned absolute path guaranteed to be inside root, +// or an error if the path escapes via traversal or absolute-path injection. +// Used as the sanitiser between caller-supplied paths and host writes. +func containedPath(root, path string) (string, error) { + if root == "" { + return "", fmt.Errorf("session root is empty") + } + cleanRoot := filepath.Clean(root) + cleanPath := filepath.Clean(path) + rel, err := filepath.Rel(cleanRoot, cleanPath) + if err != nil { + return "", fmt.Errorf("path %q outside session root %q: %w", path, root, err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path %q escapes session root %q", path, root) + } + return cleanPath, nil +} + +// SeedAgentSessionDir copies the agent's RemoteAuth.SourceFiles from the host +// home into a per-container session dir, populating just the auth-relevant +// files (typically auth.json + config.toml). Stale per-host state — codex's +// state.db with absolute macOS paths, sessions/ subdirectories, lock files — +// is intentionally NOT copied; the agent rebuilds them inside the container +// with container-local Linux paths. +// +// instanceSessionRoot is the container's session root (e.g. / +// agent-sessions/). The function selects only "files"-type +// remote-auth methods that match the host OS. +func SeedAgentSessionDir( + ctx context.Context, + ag agents.Agent, + instanceSessionRoot string, + log *logger.Logger, +) error { + if ag == nil || instanceSessionRoot == "" { + return nil + } + // Always materialise the per-instance root so the container manager has a + // stable bind-mount source even for agents whose auth is env-only (claude, + // gemini, …) and the dotdir is otherwise empty until the in-container + // SetupScript fills it. + if err := os.MkdirAll(instanceSessionRoot, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", instanceSessionRoot, err) + } + + auth := ag.RemoteAuth() + if auth == nil { + return nil + } + hostOS := runtime.GOOS + methods := make([]remoteauth.Method, 0, len(auth.Methods)) + for _, m := range auth.Methods { + if m.Type != authMethodTypeFiles || len(m.SourceFiles) == 0 { + continue + } + // Resolve the OS-specific source list into a flat one keyed by host + // OS, falling back to "linux" for cross-platform agents. + srcs := selectAuthSourceFiles(m, hostOS) + if len(srcs) == 0 { + continue + } + methods = append(methods, remoteauth.Method{ + Type: m.Type, + SourceFiles: srcs, + TargetRelDir: m.TargetRelDir, + }) + } + if len(methods) == 0 { + return nil + } + + return UploadCredentialFiles(ctx, localFileUploader{root: instanceSessionRoot}, methods, instanceSessionRoot, log) +} + +// selectAuthSourceFiles picks the SourceFiles list to use for the host OS, +// degrading gracefully to whatever the agent ships for "linux" or generic if +// the host-specific entry is absent. The returned slice is the relative paths +// (e.g. ".codex/auth.json") expected by UploadCredentialFiles. +func selectAuthSourceFiles(m agents.RemoteAuthMethod, hostOS string) []string { + if v, ok := m.SourceFiles[hostOS]; ok { + return v + } + if v, ok := m.SourceFiles["linux"]; ok { + return v + } + if v, ok := m.SourceFiles[""]; ok { + return v + } + return nil +} + +// CleanupAgentSessionDir removes the per-container session root from disk. +// Best-effort: a missing directory or a transient I/O error is logged at +// debug level and not propagated, since cleanup runs after teardown. Called +// only on destructive stops (task/session deleted/archived). +func CleanupAgentSessionDir(instanceSessionRoot string, log *logger.Logger) { + if instanceSessionRoot == "" { + return + } + if err := os.RemoveAll(instanceSessionRoot); err != nil { + log.Debug("failed to remove per-container agent session dir", + zap.String("path", instanceSessionRoot), zap.Error(err)) + } +} diff --git a/apps/backend/internal/agent/lifecycle/agent_session_seeder_test.go b/apps/backend/internal/agent/lifecycle/agent_session_seeder_test.go new file mode 100644 index 0000000000..da063044f5 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/agent_session_seeder_test.go @@ -0,0 +1,283 @@ +package lifecycle + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" +) + +func newSeederTestLogger(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("logger: %v", err) + } + return log +} + +// seedTestHostHome rebinds os.UserHomeDir() to a temp dir so the seeder reads +// from a clean fixture instead of the developer's actual ~/. Returns the temp +// dir path. +func seedTestHostHome(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + t.Setenv("HOME", tmp) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", tmp) + } + return tmp +} + +func writeFile(t *testing.T, root, rel string, contents []byte) { + t.Helper() + full := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(full, contents, 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } +} + +// TestSeedAgentSessionDir_OnlyCopiesAuthFiles is the load-bearing check for +// the codex bug: the seeder must copy auth.json/config.toml from the host +// home into the per-container session dir but must NOT bring along state.db, +// sessions/, or other host-state caches that contain absolute host paths. +func TestSeedAgentSessionDir_OnlyCopiesAuthFiles(t *testing.T) { + hostHome := seedTestHostHome(t) + writeFile(t, hostHome, ".codex/auth.json", []byte(`{"token":"abc"}`)) + writeFile(t, hostHome, ".codex/config.toml", []byte(`model = "gpt"`)) + writeFile(t, hostHome, ".codex/state.db", []byte("STATE_DB_BLOB")) + writeFile(t, hostHome, ".codex/state.db-wal", []byte("WAL")) + writeFile(t, hostHome, ".codex/sessions/2026-05/rollout-x.jsonl", []byte("ROLLOUT")) + writeFile(t, hostHome, ".codex/junk.txt", []byte("junk")) + + kandevHome := t.TempDir() + const instanceID = "abcdef0123456789" + instanceRoot := InstanceSessionRoot(kandevHome, instanceID) + + if err := SeedAgentSessionDir(context.Background(), agents.NewCodexACP(), instanceRoot, newSeederTestLogger(t)); err != nil { + t.Fatalf("SeedAgentSessionDir: %v", err) + } + + dotdir := filepath.Join(instanceRoot, ".codex") + + mustExist := []string{"auth.json", "config.toml"} + for _, name := range mustExist { + if _, err := os.Stat(filepath.Join(dotdir, name)); err != nil { + t.Fatalf("expected %s in session dir: %v", name, err) + } + } + + mustNotExist := []string{"state.db", "state.db-wal", "junk.txt", "sessions"} + for _, name := range mustNotExist { + if _, err := os.Stat(filepath.Join(dotdir, name)); !os.IsNotExist(err) { + t.Fatalf("unexpected %s leaked into session dir (err=%v)", name, err) + } + } +} + +// TestSeedAgentSessionDir_PerInstanceUnique ensures two containers running +// the same agent get independent session dirs. Without this the codex state +// DB from one task would clobber another's, defeating the whole isolation +// guarantee. +func TestSeedAgentSessionDir_PerInstanceUnique(t *testing.T) { + hostHome := seedTestHostHome(t) + writeFile(t, hostHome, ".codex/auth.json", []byte(`{"token":"a"}`)) + + kandevHome := t.TempDir() + rootA := InstanceSessionRoot(kandevHome, "instance-a") + rootB := InstanceSessionRoot(kandevHome, "instance-b") + + if rootA == rootB || rootA == "" { + t.Fatalf("unexpected dir collision: a=%q b=%q", rootA, rootB) + } + + log := newSeederTestLogger(t) + if err := SeedAgentSessionDir(context.Background(), agents.NewCodexACP(), rootA, log); err != nil { + t.Fatalf("seed A: %v", err) + } + if err := SeedAgentSessionDir(context.Background(), agents.NewCodexACP(), rootB, log); err != nil { + t.Fatalf("seed B: %v", err) + } + + for _, root := range []string{rootA, rootB} { + if _, err := os.Stat(filepath.Join(root, ".codex/auth.json")); err != nil { + t.Fatalf("missing auth.json in %s: %v", root, err) + } + } +} + +// TestSeedAgentSessionDir_MultipleAgents proves the agent-agnostic guarantee: +// the same logic seeds claude / opencode just as it seeds codex, with no +// per-agent special cases. +func TestSeedAgentSessionDir_MultipleAgents(t *testing.T) { + hostHome := seedTestHostHome(t) + // Touch every auth file each tested agent might want; the seeder skips + // missing ones silently so unrelated agents staying empty is fine. + writeFile(t, hostHome, ".codex/auth.json", []byte("codex")) + writeFile(t, hostHome, ".codex/config.toml", []byte("codex")) + writeFile(t, hostHome, ".claude/.credentials.json", []byte("claude")) + writeFile(t, hostHome, ".opencode/auth.json", []byte("opencode")) + + cases := []struct { + name string + ag agents.Agent + }{ + {"codex", agents.NewCodexACP()}, + {"claude", agents.NewClaudeACP()}, + {"opencode", agents.NewOpenCodeACP()}, + } + + kandevHome := t.TempDir() + log := newSeederTestLogger(t) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + instanceID := tc.name + "-instance" + root := InstanceSessionRoot(kandevHome, instanceID) + if err := SeedAgentSessionDir(context.Background(), tc.ag, root, log); err != nil { + t.Fatalf("seed: %v", err) + } + // At least the per-instance root must exist and be a subdir of + // the kandev-managed agent-sessions root. + if _, err := os.Stat(root); err != nil { + t.Fatalf("instance root missing: %v", err) + } + if !strings.HasPrefix(root, AgentSessionsRoot(kandevHome)) { + t.Fatalf("root %q outside kandev agent-sessions %q", root, AgentSessionsRoot(kandevHome)) + } + }) + } +} + +func TestSessionDirHostPath_TrimsHomeTemplate(t *testing.T) { + got := SessionDirHostPath("/tmp/kandev", "abc123", "{home}/.codex") + want := filepath.Join("/tmp/kandev", "agent-sessions", "abc123", ".codex") + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +// TestLocalFileUploader_RejectsTraversal locks in the path-injection +// sanitiser: even when an internal caller hands the uploader a malformed +// path that escapes the kandev session root, the write is refused before it +// touches the host filesystem. +func TestLocalFileUploader_RejectsTraversal(t *testing.T) { + root := t.TempDir() + uploader := localFileUploader{root: root} + + cases := []struct { + name string + path string + }{ + {name: "parent traversal", path: filepath.Join(root, "..", "outside.txt")}, + {name: "absolute outside", path: "/etc/passwd-kandev-test"}, + {name: "deep traversal", path: filepath.Join(root, "a", "..", "..", "outside.txt")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := uploader.WriteFile(context.Background(), tc.path, []byte("nope"), 0o600) + if err == nil { + t.Fatalf("expected traversal %q to be refused", tc.path) + } + }) + } +} + +func TestLocalFileUploader_AllowsContainedPath(t *testing.T) { + root := t.TempDir() + uploader := localFileUploader{root: root} + target := filepath.Join(root, "sub", "auth.json") + + if err := uploader.WriteFile(context.Background(), target, []byte("ok"), 0o600); err != nil { + t.Fatalf("contained write should succeed: %v", err) + } + if _, err := os.Stat(target); err != nil { + t.Fatalf("expected file at %s: %v", target, err) + } +} + +func TestCleanupAgentSessionDir_RemovesTree(t *testing.T) { + tmp := t.TempDir() + root := filepath.Join(tmp, "instance-x") + writeFile(t, tmp, "instance-x/.codex/auth.json", []byte("a")) + writeFile(t, tmp, "instance-x/.codex/state.db", []byte("b")) + + CleanupAgentSessionDir(root, newSeederTestLogger(t)) + + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("expected dir removed, got err=%v", err) + } +} + +func TestCleanupAgentSessionDir_NoopOnEmptyPath(t *testing.T) { + // Should not panic; just exercise the guard. + CleanupAgentSessionDir("", newSeederTestLogger(t)) +} + +// TestDockerStopInstance_PreservesSessionDirOnPlainStop drives the real +// DockerExecutor.StopInstance for both preserve and destructive stop +// reasons and asserts on the on-disk session dir. We pass instances with +// ContainerID == "" so StopInstance returns before talking to the docker +// daemon — the cleanup branch under test is the kandev-side dir teardown. +func TestDockerStopInstance_PreservesSessionDirOnPlainStop(t *testing.T) { + hostHome := seedTestHostHome(t) + writeFile(t, hostHome, ".codex/auth.json", []byte(`{"token":"a"}`)) + + cases := []struct { + reason string + expectExists bool + }{ + {reason: "", expectExists: true}, + {reason: "stopped via API", expectExists: true}, + {reason: "agent crashed", expectExists: true}, + {reason: "user requested", expectExists: true}, + {reason: "task archived", expectExists: false}, + {reason: "task deleted", expectExists: false}, + {reason: "session archived", expectExists: false}, + {reason: "session deleted", expectExists: false}, + } + + log := newSeederTestLogger(t) + + for _, tc := range cases { + t.Run(tc.reason, func(t *testing.T) { + kandevHome := t.TempDir() + instanceID := "stop-test-instance" + root := InstanceSessionRoot(kandevHome, instanceID) + if err := SeedAgentSessionDir(context.Background(), agents.NewCodexACP(), root, log); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := os.Stat(filepath.Join(root, ".codex/auth.json")); err != nil { + t.Fatalf("seed precondition: %v", err) + } + + exec := NewDockerExecutor(config.DockerConfig{}, kandevHome, log) + instance := &ExecutorInstance{ + InstanceID: instanceID, + StopReason: tc.reason, + // ContainerID intentionally empty so StopInstance returns + // before invoking the docker daemon — we're testing the + // kandev-side cleanup branch only. + } + if err := exec.StopInstance(context.Background(), instance, false); err != nil { + t.Fatalf("StopInstance returned error for reason=%q: %v", tc.reason, err) + } + + _, statErr := os.Stat(root) + gotExists := statErr == nil + if gotExists != tc.expectExists { + t.Fatalf("session dir for reason=%q: exists=%v, want=%v (statErr=%v)", + tc.reason, gotExists, tc.expectExists, statErr) + } + }) + } +} diff --git a/apps/backend/internal/agent/lifecycle/command.go b/apps/backend/internal/agent/lifecycle/command.go index 27fd59a97a..e713d30840 100644 --- a/apps/backend/internal/agent/lifecycle/command.go +++ b/apps/backend/internal/agent/lifecycle/command.go @@ -56,26 +56,26 @@ func (cb *CommandBuilder) BuildContinueCommandString(ag agents.Agent, opts agent return strings.Join(cmd.Args(), " ") } -// ExpandSessionDir expands the session directory template from SessionConfig. -// Replaces {home} with the user's home directory. -// Returns empty string if no session directory is configured. -func (cb *CommandBuilder) ExpandSessionDir(ag agents.Agent) string { +// ExpandSessionDir resolves the host-side directory that should be bind- +// mounted into the container at SessionDirTarget. The path is the kandev- +// managed per-container session dir (~/.kandev/agent-sessions// +// ) — isolated from the user's actual ~/ so the host's stale +// state DBs and session caches stay out of the container. +// +// Returns empty string if no session directory is configured or if kandev +// home / instance ID are unavailable. Production callers always supply a +// resolved kandev home, so the empty-string path only fires in tests. +func (cb *CommandBuilder) ExpandSessionDir(ag agents.Agent, kandevHomeDir, instanceID string) string { template := ag.Runtime().SessionConfig.SessionDirTemplate if template == "" { return "" } - - homeDir, err := os.UserHomeDir() - if err != nil { - homeDir = "/tmp" + path := SessionDirHostPath(kandevHomeDir, instanceID, template) + if path == "" { + return "" } - - result := strings.ReplaceAll(template, "{home}", homeDir) - - // Ensure the directory exists - _ = os.MkdirAll(result, 0755) - - return result + _ = os.MkdirAll(path, 0o755) + return path } // GetSessionDirTarget returns the container path for session directory mount. diff --git a/apps/backend/internal/agent/lifecycle/container.go b/apps/backend/internal/agent/lifecycle/container.go index adbd1f3fed..d162a1f740 100644 --- a/apps/backend/internal/agent/lifecycle/container.go +++ b/apps/backend/internal/agent/lifecycle/container.go @@ -7,7 +7,6 @@ import ( "crypto/rand" "encoding/hex" "fmt" - "os" "strings" "time" @@ -19,22 +18,33 @@ import ( "github.com/kandev/kandev/internal/common/logger" ) +const ( + dockerAgentctlInstancePortBase = 41001 + dockerAgentctlInstancePortMax = 41100 + boolStringTrue = "true" +) + // ContainerConfig holds configuration for launching a Docker container type ContainerConfig struct { - AgentConfig agents.Agent - WorkspacePath string // If empty, workspace is not mounted (will clone inside container) - TaskID string - TaskDescription string - Model string - SessionID string - Credentials map[string]string - ProfileInfo *AgentProfileInfo - InstanceID string - MainRepoGitDir string // Path to main repo's .git directory (for worktrees) - McpServers []McpServerConfig - McpMode string - PrepareScript string // Script to run inside container before agent starts (e.g., clone repo) - BootstrapNonce string // one-time nonce for agentctl handshake (set internally) + AgentConfig agents.Agent + WorkspacePath string // If empty, workspace is not mounted (will clone inside container) + TaskID string + TaskTitle string + TaskEnvironmentID string + TaskDescription string + Model string + SessionID string + ExecutorProfileID string + Credentials map[string]string + ProfileInfo *AgentProfileInfo + InstanceID string + MainRepoGitDir string // Path to main repo's .git directory (for worktrees) + McpServers []McpServerConfig + McpMode string + PrepareScript string // Script to run inside container before agent starts (e.g., clone repo) + ImageTagOverride string // If set, replaces the agent runtime's default image (e.g. profile.config.image_tag) + LocalClonePath string // Host path for file:// repository clone URLs; mounted read-only at the same path. + BootstrapNonce string // one-time nonce for agentctl handshake (set internally) } // ContainerManager handles Docker container lifecycle operations @@ -43,23 +53,44 @@ type ContainerManager struct { commandBuilder *CommandBuilder logger *logger.Logger networkName string + // kandevHomeDir is the resolved Kandev root dir, used to derive the + // per-container agent session dirs that replace host home bind-mounts. + // Empty means "fall back to legacy {home}/." — production callers + // always pass a non-empty value. + kandevHomeDir string + // resolveAgentctlBinary returns the host path to a linux/amd64 agentctl + // binary. Indirected so tests can inject a stub. + resolveAgentctlBinary func() (string, error) + // resolveMockAgentBinary returns the host path to a linux/amd64 mock-agent + // binary. When it returns "" without error, no mock-agent mount is added + // (production case). Used by Docker E2E tests. + resolveMockAgentBinary func() (string, error) } -// NewContainerManager creates a new ContainerManager -func NewContainerManager(dockerClient *docker.Client, networkName string, log *logger.Logger) *ContainerManager { +// NewContainerManager creates a new ContainerManager. kandevHomeDir is the +// resolved Kandev root dir used to host per-container agent session dirs; +// pass "" only in legacy callers/tests that don't exercise the session-dir +// mount path. +func NewContainerManager(dockerClient *docker.Client, networkName, kandevHomeDir string, log *logger.Logger) *ContainerManager { + resolver := NewAgentctlResolver(log) + mockResolver := NewMockAgentResolver(log) return &ContainerManager{ - dockerClient: dockerClient, - commandBuilder: NewCommandBuilder(), - logger: log.WithFields(zap.String("component", "container-manager")), - networkName: networkName, + dockerClient: dockerClient, + commandBuilder: NewCommandBuilder(), + logger: log.WithFields(zap.String("component", "container-manager")), + networkName: networkName, + kandevHomeDir: kandevHomeDir, + resolveAgentctlBinary: resolver.ResolveLinuxBinary, + resolveMockAgentBinary: mockResolver.ResolveLinuxBinary, } } // LaunchResult holds the result of a successful container launch. type LaunchResult struct { - ContainerID string - Client *agentctl.Client - AuthToken string // auth token retrieved via handshake (for encrypted storage) + ContainerID string + Client *agentctl.Client + AuthToken string // auth token retrieved via handshake (for encrypted storage) + BootstrapNonce string // nonce injected into container env for future restart handshakes } // LaunchContainer creates and starts a Docker container for an agent. @@ -74,24 +105,24 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe } config.BootstrapNonce = nonce - containerID, containerIP, err := cm.createAndStartContainer(ctx, config) + containerID, containerIP, controlHost, controlPort, err := cm.createAndStartContainer(ctx, config) if err != nil { return nil, err } // Create ControlClient (no auth token yet — handshake hasn't happened) - ctl := agentctl.NewControlClient(containerIP, AgentCtlPort, cm.logger) + ctl := agentctl.NewControlClient(controlHost, controlPort, cm.logger) // Wait for agentctl to be healthy if err := cm.waitForHealth(ctx, ctl); err != nil { - _ = cm.dockerClient.RemoveContainer(ctx, containerID, true) + cm.removeContainerBestEffort(containerID) return nil, fmt.Errorf("agentctl health check failed: %w", err) } // Perform handshake: nonce → token authToken, err := ctl.Handshake(ctx, nonce) if err != nil { - _ = cm.dockerClient.RemoveContainer(ctx, containerID, true) + cm.removeContainerBestEffort(containerID) return nil, fmt.Errorf("agentctl handshake failed: %w", err) } @@ -107,29 +138,30 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe zap.String("instance_id", config.InstanceID)) return &LaunchResult{ - ContainerID: containerID, - Client: client, - AuthToken: authToken, + ContainerID: containerID, + Client: client, + AuthToken: authToken, + BootstrapNonce: nonce, }, nil } // createAndStartContainer builds, creates, and starts a Docker container. func (cm *ContainerManager) createAndStartContainer( ctx context.Context, config ContainerConfig, -) (string, string, error) { +) (string, string, string, int, error) { containerCfg, err := cm.buildContainerConfig(config) if err != nil { - return "", "", fmt.Errorf("failed to build container config: %w", err) + return "", "", "", 0, fmt.Errorf("failed to build container config: %w", err) } containerID, err := cm.dockerClient.CreateContainer(ctx, containerCfg) if err != nil { - return "", "", fmt.Errorf("failed to create container: %w", err) + return "", "", "", 0, fmt.Errorf("failed to create container: %w", err) } if err := cm.dockerClient.StartContainer(ctx, containerID); err != nil { - _ = cm.dockerClient.RemoveContainer(ctx, containerID, true) - return "", "", fmt.Errorf("failed to start container: %w", err) + cm.removeContainerBestEffort(containerID) + return "", "", "", 0, fmt.Errorf("failed to start container: %w", err) } containerIP, err := cm.dockerClient.GetContainerIP(ctx, containerID) @@ -139,7 +171,8 @@ func (cm *ContainerManager) createAndStartContainer( containerIP = "127.0.0.1" } - return containerID, containerIP, nil + controlHost, controlPort := cm.resolveContainerEndpoint(ctx, containerID, AgentCtlPort, containerIP) + return containerID, containerIP, controlHost, controlPort, nil } // createInstanceAndClient creates an agent instance in the container and returns the client. @@ -177,13 +210,15 @@ func (cm *ContainerManager) createInstanceAndClient( resp, err := ctl.CreateInstance(ctx, createReq) if err != nil { - _ = cm.dockerClient.RemoveContainer(ctx, containerID, true) + cm.removeContainerBestEffort(containerID) return nil, fmt.Errorf("failed to create instance in container: %w", err) } + instanceHost, instancePort := cm.resolveContainerEndpoint(ctx, containerID, resp.Port, containerIP) + // ControlClient already has the auth token set via Handshake — // read it back for the per-instance Client. - client := agentctl.NewClient(containerIP, resp.Port, cm.logger, + client := agentctl.NewClient(instanceHost, instancePort, cm.logger, agentctl.WithExecutionID(config.InstanceID), agentctl.WithSessionID(config.SessionID), agentctl.WithAuthToken(ctl.AuthToken())) @@ -191,22 +226,68 @@ func (cm *ContainerManager) createInstanceAndClient( return client, nil } -// waitForHealth waits for agentctl to be healthy with retries +func (cm *ContainerManager) resolveContainerEndpoint(ctx context.Context, containerID string, containerPort int, fallbackHost string) (string, int) { + host, port, err := cm.dockerClient.GetContainerHostPort(ctx, containerID, containerPort) + if err == nil { + return host, port + } + cm.logger.Warn("failed to resolve published Docker port, falling back to container IP", + zap.String("container_id", containerID), + zap.Int("container_port", containerPort), + zap.String("fallback_host", fallbackHost), + zap.Error(err)) + return fallbackHost, containerPort +} + +func (cm *ContainerManager) removeContainerBestEffort(containerID string) { + if containerID == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := cm.dockerClient.RemoveContainer(ctx, containerID, true); err != nil { + cm.logger.Warn("failed to remove container after launch failure", + zap.String("container_id", containerID), + zap.Error(err)) + } +} + +// waitForHealth waits for agentctl to be healthy with retries. +// The budget covers the time it takes for the container's bootstrap to run the +// prepare script (git clone, optional network installs) and then exec agentctl. +// 120s is generous but matches what real workspaces need on first launch. func (cm *ContainerManager) waitForHealth(ctx context.Context, ctl *agentctl.ControlClient) error { - const maxRetries = 30 + const maxRetries = 240 const retryDelay = 500 * time.Millisecond + var lastErr error for i := 0; i < maxRetries; i++ { if err := ctl.Health(ctx); err == nil { return nil + } else { + lastErr = err } if ctx.Err() != nil { return ctx.Err() } - time.Sleep(retryDelay) + // Cancelable wait that also skips the final retry's sleep — the loop + // only re-enters if i+1 < maxRetries, so the extra delay was just + // added latency on aborted launches. + if i+1 < maxRetries { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(retryDelay): + } + } } - return fmt.Errorf("agentctl not healthy after %d retries", maxRetries) + if lastErr != nil { + return fmt.Errorf("agentctl not healthy after %s: %w", + time.Duration(maxRetries)*retryDelay, lastErr) + } + return fmt.Errorf("agentctl not healthy after %s", + time.Duration(maxRetries)*retryDelay) } // StopContainer stops and removes a Docker container @@ -236,29 +317,35 @@ func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker ag := config.AgentConfig rt := ag.Runtime() - // Build image name with tag + // Build image name with tag. A profile-level image_tag override (e.g. a + // custom Dockerfile built via the executor profile UI) takes precedence + // over the agent's hardcoded runtime default. imageName := rt.Image if rt.Tag != "" { imageName = fmt.Sprintf("%s:%s", rt.Image, rt.Tag) } - - // Build command using Agent's BuildCommand - cmdOpts := agents.CommandOptions{ - Model: config.Model, - SessionID: config.SessionID, - PermissionValues: make(map[string]bool), + if config.ImageTagOverride != "" { + imageName = config.ImageTagOverride } - // Get profile settings if available - if config.ProfileInfo != nil { - cmdOpts.AutoApprove = config.ProfileInfo.AutoApprove - cmdOpts.PermissionValues["auto_approve"] = config.ProfileInfo.AutoApprove - cmdOpts.PermissionValues["allow_indexing"] = config.ProfileInfo.AllowIndexing - cmdOpts.PermissionValues["dangerously_skip_permissions"] = config.ProfileInfo.DangerouslySkipPermissions - } - cmd := ag.BuildCommand(cmdOpts) - // Expand mounts - mounts := cm.expandMounts(rt.Mounts, config.WorkspacePath, ag) + // We don't pre-build the agent CLI command here: agentctl receives it later + // via its HTTP API (CreateInstance). The container only needs to launch + // agentctl as its main process — see the Entrypoint setup below. + + // Two paths through here use different "workspace" strings: + // - Mount sources expand against the HOST workspace path (config.WorkspacePath + // when set, otherwise no host mount in clone-inside-container mode). + // - WorkingDir is an in-container path; it must always be the container-side + // mount target. Both modes converge on /workspace (the bind-mount target + // in host mode, the clone destination in clone-inside mode), so we hard- + // code it here. Without this distinction, host-bind setups would set + // WorkingDir to the host path, and Docker would happily start the + // container in an unrelated directory. + const containerWorkspacePath = "/workspace" + + // Expand mounts using the host path so {workspace} substitutions in mount + // sources resolve to a real on-disk location. + mounts := cm.expandMounts(rt.Mounts, config.WorkspacePath, ag, config.InstanceID) // Add main repo .git directory mount for worktrees if config.MainRepoGitDir != "" { @@ -271,6 +358,48 @@ func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker zap.String("path", config.MainRepoGitDir)) } + if config.LocalClonePath != "" { + mounts = append(mounts, docker.MountConfig{ + Source: config.LocalClonePath, + Target: config.LocalClonePath, + ReadOnly: true, + }) + cm.logger.Debug("added local clone source mount", + zap.String("path", config.LocalClonePath)) + } + + // Mount the host agentctl linux binary into the container so user-built + // images don't have to bake it in. Resolved via AgentctlResolver — same path + // the Sprites executor uses. + if cm.resolveAgentctlBinary != nil { + agentctlPath, err := cm.resolveAgentctlBinary() + if err != nil { + return docker.ContainerConfig{}, fmt.Errorf("agentctl linux binary not found: %w", err) + } + mounts = append(mounts, docker.MountConfig{ + Source: agentctlPath, + Target: "/usr/local/bin/agentctl", + ReadOnly: true, + }) + } + + // Optionally mount a host mock-agent binary for Docker E2E tests. Production + // builds run real agents installed in the image; this mount only fires when + // KANDEV_MOCK_AGENT_LINUX_BINARY is set or the binary is sitting in build/. + if cm.resolveMockAgentBinary != nil { + mockPath, err := cm.resolveMockAgentBinary() + if err != nil { + return docker.ContainerConfig{}, fmt.Errorf("mock-agent binary lookup: %w", err) + } + if mockPath != "" { + mounts = append(mounts, docker.MountConfig{ + Source: mockPath, + Target: "/usr/local/bin/mock-agent", + ReadOnly: true, + }) + } + } + // Build environment variables env := cm.buildEnvVars(config) @@ -280,30 +409,67 @@ func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker containerName := fmt.Sprintf("kandev-agent-%s", config.InstanceID[:8]) - // If a prepare script is provided, pass it as env var for the entrypoint to run + // If a prepare script is provided, pass it as env var for the bootstrap to run if config.PrepareScript != "" { env = append(env, "KANDEV_PREPARE_SCRIPT="+config.PrepareScript) } + // We always launch agentctl as the container's main process and fan out the + // agent subprocess from there via the agentctl HTTP API. This frees user-built + // images from needing to bake an ENTRYPOINT or know which agent to run — they + // only need a runtime that supports the agent CLI (typically node + git). + // + // The agent's BuildCommand result intentionally stops being passed here; + // agentctl receives the agent command later via the CreateInstance API. + // + // Prepare runs in a subshell so its `set -e` (most prepare scripts opt in) + // can't kill the bootstrap before exec'ing agentctl. If prepare fails, we + // still bring agentctl up so the host can connect, surface the failure, and + // the user can debug from the Executor Settings popover. + // + //nolint:dupword // two `fi` tokens close two distinct shell blocks. + bootstrap := []string{ + "sh", "-c", + `if [ -n "$KANDEV_PREPARE_SCRIPT" ]; then + (eval "$KANDEV_PREPARE_SCRIPT") + prep_rc=$? + if [ "$prep_rc" -ne 0 ]; then + echo "[kandev-bootstrap] prepare script failed (exit $prep_rc); starting agentctl anyway so the host can connect and the user can debug via Executor Settings" >&2 + fi +fi +exec /usr/local/bin/agentctl`, + } + containerCfg := docker.ContainerConfig{ - Name: containerName, - Image: imageName, - Cmd: cmd.Args(), - Env: env, - WorkingDir: rt.WorkingDir, - Mounts: mounts, - NetworkMode: cm.networkName, - Memory: memoryBytes, - CPUQuota: cpuQuota, + Name: containerName, + Image: imageName, + Entrypoint: bootstrap, + Cmd: nil, + Env: env, + WorkingDir: cm.expandMountSource(rt.WorkingDir, containerWorkspacePath), + Mounts: mounts, + PortBindings: dockerAgentctlPortBindings(), + NetworkMode: cm.networkName, + Memory: memoryBytes, + CPUQuota: cpuQuota, Labels: map[string]string{ - "kandev.managed": "true", - "kandev.instance_id": config.InstanceID, - "kandev.task_id": config.TaskID, - "kandev.session_id": config.SessionID, + "kandev.managed": boolStringTrue, + "kandev.instance_id": config.InstanceID, + "kandev.task_id": config.TaskID, + "kandev.session_id": config.SessionID, + "kandev.task_environment_id": config.TaskEnvironmentID, + "com.kandev.image": imageName, }, AutoRemove: false, // We manage cleanup ourselves } + if config.ExecutorProfileID != "" { + containerCfg.Labels["kandev.executor_profile_id"] = config.ExecutorProfileID + containerCfg.Labels["kandev.profile_id"] = config.ExecutorProfileID + } + if config.TaskTitle != "" { + containerCfg.Labels["kandev.task_title"] = config.TaskTitle + } if config.ProfileInfo != nil && config.ProfileInfo.ProfileID != "" { containerCfg.Labels["kandev.profile_id"] = config.ProfileInfo.ProfileID } @@ -311,8 +477,32 @@ func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker return containerCfg, nil } -// expandMounts expands mount templates with actual paths -func (cm *ContainerManager) expandMounts(templates []agents.MountTemplate, workspacePath string, ag agents.Agent) []docker.MountConfig { +func dockerAgentctlPortBindings() []docker.PortBindingConfig { + bindings := make([]docker.PortBindingConfig, 0, 1+dockerAgentctlInstancePortMax-dockerAgentctlInstancePortBase+1) + bindings = append(bindings, newDockerPortBinding(AgentCtlPort)) + for port := dockerAgentctlInstancePortBase; port <= dockerAgentctlInstancePortMax; port++ { + bindings = append(bindings, newDockerPortBinding(port)) + } + return bindings +} + +func newDockerPortBinding(containerPort int) docker.PortBindingConfig { + return docker.PortBindingConfig{ + ContainerPort: containerPort, + HostIP: "127.0.0.1", + HostPort: "0", + } +} + +// expandMounts expands mount templates with actual paths. +// +// Per-agent session dirs (SessionConfig.SessionDirTemplate) are mapped to a +// kandev-managed path under /agent-sessions//, NOT +// the user's host home. The seeder (SeedAgentSessionDir) selectively copies +// auth files from the host beforehand, so every agent gets a fresh, isolated +// session dir per launch — host state DBs and session caches that contain +// absolute host paths (e.g. codex's state.db) stay out of the container. +func (cm *ContainerManager) expandMounts(templates []agents.MountTemplate, workspacePath string, ag agents.Agent, instanceID string) []docker.MountConfig { mounts := make([]docker.MountConfig, 0, len(templates)+1) // +1 for potential session dir for _, mt := range templates { @@ -332,7 +522,7 @@ func (cm *ContainerManager) expandMounts(templates []agents.MountTemplate, works } // Add session directory mount from SessionConfig - sessionDirSource := cm.commandBuilder.ExpandSessionDir(ag) + sessionDirSource := cm.commandBuilder.ExpandSessionDir(ag, cm.kandevHomeDir, instanceID) sessionDirTarget := cm.commandBuilder.GetSessionDirTarget(ag) if sessionDirSource != "" && sessionDirTarget != "" { mounts = append(mounts, docker.MountConfig{ @@ -348,21 +538,14 @@ func (cm *ContainerManager) expandMounts(templates []agents.MountTemplate, works return mounts } -// expandMountSource expands template variables in mount source paths +// expandMountSource expands template variables in mount source paths. +// Only `{workspace}` is honoured here — agent session dirs that used to use +// `{home}/.` now route through the kandev-managed per-container +// session dir (see SessionDirHostPath), which keeps host state DBs out of +// the container. Production agents don't ship `{home}` in Mounts; codex_acp +// has a regression test asserting this. func (cm *ContainerManager) expandMountSource(source, workspacePath string) string { - result := source - result = strings.ReplaceAll(result, "{workspace}", workspacePath) - - // Expand {home} to user's home directory - if strings.Contains(result, "{home}") { - homeDir, err := os.UserHomeDir() - if err != nil { - homeDir = "/tmp" - } - result = strings.ReplaceAll(result, "{home}", homeDir) - } - - return result + return strings.ReplaceAll(source, "{workspace}", workspacePath) } // buildEnvVars builds environment variables for the container @@ -422,6 +605,12 @@ func (cm *ContainerManager) buildEnvVars(config ContainerConfig) []string { env = append(env, fmt.Sprintf("KANDEV_AGENT_PROFILE_ID=%s", config.ProfileInfo.ProfileID)) } + env = append(env, + fmt.Sprintf("AGENTCTL_PORT=%d", AgentCtlPort), + fmt.Sprintf("AGENTCTL_INSTANCE_PORT_BASE=%d", dockerAgentctlInstancePortBase), + fmt.Sprintf("AGENTCTL_INSTANCE_PORT_MAX=%d", dockerAgentctlInstancePortMax), + ) + // Inject bootstrap nonce for agentctl handshake (NOT the auth token) if config.BootstrapNonce != "" { env = append(env, "AGENTCTL_BOOTSTRAP_NONCE="+config.BootstrapNonce) @@ -442,7 +631,7 @@ func generateBootstrapNonce() (string, error) { // ListManagedContainers returns all containers managed by kandev func (cm *ContainerManager) ListManagedContainers(ctx context.Context) ([]docker.ContainerInfo, error) { return cm.dockerClient.ListContainers(ctx, map[string]string{ - "kandev.managed": "true", + "kandev.managed": boolStringTrue, }) } diff --git a/apps/backend/internal/agent/lifecycle/container_config_test.go b/apps/backend/internal/agent/lifecycle/container_config_test.go new file mode 100644 index 0000000000..7a58e6af33 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/container_config_test.go @@ -0,0 +1,344 @@ +package lifecycle + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/docker" + "github.com/kandev/kandev/internal/common/logger" +) + +// configStubAgent wraps MockAgent and overrides Runtime() with a fixed +// RuntimeConfig that mimics ACP agents (image+tag, {workspace} placeholder). +type configStubAgent struct { + *agents.MockAgent + rt *agents.RuntimeConfig +} + +func (a *configStubAgent) Runtime() *agents.RuntimeConfig { return a.rt } + +func newCMTest(t *testing.T) *ContainerManager { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("logger: %v", err) + } + return &ContainerManager{ + logger: log, + networkName: "kandev", + commandBuilder: NewCommandBuilder(), + } +} + +func newConfigStubAgent() *configStubAgent { + return &configStubAgent{ + MockAgent: agents.NewMockAgent(), + rt: &agents.RuntimeConfig{ + Image: "kandev/multi-agent", + Tag: "latest", + Cmd: agents.Cmd("/bin/true").Build(), + WorkingDir: "{workspace}", + Mounts: []agents.MountTemplate{{Source: "{workspace}", Target: "/workspace"}}, + ResourceLimits: agents.ResourceLimits{ + MemoryMB: 256, + CPUCores: 0.5, + }, + }, + } +} + +func TestBuildContainerConfig_ExpandsWorkingDirPlaceholder(t *testing.T) { + cm := newCMTest(t) + cfg := ContainerConfig{ + AgentConfig: newConfigStubAgent(), + // WorkspacePath empty → clone-inside-container path; should default to /workspace. + InstanceID: "0123456789abcdef", + TaskID: "task-1", + } + + got, err := cm.buildContainerConfig(cfg) + if err != nil { + t.Fatalf("buildContainerConfig: %v", err) + } + if got.WorkingDir != "/workspace" { + t.Errorf("WorkingDir = %q, want /workspace (placeholder must be expanded)", got.WorkingDir) + } + if strings.Contains(got.WorkingDir, "{") { + t.Errorf("WorkingDir still contains placeholder syntax: %q", got.WorkingDir) + } +} + +func TestBuildContainerConfig_WorkingDirIsAlwaysContainerPath(t *testing.T) { + // Regression: WorkingDir is the container-side path, not the host path. + // In host bind-mount mode, WorkspacePath holds the host path; the bind + // mount target is the in-container /workspace, so WorkingDir must point + // at the container target — otherwise Docker happily starts the + // container in /host/path/to/repo (which doesn't exist inside) and the + // agent runs in an unrelated directory. + cm := newCMTest(t) + cfg := ContainerConfig{ + AgentConfig: newConfigStubAgent(), + WorkspacePath: "/host/path/to/repo", + InstanceID: "0123456789abcdef", + TaskID: "task-1", + } + + got, err := cm.buildContainerConfig(cfg) + if err != nil { + t.Fatalf("buildContainerConfig: %v", err) + } + if got.WorkingDir != "/workspace" { + t.Errorf("WorkingDir = %q, want /workspace (container-side path)", got.WorkingDir) + } +} + +func TestBuildContainerConfig_ImageDefaultsToRuntime(t *testing.T) { + cm := newCMTest(t) + cfg := ContainerConfig{ + AgentConfig: newConfigStubAgent(), + InstanceID: "0123456789abcdef", + TaskID: "task-1", + } + + got, err := cm.buildContainerConfig(cfg) + if err != nil { + t.Fatalf("buildContainerConfig: %v", err) + } + if got.Image != "kandev/multi-agent:latest" { + t.Errorf("Image = %q, want kandev/multi-agent:latest", got.Image) + } +} + +func TestBuildContainerConfig_ImageTagOverrideWins(t *testing.T) { + cm := newCMTest(t) + cfg := ContainerConfig{ + AgentConfig: newConfigStubAgent(), + InstanceID: "0123456789abcdef", + TaskID: "task-1", + ImageTagOverride: "kandev/agent:custom", + } + + got, err := cm.buildContainerConfig(cfg) + if err != nil { + t.Fatalf("buildContainerConfig: %v", err) + } + if got.Image != "kandev/agent:custom" { + t.Errorf("Image = %q, want kandev/agent:custom (profile override must win over rt.Image)", got.Image) + } +} + +func TestBuildContainerConfig_LabelsExecutorProfileAndTaskEnvironment(t *testing.T) { + cm := newCMTest(t) + cfg := ContainerConfig{ + AgentConfig: newConfigStubAgent(), + InstanceID: "0123456789abcdef", + TaskID: "task-1", + TaskTitle: "Readable Task Title", + SessionID: "session-1", + TaskEnvironmentID: "env-1", + ExecutorProfileID: "profile-1", + ImageTagOverride: "kandev/agent:custom", + } + + got, err := cm.buildContainerConfig(cfg) + if err != nil { + t.Fatalf("buildContainerConfig: %v", err) + } + + assertLabel(t, got.Labels, "kandev.managed", boolStringTrue) + assertLabel(t, got.Labels, "kandev.task_id", "task-1") + assertLabel(t, got.Labels, "kandev.task_title", "Readable Task Title") + assertLabel(t, got.Labels, "kandev.session_id", "session-1") + assertLabel(t, got.Labels, "kandev.task_environment_id", "env-1") + assertLabel(t, got.Labels, "kandev.executor_profile_id", "profile-1") + assertLabel(t, got.Labels, "kandev.profile_id", "profile-1") + assertLabel(t, got.Labels, "com.kandev.image", "kandev/agent:custom") +} + +func TestBuildContainerConfig_PublishesAgentctlPorts(t *testing.T) { + cm := newCMTest(t) + cfg := ContainerConfig{ + AgentConfig: newConfigStubAgent(), + InstanceID: "0123456789abcdef", + TaskID: "task-1", + } + + got, err := cm.buildContainerConfig(cfg) + if err != nil { + t.Fatalf("buildContainerConfig: %v", err) + } + + if len(got.PortBindings) == 0 { + t.Fatal("expected agentctl ports to be published") + } + assertHasPortBinding(t, got.PortBindings, AgentCtlPort) + assertHasPortBinding(t, got.PortBindings, dockerAgentctlInstancePortBase) + assertHasPortBinding(t, got.PortBindings, dockerAgentctlInstancePortMax) + assertEnvContains(t, got.Env, "AGENTCTL_INSTANCE_PORT_BASE=41001") + assertEnvContains(t, got.Env, "AGENTCTL_INSTANCE_PORT_MAX=41100") +} + +// TestDockerAgentctlPortBindings is a direct test for the helper that +// generates the published-port set for every kandev-managed Docker agent +// container. A regression here (wrong port range, missing agentctl port, +// non-loopback host IP) would silently break container reconnect, since +// `resolveDockerEndpoint` falls back to the container IP when the published +// port lookup fails. +func TestDockerAgentctlPortBindings(t *testing.T) { + bindings := dockerAgentctlPortBindings() + + wantTotal := 1 + (dockerAgentctlInstancePortMax - dockerAgentctlInstancePortBase + 1) + if len(bindings) != wantTotal { + t.Fatalf("got %d bindings, want %d (control + instance range)", len(bindings), wantTotal) + } + + // Control port must be present. + assertHasPortBinding(t, bindings, AgentCtlPort) + + // Every port in the instance range must be present and bound to loopback + // with a kernel-assigned host port. + have := make(map[int]docker.PortBindingConfig, len(bindings)) + for _, b := range bindings { + have[b.ContainerPort] = b + } + for port := dockerAgentctlInstancePortBase; port <= dockerAgentctlInstancePortMax; port++ { + b, ok := have[port] + if !ok { + t.Fatalf("missing instance port %d in published bindings", port) + } + if b.HostIP != "127.0.0.1" { + t.Errorf("port %d host_ip = %q, want 127.0.0.1", port, b.HostIP) + } + if b.HostPort != "0" { + t.Errorf("port %d host_port = %q, want kernel-assigned (\"0\")", port, b.HostPort) + } + } +} + +// TestBuildContainerConfig_SessionDirIsKandevManagedForEveryAgent locks in +// the agent-agnostic guarantee that bind sources for SessionDirTemplate +// resolve to /agent-sessions// and never to +// the user's host home — the codex bug was a leak of host state into the +// container, and any agent with a SessionDirTemplate is at the same risk. +func TestBuildContainerConfig_SessionDirIsKandevManagedForEveryAgent(t *testing.T) { + allAgents := []struct { + name string + ag agents.Agent + }{ + {"codex-acp", agents.NewCodexACP()}, + {"claude-acp", agents.NewClaudeACP()}, + {"opencode-acp", agents.NewOpenCodeACP()}, + {"copilot-acp", agents.NewCopilotACP()}, + {"amp-acp", agents.NewAmpACP()}, + {"gemini", agents.NewGemini()}, + {"auggie", agents.NewAuggie()}, + } + const kandevHome = "/tmp/kandev-test-home" + const instanceID = "0123456789abcdef" + expectedRoot := filepath.Join(kandevHome, "agent-sessions", instanceID) + + for _, tc := range allAgents { + t.Run(tc.name, func(t *testing.T) { + rt := tc.ag.Runtime() + if rt == nil { + t.Skipf("%s has no Runtime", tc.name) + } + // expandMounts only adds the session-dir bind when BOTH fields + // are set; agents that omit one rely on the in-container + // SetupScript for auth and never bind-mount the host home in the + // first place. Skip those — the test guards the resolution shape + // only for agents that DO add the bind mount. + if rt.SessionConfig.SessionDirTemplate == "" || rt.SessionConfig.SessionDirTarget == "" { + t.Skipf("%s has no full SessionDirTemplate+SessionDirTarget pair (no bind mount today)", tc.name) + } + + cm := newCMTest(t) + cm.kandevHomeDir = kandevHome + cfg := ContainerConfig{ + AgentConfig: tc.ag, + InstanceID: instanceID, + TaskID: "task-1", + } + + got, err := cm.buildContainerConfig(cfg) + if err != nil { + t.Fatalf("buildContainerConfig: %v", err) + } + + target := rt.SessionConfig.SessionDirTarget + var found *docker.MountConfig + for i := range got.Mounts { + if got.Mounts[i].Target == target { + found = &got.Mounts[i] + break + } + } + if found == nil { + t.Fatalf("expected mount for SessionDirTarget %q, got %+v", target, got.Mounts) + } + if !strings.HasPrefix(found.Source, expectedRoot) { + t.Fatalf("session-dir mount source %q not under %q (host home leaked into container?)", + found.Source, expectedRoot) + } + if strings.Contains(found.Source, "{home}") { + t.Fatalf("session-dir mount source %q still references {home} placeholder", found.Source) + } + }) + } +} + +func TestBuildContainerConfig_MountsLocalClonePath(t *testing.T) { + cm := newCMTest(t) + cfg := ContainerConfig{ + AgentConfig: newConfigStubAgent(), + InstanceID: "0123456789abcdef", + TaskID: "task-1", + LocalClonePath: "/tmp/e2e-docker-remote.git", + } + + got, err := cm.buildContainerConfig(cfg) + if err != nil { + t.Fatalf("buildContainerConfig: %v", err) + } + + assertHasMount(t, got.Mounts, "/tmp/e2e-docker-remote.git", "/tmp/e2e-docker-remote.git", true) +} + +func assertLabel(t *testing.T, labels map[string]string, key, want string) { + t.Helper() + if labels[key] != want { + t.Fatalf("label %s = %q, want %q in %#v", key, labels[key], want, labels) + } +} + +func assertHasMount(t *testing.T, mounts []docker.MountConfig, source, target string, readOnly bool) { + t.Helper() + for _, mount := range mounts { + if mount.Source == source && mount.Target == target && mount.ReadOnly == readOnly { + return + } + } + t.Fatalf("missing mount source=%q target=%q readOnly=%v in %#v", source, target, readOnly, mounts) +} + +func assertHasPortBinding(t *testing.T, bindings []docker.PortBindingConfig, port int) { + t.Helper() + for _, binding := range bindings { + if binding.ContainerPort == port && binding.HostIP == "127.0.0.1" && binding.HostPort == "0" { + return + } + } + t.Fatalf("missing published port binding for %d/tcp: %#v", port, bindings) +} + +func assertEnvContains(t *testing.T, env []string, want string) { + t.Helper() + for _, item := range env { + if item == want { + return + } + } + t.Fatalf("missing env %q in %#v", want, env) +} diff --git a/apps/backend/internal/agent/lifecycle/credential_uploader.go b/apps/backend/internal/agent/lifecycle/credential_uploader.go index 843bff7336..6ae8c2480a 100644 --- a/apps/backend/internal/agent/lifecycle/credential_uploader.go +++ b/apps/backend/internal/agent/lifecycle/credential_uploader.go @@ -15,10 +15,11 @@ import ( "github.com/kandev/kandev/internal/common/logger" ) -// FileUploader abstracts writing files and running commands on a remote environment. +// FileUploader abstracts writing files to a remote environment. Used by +// UploadCredentialFiles to seed agent auth files into the kandev-managed +// per-container session dir (local) or sprite (remote). type FileUploader interface { WriteFile(ctx context.Context, path string, data []byte, mode os.FileMode) error - RunCommand(ctx context.Context, name string, args ...string) error } // UploadCredentialFiles reads local credential files and uploads them to the remote environment. diff --git a/apps/backend/internal/agent/lifecycle/default_scripts.go b/apps/backend/internal/agent/lifecycle/default_scripts.go index d4bc69c595..641ef3a0d9 100644 --- a/apps/backend/internal/agent/lifecycle/default_scripts.go +++ b/apps/backend/internal/agent/lifecycle/default_scripts.go @@ -16,6 +16,50 @@ func DefaultPrepareScript(executorType string) string { } } +// KandevBranchCheckoutPostlude returns a kandev-managed shell snippet that +// guarantees the session's feature branch is checked out inside the +// workspace, no matter what the user's stored prepare_script does. +// +// Why a postlude instead of just relying on the default script: profiles +// created in the UI snapshot the *then-current* default into their stored +// prepare_script field. When kandev's default is updated to add a new +// kandev-managed step (like the worktree-branch checkout), older profiles +// silently miss it forever. Making the checkout an invariant — appended +// after the user's script — keeps the contract regardless of which default +// the user happens to have stored. +// +// The snippet is wrapped in a subshell + `|| true` so any failure (e.g. the +// user's script never produced /workspace, or the branch is the same as the +// base) is benign and doesn't block agentctl from starting. +// +//nolint:dupword // two `fi` tokens close two distinct shell blocks. +func KandevBranchCheckoutPostlude() string { + return ` + +# ---- kandev-managed: ensure session feature branch is checked out ---- +# Appended automatically after the user's prepare script. Idempotent and +# non-destructive: prefer an existing local branch (which may carry unpushed +# work after a container resume), then fall through to a fresh tracking +# branch off origin, and only as a last resort create the branch off HEAD. +# The previous "git checkout -B feature origin/feature" form was destructive +# for the resume case — overwriting local commits with the remote tip. +( + if [ -d "{{workspace.path}}/.git" ] \ + && [ -n "{{worktree.branch}}" ] \ + && [ "{{worktree.branch}}" != "{{repository.branch}}" ]; then + cd "{{workspace.path}}" || exit 0 + if git rev-parse --verify "{{worktree.branch}}" >/dev/null 2>&1; then + git checkout "{{worktree.branch}}" + elif git fetch --depth=1 origin "{{worktree.branch}}" 2>/dev/null; then + git checkout -b "{{worktree.branch}}" "origin/{{worktree.branch}}" + else + git checkout -b "{{worktree.branch}}" + fi + fi +) || true +` +} + const defaultLocalPrepareScript = `#!/bin/bash # Prepare local environment # Runs before launching the local agent runtime. @@ -45,6 +89,10 @@ set -eu # ---- Git identity (optional) ---- {{git.identity_setup}} +# Mounted local remotes and workspaces can be owned by a host UID that does +# not match the container user. +git config --global --add safe.directory '*' + # ---- Configure git/gh for HTTPS auth ---- git config --global url."https://github.com/".insteadOf "git@github.com:" git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" @@ -53,6 +101,9 @@ git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" {{github.auth_setup}} # ---- Clone repository ---- +# The kandev-managed feature-branch checkout is appended as an invariant +# postlude (see KandevBranchCheckoutPostlude) — keep it out of the default +# so old profiles snapshotting this script and the postlude never disagree. git clone --depth=1 --branch {{repository.branch}} {{repository.clone_url}} {{workspace.path}} cd {{workspace.path}} @@ -93,6 +144,9 @@ chmod +x /usr/local/bin/pnpm {{git.identity_setup}} # ---- Clone repository ---- +# The kandev-managed feature-branch checkout is appended as an invariant +# postlude (see KandevBranchCheckoutPostlude) — keep it out of the default +# so old profiles snapshotting this script and the postlude never disagree. echo "Cloning {{repository.clone_url}} (branch: {{repository.branch}})..." git clone --depth=1 --quiet --branch {{repository.branch}} {{repository.clone_url}} {{workspace.path}} cd {{workspace.path}} diff --git a/apps/backend/internal/agent/lifecycle/default_scripts_test.go b/apps/backend/internal/agent/lifecycle/default_scripts_test.go new file mode 100644 index 0000000000..7fdb91f60a --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/default_scripts_test.go @@ -0,0 +1,203 @@ +package lifecycle + +import ( + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestKandevBranchCheckoutPostlude_HasInvariantSteps asserts the kandev- +// managed postlude contains the steps needed to land on the session's +// feature branch. The postlude is appended to every user prepare script so +// stale stored scripts (created before the worktree-branch checkout was +// part of the default) still get the checkout. +func TestKandevBranchCheckoutPostlude_HasInvariantSteps(t *testing.T) { + postlude := KandevBranchCheckoutPostlude() + want := []string{ + `if [ -d "{{workspace.path}}/.git" ]`, + `[ -n "{{worktree.branch}}" ]`, + `[ "{{worktree.branch}}" != "{{repository.branch}}" ]`, + `cd "{{workspace.path}}"`, + `git rev-parse --verify "{{worktree.branch}}"`, + `git fetch --depth=1 origin "{{worktree.branch}}"`, + `git checkout -b "{{worktree.branch}}" "origin/{{worktree.branch}}"`, + `git checkout -b "{{worktree.branch}}"`, + `|| true`, + } + for _, w := range want { + if !strings.Contains(postlude, w) { + t.Errorf("postlude missing %q", w) + } + } + // The destructive `-B "branch" "origin/branch"` form orphaned local + // commits on resume — verify it does NOT come back. + forbidden := []string{ + `git checkout -B "{{worktree.branch}}" "origin/{{worktree.branch}}"`, + } + for _, f := range forbidden { + if strings.Contains(postlude, f) { + t.Errorf("postlude must not contain destructive form %q", f) + } + } +} + +// TestKandevBranchCheckoutPostlude_LandsOnFeatureBranch executes the +// postlude as a real shell script against a temp git repo. It is the +// behaviour test that pairs with the static-content test above: +// it catches regressions where the snippet still parses as bash but +// no longer does the right thing (wrong refspec, missing -B, swallowed +// exit code, etc.). +// +// The three cases mirror the three branches inside the postlude: +// - remote feature branch exists → tracks origin/ +// - no remote, no local → creates local branch off HEAD +// - local branch already checked out, no remote → idempotent (no error, same branch) +func TestKandevBranchCheckoutPostlude_LandsOnFeatureBranch(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + + cases := []struct { + name string + seedRemote bool // create a "feature" branch on origin before running + seedLocal bool // pre-create the local branch (idempotency check) + featureName string // branch name passed to the postlude + baseName string // base branch name (must match repository.branch) + want string // expected current branch after running + }{ + { + name: "remote tip exists", + seedRemote: true, + featureName: "feature/from-remote", + baseName: "main", + want: "feature/from-remote", + }, + { + name: "no remote, no local", + seedRemote: false, + featureName: "feature/from-scratch", + baseName: "main", + want: "feature/from-scratch", + }, + { + name: "local already checked out", + seedLocal: true, + featureName: "feature/already-here", + baseName: "main", + want: "feature/already-here", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + workspace, originDir := setupPostludeRepo(t, tc.baseName) + if tc.seedRemote { + seedOriginBranch(t, originDir, tc.featureName) + } + if tc.seedLocal { + runIn(t, workspace, "git", "checkout", "-b", tc.featureName) + } + + script := strings.NewReplacer( + "{{workspace.path}}", workspace, + "{{worktree.branch}}", tc.featureName, + "{{repository.branch}}", tc.baseName, + ).Replace(KandevBranchCheckoutPostlude()) + + cmd := exec.Command("bash", "-e", "-c", script) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("bash -e postlude failed: %v\n%s", err, out) + } + + gotBranch := strings.TrimSpace(string(runIn(t, workspace, "git", "branch", "--show-current"))) + if gotBranch != tc.want { + t.Fatalf("after postlude branch = %q, want %q\nscript output:\n%s", gotBranch, tc.want, out) + } + }) + } +} + +// setupPostludeRepo creates a fake origin (bare repo with one commit on +// baseBranch) and a workspace cloned from it, then leaves the workspace on +// baseBranch. Returns workspace path + origin (bare) path. +func setupPostludeRepo(t *testing.T, baseBranch string) (workspace, origin string) { + t.Helper() + root := t.TempDir() + origin = filepath.Join(root, "origin.git") + seed := filepath.Join(root, "seed") + workspace = filepath.Join(root, "workspace") + + runIn(t, root, "git", "init", "--quiet", "--bare", "--initial-branch="+baseBranch, origin) + runIn(t, root, "git", "init", "--quiet", "--initial-branch="+baseBranch, seed) + runIn(t, seed, "git", "config", "user.email", "test@example.com") + runIn(t, seed, "git", "config", "user.name", "Test") + runIn(t, seed, "git", "commit", "--allow-empty", "-m", "init") + runIn(t, seed, "git", "remote", "add", "origin", origin) + runIn(t, seed, "git", "push", "--quiet", "origin", baseBranch) + + runIn(t, root, "git", "clone", "--quiet", "--branch", baseBranch, origin, workspace) + runIn(t, workspace, "git", "config", "user.email", "test@example.com") + runIn(t, workspace, "git", "config", "user.name", "Test") + return workspace, origin +} + +// seedOriginBranch creates `branch` on `origin` (the bare repo) so the +// postlude's `git fetch origin ` succeeds. Uses a temporary clone of +// origin to push the new branch in. +func seedOriginBranch(t *testing.T, origin, branch string) { + t.Helper() + tmp := filepath.Join(t.TempDir(), "seed-origin-branch") + runIn(t, "", "git", "clone", "--quiet", origin, tmp) + runIn(t, tmp, "git", "config", "user.email", "test@example.com") + runIn(t, tmp, "git", "config", "user.name", "Test") + runIn(t, tmp, "git", "checkout", "-b", branch) + runIn(t, tmp, "git", "commit", "--allow-empty", "-m", "feature") + runIn(t, tmp, "git", "push", "--quiet", "origin", branch) +} + +// runIn runs cmd with args in dir (or current dir when dir == "") and fails +// the test on non-zero exit. Returns stdout for callers that need it. +func runIn(t *testing.T, dir string, cmd string, args ...string) []byte { + t.Helper() + c := exec.Command(cmd, args...) + if dir != "" { + c.Dir = dir + } + out, err := c.CombinedOutput() + if err != nil { + t.Fatalf("%s %v: %v\n%s", cmd, args, err, out) + } + return out +} + +// TestDefaultPrepareScripts_NoInlineFeatureBranchCheckout asserts that the +// clone-based remote default scripts no longer carry an inline worktree- +// branch checkout. The checkout is owned exclusively by the postlude +// (KandevBranchCheckoutPostlude) so old stored profiles and the current +// default can never disagree about how the feature branch is materialised. +func TestDefaultPrepareScripts_NoInlineFeatureBranchCheckout(t *testing.T) { + executors := []string{"local_docker", "remote_docker", "sprites"} + forbidden := []string{ + `if [ -n "{{worktree.branch}}" ] && [ "{{worktree.branch}}" != "{{repository.branch}}" ]; then`, + `git checkout -B "{{worktree.branch}}" "origin/{{worktree.branch}}"`, + } + + for _, executorType := range executors { + t.Run(executorType, func(t *testing.T) { + script := DefaultPrepareScript(executorType) + if script == "" { + t.Fatalf("DefaultPrepareScript(%q) returned empty", executorType) + } + for _, bad := range forbidden { + if strings.Contains(script, bad) { + t.Errorf("script for %q must not contain inline checkout %q (postlude owns it)", executorType, bad) + } + } + }) + } +} diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_docker.go b/apps/backend/internal/agent/lifecycle/env_preparer_docker.go index de8434161d..0ffc5986f5 100644 --- a/apps/backend/internal/agent/lifecycle/env_preparer_docker.go +++ b/apps/backend/internal/agent/lifecycle/env_preparer_docker.go @@ -7,6 +7,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/worktree" ) // DockerPreparer prepares a Docker-based execution environment. @@ -36,9 +37,28 @@ func (p *DockerPreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, on reportProgress(onProgress, step, 0, 1) return &EnvPrepareResult{ - Success: true, - Steps: steps, - WorkspacePath: req.WorkspacePath, - Duration: time.Since(start), + Success: true, + Steps: steps, + WorkspacePath: req.WorkspacePath, + Duration: time.Since(start), + WorktreeBranch: nonWorktreeTaskBranch(req), }, nil } + +// nonWorktreeTaskBranch resolves the kandev-managed feature branch for executors +// that don't go through the host-side worktree path (Docker, Sprites). On a +// fresh launch it generates a deterministic branch name from the task title + +// task ID prefix so the same task always lands on the same branch even when +// the env row was created before this fix and didn't persist a name. On resume +// the orchestrator carries the previously-generated branch back via +// req.WorktreeBranch and we keep it as-is. +func nonWorktreeTaskBranch(req *EnvPrepareRequest) string { + if req.WorktreeBranch != "" { + return req.WorktreeBranch + } + suffix := req.TaskID + if len(suffix) > 6 { + suffix = suffix[:6] + } + return worktree.TaskBranchNameWithSuffix(req.TaskTitle, req.TaskID, req.WorktreeBranchPrefix, suffix) +} diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_docker_test.go b/apps/backend/internal/agent/lifecycle/env_preparer_docker_test.go new file mode 100644 index 0000000000..9d4a5bcf6b --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/env_preparer_docker_test.go @@ -0,0 +1,57 @@ +package lifecycle + +import ( + "context" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agent/executor" +) + +func TestDockerPreparer_GeneratesTaskBranch(t *testing.T) { + preparer := NewDockerPreparer(newTestLogger()) + + result, err := preparer.Prepare(context.Background(), &EnvPrepareRequest{ + TaskID: "task-123", + SessionID: "session-123", + TaskTitle: "Manual Docker Fix", + ExecutorType: executor.NameDocker, + WorkspacePath: "/workspace", + RepositoryPath: "/tmp/repo", + BaseBranch: "main", + WorktreeBranchPrefix: "feature/", + }, nil) + if err != nil { + t.Fatalf("Prepare returned error: %v", err) + } + if result.WorktreeBranch == "" { + t.Fatal("expected Docker preparer to choose a task branch") + } + if !strings.HasPrefix(result.WorktreeBranch, "feature/manual-docker-fix-") { + t.Fatalf("WorktreeBranch = %q, want feature/manual-docker-fix-*", result.WorktreeBranch) + } + if result.WorktreeBranch == "main" { + t.Fatal("Docker task branch must not be the base branch") + } +} + +func TestDockerPreparer_ReusesExistingTaskBranch(t *testing.T) { + preparer := NewDockerPreparer(newTestLogger()) + + result, err := preparer.Prepare(context.Background(), &EnvPrepareRequest{ + TaskID: "task-123", + SessionID: "session-123", + TaskTitle: "Manual Docker Fix", + ExecutorType: executor.NameDocker, + WorkspacePath: "/workspace", + RepositoryPath: "/tmp/repo", + BaseBranch: "main", + WorktreeBranch: "feature/existing-task-abc", + }, nil) + if err != nil { + t.Fatalf("Prepare returned error: %v", err) + } + if result.WorktreeBranch != "feature/existing-task-abc" { + t.Fatalf("WorktreeBranch = %q, want existing branch", result.WorktreeBranch) + } +} diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_sprites.go b/apps/backend/internal/agent/lifecycle/env_preparer_sprites.go index 7d354a88c3..9c76fda1b3 100644 --- a/apps/backend/internal/agent/lifecycle/env_preparer_sprites.go +++ b/apps/backend/internal/agent/lifecycle/env_preparer_sprites.go @@ -39,9 +39,10 @@ func (p *SpritesPreparer) Prepare(_ context.Context, req *EnvPrepareRequest, onP reportProgress(onProgress, step, 0, 1) return &EnvPrepareResult{ - Success: true, - Steps: []PrepareStep{step}, - WorkspacePath: req.WorkspacePath, - Duration: time.Since(start), + Success: true, + Steps: []PrepareStep{step}, + WorkspacePath: req.WorkspacePath, + Duration: time.Since(start), + WorktreeBranch: nonWorktreeTaskBranch(req), }, nil } diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_sprites_test.go b/apps/backend/internal/agent/lifecycle/env_preparer_sprites_test.go new file mode 100644 index 0000000000..443b609ab4 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/env_preparer_sprites_test.go @@ -0,0 +1,70 @@ +package lifecycle + +import ( + "context" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agent/executor" +) + +func TestSpritesPreparer_GeneratesDeterministicTaskBranch(t *testing.T) { + preparer := NewSpritesPreparer(newTestLogger()) + + first, err := preparer.Prepare(context.Background(), &EnvPrepareRequest{ + TaskID: "task-abc-123", + SessionID: "session-1", + TaskTitle: "Sprite Branch Fix", + ExecutorType: executor.NameSprites, + WorkspacePath: "/workspace", + BaseBranch: "main", + WorktreeBranchPrefix: "feature/", + }, nil) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + if first.WorktreeBranch == "" { + t.Fatal("expected Sprites preparer to populate WorktreeBranch") + } + if !strings.HasPrefix(first.WorktreeBranch, "feature/sprite-branch-fix-") { + t.Fatalf("WorktreeBranch = %q, want feature/sprite-branch-fix-*", first.WorktreeBranch) + } + + // Re-running with the same task fields must yield the same branch so + // resumes (legacy env rows with empty worktree_branch) don't churn. + second, err := preparer.Prepare(context.Background(), &EnvPrepareRequest{ + TaskID: "task-abc-123", + SessionID: "session-2", + TaskTitle: "Sprite Branch Fix", + ExecutorType: executor.NameSprites, + WorkspacePath: "/workspace", + BaseBranch: "main", + WorktreeBranchPrefix: "feature/", + }, nil) + if err != nil { + t.Fatalf("Prepare (second call): %v", err) + } + if second.WorktreeBranch != first.WorktreeBranch { + t.Fatalf("non-deterministic branch: first=%q second=%q", first.WorktreeBranch, second.WorktreeBranch) + } +} + +func TestSpritesPreparer_ReusesExistingTaskBranch(t *testing.T) { + preparer := NewSpritesPreparer(newTestLogger()) + + result, err := preparer.Prepare(context.Background(), &EnvPrepareRequest{ + TaskID: "task-abc-123", + SessionID: "session-1", + TaskTitle: "Sprite Branch Fix", + ExecutorType: executor.NameSprites, + WorkspacePath: "/workspace", + BaseBranch: "main", + WorktreeBranch: "feature/already-set-xyz", + }, nil) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + if result.WorktreeBranch != "feature/already-set-xyz" { + t.Fatalf("WorktreeBranch = %q, want existing branch passthrough", result.WorktreeBranch) + } +} diff --git a/apps/backend/internal/agent/lifecycle/executor_backend.go b/apps/backend/internal/agent/lifecycle/executor_backend.go index 861871f340..b0ffe6a834 100644 --- a/apps/backend/internal/agent/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/lifecycle/executor_backend.go @@ -63,15 +63,17 @@ const ( MetadataKeyWorktreeBranch = "worktree_branch" // Remote executor metadata keys - MetadataKeyRepositoryPath = "repository_path" - MetadataKeySetupScript = "setup_script" - MetadataKeyCleanupScript = "cleanup_script" - MetadataKeyRepoSetupScript = "repository_setup_script" - MetadataKeyBaseBranch = "base_branch" - MetadataKeyIsRemote = "is_remote" - MetadataKeyRemoteAuthHome = "remote_auth_target_home" - MetadataKeyGitUserName = "git_user_name" - MetadataKeyGitUserEmail = "git_user_email" + MetadataKeyRepositoryPath = "repository_path" + MetadataKeySetupScript = "setup_script" + MetadataKeyCleanupScript = "cleanup_script" + MetadataKeyRepoSetupScript = "repository_setup_script" + MetadataKeyBaseBranch = "base_branch" + MetadataKeyIsRemote = "is_remote" + MetadataKeyRemoteAuthHome = "remote_auth_target_home" + MetadataKeyGitUserName = "git_user_name" + MetadataKeyGitUserEmail = "git_user_email" + MetadataKeyImageTagOverride = "image_tag_override" + MetadataKeyContainerID = "container_id" ) // persistentMetadataKeys lists metadata keys carried forward from a previous @@ -84,7 +86,6 @@ var persistentMetadataKeys = map[string]bool{ "sprite_state": true, "sprite_created_at": true, "local_port": true, - "instance_port": true, // Executor type marker MetadataKeyIsRemote: true, @@ -100,6 +101,9 @@ var persistentMetadataKeys = map[string]bool{ "executor_mcp_policy": true, "sprites_network_policy_rules": true, "executor_profile_id": true, + MetadataKeyImageTagOverride: true, + MetadataKeyContainerID: true, + MetadataKeyWorktreeBranch: true, } // persistentMetadataPrefixes lists key prefixes that should persist. @@ -168,6 +172,7 @@ type RemoteStatusProvider interface { type ExecutorCreateRequest struct { InstanceID string TaskID string + TaskTitle string SessionID string TaskEnvironmentID string // Env this execution belongs to (shared across sessions in same task) AgentProfileID string @@ -179,6 +184,8 @@ type ExecutorCreateRequest struct { AgentConfig agents.Agent // Agent type info needed by runtimes PreviousExecutionID string // Non-empty when reconnecting to a previous execution McpMode string // MCP tool mode: "task" (default) or "config" + AuthToken string // Previously handshaken agentctl token for reconnects + BootstrapNonce string // Stored nonce for re-handshake after container restart // OnProgress is an optional callback for streaming preparation progress. // Executors that perform multi-step setup (e.g. Sprites, remote Docker) can @@ -216,6 +223,11 @@ type ExecutorInstance struct { // Empty for standalone (launcher-owned token wired via cfg.Agent.StandaloneAuthToken) // and Sprites (no agentctl auth). AuthToken string + + // BootstrapNonce is the one-time nonce injected into Docker container env. + // It is persisted so a restarted container can complete a fresh handshake + // against the newly started agentctl process. + BootstrapNonce string } // ToAgentExecution converts a ExecutorInstance to an AgentExecution. diff --git a/apps/backend/internal/agent/lifecycle/executor_backend_test.go b/apps/backend/internal/agent/lifecycle/executor_backend_test.go index a87d8487b6..c9329c2aec 100644 --- a/apps/backend/internal/agent/lifecycle/executor_backend_test.go +++ b/apps/backend/internal/agent/lifecycle/executor_backend_test.go @@ -16,6 +16,8 @@ func TestShouldPersistMetadataKey(t *testing.T) { {name: "exact match is_remote", key: MetadataKeyIsRemote, want: true}, {name: "exact match cleanup_script", key: MetadataKeyCleanupScript, want: true}, {name: "exact match executor_profile_id", key: "executor_profile_id", want: true}, + {name: "exact match image_tag_override", key: MetadataKeyImageTagOverride, want: true}, + {name: "exact match container_id", key: MetadataKeyContainerID, want: true}, {name: "prefix env_secret_id_", key: "env_secret_id_SPRITES_API_TOKEN", want: true}, {name: "prefix env_secret_id_ another key", key: "env_secret_id_OPENAI_KEY", want: true}, {name: "not persistent task_description", key: "task_description", want: false}, diff --git a/apps/backend/internal/agent/lifecycle/executor_docker.go b/apps/backend/internal/agent/lifecycle/executor_docker.go index df3c8cfb59..c15657d159 100644 --- a/apps/backend/internal/agent/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/lifecycle/executor_docker.go @@ -3,12 +3,15 @@ package lifecycle import ( "context" "fmt" + "net/url" + "path/filepath" "strings" "sync" "time" "go.uber.org/zap" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/docker" "github.com/kandev/kandev/internal/agent/executor" agentctl "github.com/kandev/kandev/internal/agentctl/client" @@ -31,25 +34,12 @@ func getMetadataString(metadata map[string]interface{}, key string) string { return "" } -func getMetadataBool(metadata map[string]interface{}, key string) bool { - if metadata == nil { - return false - } - switch raw := metadata[key].(type) { - case bool: - return raw - case string: - return strings.EqualFold(strings.TrimSpace(raw), "true") - default: - return false - } -} - // DockerExecutor implements Runtime for Docker-based agent execution. // The Docker client is created lazily on first use (not at startup). type DockerExecutor struct { - cfg config.DockerConfig - logger *logger.Logger + cfg config.DockerConfig + kandevHomeDir string + logger *logger.Logger // newClientFunc creates the Docker client. Defaults to docker.NewClient. // Override in tests to simulate failures. @@ -66,10 +56,13 @@ type DockerExecutor struct { // NewDockerExecutor creates a new Docker runtime. // The Docker client is NOT created here — it is initialized lazily -// when CreateInstance is called. -func NewDockerExecutor(cfg config.DockerConfig, log *logger.Logger) *DockerExecutor { +// when CreateInstance is called. kandevHomeDir is the resolved kandev root +// directory used to host per-container agent session dirs (the replacement +// for host home bind mounts that were leaking host state into containers). +func NewDockerExecutor(cfg config.DockerConfig, kandevHomeDir string, log *logger.Logger) *DockerExecutor { return &DockerExecutor{ cfg: cfg, + kandevHomeDir: kandevHomeDir, logger: log.WithFields(zap.String("runtime", "docker")), newClientFunc: docker.NewClient, } @@ -92,7 +85,7 @@ func (r *DockerExecutor) ensureClient() (*docker.Client, *ContainerManager, erro } r.docker = cli - r.containerMgr = NewContainerManager(cli, "", r.logger) + r.containerMgr = NewContainerManager(cli, "", r.kandevHomeDir, r.logger) r.initialized = true return r.docker, r.containerMgr, nil @@ -119,137 +112,163 @@ func (r *DockerExecutor) HealthCheck(_ context.Context) error { return nil } -func (r *DockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { +func (r *DockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (instance *ExecutorInstance, err error) { dockerClient, containerMgr, err := r.ensureClient() if err != nil { return nil, fmt.Errorf("docker unavailable: %w", err) } - // On resume, try to reconnect to the existing container - if req.PreviousExecutionID != "" { - instance, reconnectErr := r.reconnectToContainer(ctx, dockerClient, req) - if reconnectErr == nil { - return instance, nil - } - r.logger.Info("could not reconnect to previous container, creating new one", - zap.String("previous_execution_id", req.PreviousExecutionID), - zap.Error(reconnectErr)) + if req.OnProgress != nil { + defer reportCreateInstanceProgress(req, &err)() } - // Extract runtime-specific values from metadata - worktreeID := getMetadataString(req.Metadata, MetadataKeyWorktreeID) - worktreeBranch := getMetadataString(req.Metadata, MetadataKeyWorktreeBranch) - - // Resolve prepare script for cloning repo inside container - prepareScript := r.resolvePrepareScript(req) - - // Convert ExecutorCreateRequest to ContainerConfig - // Note: WorkspacePath is empty to skip mounting - we clone inside container - containerCfg := ContainerConfig{ - AgentConfig: req.AgentConfig, - WorkspacePath: "", // Empty = no workspace mount, we'll clone instead - TaskID: req.TaskID, - SessionID: req.SessionID, - InstanceID: req.InstanceID, - Credentials: req.Env, // Env contains credentials from the caller - McpServers: req.McpServers, - PrepareScript: prepareScript, + if reconnected, ok := r.tryReconnect(ctx, dockerClient, req); ok { + return reconnected, nil } - // Use ContainerManager to launch container (includes nonce handshake) + r.seedSessionDir(ctx, req) + + containerCfg := r.buildContainerLaunchConfig(req) result, err := containerMgr.LaunchContainer(ctx, containerCfg) if err != nil { return nil, fmt.Errorf("failed to launch container: %w", err) } - // Get container IP for logging containerIP, _ := dockerClient.GetContainerIP(ctx, result.ContainerID) - - // Build metadata - metadata := make(map[string]interface{}) - metadata[MetadataKeyIsRemote] = true // Mark as remote for shell handling - if worktreeID != "" { - metadata["worktree_id"] = worktreeID - metadata["worktree_path"] = dockerWorkspacePath - metadata["worktree_branch"] = worktreeBranch - } - r.logger.Info("docker instance created", zap.String("instance_id", req.InstanceID), zap.String("container_id", result.ContainerID), zap.String("container_ip", containerIP)) + return r.buildCreatedInstance(req, result, containerIP), nil +} + +// reportCreateInstanceProgress wires the "Waiting for Docker container" step +// into the caller's OnProgress callback. The returned closure must run after +// CreateInstance finishes (deferred) so it sees the final err state. +func reportCreateInstanceProgress(req *ExecutorCreateRequest, errPtr *error) func() { + step := beginStep("Waiting for Docker container") + reportProgress(req.OnProgress, step, 0, 1) + return func() { + if *errPtr != nil { + completeStepError(&step, (*errPtr).Error()) + } else { + completeStepSuccess(&step) + } + reportProgress(req.OnProgress, step, 0, 1) + } +} + +// tryReconnect returns (instance, true) if the request points at an existing +// container that's healthy enough to resume; otherwise (nil, false) and the +// caller falls back to provisioning a fresh container. +func (r *DockerExecutor) tryReconnect(ctx context.Context, dockerClient *docker.Client, req *ExecutorCreateRequest) (*ExecutorInstance, bool) { + if req.PreviousExecutionID == "" { + return nil, false + } + reconnected, reconnectErr := r.reconnectToContainer(ctx, dockerClient, req) + if reconnectErr == nil { + return reconnected, true + } + r.logger.Info("could not reconnect to previous container, creating new one", + zap.String("previous_execution_id", req.PreviousExecutionID), + zap.Error(reconnectErr)) + return nil, false +} + +// seedSessionDir copies the agent's auth files (auth.json / config.toml / +// etc.) into the per-container session dir. Replaces the older pattern of +// bind-mounting the host's whole ~/., which leaked absolute host +// paths into agent state DBs and broke resume on codex. +func (r *DockerExecutor) seedSessionDir(ctx context.Context, req *ExecutorCreateRequest) { + if req.AgentConfig == nil || r.kandevHomeDir == "" { + return + } + instanceRoot := InstanceSessionRoot(r.kandevHomeDir, req.InstanceID) + if err := SeedAgentSessionDir(ctx, req.AgentConfig, instanceRoot, r.logger); err != nil { + r.logger.Warn("failed to seed agent session dir (continuing)", + zap.String("instance_id", req.InstanceID), + zap.String("agent_id", req.AgentConfig.ID()), + zap.Error(err)) + } +} + +func (r *DockerExecutor) buildContainerLaunchConfig(req *ExecutorCreateRequest) ContainerConfig { + return ContainerConfig{ + AgentConfig: req.AgentConfig, + WorkspacePath: "", // Empty = no workspace mount; we clone inside container. + TaskID: req.TaskID, + TaskTitle: req.TaskTitle, + TaskEnvironmentID: req.TaskEnvironmentID, + SessionID: req.SessionID, + ExecutorProfileID: getMetadataString(req.Metadata, "executor_profile_id"), + InstanceID: req.InstanceID, + Credentials: req.Env, + McpServers: req.McpServers, + PrepareScript: r.resolvePrepareScript(req), + ImageTagOverride: getMetadataString(req.Metadata, MetadataKeyImageTagOverride), + LocalClonePath: localCloneMountPath(req.Metadata), + } +} + +func (r *DockerExecutor) buildCreatedInstance(req *ExecutorCreateRequest, result *LaunchResult, containerIP string) *ExecutorInstance { + metadata := map[string]interface{}{ + MetadataKeyIsRemote: true, + } + if worktreeID := getMetadataString(req.Metadata, MetadataKeyWorktreeID); worktreeID != "" { + metadata["worktree_id"] = worktreeID + metadata["worktree_path"] = dockerWorkspacePath + metadata["worktree_branch"] = getMetadataString(req.Metadata, MetadataKeyWorktreeBranch) + } return &ExecutorInstance{ - InstanceID: req.InstanceID, - TaskID: req.TaskID, - SessionID: req.SessionID, - RuntimeName: string(r.Name()), - Client: result.Client, - ContainerID: result.ContainerID, - ContainerIP: containerIP, - WorkspacePath: dockerWorkspacePath, - Metadata: metadata, - AuthToken: result.AuthToken, - }, nil + InstanceID: req.InstanceID, + TaskID: req.TaskID, + SessionID: req.SessionID, + RuntimeName: string(r.Name()), + Client: result.Client, + ContainerID: result.ContainerID, + ContainerIP: containerIP, + WorkspacePath: dockerWorkspacePath, + Metadata: metadata, + AuthToken: result.AuthToken, + BootstrapNonce: result.BootstrapNonce, + } } // reconnectToContainer attempts to reconnect to an existing Docker container // from a previous execution. Returns the reconnected instance if successful. func (r *DockerExecutor) reconnectToContainer(ctx context.Context, dockerClient *docker.Client, req *ExecutorCreateRequest) (*ExecutorInstance, error) { - // Derive container name from previous execution ID (same pattern as LaunchContainer) - prevID := req.PreviousExecutionID - if len(prevID) < 8 { - return nil, fmt.Errorf("previous execution ID too short: %s", prevID) - } - containerName := fmt.Sprintf("kandev-agent-%s", prevID[:8]) - - // Check if the container is still running - running, err := dockerClient.IsContainerRunning(ctx, containerName) - if err != nil || !running { - return nil, fmt.Errorf("container %s not running", containerName) - } - - // Get the container IP - containerIP, err := dockerClient.GetContainerIP(ctx, containerName) + containerRef, err := resolveReconnectContainerRef(req) if err != nil { - return nil, fmt.Errorf("failed to get IP for container %s: %w", containerName, err) + return nil, err } - - // Check if agentctl is healthy - ctl := agentctl.NewControlClient(containerIP, AgentCtlPort, r.logger) - if err := ctl.Health(ctx); err != nil { - return nil, fmt.Errorf("agentctl not healthy in container %s: %w", containerName, err) - } - - // Check if the previous instance is still alive - instancePort, reusingProcess, err := r.findExistingInstance(ctx, ctl, containerIP, prevID) + info, containerIP, err := r.ensureContainerRunning(ctx, dockerClient, containerRef) if err != nil { - return nil, fmt.Errorf("failed to find instance in container %s: %w", containerName, err) + return nil, err } - // Create client pointing to the instance port - client := agentctl.NewClient(containerIP, instancePort, r.logger, + conn, err := r.bringupAgentctl(ctx, dockerClient, info.ID, containerIP, req) + if err != nil { + return nil, err + } + + client := agentctl.NewClient(conn.instanceHost, conn.instancePort, r.logger, agentctl.WithExecutionID(req.InstanceID), - agentctl.WithSessionID(req.SessionID)) - - // Get container info for the full container ID - info, err := dockerClient.GetContainerInfo(ctx, containerName) - if err != nil { - return nil, fmt.Errorf("failed to inspect container %s: %w", containerName, err) - } - - metadata := map[string]interface{}{ - MetadataKeyIsRemote: true, - "reuse_existing_process": reusingProcess, - } + agentctl.WithSessionID(req.SessionID), + agentctl.WithAuthToken(conn.authToken)) r.logger.Info("reconnected to existing docker container", - zap.String("container_name", containerName), + zap.String("container_ref", containerRef), zap.String("container_id", info.ID), zap.String("container_ip", containerIP), - zap.Int("instance_port", instancePort), - zap.Bool("reusing_process", reusingProcess)) + zap.String("instance_host", conn.instanceHost), + zap.Int("instance_port", conn.instancePort), + zap.Bool("reusing_process", conn.reusingProcess)) + refreshedAuthToken := "" + if conn.authToken != "" && conn.authToken != req.AuthToken { + refreshedAuthToken = conn.authToken + } return &ExecutorInstance{ InstanceID: req.InstanceID, TaskID: req.TaskID, @@ -259,29 +278,160 @@ func (r *DockerExecutor) reconnectToContainer(ctx context.Context, dockerClient ContainerID: info.ID, ContainerIP: containerIP, WorkspacePath: dockerWorkspacePath, - Metadata: metadata, + Metadata: map[string]interface{}{ + MetadataKeyIsRemote: true, + MetadataKeyContainerID: info.ID, + "reuse_existing_process": conn.reusingProcess, + }, + AuthToken: refreshedAuthToken, }, nil } +// ensureContainerRunning inspects containerRef, starts it if it's stopped, +// re-inspects, and returns the live container info and IP. Errors out if the +// container can't be brought to a running state — the caller can't reconnect +// to a container that isn't alive. +func (r *DockerExecutor) ensureContainerRunning(ctx context.Context, dockerClient *docker.Client, containerRef string) (*docker.ContainerInfo, string, error) { + info, err := dockerClient.GetContainerInfo(ctx, containerRef) + if err != nil { + return nil, "", fmt.Errorf("failed to inspect container %s: %w", containerRef, err) + } + if shouldStartExistingDockerContainer(info.State) { + r.logger.Info("starting stopped docker container for reconnect", + zap.String("container_id", info.ID), + zap.String("state", info.State)) + if err := dockerClient.StartContainer(ctx, info.ID); err != nil { + return nil, "", fmt.Errorf("failed to start container %s: %w", info.ID, err) + } + info, err = dockerClient.GetContainerInfo(ctx, info.ID) + if err != nil { + return nil, "", fmt.Errorf("failed to inspect started container %s: %w", containerRef, err) + } + } + if info.State != containerStateRunning { + return nil, "", fmt.Errorf("container %s is %s, not %s", containerRef, info.State, containerStateRunning) + } + containerIP, err := dockerClient.GetContainerIP(ctx, info.ID) + if err != nil { + return nil, "", fmt.Errorf("failed to get IP for container %s: %w", info.ID, err) + } + return info, containerIP, nil +} + +// reconnectAgentctlConn captures the resolved endpoint state needed by the +// caller after agentctl has been brought up: the instance-level host:port for +// the user-facing client, the auth token in effect (which may have been +// refreshed via re-handshake), and whether the agent subprocess is reusable. +type reconnectAgentctlConn struct { + instanceHost string + instancePort int + authToken string + reusingProcess bool +} + +// bringupAgentctl health-checks agentctl on the container, finds (or creates) +// the agent instance, transparently re-handshakes on a 401, and returns the +// resolved instance endpoint for the user-facing client. +func (r *DockerExecutor) bringupAgentctl(ctx context.Context, dockerClient *docker.Client, containerID, containerIP string, req *ExecutorCreateRequest) (reconnectAgentctlConn, error) { + controlHost, controlPort := resolveDockerEndpoint(ctx, dockerClient, containerID, AgentCtlPort, containerIP, r.logger) + ctl := agentctl.NewControlClient(controlHost, controlPort, r.logger, + agentctl.WithControlAuthToken(req.AuthToken)) + if err := r.waitForAgentctlHealth(ctx, ctl); err != nil { + return reconnectAgentctlConn{}, fmt.Errorf("agentctl not healthy in container %s: %w", containerID, err) + } + + authToken := req.AuthToken + instanceID := reconnectInstanceID(req, req.PreviousExecutionID) + instancePort, reusingProcess, err := r.findExistingInstance(ctx, dockerClient, ctl, req, containerID, containerIP, instanceID, authToken) + if err != nil && req.BootstrapNonce != "" && isAgentctlAuthError(err) { + var handshakeErr error + authToken, handshakeErr = ctl.Handshake(ctx, req.BootstrapNonce) + if handshakeErr != nil { + return reconnectAgentctlConn{}, fmt.Errorf("agentctl auth failed and re-handshake failed in container %s: %w", containerID, handshakeErr) + } + instancePort, reusingProcess, err = r.findExistingInstance(ctx, dockerClient, ctl, req, containerID, containerIP, instanceID, authToken) + } + if err != nil { + return reconnectAgentctlConn{}, fmt.Errorf("failed to find instance in container %s: %w", containerID, err) + } + instanceHost, resolvedInstancePort := resolveDockerEndpoint(ctx, dockerClient, containerID, instancePort, containerIP, r.logger) + return reconnectAgentctlConn{ + instanceHost: instanceHost, + instancePort: resolvedInstancePort, + authToken: authToken, + reusingProcess: reusingProcess, + }, nil +} + +func reconnectInstanceID(req *ExecutorCreateRequest, previousExecutionID string) string { + if req != nil && strings.TrimSpace(req.InstanceID) != "" { + return req.InstanceID + } + return previousExecutionID +} + +func resolveReconnectContainerRef(req *ExecutorCreateRequest) (string, error) { + if req == nil { + return "", fmt.Errorf("executor create request is nil") + } + if containerID := strings.TrimSpace(getMetadataString(req.Metadata, MetadataKeyContainerID)); containerID != "" { + return containerID, nil + } + prevID := req.PreviousExecutionID + if len(prevID) < 8 { + return "", fmt.Errorf("previous execution ID too short: %s", prevID) + } + return fmt.Sprintf("kandev-agent-%s", prevID[:8]), nil +} + +func shouldStartExistingDockerContainer(state string) bool { + switch state { + case containerStateCreated, containerStateExited: + return true + default: + return false + } +} + +func isAgentctlAuthError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "status 401") || + strings.Contains(msg, "unauthorized") || + strings.Contains(msg, "auth token") +} + // findExistingInstance checks if a previous instance is still running in the container. // Returns the instance port and whether the agent subprocess is also running. -func (r *DockerExecutor) findExistingInstance(ctx context.Context, ctl *agentctl.ControlClient, containerIP, prevExecutionID string) (int, bool, error) { +func (r *DockerExecutor) findExistingInstance( + ctx context.Context, + dockerClient *docker.Client, + ctl *agentctl.ControlClient, + req *ExecutorCreateRequest, + containerID string, + containerIP string, + prevExecutionID string, + authToken string, +) (int, bool, error) { // Try to get the existing instance by its ID instance, err := ctl.GetInstance(ctx, prevExecutionID) if err == nil && instance != nil && instance.Port > 0 { // Instance exists, check if agent subprocess is running - client := agentctl.NewClient(containerIP, instance.Port, r.logger) + instanceHost, instancePort := resolveDockerEndpoint(ctx, dockerClient, containerID, instance.Port, containerIP, r.logger) + client := agentctl.NewClient(instanceHost, instancePort, r.logger, + agentctl.WithAuthToken(authToken)) status, statusErr := client.GetStatus(ctx) processRunning := statusErr == nil && status != nil && status.IsAgentRunning() return instance.Port, processRunning, nil } + if err != nil && isAgentctlAuthError(err) { + return 0, false, err + } // Instance not found — create a new instance in the existing container - createReq := &agentctl.CreateInstanceRequest{ - ID: prevExecutionID, - WorkspacePath: dockerWorkspacePath, - AutoStart: false, - } + createReq := buildReconnectCreateInstanceRequest(req, prevExecutionID) resp, createErr := ctl.CreateInstance(ctx, createReq) if createErr != nil { return 0, false, fmt.Errorf("failed to create new instance: %w", createErr) @@ -289,7 +439,119 @@ func (r *DockerExecutor) findExistingInstance(ctx context.Context, ctl *agentctl return resp.Port, false, nil } +func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID string) *agentctl.CreateInstanceRequest { + agentType := "" + disableAskQuestion := false + assumeMcpSse := false + if req.AgentConfig != nil { + agentType = req.AgentConfig.ID() + disableAskQuestion = agents.IsPassthroughOnly(req.AgentConfig) + if rt := req.AgentConfig.Runtime(); rt != nil { + assumeMcpSse = rt.AssumeMcpSse + } + } + return &agentctl.CreateInstanceRequest{ + ID: instanceID, + WorkspacePath: dockerWorkspacePath, + AgentType: agentType, + Env: req.Env, + AutoStart: false, + McpServers: req.McpServers, + SessionID: req.SessionID, + TaskID: req.TaskID, + DisableAskQuestion: disableAskQuestion, + AssumeMcpSse: assumeMcpSse, + McpMode: req.McpMode, + } +} + +// healthChecker is the narrow interface waitForAgentctlHealth needs from the +// agentctl control client. *agentctl.ControlClient satisfies it; tests can +// stub it without spinning up an HTTP server. +type healthChecker interface { + Health(ctx context.Context) error +} + +const ( + agentctlHealthMaxRetries = 240 + agentctlHealthRetryDelay = 500 * time.Millisecond +) + +func (r *DockerExecutor) waitForAgentctlHealth(ctx context.Context, ctl healthChecker) error { + return waitForAgentctlHealthWith(ctx, ctl, agentctlHealthMaxRetries, agentctlHealthRetryDelay) +} + +// waitForAgentctlHealthWith is the parameterised body — exposed for tests so +// they can drive the retry loop without a 2-minute wait. The production path +// always calls it with the package constants. +func waitForAgentctlHealthWith(ctx context.Context, ctl healthChecker, maxRetries int, retryDelay time.Duration) error { + var lastErr error + for i := 0; i < maxRetries; i++ { + if err := ctl.Health(ctx); err == nil { + return nil + } else { + lastErr = err + } + if ctx.Err() != nil { + return ctx.Err() + } + if i+1 < maxRetries { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(retryDelay): + } + } + } + + if lastErr != nil { + return fmt.Errorf("agentctl not healthy after %s: %w", + time.Duration(maxRetries)*retryDelay, lastErr) + } + return fmt.Errorf("agentctl not healthy after %s", + time.Duration(maxRetries)*retryDelay) +} + +// hostPortLookup is the narrow interface resolveDockerEndpoint needs from the +// docker client. *docker.Client satisfies it; tests can stub it without +// having to spin up a docker daemon. +type hostPortLookup interface { + GetContainerHostPort(ctx context.Context, containerID string, containerPort int) (string, int, error) +} + +func resolveDockerEndpoint( + ctx context.Context, + dockerClient hostPortLookup, + containerID string, + containerPort int, + fallbackHost string, + log *logger.Logger, +) (string, int) { + host, port, err := dockerClient.GetContainerHostPort(ctx, containerID, containerPort) + if err == nil { + return host, port + } + log.Warn("failed to resolve published Docker port, falling back to container IP", + zap.String("container_id", containerID), + zap.Int("container_port", containerPort), + zap.String("fallback_host", fallbackHost), + zap.Error(err)) + return fallbackHost, containerPort +} + func (r *DockerExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error { + if instance == nil { + return nil + } + + // On destructive stop reasons (task/session deleted/archived), clean up + // the kandev-managed per-container session dir so we don't leak GBs of + // agent state on disk. Plain stops preserve the dir so resume re-attaches + // to the same agent state, mirroring the Sprites preserve-on-stop rule. + if shouldRunExecutorCleanup(instance.StopReason) && r.kandevHomeDir != "" && instance.InstanceID != "" { + CleanupAgentSessionDir(InstanceSessionRoot(r.kandevHomeDir, instance.InstanceID), r.logger) + } + if instance.ContainerID == "" { return nil // No container to stop } @@ -348,6 +610,11 @@ func (r *DockerExecutor) IsAlwaysResumable() bool { return true } // resolvePrepareScript builds the resolved prepare script using scriptengine. // This script clones the repository inside the container. +// +// The kandev-managed feature-branch checkout is appended as an invariant +// postlude — older profiles that snapshot a then-current default lacked it, +// so simply updating DefaultPrepareScript wouldn't reach those users. The +// postlude runs after the user's prepare script and is idempotent. func (r *DockerExecutor) resolvePrepareScript(req *ExecutorCreateRequest) string { script := getMetadataString(req.Metadata, MetadataKeySetupScript) if script == "" { @@ -356,16 +623,24 @@ func (r *DockerExecutor) resolvePrepareScript(req *ExecutorCreateRequest) string if script == "" { return "" } + script += KandevBranchCheckoutPostlude() resolver := scriptengine.NewResolver(). WithProvider(scriptengine.WorkspaceProvider(dockerWorkspacePath)). WithProvider(scriptengine.GitIdentityProvider(req.Metadata)). WithProvider(scriptengine.GitHubAuthProvider(req.Env)). + WithProvider(scriptengine.WorktreeProvider( + "", + dockerWorkspacePath, + getMetadataString(req.Metadata, MetadataKeyWorktreeID), + getMetadataString(req.Metadata, MetadataKeyWorktreeBranch), + getMetadataString(req.Metadata, MetadataKeyBaseBranch), + )). WithProvider(scriptengine.RepositoryProvider( req.Metadata, req.Env, getGitRemoteURL, - r.injectTokenIntoURL, + injectGitHubTokenIntoCloneURL, )). // Docker image has agents and agentctl pre-installed; // resolve these to empty so stored scripts with these placeholders don't break. @@ -380,26 +655,27 @@ func (r *DockerExecutor) resolvePrepareScript(req *ExecutorCreateRequest) string return resolver.Resolve(script) } -// injectTokenIntoURL adds a GitHub token to clone URLs for authentication. -// Handles both HTTPS and SSH URLs (converting SSH to authenticated HTTPS). -func (r *DockerExecutor) injectTokenIntoURL(cloneURL string, env map[string]string) string { - token := env["GITHUB_TOKEN"] - if token == "" { - token = env["GH_TOKEN"] - } - if token == "" { - return cloneURL - } - - // Convert SSH URLs to HTTPS first - if strings.HasPrefix(cloneURL, "git@github.com:") { - path := strings.TrimPrefix(cloneURL, "git@github.com:") - cloneURL = "https://github.com/" + path - } - - // Convert https://github.com/... to https://x-access-token:TOKEN@github.com/... - if strings.HasPrefix(cloneURL, "https://github.com/") { - return strings.Replace(cloneURL, "https://github.com/", "https://x-access-token:"+token+"@github.com/", 1) - } - return cloneURL +func localCloneMountPath(metadata map[string]interface{}) string { + return localPathFromCloneURL(getMetadataString(metadata, "repository_clone_url")) +} + +func localPathFromCloneURL(raw string) string { + if raw == "" { + return "" + } + if filepath.IsAbs(raw) { + return raw + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "file" { + return "" + } + if parsed.Host != "" && parsed.Host != "localhost" { + return "" + } + path, err := url.PathUnescape(parsed.Path) + if err != nil || !filepath.IsAbs(path) { + return "" + } + return path } diff --git a/apps/backend/internal/agent/lifecycle/executor_docker_reconnect_test.go b/apps/backend/internal/agent/lifecycle/executor_docker_reconnect_test.go new file mode 100644 index 0000000000..b451dc53b1 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/executor_docker_reconnect_test.go @@ -0,0 +1,134 @@ +package lifecycle + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" +) + +// stubHostPortLookup satisfies hostPortLookup for resolveDockerEndpoint +// tests without needing a real docker daemon. +type stubHostPortLookup struct { + host string + port int + err error +} + +func (s stubHostPortLookup) GetContainerHostPort(_ context.Context, _ string, _ int) (string, int, error) { + return s.host, s.port, s.err +} + +func TestResolveDockerEndpoint_PrefersPublishedHostPort(t *testing.T) { + host, port := resolveDockerEndpoint( + context.Background(), + stubHostPortLookup{host: "127.0.0.1", port: 54321}, + "container-x", 4000, "172.17.0.5", + newTestDockerLogger(), + ) + if host != "127.0.0.1" || port != 54321 { + t.Fatalf("got %s:%d, want 127.0.0.1:54321 (published port wins)", host, port) + } +} + +func TestResolveDockerEndpoint_FallsBackToContainerIPOnLookupError(t *testing.T) { + // Non-Linux dev hosts can't reach the container IP directly, so + // resolveDockerEndpoint falls back to the published host port. When the + // lookup itself errors (e.g. docker daemon hiccup), the fallback path is + // the container IP at the requested container port — anything else + // silently routes traffic to the wrong endpoint. + host, port := resolveDockerEndpoint( + context.Background(), + stubHostPortLookup{err: errors.New("port not published")}, + "container-x", 4000, "172.17.0.5", + newTestDockerLogger(), + ) + if host != "172.17.0.5" || port != 4000 { + t.Fatalf("got %s:%d, want 172.17.0.5:4000 (container IP fallback)", host, port) + } +} + +// stubHealthChecker satisfies healthChecker for waitForAgentctlHealth tests. +// Health() succeeds after `becomesHealthyAfter` invocations and returns +// `failErr` until then. Tracks call count so tests can assert the retry +// budget was respected. +type stubHealthChecker struct { + becomesHealthyAfter int32 + failErr error + calls int32 +} + +func (s *stubHealthChecker) Health(_ context.Context) error { + n := atomic.AddInt32(&s.calls, 1) + if n >= s.becomesHealthyAfter { + return nil + } + return s.failErr +} + +func TestWaitForAgentctlHealth_HappyPathReturnsImmediately(t *testing.T) { + stub := &stubHealthChecker{becomesHealthyAfter: 1} + r := &DockerExecutor{logger: newTestDockerLogger()} + + if err := waitForAgentctlHealthWith(context.Background(), stub, 5, time.Millisecond); err != nil { + t.Fatalf("happy-path Health() should not error: %v", err) + } + _ = r // keep the parameter shape parallel with production callers + if got := atomic.LoadInt32(&stub.calls); got != 1 { + t.Fatalf("expected 1 Health() call, got %d", got) + } +} + +func TestWaitForAgentctlHealth_RetriesUntilHealthy(t *testing.T) { + stub := &stubHealthChecker{ + becomesHealthyAfter: 3, + failErr: errors.New("connection refused"), + } + + if err := waitForAgentctlHealthWith(context.Background(), stub, 10, time.Millisecond); err != nil { + t.Fatalf("expected eventual success, got %v", err) + } + if got := atomic.LoadInt32(&stub.calls); got != 3 { + t.Fatalf("expected 3 Health() calls, got %d", got) + } +} + +func TestWaitForAgentctlHealth_GivesUpAfterMaxRetries(t *testing.T) { + stub := &stubHealthChecker{ + becomesHealthyAfter: 999, // never succeeds within the test budget + failErr: errors.New("connection refused"), + } + + err := waitForAgentctlHealthWith(context.Background(), stub, 4, time.Millisecond) + if err == nil { + t.Fatal("expected exhaustion error, got nil") + } + if got := atomic.LoadInt32(&stub.calls); got != 4 { + t.Fatalf("expected 4 Health() calls (one per retry), got %d", got) + } + if !errors.Is(err, stub.failErr) { + t.Fatalf("exhaustion error should wrap the last underlying error: %v", err) + } +} + +func TestWaitForAgentctlHealth_HonoursContextCancellation(t *testing.T) { + stub := &stubHealthChecker{ + becomesHealthyAfter: 999, + failErr: errors.New("connection refused"), + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // pre-cancelled + + err := waitForAgentctlHealthWith(ctx, stub, 100, time.Millisecond) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } + // One Health() call before the loop notices cancellation; the cancelable + // select exits immediately via ctx.Done() so cancellation is observed + // within the same iteration. + if got := atomic.LoadInt32(&stub.calls); got > 2 { + t.Fatalf("expected ≤2 Health() calls before honouring cancellation, got %d", got) + } +} diff --git a/apps/backend/internal/agent/lifecycle/executor_docker_test.go b/apps/backend/internal/agent/lifecycle/executor_docker_test.go index 55b6fe14f0..69dec51ff4 100644 --- a/apps/backend/internal/agent/lifecycle/executor_docker_test.go +++ b/apps/backend/internal/agent/lifecycle/executor_docker_test.go @@ -7,6 +7,7 @@ import ( "sync/atomic" "testing" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/docker" "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/common/config" @@ -27,7 +28,7 @@ func failingClientFactory(errMsg string) func(config.DockerConfig, *logger.Logge func TestNewDockerExecutor(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) if exec == nil { t.Fatal("expected non-nil executor") @@ -48,7 +49,7 @@ func TestNewDockerExecutor(t *testing.T) { func TestDockerExecutor_Name(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) if exec.Name() != executor.NameDocker { t.Errorf("expected name %q, got %q", executor.NameDocker, exec.Name()) @@ -57,7 +58,7 @@ func TestDockerExecutor_Name(t *testing.T) { func TestDockerExecutor_HealthCheck(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) if err := exec.HealthCheck(context.Background()); err != nil { t.Errorf("expected nil error, got: %v", err) @@ -66,7 +67,7 @@ func TestDockerExecutor_HealthCheck(t *testing.T) { func TestDockerExecutor_RecoverInstances(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) instances, err := exec.RecoverInstances(context.Background()) if err != nil { @@ -79,7 +80,7 @@ func TestDockerExecutor_RecoverInstances(t *testing.T) { func TestDockerExecutor_GetInteractiveRunner(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) if runner := exec.GetInteractiveRunner(); runner != nil { t.Error("expected nil interactive runner for docker executor") @@ -88,7 +89,7 @@ func TestDockerExecutor_GetInteractiveRunner(t *testing.T) { func TestDockerExecutor_EnsureClient_Success(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) // Default factory uses docker.NewClient which succeeds even without Docker running cli, cm, err := exec.ensureClient() @@ -123,7 +124,7 @@ func TestDockerExecutor_EnsureClient_Success(t *testing.T) { func TestDockerExecutor_EnsureClient_RetriesOnFailure(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) var callCount atomic.Int32 exec.newClientFunc = func(_ config.DockerConfig, _ *logger.Logger) (*docker.Client, error) { @@ -155,7 +156,7 @@ func TestDockerExecutor_EnsureClient_RetriesOnFailure(t *testing.T) { func TestDockerExecutor_EnsureClient_RecoversAfterTransientFailure(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) var callCount atomic.Int32 exec.newClientFunc = func(cfg config.DockerConfig, l *logger.Logger) (*docker.Client, error) { @@ -190,7 +191,7 @@ func TestDockerExecutor_EnsureClient_RecoversAfterTransientFailure(t *testing.T) func TestDockerExecutor_Client_ReturnsNilOnFailure(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) exec.newClientFunc = failingClientFactory("docker unavailable") if cli := exec.Client(); cli != nil { @@ -200,7 +201,7 @@ func TestDockerExecutor_Client_ReturnsNilOnFailure(t *testing.T) { func TestDockerExecutor_ContainerMgr_ReturnsNilOnFailure(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) exec.newClientFunc = failingClientFactory("docker unavailable") if cm := exec.ContainerMgr(); cm != nil { @@ -210,7 +211,7 @@ func TestDockerExecutor_ContainerMgr_ReturnsNilOnFailure(t *testing.T) { func TestDockerExecutor_Client_ReturnsClientOnSuccess(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) cli := exec.Client() if cli == nil { @@ -222,7 +223,7 @@ func TestDockerExecutor_Client_ReturnsClientOnSuccess(t *testing.T) { func TestDockerExecutor_Close_BeforeInit(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) if err := exec.Close(); err != nil { t.Errorf("expected nil error, got: %v", err) @@ -231,7 +232,7 @@ func TestDockerExecutor_Close_BeforeInit(t *testing.T) { func TestDockerExecutor_Close_AfterInit(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) // Initialize the client _, _, _ = exec.ensureClient() @@ -256,7 +257,7 @@ func TestDockerExecutor_Close_AfterInit(t *testing.T) { func TestDockerExecutor_Close_AfterFailedInit(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) exec.newClientFunc = failingClientFactory("docker unavailable") // Trigger failed init @@ -296,7 +297,7 @@ func TestDockerClientProvider_NoDockerExecutor(t *testing.T) { func TestDockerClientProvider_WithDockerExecutor(t *testing.T) { log := newTestDockerLogger() registry := NewExecutorRegistry(log) - dockerExec := NewDockerExecutor(config.DockerConfig{}, log) + dockerExec := NewDockerExecutor(config.DockerConfig{}, "", log) dockerExec.newClientFunc = failingClientFactory("docker unavailable") registry.Register(dockerExec) mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, "", log) @@ -310,7 +311,7 @@ func TestDockerClientProvider_WithDockerExecutor(t *testing.T) { func TestDockerClientProvider_WithWorkingDocker(t *testing.T) { log := newTestDockerLogger() registry := NewExecutorRegistry(log) - dockerExec := NewDockerExecutor(config.DockerConfig{}, log) + dockerExec := NewDockerExecutor(config.DockerConfig{}, "", log) registry.Register(dockerExec) mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, "", log) @@ -328,7 +329,7 @@ func TestExecutorRegistry_CloseAll(t *testing.T) { log := newTestDockerLogger() registry := NewExecutorRegistry(log) - dockerExec := NewDockerExecutor(config.DockerConfig{}, log) + dockerExec := NewDockerExecutor(config.DockerConfig{}, "", log) registry.Register(dockerExec) registry.Register(&MockExecutor{name: executor.NameStandalone}) @@ -346,9 +347,6 @@ func TestExecutorRegistry_CloseAll(t *testing.T) { } func TestInjectTokenIntoURL(t *testing.T) { - log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) - tests := []struct { name string url string @@ -407,7 +405,7 @@ func TestInjectTokenIntoURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := exec.injectTokenIntoURL(tt.url, tt.env) + got := injectGitHubTokenIntoCloneURL(tt.url, tt.env) if got != tt.expected { t.Errorf("injectTokenIntoURL(%q) = %q, want %q", tt.url, got, tt.expected) } @@ -417,7 +415,7 @@ func TestInjectTokenIntoURL(t *testing.T) { func TestReconnectToContainer_ValidationErrors(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) t.Run("short execution ID rejected", func(t *testing.T) { req := &ExecutorCreateRequest{ @@ -443,9 +441,120 @@ func TestReconnectToContainer_ValidationErrors(t *testing.T) { }) } +func TestResolveReconnectContainerRef(t *testing.T) { + t.Run("uses stored container id before derived execution name", func(t *testing.T) { + req := &ExecutorCreateRequest{ + PreviousExecutionID: "exec-previous", + Metadata: map[string]interface{}{ + MetadataKeyContainerID: "container-real", + }, + } + got, err := resolveReconnectContainerRef(req) + if err != nil { + t.Fatalf("resolveReconnectContainerRef returned error: %v", err) + } + if got != "container-real" { + t.Fatalf("expected stored container id, got %q", got) + } + }) + + t.Run("falls back to legacy name from previous execution id", func(t *testing.T) { + req := &ExecutorCreateRequest{PreviousExecutionID: "12345678-abcdef"} + got, err := resolveReconnectContainerRef(req) + if err != nil { + t.Fatalf("resolveReconnectContainerRef returned error: %v", err) + } + if got != "kandev-agent-12345678" { + t.Fatalf("expected derived container name, got %q", got) + } + }) +} + +func TestReconnectInstanceID(t *testing.T) { + t.Run("uses requested instance for new session in existing container", func(t *testing.T) { + req := &ExecutorCreateRequest{InstanceID: "exec-new"} + got := reconnectInstanceID(req, "exec-old") + if got != "exec-new" { + t.Fatalf("reconnectInstanceID = %q, want exec-new", got) + } + }) + + t.Run("falls back to previous execution for legacy callers", func(t *testing.T) { + got := reconnectInstanceID(&ExecutorCreateRequest{}, "exec-old") + if got != "exec-old" { + t.Fatalf("reconnectInstanceID = %q, want exec-old", got) + } + }) +} + +func TestShouldStartExistingDockerContainer(t *testing.T) { + tests := []struct { + state string + want bool + }{ + {state: "created", want: true}, + {state: "exited", want: true}, + {state: "running", want: false}, + {state: "paused", want: false}, + {state: "dead", want: false}, + } + for _, tt := range tests { + t.Run(tt.state, func(t *testing.T) { + if got := shouldStartExistingDockerContainer(tt.state); got != tt.want { + t.Fatalf("shouldStartExistingDockerContainer(%q) = %v, want %v", tt.state, got, tt.want) + } + }) + } +} + +func TestBuildReconnectCreateInstanceRequest(t *testing.T) { + req := &ExecutorCreateRequest{ + TaskID: "task-1", + SessionID: "session-1", + AgentConfig: &testAgent{ + id: "codex", + enabled: true, + runtimeConfig: &agents.RuntimeConfig{ + AssumeMcpSse: true, + }, + }, + Env: map[string]string{ + "OPENAI_API_KEY": "token", + }, + McpServers: []McpServerConfig{{Name: "test-mcp"}}, + McpMode: "config", + } + + got := buildReconnectCreateInstanceRequest(req, "previous-exec") + if got.ID != "previous-exec" { + t.Fatalf("ID = %q, want previous-exec", got.ID) + } + if got.WorkspacePath != dockerWorkspacePath { + t.Fatalf("WorkspacePath = %q, want %q", got.WorkspacePath, dockerWorkspacePath) + } + if got.AgentType != "codex" { + t.Fatalf("AgentType = %q, want codex", got.AgentType) + } + if got.SessionID != "session-1" || got.TaskID != "task-1" { + t.Fatalf("task/session not propagated: task=%q session=%q", got.TaskID, got.SessionID) + } + if got.Env["OPENAI_API_KEY"] != "token" { + t.Fatalf("env not propagated: %v", got.Env) + } + if len(got.McpServers) != 1 || got.McpServers[0].Name != "test-mcp" { + t.Fatalf("mcp servers not propagated: %v", got.McpServers) + } + if !got.AssumeMcpSse { + t.Fatal("expected AssumeMcpSse to be propagated") + } + if got.McpMode != "config" { + t.Fatalf("McpMode = %q, want config", got.McpMode) + } +} + func TestResolvePrepareScript(t *testing.T) { log := newTestDockerLogger() - exec := NewDockerExecutor(config.DockerConfig{}, log) + exec := NewDockerExecutor(config.DockerConfig{}, "", log) t.Run("uses default docker script when no metadata", func(t *testing.T) { req := &ExecutorCreateRequest{ @@ -453,6 +562,7 @@ func TestResolvePrepareScript(t *testing.T) { "repository_path": "/tmp/repo", "base_branch": "main", "repository_clone_url": "https://github.com/org/repo.git", + "worktree_branch": "feature/task-abc", }, Env: map[string]string{"GH_TOKEN": "ghp_test"}, } @@ -464,10 +574,16 @@ func TestResolvePrepareScript(t *testing.T) { if !strings.Contains(script, "git clone") { t.Error("expected git clone in script") } + if !strings.Contains(script, "git config --global --add safe.directory '*'") { + t.Error("expected default Docker prepare script to trust mounted local repositories") + } // Should contain token stripping if !strings.Contains(script, "git remote set-url") { t.Error("expected token stripping after clone") } + if !strings.Contains(script, "git checkout -b \"feature/task-abc\"") { + t.Fatalf("expected Docker prepare script to create task branch, got:\n%s", script) + } }) t.Run("empty metadata uses defaults", func(t *testing.T) { @@ -482,3 +598,26 @@ func TestResolvePrepareScript(t *testing.T) { } }) } + +func TestLocalPathFromCloneURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "file URL", raw: "file:///tmp/e2e-docker-remote.git", want: "/tmp/e2e-docker-remote.git"}, + {name: "escaped file URL", raw: "file:///tmp/repo%20remote.git", want: "/tmp/repo remote.git"}, + {name: "localhost file URL", raw: "file://localhost/tmp/repo.git", want: "/tmp/repo.git"}, + {name: "plain absolute path", raw: "/tmp/repo.git", want: "/tmp/repo.git"}, + {name: "https URL", raw: "https://github.com/org/repo.git", want: ""}, + {name: "remote file host", raw: "file://server/tmp/repo.git", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := localPathFromCloneURL(tt.raw); got != tt.want { + t.Fatalf("localPathFromCloneURL(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites.go b/apps/backend/internal/agent/lifecycle/executor_sprites.go index d838c49b93..285e658edb 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites.go @@ -2,6 +2,7 @@ package lifecycle import ( "context" + "errors" "fmt" "os" "strings" @@ -17,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/secrets" + spritesutil "github.com/kandev/kandev/internal/sprites" ) type RemoteAuthAgentLister interface { @@ -36,11 +38,6 @@ func (u *spriteFileUploader) WriteFile(ctx context.Context, path string, data [] return u.runtime.writeFileWithRetry(ctx, u.sprite, path, data, mode) } -func (u *spriteFileUploader) RunCommand(ctx context.Context, name string, args ...string) error { - _, err := u.sprite.CommandContext(ctx, name, args...).Output() - return err -} - func (u *spriteFileUploader) RunCommandOutput(ctx context.Context, name string, args ...string) ([]byte, error) { return u.sprite.CommandContext(ctx, name, args...).Output() } @@ -57,7 +54,6 @@ const ( spriteHealthTimeout = 15 * time.Second spriteDestroyTimeout = 30 * time.Second spriteHealthRetryWait = 500 * time.Millisecond - spritesTotalSteps = 7 // create, upload, credentials, prepare script, health, create instance, network policy spriteOutputMaxLines = 20 spriteUploadMaxRetries = 3 ) @@ -126,10 +122,11 @@ func (r *SpritesExecutor) CreateInstance(ctx context.Context, req *ExecutorCreat r.tokens[req.InstanceID] = token r.mu.Unlock() - reconnect := req.PreviousExecutionID != "" + reconnect := spritesShouldReconnect(req) spriteName := r.resolveSpriteName(req, reconnect) client := sprites.New(token, sprites.WithDisableControl()) - report := newStepReporter(req.OnProgress) + progressPlan := newSpritesProgressPlan(reconnect) + report := newSpritesStepReporter(req.OnProgress, progressPlan) destroyOnFailure := !reconnect r.logger.Info("creating sprite instance", @@ -137,11 +134,21 @@ func (r *SpritesExecutor) CreateInstance(ctx context.Context, req *ExecutorCreat zap.String("sprite_name", spriteName), zap.Bool("reconnect_required", reconnect)) - // Step 0: Create or reconnect sprite + // Step 0: Create or reconnect sprite. On reconnect-then-not-found we fall + // through to fresh provisioning under a new name on the same branch. sprite, err := r.stepCreateSprite(ctx, client, spriteName, reconnect, report) if err != nil { - r.cleanupOnFailure(ctx, sprite, req.InstanceID, destroyOnFailure) - return nil, err + if reconnect && errors.Is(err, spritesutil.ErrSpriteNotFound) { + oldName := spriteName + spriteName = r.fallbackToFreshSandbox(req, progressPlan, report, oldName) + reconnect = false + destroyOnFailure = true + sprite, err = r.stepCreateSprite(ctx, client, spriteName, false, report) + } + if err != nil { + r.cleanupOnFailure(ctx, sprite, req.InstanceID, destroyOnFailure) + return nil, err + } } // Steps 1-3: Upload agentctl, credentials, prepare script @@ -164,7 +171,9 @@ func (r *SpritesExecutor) CreateInstance(ctx context.Context, req *ExecutorCreat } // Step 6: Network policy - r.stepApplyNetworkPolicy(ctx, client, spriteName, req, report) + if progressPlan.has(spriteStepApplyNetworkPolicy) { + r.stepApplyNetworkPolicy(ctx, client, spriteName, req, report) + } // Port forwarding to the per-instance server localPort, err := r.setupPortForwarding(ctx, sprite, spriteName, req.InstanceID, instancePort) @@ -197,20 +206,93 @@ func (r *SpritesExecutor) resolveSpriteName(req *ExecutorCreateRequest, reconnec return spritesNamePrefix + suffix } +// fallbackToFreshSandbox flips a stalled reconnect into a fresh provision. It +// emits a skipped "Reconnecting cloud sandbox" step carrying the user-facing +// warning that explains why we're reprovisioning, then mutates the live plan +// in place so the rest of CreateInstance streams the normal fresh-provision +// progress rows. Returns the new sprite name. The user-visible explanation is +// the only place we surface this transition. +func (r *SpritesExecutor) fallbackToFreshSandbox( + req *ExecutorCreateRequest, + plan *spritesProgressPlan, + report func(spritesStepKey, PrepareStep), + oldName string, +) string { + newName := spritesNamePrefix + req.InstanceID[:12] + branch := getMetadataString(req.Metadata, MetadataKeyWorktreeBranch) + if branch == "" { + branch = getMetadataString(req.Metadata, MetadataKeyBaseBranch) + } + + r.logger.Info("sprite missing on reconnect, provisioning fresh", + zap.String("instance_id", req.InstanceID), + zap.String("old_sprite_name", oldName), + zap.String("new_sprite_name", newName), + zap.String("branch", branch)) + + plan.replacePlan([]spritesStepKey{ + spriteStepCreateSprite, + spriteStepUploadAgentctl, + spriteStepUploadCredentials, + spriteStepRunPrepareScript, + spriteStepWaitHealthy, + spriteStepAgentInstance, + spriteStepApplyNetworkPolicy, + }) + + notice := beginStep("Reconnecting cloud sandbox") + notice.Warning = "Previous sandbox is no longer available — provisioning a fresh one for this branch." + notice.WarningDetail = fmt.Sprintf( + "The Sprites sandbox %s could not be reached (it was likely destroyed or expired). "+ + "Kandev is starting a fresh sandbox %s on the same branch %s; this typically takes 30–60 seconds.", + oldName, newName, branchOrPlaceholder(branch), + ) + notice.Output = fmt.Sprintf("Old sandbox: %s\nNew sandbox: %s\nBranch: %s", + oldName, newName, branchOrPlaceholder(branch)) + completeStepSkipped(¬ice) + report(spriteStepCreateSprite, notice) + + return newName +} + +func branchOrPlaceholder(branch string) string { + if branch == "" { + return "(unknown)" + } + return branch +} + +func spritesShouldReconnect(req *ExecutorCreateRequest) bool { + if req == nil { + return false + } + return req.PreviousExecutionID != "" || getMetadataString(req.Metadata, "sprite_name") != "" +} + +func spritesAgentInstanceID(req *ExecutorCreateRequest) string { + if req == nil { + return "" + } + if req.InstanceID != "" { + return req.InstanceID + } + return req.PreviousExecutionID +} + // stepCreateSprite handles step 0: create or reconnect a sprite. func (r *SpritesExecutor) stepCreateSprite( ctx context.Context, client *sprites.Client, name string, reconnect bool, - report func(PrepareStep, int), + report func(spritesStepKey, PrepareStep), ) (*sprites.Sprite, error) { stepName := "Creating cloud sandbox" if reconnect { stepName = "Reconnecting cloud sandbox" } step := beginStep(stepName) - report(step, 0) + report(spriteStepCreateSprite, step) var sprite *sprites.Sprite var err error @@ -221,11 +303,11 @@ func (r *SpritesExecutor) stepCreateSprite( } if err != nil { completeStepError(&step, err.Error()) - report(step, 0) + report(spriteStepCreateSprite, step) return sprite, err } completeStepSuccess(&step) - report(step, 0) + report(spriteStepCreateSprite, step) return sprite, nil } @@ -236,60 +318,51 @@ func (r *SpritesExecutor) stepSetupEnvironment( sprite *sprites.Sprite, req *ExecutorCreateRequest, reconnect bool, - report func(PrepareStep, int), + report func(spritesStepKey, PrepareStep), ) error { - // Step 1: Upload agentctl binary - step := beginStep("Uploading agent controller") - report(step, 1) if reconnect { - completeStepSkipped(&step) - } else { - if err := r.uploadAgentctl(ctx, sprite); err != nil { - completeStepError(&step, err.Error()) - report(step, 1) - return err - } - completeStepSuccess(&step) - } - report(step, 1) - - // Step 2: Upload remote credentials (SSH keys, gh CLI, agent auth) - step = beginStep("Uploading credentials") - report(step, 2) - if reconnect { - completeStepSkipped(&step) - } else { - if err := r.uploadCredentials(ctx, sprite, req, func(output string) { - step.Output = output - report(step, 2) - }); err != nil { - r.logger.Warn("failed to upload credentials (non-fatal)", zap.Error(err)) - completeStepSkipped(&step) - } else { - completeStepSuccess(&step) - } - } - report(step, 2) - - // Step 3: Run prepare script (with output streaming) - step = beginStep("Running prepare script") - report(step, 3) - if reconnect { - completeStepSkipped(&step) - report(step, 3) return nil } - err := r.runPrepareScript(ctx, sprite, req, func(output string) { - step.Output = output - report(step, 3) - }) - if err != nil { + + // Step 1: Upload agentctl binary + step := beginStep("Uploading agent controller") + report(spriteStepUploadAgentctl, step) + if err := r.uploadAgentctl(ctx, sprite); err != nil { completeStepError(&step, err.Error()) - report(step, 3) + report(spriteStepUploadAgentctl, step) return err } completeStepSuccess(&step) - report(step, 3) + report(spriteStepUploadAgentctl, step) + + // Step 2: Upload remote credentials (SSH keys, gh CLI, agent auth) + step = beginStep("Uploading credentials") + report(spriteStepUploadCredentials, step) + if err := r.uploadCredentials(ctx, sprite, req, func(output string) { + step.Output = output + report(spriteStepUploadCredentials, step) + }); err != nil { + r.logger.Warn("failed to upload credentials (non-fatal)", zap.Error(err)) + completeStepSkipped(&step) + } else { + completeStepSuccess(&step) + } + report(spriteStepUploadCredentials, step) + + // Step 3: Run prepare script (with output streaming) + step = beginStep("Running prepare script") + report(spriteStepRunPrepareScript, step) + err := r.runPrepareScript(ctx, sprite, req, func(output string) { + step.Output = output + report(spriteStepRunPrepareScript, step) + }) + if err != nil { + completeStepError(&step, err.Error()) + report(spriteStepRunPrepareScript, step) + return err + } + completeStepSuccess(&step) + report(spriteStepRunPrepareScript, step) return nil } @@ -297,17 +370,17 @@ func (r *SpritesExecutor) stepSetupEnvironment( func (r *SpritesExecutor) stepWaitHealthy( ctx context.Context, sprite *sprites.Sprite, - report func(PrepareStep, int), + report func(spritesStepKey, PrepareStep), ) error { step := beginStep("Waiting for agent controller") - report(step, 4) + report(spriteStepWaitHealthy, step) if err := r.waitForHealth(ctx, sprite); err != nil { completeStepError(&step, err.Error()) - report(step, 4) + report(spriteStepWaitHealthy, step) return err } completeStepSuccess(&step) - report(step, 4) + report(spriteStepWaitHealthy, step) return nil } @@ -318,21 +391,23 @@ func (r *SpritesExecutor) stepEnsureAgentInstance( sprite *sprites.Sprite, req *ExecutorCreateRequest, reconnect bool, - report func(PrepareStep, int), + report func(spritesStepKey, PrepareStep), ) (int, bool, error) { - step := beginStep("Creating agent instance") - report(step, 5) + step := beginStep("Starting agent session") + report(spriteStepAgentInstance, step) var instancePort int var reusingExisting bool if reconnect { - port, portErr := r.getExistingInstancePort(ctx, sprite, req.PreviousExecutionID) + instanceID := spritesAgentInstanceID(req) + port, portErr := r.getExistingInstancePort(ctx, sprite, instanceID) if portErr == nil && port > 0 && r.isAgentSubprocessRunning(ctx, sprite, port) { instancePort = port reusingExisting = true } else if portErr == nil && port > 0 { - r.logger.Info("existing instance found but agent subprocess not running, creating fresh instance", - zap.String("instance_id", req.PreviousExecutionID), + instancePort = port + r.logger.Info("existing instance found but agent subprocess not running", + zap.String("instance_id", instanceID), zap.Int("port", port)) } } @@ -340,13 +415,13 @@ func (r *SpritesExecutor) stepEnsureAgentInstance( port, err := r.createAgentInstance(ctx, sprite, req) if err != nil { completeStepError(&step, err.Error()) - report(step, 5) + report(spriteStepAgentInstance, step) return 0, false, err } instancePort = port } completeStepSuccess(&step) - report(step, 5) + report(spriteStepAgentInstance, step) return instancePort, reusingExisting, nil } @@ -356,17 +431,17 @@ func (r *SpritesExecutor) stepApplyNetworkPolicy( client *sprites.Client, spriteName string, req *ExecutorCreateRequest, - report func(PrepareStep, int), + report func(spritesStepKey, PrepareStep), ) { step := beginStep("Applying network policy") - report(step, 6) + report(spriteStepApplyNetworkPolicy, step) if err := r.applyNetworkPolicy(ctx, client, spriteName, req); err != nil { r.logger.Warn("failed to apply network policy from profile", zap.Error(err)) completeStepSkipped(&step) } else { completeStepSuccess(&step) } - report(step, 6) + report(spriteStepApplyNetworkPolicy, step) } // buildInstanceResult constructs the final ExecutorInstance from sprite creation results. diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites_clone_auth_test.go b/apps/backend/internal/agent/lifecycle/executor_sprites_clone_auth_test.go index 6d3905a7d8..cf2acf50e1 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites_clone_auth_test.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites_clone_auth_test.go @@ -38,10 +38,17 @@ func TestRewriteGitHubSSHToHTTPS(t *testing.T) { } func TestInjectTokenIntoURL_RewritesGitHubSSHWhenTokenExists(t *testing.T) { - exec := &SpritesExecutor{} env := map[string]string{"GITHUB_TOKEN": "test-token"} - got := exec.injectTokenIntoURL("git@github.com:kdlbs/agents-protocol-debug.git", env) - require.Equal(t, "https://test-token@github.com/kdlbs/agents-protocol-debug.git", got) + got := injectGitHubTokenIntoCloneURL("git@github.com:kdlbs/agents-protocol-debug.git", env) + require.Equal(t, "https://x-access-token:test-token@github.com/kdlbs/agents-protocol-debug.git", got) +} + +func TestInjectTokenIntoURL_HonoursGHTokenFallback(t *testing.T) { + // Sprites used to ignore GH_TOKEN; since unifying with Docker, both env + // var names are accepted (GITHUB_TOKEN wins when both are set). + env := map[string]string{"GH_TOKEN": "gh-token"} + got := injectGitHubTokenIntoCloneURL("https://github.com/org/repo.git", env) + require.Equal(t, "https://x-access-token:gh-token@github.com/org/repo.git", got) } func TestIsTransientUploadError(t *testing.T) { diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites_fallback_test.go b/apps/backend/internal/agent/lifecycle/executor_sprites_fallback_test.go new file mode 100644 index 0000000000..10ec044f45 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/executor_sprites_fallback_test.go @@ -0,0 +1,169 @@ +package lifecycle + +import ( + "strings" + "testing" +) + +func TestSpritesProgressPlanGrowsOnFallback(t *testing.T) { + plan := newSpritesProgressPlan(true) + if plan.total() != 3 { + t.Fatalf("reconnect plan total = %d, want 3", plan.total()) + } + + plan.replacePlan([]spritesStepKey{ + spriteStepCreateSprite, + spriteStepUploadAgentctl, + spriteStepUploadCredentials, + spriteStepRunPrepareScript, + spriteStepWaitHealthy, + spriteStepAgentInstance, + spriteStepApplyNetworkPolicy, + }) + + if plan.total() != 7 { + t.Fatalf("after fallback, plan total = %d, want 7", plan.total()) + } + for _, key := range []spritesStepKey{ + spriteStepUploadAgentctl, + spriteStepUploadCredentials, + spriteStepRunPrepareScript, + spriteStepApplyNetworkPolicy, + } { + if !plan.has(key) { + t.Fatalf("after fallback, plan should include %s", key) + } + } +} + +func TestSpritesStepReporterFollowsPlanReplacement(t *testing.T) { + plan := newSpritesProgressPlan(true) + type reportEvent struct { + index int + total int + } + var events []reportEvent + cb := func(_ PrepareStep, idx, total int) { + events = append(events, reportEvent{index: idx, total: total}) + } + report := newSpritesStepReporter(cb, plan) + + report(spriteStepWaitHealthy, beginStep("Waiting for agent controller")) + if len(events) != 1 || events[0].index != 1 || events[0].total != 3 { + t.Fatalf("pre-replace event = %+v, want index 1 of 3", events) + } + + plan.replacePlan([]spritesStepKey{ + spriteStepCreateSprite, + spriteStepUploadAgentctl, + spriteStepUploadCredentials, + spriteStepRunPrepareScript, + spriteStepWaitHealthy, + spriteStepAgentInstance, + spriteStepApplyNetworkPolicy, + }) + report(spriteStepWaitHealthy, beginStep("Waiting for agent controller")) + if len(events) != 2 || events[1].index != 4 || events[1].total != 7 { + t.Fatalf("post-replace event = %+v, want index 4 of 7", events) + } +} + +func TestSpritesFallbackToFreshSandboxEmitsWarningAndMutatesPlan(t *testing.T) { + r := newTestSpritesExecutor(nil) + plan := newSpritesProgressPlan(true) + + var emitted []PrepareStep + report := func(_ spritesStepKey, step PrepareStep) { + emitted = append(emitted, step) + } + + req := &ExecutorCreateRequest{ + InstanceID: "abcdef0123456789aaaa", + Metadata: map[string]interface{}{ + MetadataKeyWorktreeBranch: "feature/something", + "sprite_name": "kandev-old1234567890", + }, + } + + newName := r.fallbackToFreshSandbox(req, plan, report, "kandev-old1234567890") + + wantName := spritesNamePrefix + req.InstanceID[:12] + if newName != wantName { + t.Fatalf("new sprite name = %q, want %q", newName, wantName) + } + if plan.total() != 7 { + t.Fatalf("plan total after fallback = %d, want 7", plan.total()) + } + if len(emitted) != 1 { + t.Fatalf("expected exactly 1 progress event from fallback, got %d", len(emitted)) + } + + notice := emitted[0] + if notice.Status != PrepareStepSkipped { + t.Fatalf("notice status = %s, want skipped", notice.Status) + } + if !strings.Contains(notice.Warning, "Previous sandbox is no longer available") { + t.Fatalf("notice warning missing user-facing copy: %q", notice.Warning) + } + if !strings.Contains(notice.WarningDetail, "kandev-old1234567890") || + !strings.Contains(notice.WarningDetail, wantName) || + !strings.Contains(notice.WarningDetail, "feature/something") { + t.Fatalf("notice detail missing context: %q", notice.WarningDetail) + } + if !strings.Contains(notice.Output, "Old sandbox: kandev-old1234567890") || + !strings.Contains(notice.Output, "New sandbox: "+wantName) || + !strings.Contains(notice.Output, "Branch: feature/something") { + t.Fatalf("notice output missing structured block: %q", notice.Output) + } +} + +func TestSpritesFallbackNamesBaseBranchInWarningDetail(t *testing.T) { + r := newTestSpritesExecutor(nil) + plan := newSpritesProgressPlan(true) + var emitted []PrepareStep + + // After Fix 1 (preserve BaseBranch on resume for clone-based executors), + // the fallback request always carries MetadataKeyBaseBranch — this test + // locks in that the user-facing warning detail names the actual base + // branch rather than the "(unknown)" placeholder. + req := &ExecutorCreateRequest{ + InstanceID: "abcdef0123456789aaaa", + Metadata: map[string]interface{}{ + MetadataKeyBaseBranch: "main", + }, + } + r.fallbackToFreshSandbox(req, plan, func(_ spritesStepKey, step PrepareStep) { + emitted = append(emitted, step) + }, "kandev-old1234567890") + + if len(emitted) != 1 { + t.Fatalf("expected 1 event, got %d", len(emitted)) + } + if !strings.Contains(emitted[0].WarningDetail, "branch main") { + t.Fatalf("warning detail should name base branch, got %q", emitted[0].WarningDetail) + } + if !strings.Contains(emitted[0].Output, "Branch: main") { + t.Fatalf("output should name base branch, got %q", emitted[0].Output) + } +} + +func TestSpritesFallbackUsesPlaceholderBranchWhenMissing(t *testing.T) { + r := newTestSpritesExecutor(nil) + plan := newSpritesProgressPlan(true) + var emitted []PrepareStep + + req := &ExecutorCreateRequest{ + InstanceID: "abcdef0123456789aaaa", + Metadata: map[string]interface{}{}, + } + r.fallbackToFreshSandbox(req, plan, func(_ spritesStepKey, step PrepareStep) { + emitted = append(emitted, step) + }, "kandev-old1234567890") + + if len(emitted) != 1 { + t.Fatalf("expected 1 event, got %d", len(emitted)) + } + if !strings.Contains(emitted[0].Output, "Branch: (unknown)") { + t.Fatalf("expected placeholder branch in output, got %q", emitted[0].Output) + } +} diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites_helpers.go b/apps/backend/internal/agent/lifecycle/executor_sprites_helpers.go index 5f02244636..882daa6ed9 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites_helpers.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites_helpers.go @@ -16,18 +16,32 @@ import ( var uploadHTTPStatusRE = regexp.MustCompile(`(?i)\b(?:http|status)\s*:?\s*(\d{3})\b`) -func (r *SpritesExecutor) injectTokenIntoURL(remoteURL string, env map[string]string) string { +// injectGitHubTokenIntoCloneURL injects a GitHub token into a clone URL so +// in-container `git clone` works without prompting. Honours both +// GITHUB_TOKEN and GH_TOKEN (gh CLI uses GH_TOKEN; Actions/most workflows +// use GITHUB_TOKEN — accepting either keeps callers from a 401 because they +// only set one). GITHUB_TOKEN wins when both are present, matching the +// original Docker behaviour. Uses the documented +// `https://x-access-token:T@github.com/` form, which works for both clone +// and gh CLI authentication. +// +// Used by both Docker and Sprites executors. SSH→HTTPS rewrite is delegated +// to rewriteGitHubSSHToHTTPS so the two surfaces never drift again. +func injectGitHubTokenIntoCloneURL(cloneURL string, env map[string]string) string { token := env["GITHUB_TOKEN"] if token == "" { - return remoteURL + token = env["GH_TOKEN"] } - if converted := rewriteGitHubSSHToHTTPS(remoteURL); converted != "" { - remoteURL = converted + if token == "" { + return cloneURL } - if strings.HasPrefix(remoteURL, "https://") { - return strings.Replace(remoteURL, "https://", "https://"+token+"@", 1) + if converted := rewriteGitHubSSHToHTTPS(cloneURL); converted != "" { + cloneURL = converted } - return remoteURL + if strings.HasPrefix(cloneURL, "https://github.com/") { + return strings.Replace(cloneURL, "https://github.com/", "https://x-access-token:"+token+"@github.com/", 1) + } + return cloneURL } func rewriteGitHubSSHToHTTPS(remoteURL string) string { @@ -119,12 +133,108 @@ func (r *SpritesExecutor) buildSpriteEnv(env map[string]string) []string { return result } -// newStepReporter creates a reporting function that calls OnProgress if non-nil. -func newStepReporter(onProgress PrepareProgressCallback) func(PrepareStep, int) { - return func(step PrepareStep, idx int) { - if onProgress != nil { - onProgress(step, idx, spritesTotalSteps) +type spritesStepKey string + +const ( + spriteStepCreateSprite spritesStepKey = "create_sprite" + spriteStepUploadAgentctl spritesStepKey = "upload_agentctl" + spriteStepUploadCredentials spritesStepKey = "upload_credentials" + spriteStepRunPrepareScript spritesStepKey = "run_prepare_script" + spriteStepWaitHealthy spritesStepKey = "wait_healthy" + spriteStepAgentInstance spritesStepKey = "agent_instance" + spriteStepApplyNetworkPolicy spritesStepKey = "apply_network_policy" +) + +// spritesProgressPlan is mutable so the reconnect path can swap to the fresh +// plan mid-flight when a stale sandbox falls back to provisioning a new one. +// The reporter created by newSpritesStepReporter resolves indexes lazily off +// the live plan, so callers don't need to re-create it after replacePlan. +type spritesProgressPlan struct { + steps []spritesStepKey + indexes map[spritesStepKey]int +} + +func newSpritesProgressPlan(reconnect bool) *spritesProgressPlan { + if reconnect { + return buildPlan([]spritesStepKey{ + spriteStepCreateSprite, + spriteStepWaitHealthy, + spriteStepAgentInstance, + }) + } + return buildPlan([]spritesStepKey{ + spriteStepCreateSprite, + spriteStepUploadAgentctl, + spriteStepUploadCredentials, + spriteStepRunPrepareScript, + spriteStepWaitHealthy, + spriteStepAgentInstance, + spriteStepApplyNetworkPolicy, + }) +} + +func buildPlan(steps []spritesStepKey) *spritesProgressPlan { + indexes := make(map[spritesStepKey]int, len(steps)) + for i, key := range steps { + indexes[key] = i + } + return &spritesProgressPlan{steps: steps, indexes: indexes} +} + +func (p *spritesProgressPlan) total() int { + if p == nil { + return 0 + } + return len(p.steps) +} + +func (p *spritesProgressPlan) has(key spritesStepKey) bool { + if p == nil { + return false + } + _, ok := p.indexes[key] + return ok +} + +func (p *spritesProgressPlan) index(key spritesStepKey) int { + if p == nil { + return -1 + } + idx, ok := p.indexes[key] + if !ok { + return -1 + } + return idx +} + +// replacePlan swaps the active plan in place (used by the reconnect-fell-back- +// to-fresh path). The reporter dispatches off the live indexes, so existing +// closures keep working after replacement. +func (p *spritesProgressPlan) replacePlan(steps []spritesStepKey) { + if p == nil { + return + } + indexes := make(map[spritesStepKey]int, len(steps)) + for i, key := range steps { + indexes[key] = i + } + p.steps = steps + p.indexes = indexes +} + +// newSpritesStepReporter creates a reporting function that calls OnProgress if +// non-nil. Index/total are resolved against the live plan on each call so +// replacePlan is reflected in subsequent reports. +func newSpritesStepReporter(onProgress PrepareProgressCallback, plan *spritesProgressPlan) func(spritesStepKey, PrepareStep) { + return func(key spritesStepKey, step PrepareStep) { + if onProgress == nil { + return } + idx := plan.index(key) + if idx < 0 { + return + } + onProgress(step, idx, plan.total()) } } diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites_lifecycle.go b/apps/backend/internal/agent/lifecycle/executor_sprites_lifecycle.go index d7807f9b45..28223a3e10 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites_lifecycle.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites_lifecycle.go @@ -18,7 +18,7 @@ func (r *SpritesExecutor) reconnectSprite(ctx context.Context, client *sprites.C defer cancel() sprite, err := client.GetSprite(stepCtx, name) if err != nil { - return nil, fmt.Errorf("failed to reconnect sprite %q: %w", name, err) + return nil, fmt.Errorf("failed to reconnect sprite %q: %w", name, spritesutil.WrapNotFound(err)) } return sprite, nil } @@ -32,6 +32,9 @@ func (r *SpritesExecutor) StopInstance(ctx context.Context, instance *ExecutorIn return nil } + // Always tear down the local proxy session — it's tied to the agentctl + // instance we just stopped and would point at a stale TCP connection + // after the agent process exits. r.mu.Lock() if proxy, ok := r.proxies[instance.InstanceID]; ok { r.closeProxySession(proxy) @@ -39,6 +42,19 @@ func (r *SpritesExecutor) StopInstance(ctx context.Context, instance *ExecutorIn } r.mu.Unlock() + // Plain "stop the agent" runs (e.g. user clicks Stop, then later wants to + // resume) must NOT destroy the cloud sandbox: the user's working tree, + // installed deps, and any in-progress files live there. Only destroy the + // sandbox for explicit terminal lifecycle events (task/session deleted or + // archived). Resume then re-attaches the same sandbox in seconds. + if !shouldRunExecutorCleanup(instance.StopReason) { + r.logger.Info("preserving sprite sandbox after agent stop", + zap.String("sprite_name", spriteName), + zap.String("instance_id", instance.InstanceID), + zap.String("stop_reason", instance.StopReason)) + return nil + } + r.mu.RLock() token := r.tokens[instance.InstanceID] r.mu.RUnlock() @@ -64,7 +80,8 @@ func (r *SpritesExecutor) StopInstance(ctx context.Context, instance *ExecutorIn delete(r.tokens, instance.InstanceID) r.mu.Unlock() - r.logger.Info("sprite destroyed", zap.String("sprite_name", spriteName)) + r.logger.Info("sprite destroyed", zap.String("sprite_name", spriteName), + zap.String("stop_reason", instance.StopReason)) return nil } @@ -88,7 +105,7 @@ func (r *SpritesExecutor) runTerminalCleanupScript(ctx context.Context, sprite * instance.Metadata, nil, getGitRemoteURL, - r.injectTokenIntoURL, + injectGitHubTokenIntoCloneURL, )) resolved := resolver.Resolve(script) if strings.TrimSpace(resolved) == "" { @@ -112,9 +129,20 @@ func (r *SpritesExecutor) runTerminalCleanupScript(ctx context.Context, sprite * zap.String("reason", instance.StopReason)) } +// Terminal stop reasons that trigger destructive executor cleanup +// (sandbox teardown, container removal, per-instance session-dir removal). +// Anything outside this set is treated as a "preserve" stop — see +// shouldRunExecutorCleanup. +const ( + StopReasonTaskArchived = "task archived" + StopReasonTaskDeleted = "task deleted" + StopReasonSessionArchived = "session archived" + StopReasonSessionDeleted = "session deleted" +) + func shouldRunExecutorCleanup(reason string) bool { switch strings.ToLower(strings.TrimSpace(reason)) { - case "task archived", "task deleted", "session archived", "session deleted": + case StopReasonTaskArchived, StopReasonTaskDeleted, StopReasonSessionArchived, StopReasonSessionDeleted: return true default: return false diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites_lifecycle_test.go b/apps/backend/internal/agent/lifecycle/executor_sprites_lifecycle_test.go index 77ebeb5964..2ce448ee8a 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites_lifecycle_test.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites_lifecycle_test.go @@ -42,6 +42,156 @@ func newTestSpritesExecutor(store secrets.SecretStore) *SpritesExecutor { } } +func TestSpritesReconnectSetupEnvironmentDoesNotEmitSkippedSetupSteps(t *testing.T) { + r := newTestSpritesExecutor(nil) + var reported []PrepareStep + + err := r.stepSetupEnvironment(context.Background(), nil, &ExecutorCreateRequest{}, true, func(_ spritesStepKey, step PrepareStep) { + reported = append(reported, step) + }) + + if err != nil { + t.Fatalf("stepSetupEnvironment returned error: %v", err) + } + if len(reported) != 0 { + t.Fatalf("expected reconnect setup to emit no upload/credentials/prepare steps, got %d: %#v", len(reported), reported) + } +} + +func TestSpritesAgentInstanceIDPrefersCurrentLaunchInstance(t *testing.T) { + req := &ExecutorCreateRequest{ + InstanceID: "current-session-exec", + PreviousExecutionID: "previous-session-exec", + } + + if got := spritesAgentInstanceID(req); got != "current-session-exec" { + t.Fatalf("spritesAgentInstanceID() = %q, want current session instance", got) + } +} + +func TestSpritesShouldReconnectWhenSpriteNameMetadataExists(t *testing.T) { + req := &ExecutorCreateRequest{ + InstanceID: "current-session-exec", + Metadata: map[string]interface{}{ + "sprite_name": "kandev-existing-sprite", + }, + } + + if !spritesShouldReconnect(req) { + t.Fatal("expected sprite_name metadata to trigger reconnect") + } +} + +// TestSpritesStopInstancePreservesSandboxOnSessionStop locks in the resume-fast +// invariant: a plain agent stop must not destroy the upstream sandbox. +// Otherwise the next resume always falls into the missing-sandbox fallback, +// re-runs prepare, and loses the working tree the user had inside the sandbox. +func TestSpritesStopInstancePreservesSandboxOnSessionStop(t *testing.T) { + r := newTestSpritesExecutor(nil) + r.tokens["inst-1"] = "tok" + + preserveReasons := []string{ + "", + "stopped via API", + "agent crashed", + "user requested", + } + + for _, reason := range preserveReasons { + t.Run(reason, func(t *testing.T) { + err := r.StopInstance(context.Background(), &ExecutorInstance{ + InstanceID: "inst-1", + Metadata: map[string]interface{}{"sprite_name": "kandev-abc"}, + StopReason: reason, + }, false) + if err != nil { + t.Fatalf("StopInstance: %v", err) + } + // Token cache is NOT cleared on a preserving stop; we'll need it + // to talk to the same sandbox on resume. + r.mu.RLock() + tok := r.tokens["inst-1"] + r.mu.RUnlock() + if tok != "tok" { + t.Fatalf("token cache cleared on preserving stop (reason=%q)", reason) + } + }) + } +} + +// TestSpritesStopThenResumeReconnects proves the end-to-end integration of +// the resume-fast path: a preserving StopInstance leaves enough state on the +// instance metadata for the next launch to (a) decide it's a reconnect and +// (b) build the 3-step reconnect plan instead of the full 7-step bootstrap. +// The pieces are unit-tested individually elsewhere; this test guards +// against the wiring drifting (e.g. StopInstance accidentally stripping +// sprite_name, or the reconnect predicate forgetting to look at metadata). +func TestSpritesStopThenResumeReconnects(t *testing.T) { + r := newTestSpritesExecutor(nil) + + stopped := &ExecutorInstance{ + InstanceID: "inst-stop-resume", + Metadata: map[string]interface{}{"sprite_name": "kandev-keep-me"}, + StopReason: "stopped via API", + } + if err := r.StopInstance(context.Background(), stopped, false); err != nil { + t.Fatalf("StopInstance: %v", err) + } + + // Mirror what executor_resume.go does: it carries forward the previous + // execution's sprite_name into the next launch request's Metadata. If + // StopInstance had cleared sprite_name, this map would be empty. + resume := &ExecutorCreateRequest{ + Metadata: map[string]interface{}{"sprite_name": stopped.Metadata["sprite_name"]}, + PreviousExecutionID: "previous-exec", + } + + if !spritesShouldReconnect(resume) { + t.Fatal("resume after preserving stop must be detected as a reconnect") + } + + plan := newSpritesProgressPlan(spritesShouldReconnect(resume)) + if plan.total() != 3 { + t.Fatalf("reconnect plan total = %d, want 3 (got: %v)", plan.total(), plan.steps) + } +} + +func TestSpritesProgressPlanReconnectOmitsSetupAndNetworkSteps(t *testing.T) { + plan := newSpritesProgressPlan(true) + + if plan.total() != 3 { + t.Fatalf("reconnect runtime plan has %d steps, want 3", plan.total()) + } + for _, key := range []spritesStepKey{ + spriteStepUploadAgentctl, + spriteStepUploadCredentials, + spriteStepRunPrepareScript, + spriteStepApplyNetworkPolicy, + } { + if plan.has(key) { + t.Fatalf("reconnect runtime plan unexpectedly includes %s", key) + } + } + // Assert ordering rather than absolute indexes — the plan can grow new + // steps without invalidating the contract this test guards (create → + // wait healthy → agent instance must run in that order). + for _, key := range []spritesStepKey{spriteStepCreateSprite, spriteStepWaitHealthy, spriteStepAgentInstance} { + if !plan.has(key) { + t.Fatalf("reconnect plan missing required step %s", key) + } + } + assertPlanOrder(t, plan, spriteStepCreateSprite, spriteStepWaitHealthy) + assertPlanOrder(t, plan, spriteStepWaitHealthy, spriteStepAgentInstance) +} + +func assertPlanOrder(t *testing.T, plan *spritesProgressPlan, before, after spritesStepKey) { + t.Helper() + if plan.index(before) >= plan.index(after) { + t.Fatalf("expected %s to run before %s, got indexes %d / %d", + before, after, plan.index(before), plan.index(after)) + } +} + func TestResolveTokenFromMetadata(t *testing.T) { t.Run("nil secret store returns empty", func(t *testing.T) { r := &SpritesExecutor{ diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites_operations.go b/apps/backend/internal/agent/lifecycle/executor_sprites_operations.go index f8a0315c49..614d31bd77 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites_operations.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites_operations.go @@ -134,6 +134,12 @@ func (r *SpritesExecutor) runPrepareScript( } // resolvePrepareScript builds the resolved prepare script using scriptengine. +// +// The kandev-managed feature-branch checkout is appended as an invariant +// postlude after the user's script — see executor_docker.go for the same +// pattern. Profiles created in the UI snapshot the default at create time, +// so without an unconditional postlude older Sprites profiles would still +// commit straight onto main. func (r *SpritesExecutor) resolvePrepareScript(req *ExecutorCreateRequest) string { script := getMetadataString(req.Metadata, MetadataKeySetupScript) if script == "" { @@ -142,6 +148,7 @@ func (r *SpritesExecutor) resolvePrepareScript(req *ExecutorCreateRequest) strin if script == "" { return "" } + script += KandevBranchCheckoutPostlude() installScripts := r.collectAgentInstallScripts(req) @@ -151,11 +158,18 @@ func (r *SpritesExecutor) resolvePrepareScript(req *ExecutorCreateRequest) strin WithProvider(scriptengine.GitIdentityProvider(req.Metadata)). WithProvider(scriptengine.GitHubAuthProvider(req.Env)). WithProvider(scriptengine.AgentInstallProvider(installScripts)). + WithProvider(scriptengine.WorktreeProvider( + "", + spritesWorkspacePath, + getMetadataString(req.Metadata, MetadataKeyWorktreeID), + getMetadataString(req.Metadata, MetadataKeyWorktreeBranch), + getMetadataString(req.Metadata, MetadataKeyBaseBranch), + )). WithProvider(scriptengine.RepositoryProvider( req.Metadata, req.Env, getGitRemoteURL, - r.injectTokenIntoURL, + injectGitHubTokenIntoCloneURL, )) return resolver.Resolve(script) diff --git a/apps/backend/internal/agent/lifecycle/manager_auth_test.go b/apps/backend/internal/agent/lifecycle/manager_auth_test.go index 3a6ffdf0d0..cd9b60afb1 100644 --- a/apps/backend/internal/agent/lifecycle/manager_auth_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_auth_test.go @@ -143,3 +143,34 @@ func TestPersistAuthToken(t *testing.T) { } }) } + +func TestPersistRuntimeSecrets(t *testing.T) { + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + store := newInMemorySecretStore() + m := &Manager{logger: log, secretStore: store} + + instance := &ExecutorInstance{ + InstanceID: "exec-123456789012", + AuthToken: "agentctl-token", + BootstrapNonce: "bootstrap-nonce", + } + execution := &AgentExecution{Metadata: make(map[string]interface{})} + + m.persistRuntimeSecrets(context.Background(), instance, execution) + + authSecretID, ok := execution.Metadata[MetadataKeyAuthTokenSecret].(string) + if !ok || authSecretID == "" { + t.Fatalf("expected auth token secret ID, got %v", execution.Metadata[MetadataKeyAuthTokenSecret]) + } + nonceSecretID, ok := execution.Metadata[MetadataKeyBootstrapNonceSecret].(string) + if !ok || nonceSecretID == "" { + t.Fatalf("expected bootstrap nonce secret ID, got %v", execution.Metadata[MetadataKeyBootstrapNonceSecret]) + } + + if got := m.revealRuntimeSecret(context.Background(), execution.Metadata, MetadataKeyAuthTokenSecret); got != "agentctl-token" { + t.Fatalf("revealed auth token = %q, want agentctl-token", got) + } + if got := m.revealRuntimeSecret(context.Background(), execution.Metadata, MetadataKeyBootstrapNonceSecret); got != "bootstrap-nonce" { + t.Fatalf("revealed bootstrap nonce = %q, want bootstrap-nonce", got) + } +} diff --git a/apps/backend/internal/agent/lifecycle/manager_environment.go b/apps/backend/internal/agent/lifecycle/manager_environment.go new file mode 100644 index 0000000000..3fd8e3a8fb --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_environment.go @@ -0,0 +1,124 @@ +package lifecycle + +import ( + "context" + "fmt" + "time" + + "github.com/kandev/kandev/internal/agent/executor" +) + +// ContainerLiveStatus is a runtime snapshot of a Docker container's state, +// suitable for surfacing in the Executor Settings popover. +type ContainerLiveStatus struct { + ContainerID string `json:"container_id"` + State string `json:"state"` // running, exited, paused, restarting, removing, dead, created + Status string `json:"status"` // human-readable, e.g. "Up 5 minutes" + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + Health string `json:"health,omitempty"` + Missing bool `json:"missing,omitempty"` // true when the container no longer exists +} + +// DestroyContainer removes a Docker container (forcefully, along with its filesystem). +// Used by Reset Environment to tear down the container layer without touching worktrees +// or sprite sandboxes. +func (m *Manager) DestroyContainer(ctx context.Context, containerID string) error { + if containerID == "" { + return nil + } + backend, err := m.executorRegistry.GetBackend(executor.NameDocker) + if err != nil { + return fmt.Errorf("docker backend unavailable: %w", err) + } + dockerExec, ok := backend.(*DockerExecutor) + if !ok { + return fmt.Errorf("docker backend has unexpected type %T", backend) + } + // Force lazy initialization here. After a backend restart this method + // can be the first caller to touch Docker, and a missing daemon should + // surface as a clear "docker unavailable" error rather than a generic + // "container manager not initialized". + if _, _, err := dockerExec.ensureClient(); err != nil { + return fmt.Errorf("initialize docker backend: %w", err) + } + cm := dockerExec.ContainerMgr() + if cm == nil { + return fmt.Errorf("docker container manager not initialized") + } + return cm.RemoveContainer(ctx, containerID, true) +} + +// GetContainerLiveStatus inspects a Docker container and returns a live +// snapshot. Returns Missing=true when the container has been removed. +func (m *Manager) GetContainerLiveStatus(ctx context.Context, containerID string) (*ContainerLiveStatus, error) { + if containerID == "" { + return nil, nil + } + backend, err := m.executorRegistry.GetBackend(executor.NameDocker) + if err != nil { + return nil, fmt.Errorf("docker backend unavailable: %w", err) + } + dockerExec, ok := backend.(*DockerExecutor) + if !ok { + return nil, fmt.Errorf("docker backend has unexpected type %T", backend) + } + if _, _, err := dockerExec.ensureClient(); err != nil { + return nil, fmt.Errorf("initialize docker backend: %w", err) + } + cm := dockerExec.ContainerMgr() + if cm == nil { + return nil, fmt.Errorf("docker container manager not initialized") + } + info, err := cm.GetContainerInfo(ctx, containerID) + if err != nil { + // Treat any inspect failure as "missing" so the UI can surface it + // without bubbling a 500 to the popover. + return &ContainerLiveStatus{ContainerID: containerID, State: "missing", Missing: true}, nil + } + out := &ContainerLiveStatus{ + ContainerID: containerID, + State: info.State, + Status: info.Status, + ExitCode: info.ExitCode, + Health: info.Health, + } + if !info.StartedAt.IsZero() { + t := info.StartedAt + out.StartedAt = &t + } + if !info.FinishedAt.IsZero() { + t := info.FinishedAt + out.FinishedAt = &t + } + return out, nil +} + +// DestroySandbox destroys a Sprites sandbox by name. The executionID is used to +// resolve a cached API token when one exists; if no token is cached, the sprite's +// metadata-resolver fallback kicks in. +func (m *Manager) DestroySandbox(ctx context.Context, sandboxID, executionID string) error { + if sandboxID == "" { + return nil + } + backend, err := m.executorRegistry.GetBackend(executor.NameSprites) + if err != nil { + return fmt.Errorf("sprites backend unavailable: %w", err) + } + spritesExec, ok := backend.(*SpritesExecutor) + if !ok { + return fmt.Errorf("sprites backend has unexpected type %T", backend) + } + // StopInstance only runs sandbox cleanup when StopReason matches a + // terminal reason (see shouldRunExecutorCleanup). DestroySandbox IS the + // destruction path, so set StopReasonTaskDeleted explicitly — the + // empty StopReason this previously sent was silently treated as a + // preserve-on-stop signal and the sandbox was never destroyed. + instance := &ExecutorInstance{ + InstanceID: executionID, + Metadata: map[string]interface{}{"sprite_name": sandboxID}, + StopReason: StopReasonTaskDeleted, + } + return spritesExec.StopInstance(ctx, instance, true) +} diff --git a/apps/backend/internal/agent/lifecycle/manager_execution.go b/apps/backend/internal/agent/lifecycle/manager_execution.go index c3c856f4bf..9117533e2b 100644 --- a/apps/backend/internal/agent/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/lifecycle/manager_execution.go @@ -169,6 +169,17 @@ func (m *Manager) ensureWorkspaceExecutionLocked(ctx context.Context, taskID, se taskID = info.TaskID } + if info.TaskEnvironmentID != "" { + if execution, exists := m.executionStore.GetByTaskEnvironmentID(info.TaskEnvironmentID); exists { + m.logger.Info("reusing existing execution for task environment", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("task_environment_id", info.TaskEnvironmentID), + zap.String("execution_id", execution.ID)) + return execution, nil + } + } + if info.WorkspacePath == "" { return nil, fmt.Errorf("%w: session %s has no workspace path yet", ErrSessionWorkspaceNotReady, sessionID) } @@ -391,6 +402,8 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work AgentConfig: agentConfig, Metadata: info.Metadata, PreviousExecutionID: info.AgentExecutionID, + AuthToken: m.revealRuntimeSecret(ctx, info.Metadata, MetadataKeyAuthTokenSecret), + BootstrapNonce: m.revealRuntimeSecret(ctx, info.Metadata, MetadataKeyBootstrapNonceSecret), } runtimeInstance, err := rt.CreateInstance(ctx, req) @@ -437,7 +450,7 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work // Persist agentctl auth token only after the execution is tracked, so a // race-lost rollback never leaves an orphaned secret in the store. - m.persistAuthToken(ctx, runtimeInstance, execution) + m.persistRuntimeSecrets(ctx, runtimeInstance, execution) go m.pollOneRemoteStatus(context.Background(), execution) // Publish Starting BEFORE spawning waitForAgentctlReady so subscribers @@ -477,25 +490,52 @@ func (m *Manager) rollbackRacedExecution(ctx context.Context, rt ExecutorBackend execution.EndSessionSpan() } -// MetadataKeyAuthTokenSecret is the metadata key for the encrypted agentctl auth token secret ID. -const MetadataKeyAuthTokenSecret = "env_secret_id_AGENTCTL_AUTH_TOKEN" +const ( + // MetadataKeyAuthTokenSecret is the metadata key for the encrypted agentctl auth token secret ID. + MetadataKeyAuthTokenSecret = "env_secret_id_AGENTCTL_AUTH_TOKEN" + // MetadataKeyBootstrapNonceSecret stores the encrypted Docker bootstrap nonce. + // It lets the backend re-handshake after a container restart starts a new + // agentctl process with a fresh auth token. + MetadataKeyBootstrapNonceSecret = "env_secret_id_AGENTCTL_BOOTSTRAP_NONCE" +) + +func (m *Manager) persistRuntimeSecrets(ctx context.Context, instance *ExecutorInstance, execution *AgentExecution) { + m.persistAuthToken(ctx, instance, execution) + m.persistBootstrapNonce(ctx, instance, execution) +} // persistAuthToken stores the agentctl handshake auth token in SecretStore // and saves the secret ID in the execution's metadata for recovery after restart. func (m *Manager) persistAuthToken(ctx context.Context, instance *ExecutorInstance, execution *AgentExecution) { - if instance.AuthToken == "" || m.secretStore == nil { + m.persistRuntimeSecret(ctx, instance, execution, MetadataKeyAuthTokenSecret, "agentctl-auth", instance.AuthToken) +} + +func (m *Manager) persistBootstrapNonce(ctx context.Context, instance *ExecutorInstance, execution *AgentExecution) { + m.persistRuntimeSecret(ctx, instance, execution, MetadataKeyBootstrapNonceSecret, "agentctl-bootstrap", instance.BootstrapNonce) +} + +func (m *Manager) persistRuntimeSecret( + ctx context.Context, + instance *ExecutorInstance, + execution *AgentExecution, + metadataKey string, + secretNamePrefix string, + value string, +) { + if value == "" || m.secretStore == nil { return } secret := &secrets.SecretWithValue{ Secret: secrets.Secret{ - Name: fmt.Sprintf("agentctl-auth-%s", truncateID(instance.InstanceID, 12)), + Name: fmt.Sprintf("%s-%s", secretNamePrefix, truncateID(instance.InstanceID, 12)), }, - Value: instance.AuthToken, + Value: value, } if err := m.secretStore.Create(ctx, secret); err != nil { - m.logger.Error("failed to persist agentctl auth token", + m.logger.Error("failed to persist runtime secret", zap.String("instance_id", instance.InstanceID), + zap.String("metadata_key", metadataKey), zap.Error(err)) return } @@ -503,13 +543,33 @@ func (m *Manager) persistAuthToken(ctx context.Context, instance *ExecutorInstan if execution.Metadata == nil { execution.Metadata = make(map[string]interface{}) } - execution.Metadata[MetadataKeyAuthTokenSecret] = secret.ID + execution.Metadata[metadataKey] = secret.ID - m.logger.Debug("persisted agentctl auth token in secret store", + m.logger.Debug("persisted runtime secret in secret store", zap.String("instance_id", instance.InstanceID), + zap.String("metadata_key", metadataKey), zap.String("secret_id", secret.ID)) } +func (m *Manager) revealRuntimeSecret(ctx context.Context, metadata map[string]interface{}, metadataKey string) string { + if m.secretStore == nil { + return "" + } + secretID := getMetadataString(metadata, metadataKey) + if secretID == "" { + return "" + } + value, err := m.secretStore.Reveal(ctx, secretID) + if err != nil { + m.logger.Warn("failed to reveal runtime secret", + zap.String("metadata_key", metadataKey), + zap.String("secret_id", secretID), + zap.Error(err)) + return "" + } + return value +} + // truncateID safely truncates an ID string to maxLen characters. func truncateID(id string, maxLen int) string { if len(id) <= maxLen { diff --git a/apps/backend/internal/agent/lifecycle/manager_execution_test.go b/apps/backend/internal/agent/lifecycle/manager_execution_test.go index 35763d7da0..8641cfb7ca 100644 --- a/apps/backend/internal/agent/lifecycle/manager_execution_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_execution_test.go @@ -544,14 +544,63 @@ func TestEnsureWorkspaceExecutionForSession_EmptyTaskID(t *testing.T) { }) } +func TestEnsureWorkspaceExecutionForSession_ReusesExistingTaskEnvironmentExecution(t *testing.T) { + mgr, backend := newEnvironmentExecutionTestManager(t, &mockWorkspaceInfoProvider{ + infos: map[string]*WorkspaceInfo{ + "session-1": { + TaskID: "task-1", + SessionID: "session-1", + TaskEnvironmentID: "env-1", + WorkspacePath: "/workspace/task-1", + AgentID: "auggie", + }, + "session-2": { + TaskID: "task-1", + SessionID: "session-2", + TaskEnvironmentID: "env-1", + WorkspacePath: "/workspace/task-1", + AgentID: "auggie", + }, + }, + }) + existing := &AgentExecution{ + ID: "exec-existing", + SessionID: "session-1", + TaskID: "task-1", + TaskEnvironmentID: "env-1", + Status: v1.AgentStatusRunning, + agentctl: newReadyAgentctlClient(t, newTestLogger()), + } + if err := mgr.executionStore.Add(existing); err != nil { + t.Fatalf("add existing execution: %v", err) + } + + got, err := mgr.EnsureWorkspaceExecutionForSession(context.Background(), "task-1", "session-2") + if err != nil { + t.Fatalf("EnsureWorkspaceExecutionForSession returned error: %v", err) + } + if got.ID != existing.ID { + t.Fatalf("execution ID = %q, want existing environment execution %q", got.ID, existing.ID) + } + if got.SessionID != "session-1" { + t.Fatalf("execution session ID = %q, want original owner session", got.SessionID) + } + if backend.createCount.Load() != 0 { + t.Fatalf("CreateInstance calls = %d, want 0", backend.createCount.Load()) + } +} + // --- test helpers --- type createInstanceExecutor struct { MockExecutor - client *agentctl.Client - createCount atomic.Int32 - stopCount atomic.Int32 - delay time.Duration + client *agentctl.Client + createCount atomic.Int32 + stopCount atomic.Int32 + authToken string + nonce string + delay time.Duration + progressStep string // Barrier-based deterministic synchronization for race tests. // Set entered (buffered 1) to receive a signal when CreateInstance begins. // Set barrier (unbuffered, closed to release) to block until the test is ready. @@ -560,6 +609,12 @@ type createInstanceExecutor struct { } func (e *createInstanceExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { + var progress *PrepareStep + if e.progressStep != "" && req.OnProgress != nil { + step := beginStep(e.progressStep) + progress = &step + reportProgress(req.OnProgress, step, 0, 1) + } if e.entered != nil { select { case e.entered <- struct{}{}: @@ -580,13 +635,19 @@ func (e *createInstanceExecutor) CreateInstance(ctx context.Context, req *Execut } } e.createCount.Add(1) + if progress != nil { + completeStepSuccess(progress) + reportProgress(req.OnProgress, *progress, 0, 1) + } return &ExecutorInstance{ - InstanceID: req.InstanceID, - TaskID: req.TaskID, - SessionID: req.SessionID, - RuntimeName: string(e.Name()), - Client: e.client, - WorkspacePath: req.WorkspacePath, + InstanceID: req.InstanceID, + TaskID: req.TaskID, + SessionID: req.SessionID, + RuntimeName: string(e.Name()), + Client: e.client, + WorkspacePath: req.WorkspacePath, + AuthToken: e.authToken, + BootstrapNonce: e.nonce, }, nil } diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index 9f7971a262..c3efa2ca5b 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" "github.com/google/uuid" @@ -300,9 +301,66 @@ func (m *Manager) newProgressCallback(taskID, sessionID string) PrepareProgressC } } +type prepareProgressRecorder struct { + mu sync.Mutex + steps []PrepareStep + callback PrepareProgressCallback +} + +func newPrepareProgressRecorder(callback PrepareProgressCallback) *prepareProgressRecorder { + return &prepareProgressRecorder{callback: callback} +} + +func (r *prepareProgressRecorder) Callback(offset int) PrepareProgressCallback { + return func(step PrepareStep, stepIndex int, totalSteps int) { + absoluteIndex := stepIndex + offset + r.recordStep(step, absoluteIndex) + if r.callback != nil { + r.callback(step, absoluteIndex, totalSteps+offset) + } + } +} + +func (r *prepareProgressRecorder) Merge(steps []PrepareStep) { + r.mu.Lock() + defer r.mu.Unlock() + for i, step := range steps { + if i >= len(r.steps) { + r.steps = append(r.steps, step) + continue + } + if r.steps[i].Name == "" { + r.steps[i] = step + } + } +} + +func (r *prepareProgressRecorder) Len() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.steps) +} + +func (r *prepareProgressRecorder) Steps() []PrepareStep { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]PrepareStep, len(r.steps)) + copy(out, r.steps) + return out +} + +func (r *prepareProgressRecorder) recordStep(step PrepareStep, index int) { + r.mu.Lock() + defer r.mu.Unlock() + for len(r.steps) <= index { + r.steps = append(r.steps, PrepareStep{}) + } + r.steps[index] = step +} + // launchBuildExecutorRequest resolves MCP servers, builds the ExecutorCreateRequest, // and creates the runtime instance. -func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID string, reqWithWorktree *LaunchRequest, agentConfig agents.Agent, mainRepoGitDir, worktreeID, worktreeBranch string) (*ExecutorCreateRequest, *ExecutorInstance, ExecutorBackend, error) { +func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID string, reqWithWorktree *LaunchRequest, agentConfig agents.Agent, mainRepoGitDir, worktreeID, worktreeBranch string, onProgress PrepareProgressCallback) (*ExecutorCreateRequest, *ExecutorInstance, ExecutorBackend, error) { rt, err := m.getExecutorBackend(reqWithWorktree.ExecutorType) if err != nil { return nil, nil, nil, fmt.Errorf("no runtime configured: %w", err) @@ -333,6 +391,7 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st execReq := &ExecutorCreateRequest{ InstanceID: executionID, TaskID: reqWithWorktree.TaskID, + TaskTitle: reqWithWorktree.TaskTitle, SessionID: reqWithWorktree.SessionID, TaskEnvironmentID: reqWithWorktree.TaskEnvironmentID, AgentProfileID: reqWithWorktree.AgentProfileID, @@ -344,7 +403,9 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st McpServers: mcpServers, PreviousExecutionID: reqWithWorktree.PreviousExecutionID, McpMode: reqWithWorktree.McpMode, - OnProgress: m.newProgressCallback(reqWithWorktree.TaskID, reqWithWorktree.SessionID), + AuthToken: m.revealRuntimeSecret(ctx, metadata, MetadataKeyAuthTokenSecret), + BootstrapNonce: m.revealRuntimeSecret(ctx, metadata, MetadataKeyBootstrapNonceSecret), + OnProgress: onProgress, } if resumer, ok := rt.(RemoteSessionResumer); ok { @@ -367,6 +428,15 @@ func (m *Manager) runEnvironmentPreparer( ctx context.Context, req *LaunchRequest, workspacePath string, +) *EnvPrepareResult { + return m.runEnvironmentPreparerWithProgress(ctx, req, workspacePath, m.newProgressCallback(req.TaskID, req.SessionID)) +} + +func (m *Manager) runEnvironmentPreparerWithProgress( + ctx context.Context, + req *LaunchRequest, + workspacePath string, + onProgress PrepareProgressCallback, ) *EnvPrepareResult { if m.preparerRegistry == nil { return nil @@ -396,6 +466,24 @@ func (m *Manager) runEnvironmentPreparer( return nil } + prepReq := buildEnvPrepareRequest(req, workspacePath, execName) + + result, err := preparer.Prepare(ctx, prepReq, onProgress) + if err != nil { + m.logger.Warn("environment preparation failed", + zap.String("task_id", req.TaskID), + zap.String("preparer", preparer.Name()), + zap.Error(err)) + return &EnvPrepareResult{ + Success: false, + ErrorMessage: err.Error(), + } + } + + return result +} + +func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName executor.Name) *EnvPrepareRequest { repoSetupScript, _ := req.Metadata[MetadataKeyRepoSetupScript].(string) prepReq := &EnvPrepareRequest{ TaskID: req.TaskID, @@ -412,6 +500,7 @@ func (m *Manager) runEnvironmentPreparer( BaseBranch: req.BaseBranch, DefaultBranch: req.DefaultBranch, CheckoutBranch: req.CheckoutBranch, + WorktreeBranch: getMetadataString(req.Metadata, MetadataKeyWorktreeBranch), WorktreeBranchPrefix: req.WorktreeBranchPrefix, PullBeforeWorktree: req.PullBeforeWorktree, TaskDirName: req.TaskDirName, @@ -443,23 +532,10 @@ func (m *Manager) runEnvironmentPreparer( } prepReq.Repositories = specs } - - result, err := preparer.Prepare(ctx, prepReq, m.newProgressCallback(req.TaskID, req.SessionID)) - if err != nil { - m.logger.Warn("environment preparation failed", - zap.String("task_id", req.TaskID), - zap.String("preparer", preparer.Name()), - zap.Error(err)) - return &EnvPrepareResult{ - Success: false, - ErrorMessage: err.Error(), - } - } - - return result + return prepReq } -// launchApplyPrepareResult applies workspace metadata from the preparer result and publishes completion. +// launchApplyPrepareResult applies workspace metadata from the preparer result. // Returns an error if the preparer failed. func (m *Manager) launchApplyPrepareResult( req *LaunchRequest, @@ -488,15 +564,37 @@ func (m *Manager) launchApplyPrepareResult( if result.WorktreeBranch != "" { *worktreeBranch = result.WorktreeBranch } - m.eventPublisher.PublishPrepareCompleted(req.SessionID, &PrepareCompletedEventPayload{ + return nil +} + +func (m *Manager) publishLaunchPrepareCompleted(req *LaunchRequest, result *EnvPrepareResult, recorder *prepareProgressRecorder, workspacePath string, success bool, err error) { + if req.ACPSessionID != "" { + return + } + + steps := recorder.Steps() + if len(steps) == 0 && result != nil { + steps = result.Steps + } + + payload := &PrepareCompletedEventPayload{ TaskID: req.TaskID, SessionID: req.SessionID, - Success: true, - DurationMs: result.Duration.Milliseconds(), - WorkspacePath: result.WorkspacePath, - Steps: result.Steps, - }) - return nil + Success: success, + WorkspacePath: workspacePath, + Steps: steps, + } + if result != nil { + payload.DurationMs = result.Duration.Milliseconds() + if payload.WorkspacePath == "" { + payload.WorkspacePath = result.WorkspacePath + } + } + if err != nil { + payload.Success = false + payload.ErrorMessage = err.Error() + } + m.eventPublisher.PublishPrepareCompleted(req.SessionID, payload) } // Launch launches a new agent for a task. Concurrent calls for the same @@ -614,18 +712,20 @@ func (m *Manager) launchInternal(ctx context.Context, req *LaunchRequest) (*Agen // 4. Resolve workspace path (non-worktree executors use this directly) workspacePath, mainRepoGitDir, worktreeID, worktreeBranch := m.launchResolveWorkspacePath(ctx, req) + progressRecorder := newPrepareProgressRecorder(m.newProgressCallback(req.TaskID, req.SessionID)) // 4b. Run environment preparation (if preparer registered for this executor type). // Skip on resume (ACPSessionID set) — workspace was already prepared during initial launch. var prepResult *EnvPrepareResult if req.ACPSessionID == "" { - prepResult = m.runEnvironmentPreparer(ctx, req, workspacePath) + prepResult = m.runEnvironmentPreparerWithProgress(ctx, req, workspacePath, progressRecorder.Callback(0)) } else { m.logger.Debug("skipping environment preparation for resumed session", zap.String("task_id", req.TaskID), zap.String("session_id", req.SessionID)) } if prepResult != nil { + progressRecorder.Merge(prepResult.Steps) if err := m.launchApplyPrepareResult(req, prepResult, &workspacePath, &mainRepoGitDir, &worktreeID, &worktreeBranch); err != nil { return nil, err } @@ -635,26 +735,19 @@ func (m *Manager) launchInternal(ctx context.Context, req *LaunchRequest) (*Agen reqWithWorktree, executionID := m.launchPrepareRequest(req, profileInfo, workspacePath) // 7. Build runtime request and create instance (agent not started yet) - execReq, execInstance, rt, err := m.launchBuildExecutorRequest(ctx, executionID, &reqWithWorktree, agentConfig, mainRepoGitDir, worktreeID, worktreeBranch) + var runtimeProgress PrepareProgressCallback + if req.ACPSessionID == "" { + runtimeProgress = progressRecorder.Callback(progressRecorder.Len()) + } + execReq, execInstance, rt, err := m.launchBuildExecutorRequest(ctx, executionID, &reqWithWorktree, agentConfig, mainRepoGitDir, worktreeID, worktreeBranch, runtimeProgress) if err != nil { - m.eventPublisher.PublishPrepareCompleted(req.SessionID, &PrepareCompletedEventPayload{ - TaskID: req.TaskID, - SessionID: req.SessionID, - Success: false, - ErrorMessage: err.Error(), - }) + m.publishLaunchPrepareCompleted(req, prepResult, progressRecorder, workspacePath, false, err) return nil, err } - // Publish PrepareCompleted for CreateInstance only if no preparer ran - // AND this is not a resume (resume already has persisted prepare_result). - if prepResult == nil && req.ACPSessionID == "" { - m.eventPublisher.PublishPrepareCompleted(req.SessionID, &PrepareCompletedEventPayload{ - TaskID: req.TaskID, - SessionID: req.SessionID, - Success: true, - DurationMs: 0, - }) + if prepResult != nil { + prepResult.Steps = progressRecorder.Steps() } + m.publishLaunchPrepareCompleted(req, prepResult, progressRecorder, workspacePath, true, nil) // Build the in-memory AgentExecution from the runtime instance. Extracted // to keep launchInternal under the cyclomatic-complexity budget. @@ -719,6 +812,8 @@ func (m *Manager) registerAndPublishExecution( return fmt.Errorf("failed to register execution: %w", addErr) } + m.persistRuntimeSecrets(ctx, execInstance, execution) + // Persist executors_running in lockstep with Add — see persistence.go for the // invariant. Carries forward resume_token / metadata from a prior row so the // lifecycle write doesn't clobber data the orchestrator's narrow CAS updates diff --git a/apps/backend/internal/agent/lifecycle/manager_launch_test.go b/apps/backend/internal/agent/lifecycle/manager_launch_test.go index c308ea00e6..aa578baf39 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch_test.go @@ -143,6 +143,101 @@ func (p *trackingPreparer) Prepare(_ context.Context, _ *EnvPrepareRequest, _ Pr return &EnvPrepareResult{Success: true, WorkspacePath: "/tmp/ws"}, nil } +type progressPreparer struct{} + +func (p *progressPreparer) Name() string { return "docker" } + +func (p *progressPreparer) Prepare(_ context.Context, _ *EnvPrepareRequest, onProgress PrepareProgressCallback) (*EnvPrepareResult, error) { + step := beginStep("Validate Docker") + reportProgress(onProgress, step, 0, 1) + completeStepSuccess(&step) + reportProgress(onProgress, step, 0, 1) + return &EnvPrepareResult{Success: true, Steps: []PrepareStep{step}, WorkspacePath: "/tmp/ws"}, nil +} + +func TestLaunch_PublishesPrepareCompletedAfterRuntimeProgress(t *testing.T) { + log := newTestLogger() + execRegistry := NewExecutorRegistry(log) + entered := make(chan struct{}, 1) + barrier := make(chan struct{}) + backend := &createInstanceExecutor{ + MockExecutor: MockExecutor{name: executor.NameDocker}, + client: newReadyAgentctlClient(t, log), + entered: entered, + barrier: barrier, + progressStep: "Waiting for Docker container", + } + execRegistry.Register(backend) + + eventBus := &MockEventBusWithTracking{} + mgr := NewManager( + newTestRegistry(), eventBus, execRegistry, + &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log, + ) + mgr.preparerRegistry = NewPreparerRegistry(log) + mgr.preparerRegistry.Register(executor.NameDocker, &progressPreparer{}) + t.Cleanup(func() { close(mgr.stopCh) }) + + errCh := make(chan error, 1) + go func() { + _, err := mgr.Launch(context.Background(), &LaunchRequest{ + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: "profile-1", + ExecutorType: "local_docker", + RepositoryPath: "/tmp/repo", + BaseBranch: "main", + }) + errCh <- err + }() + + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for runtime CreateInstance") + } + + if completed := prepareCompletedPayloads(eventBus); len(completed) != 0 { + t.Fatalf("PrepareCompleted published before runtime finished: %#v", completed) + } + + close(barrier) + if err := <-errCh; err != nil { + t.Fatalf("Launch returned error: %v", err) + } + + completed := prepareCompletedPayloads(eventBus) + require.NotEmpty(t, completed) + final := completed[len(completed)-1] + require.True(t, final.Success) + requirePrepareStep(t, final.Steps, "Validate Docker") + requirePrepareStep(t, final.Steps, "Waiting for Docker container") +} + +func prepareCompletedPayloads(eventBus *MockEventBusWithTracking) []*PrepareCompletedEventPayload { + eventBus.mu.Lock() + defer eventBus.mu.Unlock() + var out []*PrepareCompletedEventPayload + for _, tracked := range eventBus.PublishedEvents { + payload, ok := tracked.Event.Data.(*PrepareCompletedEventPayload) + if ok { + out = append(out, payload) + } + } + return out +} + +func requirePrepareStep(t *testing.T, steps []PrepareStep, name string) { + t.Helper() + for _, step := range steps { + if step.Name == name { + return + } + } + t.Fatalf("expected prepare step %q in %#v", name, steps) +} + func TestRunEnvironmentPreparer_CalledOnFreshLaunch(t *testing.T) { mgr := newTestManager() preparer := &trackingPreparer{} @@ -396,3 +491,43 @@ func TestLaunch_RaceRollback(t *testing.T) { t.Errorf("StopInstance called %d times, want 1 (runtime instance must be stopped on rollback)", got) } } + +func TestLaunch_PersistsDockerRuntimeSecrets(t *testing.T) { + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + execRegistry := NewExecutorRegistry(log) + backend := &createInstanceExecutor{ + MockExecutor: MockExecutor{name: executor.NameDocker}, + client: newReadyAgentctlClient(t, log), + authToken: "agentctl-token", + nonce: "bootstrap-nonce", + } + execRegistry.Register(backend) + + store := newInMemorySecretStore() + mgr := NewManager( + newTestRegistry(), &MockEventBus{}, execRegistry, + &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log, + ) + mgr.SetSecretStore(store) + mgr.dataDir = t.TempDir() + t.Cleanup(func() { close(mgr.stopCh) }) + + execution, err := mgr.Launch(context.Background(), &LaunchRequest{ + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: "profile-1", + ExecutorType: "local_docker", + IsEphemeral: true, + }) + if err != nil { + t.Fatalf("Launch returned error: %v", err) + } + + if got := mgr.revealRuntimeSecret(context.Background(), execution.Metadata, MetadataKeyAuthTokenSecret); got != "agentctl-token" { + t.Fatalf("revealed auth token = %q, want agentctl-token", got) + } + if got := mgr.revealRuntimeSecret(context.Background(), execution.Metadata, MetadataKeyBootstrapNonceSecret); got != "bootstrap-nonce" { + t.Fatalf("revealed bootstrap nonce = %q, want bootstrap-nonce", got) + } +} diff --git a/apps/backend/internal/agent/lifecycle/manager_lifecycle.go b/apps/backend/internal/agent/lifecycle/manager_lifecycle.go index 9856170974..921d1497f3 100644 --- a/apps/backend/internal/agent/lifecycle/manager_lifecycle.go +++ b/apps/backend/internal/agent/lifecycle/manager_lifecycle.go @@ -15,7 +15,11 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) -const containerStateExited = "exited" +const ( + containerStateCreated = "created" + containerStateExited = "exited" + containerStateRunning = "running" +) // Start starts the lifecycle manager background tasks func (m *Manager) Start(ctx context.Context) error { diff --git a/apps/backend/internal/agent/lifecycle/mock_agent_resolver.go b/apps/backend/internal/agent/lifecycle/mock_agent_resolver.go new file mode 100644 index 0000000000..19aa62c491 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/mock_agent_resolver.go @@ -0,0 +1,69 @@ +package lifecycle + +import ( + "fmt" + "os" + "path/filepath" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// MockAgentResolver finds the path to a linux/amd64 mock-agent binary so it +// can be bind-mounted into Docker containers for E2E tests. Resolution order: +// 1. KANDEV_MOCK_AGENT_LINUX_BINARY env var +// 2. build/mock-agent-linux-amd64 relative to the running binary (dev mode) +// +// Unlike AgentctlResolver, "not found" is not an error — only the Docker E2E +// suite needs this binary, so production paths get a nil mount silently. +type MockAgentResolver struct { + logger *logger.Logger +} + +// NewMockAgentResolver creates a new resolver. +func NewMockAgentResolver(log *logger.Logger) *MockAgentResolver { + return &MockAgentResolver{ + logger: log.WithFields(zap.String("component", "mock_agent_resolver")), + } +} + +// ResolveLinuxBinary returns the path to a linux/amd64 mock-agent binary, or +// ("", nil) when none is configured. Returns an error only when an explicit +// env var path is invalid. +func (r *MockAgentResolver) ResolveLinuxBinary() (string, error) { + if envPath := os.Getenv("KANDEV_MOCK_AGENT_LINUX_BINARY"); envPath != "" { + info, err := os.Stat(envPath) + if err != nil { + return "", fmt.Errorf("KANDEV_MOCK_AGENT_LINUX_BINARY=%q does not exist", envPath) + } + // Reject directories (and anything that isn't a regular file) up + // front. Otherwise the path slips through to the bind-mount + exec + // step and surfaces as a less actionable "permission denied" or + // "exec format error" deep inside container startup. + if !info.Mode().IsRegular() { + return "", fmt.Errorf("KANDEV_MOCK_AGENT_LINUX_BINARY=%q is not a regular file", envPath) + } + r.logger.Debug("using mock-agent from env var", zap.String("path", envPath)) + return envPath, nil + } + + exePath, err := os.Executable() + if err != nil { + return "", nil + } + exeDir := filepath.Dir(exePath) + candidates := []string{ + filepath.Join(exeDir, "mock-agent-linux-amd64"), + filepath.Join(exeDir, "..", "build", "mock-agent-linux-amd64"), + filepath.Join(exeDir, "..", "bin", "mock-agent-linux-amd64"), + } + for _, candidate := range candidates { + if _, statErr := os.Stat(candidate); statErr == nil { + abs, _ := filepath.Abs(candidate) + r.logger.Debug("found mock-agent binary", zap.String("path", abs)) + return abs, nil + } + } + return "", nil +} diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go index 4cd6f91894..44359d07f7 100644 --- a/apps/backend/internal/agent/lifecycle/session.go +++ b/apps/backend/internal/agent/lifecycle/session.go @@ -131,11 +131,14 @@ func (sm *SessionManager) createOrLoadSession( if rt.SessionConfig.NativeSessionResume && existingSessionID != "" { sessionID, err := sm.loadSession(ctx, client, agentConfig, existingSessionID, mcpServers) if err != nil { - // If session/load fails because the agent doesn't support it, fall back to session/new. - // Check for: JSON-RPC -32601 (Method not found) or our own capability check string. + // Fall back to session/new when the agent either doesn't implement + // session/load (-32601, capability false) or doesn't know this session + // ID anymore (-32002 "Resource not found" — typically because the + // agent process restarted and lost its in-memory session map). if isMethodNotFoundErr(err) || - strings.Contains(err.Error(), "LoadSession capability is false") { - sm.logger.Warn("agent does not support session loading, falling back to session/new", + strings.Contains(err.Error(), "LoadSession capability is false") || + isSessionUnknownErr(err) { + sm.logger.Warn("session/load failed, falling back to session/new", zap.String("agent_type", agentConfig.ID()), zap.String("existing_session_id", existingSessionID), zap.String("reason", err.Error())) @@ -666,6 +669,11 @@ func (sm *SessionManager) retryPromptAfterReconnect( // jsonRPCMethodNotFound is the JSON-RPC 2.0 error code for "Method not found". const jsonRPCMethodNotFound = -32601 +// jsonRPCResourceNotFound is the ACP-specific error code that agents return when +// asked to load a session ID they don't know about (e.g. claude-agent-acp after +// its in-memory session map was cleared by a restart). +const jsonRPCResourceNotFound = -32002 + // isMethodNotFoundErr checks if an error wraps a JSON-RPC "Method not found" error. func isMethodNotFoundErr(err error) bool { var reqErr *acp.RequestError @@ -675,6 +683,23 @@ func isMethodNotFoundErr(err error) bool { return false } +// isSessionUnknownErr reports whether an error from session/load means the +// agent doesn't know about the requested session ID — typically because the +// agent process restarted and lost its in-memory session map. The caller +// should fall back to creating a fresh session rather than aborting the launch. +func isSessionUnknownErr(err error) bool { + if err == nil { + return false + } + var reqErr *acp.RequestError + if errors.As(err, &reqErr) && reqErr.Code == jsonRPCResourceNotFound { + return true + } + // Some agents return the error in the wrapped message string instead of a + // structured RequestError. Match the canonical phrase as a safety net. + return strings.Contains(err.Error(), "Resource not found") +} + func isAgentStreamNotConnectedErr(err error) bool { if err == nil { return false diff --git a/apps/backend/internal/agent/lifecycle/streams.go b/apps/backend/internal/agent/lifecycle/streams.go index 9baa2c25de..24b31185cf 100644 --- a/apps/backend/internal/agent/lifecycle/streams.go +++ b/apps/backend/internal/agent/lifecycle/streams.go @@ -245,6 +245,7 @@ func (sm *StreamManager) connectWorkspaceStream(execution *AgentExecution, ready // Wait for the stream to close (it stays open until disconnected) <-ws.Done() + execution.ClearWorkspaceStream(ws) return } diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index c2b847c7b5..03f36d0bb8 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -157,6 +157,15 @@ func (ae *AgentExecution) SetWorkspaceStream(ws *agentctl.WorkspaceStream) { ae.workspaceStream = ws } +// ClearWorkspaceStream clears the workspace stream if it is still the active stream. +func (ae *AgentExecution) ClearWorkspaceStream(ws *agentctl.WorkspaceStream) { + ae.workspaceStreamMu.Lock() + defer ae.workspaceStreamMu.Unlock() + if ae.workspaceStream == ws { + ae.workspaceStream = nil + } +} + // GetWorkspaceStream returns the unified workspace stream for this execution func (ae *AgentExecution) GetWorkspaceStream() *agentctl.WorkspaceStream { ae.workspaceStreamMu.RLock() diff --git a/apps/backend/internal/agent/remoteauth/catalog.go b/apps/backend/internal/agent/remoteauth/catalog.go index 07ff2c3eb1..906f1f4444 100644 --- a/apps/backend/internal/agent/remoteauth/catalog.go +++ b/apps/backend/internal/agent/remoteauth/catalog.go @@ -123,9 +123,10 @@ func BuildCatalogForHost(enabledAgents []agents.Agent, currentOS, homeDir string } } - if len(spec.Methods) > 0 { - specs = append(specs, spec) - } + // Include the spec even when it has no methods — this is the + // "no remote credentials needed" signal (e.g. mock agent for tests). + // Frontend gating treats a methods-less spec as "always configured". + specs = append(specs, spec) } return Catalog{Specs: specs, methodsByID: methodsByID} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 04ebfa3857..8e9c254f2c 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -65,6 +65,8 @@ type PermissionNotification struct { // defaultStderrBufferSize is the number of recent stderr lines to keep for error context const defaultStderrBufferSize = 50 +const agentTempDirRoot = "kandev-agent" + // Manager manages the agent subprocess type Manager struct { cfg *config.InstanceConfig @@ -482,6 +484,11 @@ func (m *Manager) Start(ctx context.Context) error { return fmt.Errorf("no agent command configured") } + if err := m.ensureAgentTempEnv(); err != nil { + m.status.Store(StatusError) + return err + } + // Build adapter config and create protocol adapter if err := m.buildAdapterConfig(); err != nil { m.status.Store(StatusError) @@ -631,6 +638,10 @@ func (m *Manager) buildAdapterConfig() error { // buildFinalCommand assembles the full command args and creates the exec.Cmd. // The process group is set so child processes can be killed together. func (m *Manager) buildFinalCommand() error { + if err := m.ensureAgentTempEnv(); err != nil { + return err + } + extraArgs := m.adapter.PrepareCommandArgs() cmdArgs := make([]string, 0, len(m.cfg.AgentArgs)-1+len(extraArgs)) @@ -663,6 +674,68 @@ func (m *Manager) buildFinalCommand() error { return nil } +func (m *Manager) ensureAgentTempEnv() error { + dir := filepath.Join(os.TempDir(), agentTempDirRoot, agentTempDirName(m.cfg.SessionID, m.cfg.Port)) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("failed to create agent temp dir: %w", err) + } + for _, key := range []string{"TMPDIR", "TMP", "TEMP"} { + m.cfg.AgentEnv = upsertEnvValue(m.cfg.AgentEnv, key, dir) + } + return nil +} + +func agentTempDirName(sessionID string, port int) string { + name := strings.TrimSpace(sessionID) + if name == "" && port > 0 { + name = fmt.Sprintf("port-%d", port) + } + if name == "" { + return "default" + } + + var b strings.Builder + for _, r := range name { + if isAgentTempDirRune(r) { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + cleaned := strings.Trim(b.String(), "._-") + if cleaned == "" { + return "default" + } + return cleaned +} + +func isAgentTempDirRune(r rune) bool { + return r >= 'a' && r <= 'z' || + r >= 'A' && r <= 'Z' || + r >= '0' && r <= '9' || + r == '.' || r == '_' || r == '-' +} + +func upsertEnvValue(env []string, key, value string) []string { + prefix := key + "=" + next := make([]string, 0, len(env)+1) + replaced := false + for _, item := range env { + if strings.HasPrefix(item, prefix) { + if !replaced { + next = append(next, prefix+value) + replaced = true + } + continue + } + next = append(next, item) + } + if !replaced { + next = append(next, prefix+value) + } + return next +} + // startProcessPipes creates stdin, stdout, and stderr pipes for the agent subprocess. // The pipes must be created before the process starts. func (m *Manager) startProcessPipes() error { diff --git a/apps/backend/internal/agentctl/server/process/manager_temp_test.go b/apps/backend/internal/agentctl/server/process/manager_temp_test.go new file mode 100644 index 0000000000..5f2ea894ff --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/manager_temp_test.go @@ -0,0 +1,72 @@ +package process + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/types" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type stubAgentAdapter struct{} + +func (stubAgentAdapter) PrepareEnvironment() (map[string]string, error) { return nil, nil } +func (stubAgentAdapter) PrepareCommandArgs() []string { return nil } +func (stubAgentAdapter) Connect(io.Writer, io.Reader) error { return nil } +func (stubAgentAdapter) Initialize(context.Context) error { return nil } +func (stubAgentAdapter) GetAgentInfo() *adapter.AgentInfo { return nil } +func (stubAgentAdapter) NewSession(context.Context, []types.McpServer) (string, error) { + return "", nil +} +func (stubAgentAdapter) LoadSession(context.Context, string, []types.McpServer) error { + return nil +} +func (stubAgentAdapter) Prompt(context.Context, string, []v1.MessageAttachment) error { + return nil +} +func (stubAgentAdapter) Cancel(context.Context) error { return nil } +func (stubAgentAdapter) Updates() <-chan adapter.AgentEvent { return nil } +func (stubAgentAdapter) GetSessionID() string { return "" } +func (stubAgentAdapter) GetOperationID() string { return "" } +func (stubAgentAdapter) SetPermissionHandler(adapter.PermissionHandler) {} +func (stubAgentAdapter) Close() error { return nil } +func (stubAgentAdapter) RequiresProcessKill() bool { return false } + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, item := range env { + if len(item) > len(prefix) && item[:len(prefix)] == prefix { + return item[len(prefix):] + } + } + return "" +} + +func TestManager_BuildFinalCommandInjectsIsolatedTempDir(t *testing.T) { + mgr := NewManager(&config.InstanceConfig{ + WorkDir: t.TempDir(), + SessionID: "session-123", + AgentArgs: []string{"echo"}, + AgentEnv: []string{"PATH=/usr/bin"}, + }, newTestLogger(t)) + mgr.adapter = stubAgentAdapter{} + + if err := mgr.buildFinalCommand(); err != nil { + t.Fatalf("buildFinalCommand() error = %v", err) + } + + want := filepath.Join(os.TempDir(), "kandev-agent", "session-123") + for _, key := range []string{"TMPDIR", "TMP", "TEMP"} { + if got := envValue(mgr.cmd.Env, key); got != want { + t.Fatalf("%s = %q, want %q", key, got, want) + } + } + if info, err := os.Stat(want); err != nil || !info.IsDir() { + t.Fatalf("expected temp dir %q to exist, stat=%v err=%v", want, info, err) + } +} diff --git a/apps/backend/internal/gateway/websocket/terminal_handler.go b/apps/backend/internal/gateway/websocket/terminal_handler.go index c267aa27b8..8b6b5106ca 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler.go @@ -112,6 +112,7 @@ func isResizeCommand(data []byte) bool { const ( passthroughReadyTimeout = 30 * time.Second + remoteReadyProbeTimeout = 500 * time.Millisecond pollInterval = 500 * time.Millisecond ) @@ -296,7 +297,14 @@ func (h *TerminalHandler) remoteExecutionWithClientReady( if remaining <= 0 { return nil, false } - if err := execution.GetAgentCtlClient().WaitForReady(ctx, remaining); err != nil { + + probeTimeout := remoteReadyProbeTimeout + if remaining < probeTimeout { + probeTimeout = remaining + } + probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + if err := execution.GetAgentCtlClient().Health(probeCtx); err != nil { h.logger.Debug("remote execution agentctl client not ready", zap.String("session_id", sessionID), zap.String("execution_id", execution.ID), diff --git a/apps/backend/internal/gateway/websocket/terminal_handler_test.go b/apps/backend/internal/gateway/websocket/terminal_handler_test.go index d32fd746a2..71ac2fd809 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler_test.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler_test.go @@ -2,13 +2,21 @@ package websocket import ( "bytes" + "context" "encoding/json" + "net" "net/http" "net/http/httptest" "net/url" + "strconv" "testing" + "time" "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/agent/lifecycle" + agentctlclient "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" ) func TestCheckWebSocketOrigin(t *testing.T) { @@ -204,3 +212,103 @@ func TestSessionTerminalRouteRequiresAgentMode(t *testing.T) { }) } } + +func TestWaitForRemoteExecutionReadyRechecksReplacedExecution(t *testing.T) { + log := testTerminalLogger(t) + manager := lifecycle.NewManager( + nil, + bus.NewMemoryEventBus(log), + nil, + nil, + nil, + nil, + lifecycle.ExecutorFallbackDeny, + t.TempDir(), + log, + ) + handler := NewTerminalHandler(manager, nil, nil, log) + + staleServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer staleServer.Close() + readyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer readyServer.Close() + + oldExecution := testRemoteExecution(t, staleServer.URL, "exec-old", "session-1", "env-1", log) + newExecution := testRemoteExecution(t, readyServer.URL, "exec-new", "session-1", "env-1", log) + + if err := manager.ExecutionStoreForTesting().Add(oldExecution); err != nil { + t.Fatalf("add old execution: %v", err) + } + + go func() { + time.Sleep(100 * time.Millisecond) + manager.RemoveExecution(oldExecution.ID) + if err := manager.ExecutionStoreForTesting().Add(newExecution); err != nil { + t.Errorf("add new execution: %v", err) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + got, ok := handler.waitForRemoteExecutionReadyWithTimeout(ctx, "session-1", 1500*time.Millisecond) + if !ok { + t.Fatal("waitForRemoteExecutionReadyWithTimeout returned not ready") + } + if got.ID != newExecution.ID { + t.Fatalf("execution ID = %q, want %q", got.ID, newExecution.ID) + } +} + +func testRemoteExecution( + t *testing.T, + serverURL string, + executionID string, + sessionID string, + taskEnvironmentID string, + log *logger.Logger, +) *lifecycle.AgentExecution { + t.Helper() + + parsed, err := url.Parse(serverURL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + host, portString, err := net.SplitHostPort(parsed.Host) + if err != nil { + t.Fatalf("split server host/port: %v", err) + } + port, err := strconv.Atoi(portString) + if err != nil { + t.Fatalf("parse server port: %v", err) + } + + instance := &lifecycle.ExecutorInstance{ + InstanceID: executionID, + Client: agentctlclient.NewClient(host, port, log), + RuntimeName: "docker", + WorkspacePath: "/workspace", + } + return instance.ToAgentExecution(&lifecycle.ExecutorCreateRequest{ + TaskID: "task-1", + SessionID: sessionID, + TaskEnvironmentID: taskEnvironmentID, + WorkspacePath: "/workspace", + }) +} + +func testTerminalLogger(t *testing.T) *logger.Logger { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "json", + OutputPath: t.TempDir() + "/test.log", + }) + if err != nil { + t.Fatalf("create logger: %v", err) + } + return log +} diff --git a/apps/backend/internal/orchestrator/event_handlers_linear.go b/apps/backend/internal/orchestrator/event_handlers_linear.go index e0e6e80e7d..59fa18753c 100644 --- a/apps/backend/internal/orchestrator/event_handlers_linear.go +++ b/apps/backend/internal/orchestrator/event_handlers_linear.go @@ -11,6 +11,7 @@ import ( "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/linear" + "github.com/kandev/kandev/internal/task/models" ) // LinearService is the subset of linear.Service the orchestrator needs to @@ -81,13 +82,13 @@ func (s *Service) createLinearIssueTask(ctx context.Context, evt *linear.NewLine Title: fmt.Sprintf("[%s] %s", issue.Identifier, issue.Title), Description: interpolateLinearPrompt(evt.Prompt, issue), Metadata: map[string]interface{}{ - "linear_issue_watch_id": evt.IssueWatchID, - "linear_issue_identifier": issue.Identifier, - "linear_issue_url": issue.URL, - "linear_state": issue.StateName, - "linear_assignee": issue.AssigneeName, - "agent_profile_id": evt.AgentProfileID, - "executor_profile_id": evt.ExecutorProfileID, + "linear_issue_watch_id": evt.IssueWatchID, + "linear_issue_identifier": issue.Identifier, + "linear_issue_url": issue.URL, + "linear_state": issue.StateName, + "linear_assignee": issue.AssigneeName, + models.MetaKeyAgentProfileID: evt.AgentProfileID, + models.MetaKeyExecutorProfileID: evt.ExecutorProfileID, }, } diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go new file mode 100644 index 0000000000..953e859489 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go @@ -0,0 +1,147 @@ +package executor + +import ( + "context" + "sort" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/task/models" + "go.uber.org/zap" +) + +// reuseExistingEnvironment carries forward worktree, container, sandbox, and +// runtime metadata from an existing TaskEnvironment into the launch request +// so that executor backends can reuse the prior execution. +// +// Reuse is gated on the env's executor_type matching the launch request: if +// the user switched the task's executor profile to a different type, we must +// NOT pass stale PreviousExecutionID / container_id / sprite_name to the +// wrong backend (it would either fail loudly or, worse, overwrite the +// persisted env with mixed resource IDs on the next save). +// +// Two layers of metadata feed in: +// - env-level (stable IDs: worktree id, container id, sandbox id, branch) +// - the latest matching ExecutorRunning row (live runtime metadata: agent +// execution id, secret references, anything in persistentMetadataKeys) +// +// applyExecutorRunningMetadata overwrites container_id with running.ContainerID +// (running wins for live runtime values) but only adds keys that don't already +// exist for the rest (env wins for sprite_name and other stable IDs). +func (e *Executor) reuseExistingEnvironment(ctx context.Context, req *LaunchAgentRequest, env *models.TaskEnvironment) { + if env == nil { + return + } + if env.ExecutorType != "" && env.ExecutorType != req.ExecutorType { + e.logger.Info("skipping task environment reuse: executor type changed", + zap.String("task_id", req.TaskID), + zap.String("env_executor_type", env.ExecutorType), + zap.String("req_executor_type", req.ExecutorType)) + return + } + + if env.WorktreeID != "" && req.UseWorktree { + req.WorktreeID = env.WorktreeID + e.logger.Info("reusing existing task environment worktree", + zap.String("task_id", req.TaskID), + zap.String("worktree_id", env.WorktreeID)) + } + + if env.ContainerID != "" || env.SandboxID != "" { + metadata := ensureLaunchMetadata(req) + if env.ContainerID != "" { + metadata[lifecycle.MetadataKeyContainerID] = env.ContainerID + } + if env.SandboxID != "" { + metadata["sprite_name"] = env.SandboxID + } + } + + // Forward the persisted feature branch so the in-sandbox prepare script + // can re-create or reuse it. Applies to every clone-based remote executor + // (the preparer is responsible for stamping env.WorktreeBranch in the + // first place); the host-side worktree path uses req.WorktreeID instead. + if env.WorktreeBranch != "" && isContainerizedExecutor(req.ExecutorType) { + ensureLaunchMetadata(req)[lifecycle.MetadataKeyWorktreeBranch] = env.WorktreeBranch + } + + if env.ID == "" { + return + } + if running := e.latestExecutorRunningForEnvironment(ctx, req.TaskID, env); running != nil { + applyExecutorRunningMetadata(req, running) + } +} + +func (e *Executor) latestExecutorRunningForEnvironment(ctx context.Context, taskID string, env *models.TaskEnvironment) *models.ExecutorRunning { + sessions, err := e.repo.ListTaskSessions(ctx, taskID) + if err != nil { + e.logger.Warn("failed to list sessions for task environment metadata reuse", + zap.String("task_id", taskID), + zap.String("task_environment_id", env.ID), + zap.Error(err)) + return nil + } + sort.SliceStable(sessions, func(i, j int) bool { + if !sessions[i].StartedAt.Equal(sessions[j].StartedAt) { + return sessions[i].StartedAt.After(sessions[j].StartedAt) + } + if !sessions[i].UpdatedAt.Equal(sessions[j].UpdatedAt) { + return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt) + } + return sessions[i].ID > sessions[j].ID + }) + + var fallback *models.ExecutorRunning + for _, s := range sessions { + running, runErr := e.repo.GetExecutorRunningBySessionID(ctx, s.ID) + if runErr != nil || running == nil { + continue + } + if s.TaskEnvironmentID == env.ID { + return running + } + if fallback == nil && executorRunningMatchesEnvironment(running, env) { + fallback = running + } + } + return fallback +} + +func executorRunningMatchesEnvironment(running *models.ExecutorRunning, env *models.TaskEnvironment) bool { + if running == nil || env == nil { + return false + } + if env.ContainerID != "" && running.ContainerID == env.ContainerID { + return true + } + if env.SandboxID != "" && running.Metadata != nil && running.Metadata["sprite_name"] == env.SandboxID { + return true + } + return false +} + +func applyExecutorRunningMetadata(req *LaunchAgentRequest, running *models.ExecutorRunning) { + if running.AgentExecutionID != "" && req.PreviousExecutionID == "" { + req.PreviousExecutionID = running.AgentExecutionID + } + var metadata map[string]interface{} + if running.ContainerID != "" { + metadata = ensureLaunchMetadata(req) + metadata[lifecycle.MetadataKeyContainerID] = running.ContainerID + } + for k, v := range running.Metadata { + if metadata == nil { + metadata = ensureLaunchMetadata(req) + } + if _, exists := metadata[k]; !exists && lifecycle.ShouldPersistMetadataKey(k) { + metadata[k] = v + } + } +} + +func ensureLaunchMetadata(req *LaunchAgentRequest) map[string]interface{} { + if req.Metadata == nil { + req.Metadata = make(map[string]interface{}) + } + return req.Metadata +} diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_test.go b/apps/backend/internal/orchestrator/executor/executor_environment_test.go new file mode 100644 index 0000000000..a060aba32b --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_environment_test.go @@ -0,0 +1,370 @@ +package executor + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +func newEnvTestExecutor(t *testing.T) *Executor { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("failed to create logger: %v", err) + } + return &Executor{logger: log} +} + +func TestReuseExistingEnvironment_NilEnv(t *testing.T) { + e := newEnvTestExecutor(t) + req := &LaunchAgentRequest{TaskID: "task-1"} + + e.reuseExistingEnvironment(context.Background(), req, nil) + + if req.Metadata != nil { + t.Error("expected nil metadata for nil env") + } + if req.PreviousExecutionID != "" { + t.Error("expected empty PreviousExecutionID for nil env") + } +} + +func TestReuseExistingEnvironment_WorktreeReuse(t *testing.T) { + e := newEnvTestExecutor(t) + req := &LaunchAgentRequest{TaskID: "task-1", UseWorktree: true} + env := &models.TaskEnvironment{ + WorktreeID: "wt-1", + } + + e.reuseExistingEnvironment(context.Background(), req, env) + + if req.WorktreeID != "wt-1" { + t.Errorf("expected WorktreeID=wt-1, got %s", req.WorktreeID) + } +} + +func TestReuseExistingEnvironment_SkipsReuseOnExecutorTypeMismatch(t *testing.T) { + // Switching the task's executor profile to a different type must invalidate + // reuse: stale PreviousExecutionID/ContainerID/sprite_name from the old + // backend would otherwise leak into the new launch and overwrite the + // persisted env with mixed resource IDs on the next save. + e := newEnvTestExecutor(t) + req := &LaunchAgentRequest{ + TaskID: "task-1", + ExecutorType: "local_docker", + UseWorktree: true, + } + env := &models.TaskEnvironment{ + ExecutorType: "sprites", + ContainerID: "container-abc", + WorktreeID: "wt-1", + } + + e.reuseExistingEnvironment(context.Background(), req, env) + + if req.WorktreeID != "" { + t.Errorf("expected WorktreeID to be empty on executor mismatch, got %q", req.WorktreeID) + } + if req.PreviousExecutionID != "" { + t.Errorf("expected PreviousExecutionID empty on mismatch, got %q", req.PreviousExecutionID) + } + if req.Metadata != nil { + t.Errorf("expected nil metadata on mismatch, got %v", req.Metadata) + } +} + +func TestReuseExistingEnvironment_WorktreeSkippedWhenNotRequested(t *testing.T) { + e := newEnvTestExecutor(t) + req := &LaunchAgentRequest{TaskID: "task-1", UseWorktree: false} + env := &models.TaskEnvironment{ + WorktreeID: "wt-1", + } + + e.reuseExistingEnvironment(context.Background(), req, env) + + if req.WorktreeID != "" { + t.Errorf("expected empty WorktreeID when UseWorktree=false, got %s", req.WorktreeID) + } +} + +func TestReuseExistingEnvironment_ContainerReuse(t *testing.T) { + e := newEnvTestExecutor(t) + req := &LaunchAgentRequest{TaskID: "task-1"} + env := &models.TaskEnvironment{ + ContainerID: "container-abc", + } + + e.reuseExistingEnvironment(context.Background(), req, env) + + if req.PreviousExecutionID != "" { + t.Errorf("expected empty PreviousExecutionID, got %s", req.PreviousExecutionID) + } + if req.Metadata["container_id"] != "container-abc" { + t.Errorf("expected metadata container_id=container-abc, got %v", req.Metadata["container_id"]) + } +} + +func TestReuseExistingEnvironment_DockerBranchReuse(t *testing.T) { + e := newEnvTestExecutor(t) + req := &LaunchAgentRequest{TaskID: "task-1", ExecutorType: "local_docker"} + env := &models.TaskEnvironment{ + ExecutorType: "local_docker", + ContainerID: "container-abc", + WorktreeBranch: "feature/existing-task-abc", + } + + e.reuseExistingEnvironment(context.Background(), req, env) + + if req.Metadata[lifecycle.MetadataKeyWorktreeBranch] != "feature/existing-task-abc" { + t.Fatalf("metadata worktree_branch = %v, want existing branch", req.Metadata[lifecycle.MetadataKeyWorktreeBranch]) + } +} + +func TestReuseExistingEnvironment_RuntimeMetadata_CarriesPersistentSecrets(t *testing.T) { + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("failed to create logger: %v", err) + } + repo := newMockRepository() + now := time.Now().UTC() + repo.sessions["session-old"] = &models.TaskSession{ + ID: "session-old", + TaskID: "task-1", + TaskEnvironmentID: "env-1", + StartedAt: now, + UpdatedAt: now, + } + repo.executorsRunning["session-old"] = &models.ExecutorRunning{ + SessionID: "session-old", + AgentExecutionID: "exec-old", + ContainerID: "container-old", + Metadata: map[string]interface{}{ + lifecycle.MetadataKeyAuthTokenSecret: "secret-token", + lifecycle.MetadataKeyBootstrapNonceSecret: "secret-nonce", + "task_description": "drop me", + }, + } + e := &Executor{logger: log, repo: repo} + req := &LaunchAgentRequest{TaskID: "task-1"} + + e.reuseExistingEnvironment(context.Background(), req, &models.TaskEnvironment{ + ID: "env-1", + }) + + if req.PreviousExecutionID != "exec-old" { + t.Fatalf("PreviousExecutionID = %q, want exec-old", req.PreviousExecutionID) + } + if req.Metadata[lifecycle.MetadataKeyContainerID] != "container-old" { + t.Fatalf("container metadata = %v, want container-old", req.Metadata[lifecycle.MetadataKeyContainerID]) + } + if req.Metadata[lifecycle.MetadataKeyAuthTokenSecret] != "secret-token" { + t.Fatalf("auth token secret missing: %v", req.Metadata) + } + if req.Metadata[lifecycle.MetadataKeyBootstrapNonceSecret] != "secret-nonce" { + t.Fatalf("bootstrap nonce secret missing: %v", req.Metadata) + } + if _, ok := req.Metadata["task_description"]; ok { + t.Fatalf("launch-only metadata should be filtered out: %v", req.Metadata) + } +} + +func TestReuseExistingEnvironment_RuntimeMetadata_FallsBackToMatchingContainer(t *testing.T) { + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("failed to create logger: %v", err) + } + repo := newMockRepository() + now := time.Now().UTC() + repo.sessions["session-old"] = &models.TaskSession{ + ID: "session-old", + TaskID: "task-1", + StartedAt: now, + UpdatedAt: now, + } + repo.executorsRunning["session-old"] = &models.ExecutorRunning{ + SessionID: "session-old", + AgentExecutionID: "exec-old", + ContainerID: "container-old", + Metadata: map[string]interface{}{ + lifecycle.MetadataKeyAuthTokenSecret: "secret-token", + lifecycle.MetadataKeyBootstrapNonceSecret: "secret-nonce", + }, + } + e := &Executor{logger: log, repo: repo} + req := &LaunchAgentRequest{TaskID: "task-1"} + + e.reuseExistingEnvironment(context.Background(), req, &models.TaskEnvironment{ + ID: "env-1", + ContainerID: "container-old", + }) + + if req.PreviousExecutionID != "exec-old" { + t.Fatalf("PreviousExecutionID = %q, want exec-old", req.PreviousExecutionID) + } + if req.Metadata[lifecycle.MetadataKeyContainerID] != "container-old" { + t.Fatalf("container metadata = %v, want container-old", req.Metadata[lifecycle.MetadataKeyContainerID]) + } + if req.Metadata[lifecycle.MetadataKeyAuthTokenSecret] != "secret-token" { + t.Fatalf("auth token secret missing: %v", req.Metadata) + } + if req.Metadata[lifecycle.MetadataKeyBootstrapNonceSecret] != "secret-nonce" { + t.Fatalf("bootstrap nonce secret missing: %v", req.Metadata) + } +} + +func TestBuildResumeRequest_ReusesTaskEnvironmentRuntimeMetadata(t *testing.T) { + repo := newMockRepository() + agentManager := &mockAgentManager{} + exec := newTestExecutor(t, agentManager, repo) + now := time.Now().UTC() + task := &v1.Task{ + ID: "task-1", + WorkspaceID: "workspace-1", + Title: "Task 1", + } + session := &models.TaskSession{ + ID: "session-new", + TaskID: "task-1", + AgentProfileID: "profile-1", + ExecutorID: models.ExecutorIDLocalDocker, + TaskEnvironmentID: "env-1", + State: models.TaskSessionStateWaitingForInput, + StartedAt: now, + UpdatedAt: now, + } + repo.executors[models.ExecutorIDLocalDocker] = &models.Executor{ + ID: models.ExecutorIDLocalDocker, + Type: models.ExecutorTypeLocalDocker, + Status: models.ExecutorStatusActive, + Resumable: true, + } + repo.taskEnvironments["env-1"] = &models.TaskEnvironment{ + ID: "env-1", + TaskID: "task-1", + ExecutorType: string(models.ExecutorTypeLocalDocker), + ContainerID: "container-old", + Status: models.TaskEnvironmentStatusReady, + } + repo.sessions["session-old"] = &models.TaskSession{ + ID: "session-old", + TaskID: "task-1", + TaskEnvironmentID: "env-1", + StartedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + } + repo.executorsRunning["session-old"] = &models.ExecutorRunning{ + SessionID: "session-old", + TaskID: "task-1", + AgentExecutionID: "exec-old", + ContainerID: "container-old", + Runtime: string(models.ExecutorTypeLocalDocker), + Metadata: map[string]interface{}{ + lifecycle.MetadataKeyAuthTokenSecret: "secret-token", + "task_description": "drop me", + }, + } + + req, _, _, running, err := exec.buildResumeRequest(context.Background(), task, session, true) + if err != nil { + t.Fatalf("buildResumeRequest returned error: %v", err) + } + + if running != nil { + t.Fatalf("current session should not have an ExecutorRunning row") + } + if req.TaskEnvironmentID != "env-1" { + t.Fatalf("TaskEnvironmentID = %q, want env-1", req.TaskEnvironmentID) + } + if req.PreviousExecutionID != "exec-old" { + t.Fatalf("PreviousExecutionID = %q, want latest environment execution exec-old", req.PreviousExecutionID) + } + if req.Metadata[lifecycle.MetadataKeyContainerID] != "container-old" { + t.Fatalf("container metadata = %v, want container-old", req.Metadata[lifecycle.MetadataKeyContainerID]) + } + if req.Metadata[lifecycle.MetadataKeyAuthTokenSecret] != "secret-token" { + t.Fatalf("auth token secret missing: %v", req.Metadata) + } + if _, ok := req.Metadata["task_description"]; ok { + t.Fatalf("launch-only metadata should be filtered out: %v", req.Metadata) + } +} + +func TestReuseExistingEnvironment_SandboxReuse(t *testing.T) { + e := newEnvTestExecutor(t) + req := &LaunchAgentRequest{TaskID: "task-1"} + env := &models.TaskEnvironment{ + SandboxID: "kandev-sprite-abc", + } + + e.reuseExistingEnvironment(context.Background(), req, env) + + if req.PreviousExecutionID != "" { + t.Errorf("expected empty PreviousExecutionID, got %s", req.PreviousExecutionID) + } + if req.Metadata["sprite_name"] != "kandev-sprite-abc" { + t.Errorf("expected metadata sprite_name=kandev-sprite-abc, got %v", req.Metadata["sprite_name"]) + } +} + +func TestReuseExistingEnvironment_WorktreeAndContainer(t *testing.T) { + e := newEnvTestExecutor(t) + req := &LaunchAgentRequest{TaskID: "task-1", UseWorktree: true} + env := &models.TaskEnvironment{ + WorktreeID: "wt-1", + ContainerID: "container-abc", + } + + e.reuseExistingEnvironment(context.Background(), req, env) + + if req.WorktreeID != "wt-1" { + t.Errorf("expected WorktreeID=wt-1, got %s", req.WorktreeID) + } + if req.Metadata["container_id"] != "container-abc" { + t.Errorf("expected metadata container_id=container-abc, got %v", req.Metadata["container_id"]) + } + if req.PreviousExecutionID != "" { + t.Errorf("expected empty PreviousExecutionID, got %s", req.PreviousExecutionID) + } +} + +func TestReuseExistingEnvironment_EmptyEnvFieldsDoNothing(t *testing.T) { + e := newEnvTestExecutor(t) + req := &LaunchAgentRequest{TaskID: "task-1"} + env := &models.TaskEnvironment{} + + e.reuseExistingEnvironment(context.Background(), req, env) + + if req.Metadata != nil { + t.Error("expected nil metadata when no container/sandbox IDs") + } + if req.PreviousExecutionID != "" { + t.Error("expected empty PreviousExecutionID when no container/sandbox IDs") + } +} + +func TestExtractSandboxID(t *testing.T) { + tests := []struct { + name string + metadata map[string]interface{} + want string + }{ + {"nil metadata", nil, ""}, + {"no sprite_name", map[string]interface{}{"other": "val"}, ""}, + {"with sprite_name", map[string]interface{}{"sprite_name": "kandev-abc"}, "kandev-abc"}, + {"non-string sprite_name", map[string]interface{}{"sprite_name": 42}, ""}, + {"empty sprite_name", map[string]interface{}{"sprite_name": ""}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractSandboxID(tt.metadata) + if got != tt.want { + t.Errorf("extractSandboxID() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 29eb72bef6..a1ea9f7288 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os/exec" "path/filepath" "strings" "sync" @@ -28,9 +29,11 @@ func isConfigModeSession(session *models.TaskSession) bool { return ok && cm } -// isContainerizedExecutor returns true for executor types that run in containers. -// These executors need GitHub token injection for git operations since they don't -// have access to the host's git credentials. +// isContainerizedExecutor returns true for executor types that run agents in +// containers or remote sandboxes (Docker variants + Sprites). These executors +// need GitHub token injection for git operations since they don't have access +// to the host's git credentials, and they're the same set that needs the +// kandev-managed feature branch propagated through env metadata. func isContainerizedExecutor(executorType string) bool { switch models.ExecutorType(executorType) { case models.ExecutorTypeLocalDocker, models.ExecutorTypeRemoteDocker, models.ExecutorTypeSprites: @@ -125,25 +128,37 @@ func shouldUseWorktree(executorType string) bool { return models.ExecutorType(executorType) == models.ExecutorTypeWorktree } -// repositoryCloneURL builds an HTTPS clone URL from the repository's provider info. -// Returns an empty string if the repository has no provider owner/name or if the -// provider is not recognized. +const providerGitHub = "github" + +// repositoryCloneURL builds a clone URL for the repository. It prefers the +// provider info when present (HTTPS GitHub/GitLab/Bitbucket URL); otherwise +// it inspects the local checkout's `origin` remote. The latter lets local-only +// repos with a real remote (or a file:// remote, used by Docker E2E tests) +// participate in remote executors that clone inside the container/sandbox. func repositoryCloneURL(repo *models.Repository) string { - if repo.ProviderOwner == "" || repo.ProviderName == "" { + if repo.ProviderOwner != "" && repo.ProviderName != "" { + var host string + switch strings.ToLower(repo.Provider) { + case providerGitHub, "": + host = "github.com" + case "gitlab": + host = "gitlab.com" + case "bitbucket": + host = "bitbucket.org" + default: + return "" + } + return fmt.Sprintf("https://%s/%s/%s.git", host, repo.ProviderOwner, repo.ProviderName) + } + if repo.LocalPath == "" { return "" } - var host string - switch strings.ToLower(repo.Provider) { - case "github", "": - host = "github.com" - case "gitlab": - host = "gitlab.com" - case "bitbucket": - host = "bitbucket.org" - default: + cmd := exec.Command("git", "-C", repo.LocalPath, "remote", "get-url", "origin") + out, err := cmd.Output() + if err != nil { return "" } - return fmt.Sprintf("https://%s/%s/%s.git", host, repo.ProviderOwner, repo.ProviderName) + return strings.TrimSpace(string(out)) } // getSessionLock returns a per-session mutex, creating one if it doesn't exist. @@ -413,8 +428,14 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses // Resolve the env ID before LaunchAgent so the in-memory AgentExecution // is env-scoped from the first shell/layout request, not only after DB - // persistence succeeds. - existingEnv, _ := e.repo.GetTaskEnvironmentByTaskID(ctx, task.ID) + // persistence succeeds. GetTaskEnvironmentByTaskID returns (nil, nil) + // when no row exists; a real DB error must propagate so the launch + // fails closed instead of silently launching a fresh environment that + // orphans the existing container/sandbox/worktree. + existingEnv, err := e.repo.GetTaskEnvironmentByTaskID(ctx, task.ID) + if err != nil { + return nil, fmt.Errorf("lookup existing task environment: %w", err) + } assignLaunchTaskEnvironmentID(session, existingEnv) req, execCfg, err := e.buildLaunchAgentRequest(ctx, task, session, agentProfileID, executorID, prompt, primaryRepo, allRepos) @@ -432,13 +453,8 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses req.Attachments = opts.Attachments } - // Check for an existing task environment to reuse its worktree - if existingEnv != nil && existingEnv.WorktreeID != "" && req.UseWorktree { - req.WorktreeID = existingEnv.WorktreeID - e.logger.Info("reusing existing task environment worktree", - zap.String("task_id", task.ID), - zap.String("worktree_id", existingEnv.WorktreeID)) - } + // Check for an existing task environment to reuse worktree, container, or sandbox + e.reuseExistingEnvironment(ctx, req, existingEnv) e.logger.Info("launching agent for prepared session", zap.String("task_id", task.ID), @@ -500,8 +516,7 @@ func (e *Executor) handleLaunchFailure(ctx context.Context, taskID, sessionID st // finalizeLaunch persists launch state and returns the resulting TaskExecution. func (e *Executor) finalizeLaunch(ctx context.Context, task *v1.Task, session *models.TaskSession, agentProfileID, sessionID string, repoInfo *repoInfo, resp *LaunchAgentResponse, startAgent bool, execCfg executorConfig) (*TaskExecution, error) { now := time.Now().UTC() - // On initial launch there is no existing ExecutorRunning record to carry forward. - e.persistLaunchState(ctx, task.ID, sessionID, session, resp, startAgent, now, execCfg, nil) + e.persistLaunchState(ctx, task.ID, sessionID, session, resp, startAgent, now) e.persistWorktreeAssociation(ctx, task.ID, session, repoInfo.RepositoryID, resp) sessionState := v1.TaskSessionStateCreated @@ -690,6 +705,12 @@ func (e *Executor) applyRepositoryConfig(req *LaunchAgentRequest, task *v1.Task, return metadata, ErrNoCloneURL } req.RepositoryURL = cloneURL + // Surface the clone URL to the script engine so {{repository.clone_url}} + // resolves in prepare scripts even when no host repo path is mounted. + if metadata == nil { + metadata = make(map[string]interface{}) + } + metadata["repository_clone_url"] = cloneURL } return metadata, nil @@ -947,9 +968,11 @@ func (e *Executor) persistTaskEnvironment( // is being dropped (executors_running is the single source of truth). // Status, worktree, and container fields are still env-row-owned. existingEnv.Status = models.TaskEnvironmentStatusReady - // Refresh worktree + workspace fields. The original update branch only - // touched AgentExecutionID/Status, so envs created with empty paths - // (e.g. before the worktree resolved) stayed permanently broken. + // Refresh worktree + workspace + container/sandbox fields. The original + // update branch only touched AgentExecutionID/Status, so envs created + // with empty paths (e.g. before the worktree resolved) stayed + // permanently broken. Sandbox ID gets refreshed too in case a fallback + // created a new sprite. if resp.WorktreeID != "" { existingEnv.WorktreeID = resp.WorktreeID } @@ -965,6 +988,9 @@ func (e *Executor) persistTaskEnvironment( if resp.ContainerID != "" { existingEnv.ContainerID = resp.ContainerID } + if sandboxID := extractSandboxID(resp.Metadata); sandboxID != "" { + existingEnv.SandboxID = sandboxID + } if err := e.repo.UpdateTaskEnvironment(ctx, existingEnv); err != nil { e.logger.Warn("failed to update task environment", zap.String("task_id", taskID), @@ -993,6 +1019,7 @@ func (e *Executor) persistTaskEnvironment( WorkspacePath: workspacePath, ContainerID: resp.ContainerID, TaskDirName: req.TaskDirName, + SandboxID: extractSandboxID(resp.Metadata), } // Embed per-repo rows in the same create transaction when multi-repo. if len(resp.Worktrees) > 0 { @@ -1063,3 +1090,15 @@ func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string } } } + +// extractSandboxID extracts the sandbox identifier from launch response metadata. +// For Sprites executors, this is the sprite_name. +func extractSandboxID(metadata map[string]interface{}) string { + if metadata == nil { + return "" + } + if name, ok := metadata["sprite_name"].(string); ok { + return name + } + return "" +} diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go index 70fbc9dadf..a125e0e71a 100644 --- a/apps/backend/internal/orchestrator/executor/executor_interaction.go +++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go @@ -262,7 +262,7 @@ func (e *Executor) launchModelSwitchAgent(ctx context.Context, taskID, sessionID return fmt.Errorf("failed to launch agent with new model: %w", err) } - e.persistModelSwitchState(ctx, taskID, sessionID, session, resp, newModel, existingRunning) + e.persistModelSwitchState(ctx, taskID, sessionID, session, newModel) if err := e.agentManager.StartAgentProcess(ctx, resp.AgentExecutionID); err != nil { e.logger.Error("failed to start agent process after model switch", @@ -335,6 +335,10 @@ func (e *Executor) applyRepositoryToSwitchRequest(ctx context.Context, req *Laun return "", ErrNoCloneURL } req.RepositoryURL = cloneURL + if req.Metadata == nil { + req.Metadata = make(map[string]interface{}) + } + req.Metadata["repository_clone_url"] = cloneURL } return repository.LocalPath, nil } @@ -364,10 +368,7 @@ func (e *Executor) applyWorktreeToSwitchRequest(req *LaunchAgentRequest, session // after a model switch launch. The executors_running row's agent_execution_id / // container_id / status are written by the lifecycle manager during the launch // itself (lifecycle.persistExecutorRunning) and not touched here. -func (e *Executor) persistModelSwitchState(ctx context.Context, taskID, sessionID string, session *models.TaskSession, resp *LaunchAgentResponse, newModel string, existingRunning *models.ExecutorRunning) { - _ = resp - _ = existingRunning - +func (e *Executor) persistModelSwitchState(ctx context.Context, taskID, sessionID string, session *models.TaskSession, newModel string) { session.State = models.TaskSessionStateStarting session.UpdatedAt = time.Now().UTC() diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 3ffa3f42dd..bdb5946622 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -466,7 +466,15 @@ func (m *mockRepository) GetActiveTaskSessionByTaskID(ctx context.Context, taskI return nil, nil } func (m *mockRepository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { - return nil, nil + m.mu.Lock() + defer m.mu.Unlock() + sessions := make([]*models.TaskSession, 0) + for _, session := range m.sessions { + if session.TaskID == taskID { + sessions = append(sessions, session) + } + } + return sessions, nil } func (m *mockRepository) ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) { return nil, nil @@ -739,7 +747,7 @@ type mockCapabilities struct{} func (m *mockCapabilities) RequiresCloneURL(executorType string) bool { switch models.ExecutorType(executorType) { - case models.ExecutorTypeRemoteDocker, models.ExecutorTypeSprites: + case models.ExecutorTypeLocalDocker, models.ExecutorTypeRemoteDocker, models.ExecutorTypeSprites: return true default: return false diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index d53b76d3d7..6924a34c3c 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -2,6 +2,7 @@ package executor import ( "context" + "fmt" "strings" "time" @@ -172,19 +173,15 @@ func (e *Executor) ensureRepoCloned(ctx context.Context, repo *models.Repository // persistLaunchState updates the session record after a successful agent launch. // The executors_running row is now written by the lifecycle manager itself in // lockstep with executionStore.Add (see lifecycle.persistExecutorRunning) — this -// function no longer touches it. The lifecycle manager also owns the columns that -// used to live on task_sessions (agent_execution_id, container_id); the orchestrator -// stops writing them so the only remaining source of truth is executors_running. +// function no longer touches it. The lifecycle manager also owns the columns +// that used to live on task_sessions (agent_execution_id, container_id); the +// orchestrator stops writing them so the only remaining source of truth is +// executors_running. // -// What remains here: state transitions (e.g., STARTING) and prepare-result metadata -// merge, both of which are session-row concerns the lifecycle manager doesn't know -// about. The execCfg / existingRunning parameters are kept on the signature for -// historical call shape but are no longer used. -func (e *Executor) persistLaunchState(ctx context.Context, taskID, sessionID string, session *models.TaskSession, resp *LaunchAgentResponse, startAgent bool, now time.Time, execCfg executorConfig, existingRunning *models.ExecutorRunning) { - _ = resp // executors_running fields are written by lifecycle manager - _ = execCfg // ditto - _ = existingRunning // ditto - +// What remains here: state transitions (e.g., STARTING) and prepare-result +// metadata merge, both of which are session-row concerns the lifecycle manager +// doesn't know about. +func (e *Executor) persistLaunchState(ctx context.Context, taskID, sessionID string, session *models.TaskSession, resp *LaunchAgentResponse, startAgent bool, now time.Time) { if startAgent { session.State = models.TaskSessionStateStarting } @@ -282,7 +279,7 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio } defer unlock() - req, repositoryID, execCfg, existingRunning, err := e.buildResumeRequest(ctx, task, session, startAgent) + req, repositoryID, _, _, err := e.buildResumeRequest(ctx, task, session, startAgent) if err != nil { return nil, err } @@ -342,7 +339,7 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio return nil, err } - e.persistResumeState(ctx, task.ID, session, resp, startAgent, execCfg, existingRunning) + e.persistResumeState(ctx, task.ID, session, startAgent) e.persistWorktreeAssociation(ctx, task.ID, session, repositoryID, resp) worktreePath := resp.WorktreePath @@ -477,11 +474,21 @@ func (e *Executor) buildResumeRequest(ctx context.Context, task *v1.Task, sessio execConfig := e.applyExecutorConfigToResumeRequest(ctx, req, task, session, metadata) + existingEnv, err := e.resolveResumeTaskEnvironment(ctx, task.ID, session) + if err != nil { + return nil, "", execConfig, nil, err + } + if session.TaskEnvironmentID != "" { + req.TaskEnvironmentID = session.TaskEnvironmentID + } + repositoryID, err := e.applyResumeRepoConfig(ctx, task, session, req) if err != nil { return nil, "", execConfig, nil, err } + e.reuseExistingEnvironment(ctx, req, existingEnv) + // Activate config-mode MCP tools when config_mode is set in session metadata. if isConfigModeSession(session) { req.McpMode = McpModeConfig @@ -492,6 +499,20 @@ func (e *Executor) buildResumeRequest(ctx context.Context, task *v1.Task, sessio return req, repositoryID, execConfig, existingRunning, nil } +func (e *Executor) resolveResumeTaskEnvironment(ctx context.Context, taskID string, session *models.TaskSession) (*models.TaskEnvironment, error) { + env, err := e.repo.GetTaskEnvironmentByTaskID(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("lookup existing task environment: %w", err) + } + if env == nil { + return nil, nil + } + if session.TaskEnvironmentID != env.ID { + session.TaskEnvironmentID = env.ID + } + return env, nil +} + // applyExecutorConfigToResumeRequest resolves executor config and applies it to the // resume request, persisting executor assignment if newly resolved. func (e *Executor) applyExecutorConfigToResumeRequest(ctx context.Context, req *LaunchAgentRequest, task *v1.Task, session *models.TaskSession, metadata map[string]interface{}) executorConfig { @@ -619,6 +640,20 @@ func (e *Executor) applyResumeRepoConfig(ctx context.Context, task *v1.Task, ses return "", ErrNoCloneURL } req.RepositoryURL = cloneURL + if req.Metadata == nil { + req.Metadata = make(map[string]interface{}) + } + req.Metadata["repository_clone_url"] = cloneURL + + // Clone-based remote executors (Sprites, Docker, …) need BaseBranch + // in launch metadata so the prepare script's `git clone --branch ` + // resolves. The local executor must NOT receive BaseBranch on resume: + // LocalPreparer would force a `git fetch && git checkout` against + // the user's actual workspace and clobber the "use current state" UX. + // Worktree executors set their own BaseBranch in the block below. + if baseBranch != "" { + req.BaseBranch = baseBranch + } } if shouldUseWorktree(req.ExecutorType) && repositoryPath != "" { @@ -663,14 +698,10 @@ func (e *Executor) applyResumeRepoConfig(ctx context.Context, task *v1.Task, ses // persistResumeState updates the session row after a successful resume launch. // Like persistLaunchState, executors_running is owned by the lifecycle manager -// and not touched here — see lifecycle.persistExecutorRunning. The orchestrator's -// only remaining responsibility is the session-row state machine (STARTING / -// CompletedAt-clear). -func (e *Executor) persistResumeState(ctx context.Context, taskID string, session *models.TaskSession, resp *LaunchAgentResponse, startAgent bool, execCfg executorConfig, existingRunning *models.ExecutorRunning) { - _ = resp - _ = execCfg - _ = existingRunning - +// and not touched here — see lifecycle.persistExecutorRunning. The +// orchestrator's only remaining responsibility is the session-row state +// machine (STARTING / CompletedAt-clear). +func (e *Executor) persistResumeState(ctx context.Context, taskID string, session *models.TaskSession, startAgent bool) { session.ErrorMessage = "" if startAgent { session.State = models.TaskSessionStateStarting diff --git a/apps/backend/internal/orchestrator/executor/executor_resume_test.go b/apps/backend/internal/orchestrator/executor/executor_resume_test.go index 5aedbc056e..c245225f2f 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume_test.go @@ -402,6 +402,68 @@ func TestResumeSession_AbortsIfSessionReReadFails(t *testing.T) { // TestIsTerminalSessionState is a small unit test for the helper that drives // both the preemptive cleanup and the validateAndLockResume carve-out. +// TestApplyResumeRepoConfig_BaseBranchByExecutorType locks in the per-executor +// rules for BaseBranch propagation on resume: +// - clone-based remote executors (Sprites, Docker variants) MUST receive +// BaseBranch so the in-sandbox prepare script's `git clone --branch ` +// resolves; +// - the worktree executor receives BaseBranch via its dedicated block; +// - the local executor MUST NOT receive BaseBranch — LocalPreparer reads it +// and would force a `git fetch && git checkout` against the user's actual +// workspace, clobbering the "use current state" UX. +func TestApplyResumeRepoConfig_BaseBranchByExecutorType(t *testing.T) { + cases := []struct { + executorType string + wantBaseBranch string + wantWorktreeApplied bool + }{ + {executorType: "sprites", wantBaseBranch: "main"}, + {executorType: "local_docker", wantBaseBranch: "main"}, + {executorType: "remote_docker", wantBaseBranch: "main"}, + {executorType: "worktree", wantBaseBranch: "main", wantWorktreeApplied: true}, + {executorType: "local", wantBaseBranch: ""}, + } + + for _, tc := range cases { + t.Run(tc.executorType, func(t *testing.T) { + repo := newMockRepository() + repo.repositories["repo-1"] = &models.Repository{ + ID: "repo-1", + LocalPath: "/tmp/repo", + Provider: "github", + ProviderOwner: "kdlbs", + ProviderName: "kandev", + } + task := &v1.Task{ID: "task-1"} + repo.tasks["task-1"] = &models.Task{ID: "task-1"} + session := &models.TaskSession{ + ID: "sess-1", + TaskID: "task-1", + RepositoryID: "repo-1", + BaseBranch: "main", + } + exec := newTestExecutor(t, &mockAgentManager{}, repo) + + req := &LaunchAgentRequest{ + TaskID: "task-1", + SessionID: "sess-1", + ExecutorType: tc.executorType, + } + + if _, err := exec.applyResumeRepoConfig(context.Background(), task, session, req); err != nil { + t.Fatalf("applyResumeRepoConfig: %v", err) + } + + if req.BaseBranch != tc.wantBaseBranch { + t.Fatalf("BaseBranch = %q, want %q", req.BaseBranch, tc.wantBaseBranch) + } + if tc.wantWorktreeApplied != req.UseWorktree { + t.Fatalf("UseWorktree = %v, want %v", req.UseWorktree, tc.wantWorktreeApplied) + } + }) + } +} + func TestIsTerminalSessionState(t *testing.T) { cases := []struct { state models.TaskSessionState diff --git a/apps/backend/internal/orchestrator/executor/executor_state.go b/apps/backend/internal/orchestrator/executor/executor_state.go index 2ca465f1ab..48040d6ff0 100644 --- a/apps/backend/internal/orchestrator/executor/executor_state.go +++ b/apps/backend/internal/orchestrator/executor/executor_state.go @@ -232,6 +232,9 @@ func (e *Executor) applyProfile(ctx context.Context, profileID string, cfg *exec if gitUserEmail := profile.Config["git_user_email"]; gitUserEmail != "" { metadata["git_user_email"] = gitUserEmail } + if imageTag := profile.Config["image_tag"]; imageTag != "" { + metadata["image_tag_override"] = imageTag + } } // resolveProfileEnvVars resolves profile env vars, dereferencing secret IDs to their values. diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 2fb5c98e87..95669d38d4 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -3,6 +3,7 @@ package executor import ( "context" "fmt" + "os/exec" "sync" "sync/atomic" "testing" @@ -824,6 +825,60 @@ func TestRepositoryCloneURL(t *testing.T) { } } +// TestRepositoryCloneURL_LocalPathFallback covers the second code path +// added to support Docker E2E tests: when the repository has no +// ProviderOwner/ProviderName, the function shells out to +// `git -C LocalPath remote get-url origin` and returns the result. +// Without these tests the file://-remote E2E fixture would have no +// regression guard. +func TestRepositoryCloneURL_LocalPathFallback(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + t.Run("local-only repo with valid git remote", func(t *testing.T) { + dir := t.TempDir() + mustRunGit(t, dir, "init", "--quiet") + mustRunGit(t, dir, "remote", "add", "origin", "file:///some/path.git") + + got := repositoryCloneURL(&models.Repository{LocalPath: dir}) + if got != "file:///some/path.git" { + t.Errorf("got %q, want file:///some/path.git", got) + } + }) + + t.Run("local-only repo without remote returns empty", func(t *testing.T) { + dir := t.TempDir() + mustRunGit(t, dir, "init", "--quiet") + + if got := repositoryCloneURL(&models.Repository{LocalPath: dir}); got != "" { + t.Errorf("repo without origin remote should return empty, got %q", got) + } + }) + + t.Run("local-only non-git dir returns empty", func(t *testing.T) { + if got := repositoryCloneURL(&models.Repository{LocalPath: t.TempDir()}); got != "" { + t.Errorf("non-git dir should return empty, got %q", got) + } + }) + + t.Run("no provider and no local path returns empty", func(t *testing.T) { + if got := repositoryCloneURL(&models.Repository{}); got != "" { + t.Errorf("empty repository should return empty, got %q", got) + } + }) +} + +// mustRunGit fails the test if `git -C dir ` exits non-zero. +// Kept tiny on purpose — only used by the LocalPath-fallback tests. +func mustRunGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + func TestPersistResumeState_SetsStartingState(t *testing.T) { repo := newMockRepository() agentManager := &mockAgentManager{} @@ -841,8 +896,7 @@ func TestPersistResumeState_SetsStartingState(t *testing.T) { } repo.sessions[session.ID] = session - resp := &LaunchAgentResponse{AgentExecutionID: "exec-1"} - executor.persistResumeState(context.Background(), "task-1", session, resp, true, executorConfig{}, nil) + executor.persistResumeState(context.Background(), "task-1", session, true) if session.State != models.TaskSessionStateStarting { t.Errorf("expected state STARTING, got %s", session.State) @@ -861,8 +915,7 @@ func TestPersistResumeState_SetsStartingState(t *testing.T) { } repo.sessions[session.ID] = session - resp := &LaunchAgentResponse{AgentExecutionID: "exec-2"} - executor.persistResumeState(context.Background(), "task-1", session, resp, false, executorConfig{}, nil) + executor.persistResumeState(context.Background(), "task-1", session, false) if session.State != models.TaskSessionStateWaitingForInput { t.Errorf("expected state WAITING_FOR_INPUT, got %s", session.State) diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 56a6c86752..c164ccd355 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -1077,6 +1077,7 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st // Extract worktree info populateWorktreeInfo(session, &resp) + s.populateEnvironmentWorkspaceInfo(ctx, session, &resp) // 2. Check if this session's agent is running if exec, ok := s.executor.GetExecutionBySession(sessionID); ok && exec != nil { @@ -1197,6 +1198,31 @@ func populateWorktreeInfo(session *models.TaskSession, resp *dto.TaskSessionStat } } +func (s *Service) populateEnvironmentWorkspaceInfo(ctx context.Context, session *models.TaskSession, resp *dto.TaskSessionStatusResponse) { + if hasWorktreeStatus(resp) { + return + } + env, err := s.repo.GetTaskEnvironmentByTaskID(ctx, session.TaskID) + if err != nil || env == nil { + return + } + if session.TaskEnvironmentID != "" && env.ID != session.TaskEnvironmentID { + return + } + if resp.WorktreePath == nil && env.WorktreePath != "" { + resp.WorktreePath = &env.WorktreePath + } + if resp.WorktreeBranch == nil && env.WorktreeBranch != "" { + resp.WorktreeBranch = &env.WorktreeBranch + } +} + +func hasWorktreeStatus(resp *dto.TaskSessionStatusResponse) bool { + return resp != nil && + resp.WorktreePath != nil && *resp.WorktreePath != "" && + resp.WorktreeBranch != nil && *resp.WorktreeBranch != "" +} + // isActiveSessionState returns true for session states where lazy resume makes sense. func isActiveSessionState(state models.TaskSessionState) bool { switch state { diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index f494a64919..68906aad15 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -903,6 +903,50 @@ func TestGetTaskSessionStatus_HealsStuckStartingSession(t *testing.T) { // mismatch this test simulated cannot occur, and the band-aid was removed // (see shouldHealStuckStartingSession in task_operations.go). +func TestGetTaskSessionStatus_UsesTaskEnvironmentBranchForDocker(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + session.TaskEnvironmentID = "env1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + + now := time.Now().UTC() + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env1", + TaskID: "task1", + ExecutorType: string(models.ExecutorTypeLocalDocker), + WorktreePath: "/workspace", + WorktreeBranch: "feature/test-task-abc", + WorkspacePath: "/workspace", + Status: models.TaskEnvironmentStatusReady, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to create task environment: %v", err) + } + + agentMgr := &mockAgentManager{} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + resp, err := svc.GetTaskSessionStatus(ctx, "task1", "session1") + if err != nil { + t.Fatalf("GetTaskSessionStatus returned error: %v", err) + } + if resp.WorktreeBranch == nil || *resp.WorktreeBranch != "feature/test-task-abc" { + t.Fatalf("worktree_branch = %v, want feature/test-task-abc", resp.WorktreeBranch) + } + if resp.WorktreePath == nil || *resp.WorktreePath != "/workspace" { + t.Fatalf("worktree_path = %v, want /workspace", resp.WorktreePath) + } +} + // --- ReconcileSessionsOnStartup --- func TestReconcileSessionsOnStartup(t *testing.T) { diff --git a/apps/backend/internal/sprites/errors.go b/apps/backend/internal/sprites/errors.go new file mode 100644 index 0000000000..5788eb6f39 --- /dev/null +++ b/apps/backend/internal/sprites/errors.go @@ -0,0 +1,48 @@ +package sprites + +import ( + "errors" + "fmt" + "strings" +) + +// ErrSpriteNotFound is returned when an upstream Sprites API call indicates the +// sandbox does not exist. The upstream SDK currently returns plain errors with +// a "sprite not found: " message for 404 responses; this sentinel lets +// callers branch on that case without ad-hoc string matching. +var ErrSpriteNotFound = errors.New("sprite not found") + +// WrapNotFound wraps err with ErrSpriteNotFound when err looks like an upstream +// "sprite not found" / 404 response. Other errors are returned unchanged. Pass +// the returned error to errors.Is(err, ErrSpriteNotFound) to detect the case. +func WrapNotFound(err error) error { + if err == nil { + return nil + } + if errors.Is(err, ErrSpriteNotFound) { + return err + } + if !looksLikeNotFound(err.Error()) { + return err + } + return fmt.Errorf("%w: %w", ErrSpriteNotFound, err) +} + +// IsNotFound reports whether err originated from an upstream "not found" +// response, even when WrapNotFound has not been applied. Useful for callers +// that receive a raw upstream error. +func IsNotFound(err error) bool { + if err == nil { + return false + } + if errors.Is(err, ErrSpriteNotFound) { + return true + } + return looksLikeNotFound(err.Error()) +} + +func looksLikeNotFound(msg string) bool { + lower := strings.ToLower(msg) + return strings.Contains(lower, "sprite not found") || + strings.Contains(lower, "not found") +} diff --git a/apps/backend/internal/sprites/errors_test.go b/apps/backend/internal/sprites/errors_test.go new file mode 100644 index 0000000000..01eb60e702 --- /dev/null +++ b/apps/backend/internal/sprites/errors_test.go @@ -0,0 +1,62 @@ +package sprites + +import ( + "errors" + "fmt" + "testing" +) + +func TestWrapNotFoundWraps404Errors(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {name: "sprite not found exact", err: fmt.Errorf("sprite not found: kandev-abc"), want: true}, + {name: "uppercase variant", err: fmt.Errorf("Sprite Not Found: kandev-abc"), want: true}, + {name: "wrapped not found", err: fmt.Errorf("get sprite: %w", fmt.Errorf("sprite not found: kandev-abc")), want: true}, + {name: "generic 404 mention", err: fmt.Errorf("HTTP 404: not found"), want: true}, + {name: "auth failure", err: fmt.Errorf("401 unauthorized"), want: false}, + {name: "transient 500", err: fmt.Errorf("internal server error"), want: false}, + {name: "nil", err: nil, want: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wrapped := WrapNotFound(tc.err) + got := errors.Is(wrapped, ErrSpriteNotFound) + if got != tc.want { + t.Fatalf("errors.Is(WrapNotFound(%v), ErrSpriteNotFound) = %v, want %v", tc.err, got, tc.want) + } + if got != IsNotFound(tc.err) { + t.Fatalf("IsNotFound(%v) disagrees with errors.Is on wrapped form", tc.err) + } + }) + } +} + +func TestWrapNotFoundPreservesOriginalError(t *testing.T) { + orig := fmt.Errorf("sprite not found: kandev-abc") + wrapped := WrapNotFound(orig) + + if !errors.Is(wrapped, orig) { + t.Fatalf("WrapNotFound dropped the original error from the chain") + } + if !errors.Is(wrapped, ErrSpriteNotFound) { + t.Fatalf("WrapNotFound dropped the sentinel from the chain") + } +} + +func TestWrapNotFoundIsIdempotent(t *testing.T) { + once := WrapNotFound(fmt.Errorf("sprite not found: kandev-abc")) + twice := WrapNotFound(once) + if once.Error() != twice.Error() { + t.Fatalf("WrapNotFound is not idempotent: once=%q, twice=%q", once, twice) + } +} + +func TestWrapNotFoundNilPassthrough(t *testing.T) { + if WrapNotFound(nil) != nil { + t.Fatalf("WrapNotFound(nil) should return nil") + } +} diff --git a/apps/backend/internal/task/handlers/task_environment_handlers.go b/apps/backend/internal/task/handlers/task_environment_handlers.go index 9a75b19e96..0eeff54860 100644 --- a/apps/backend/internal/task/handlers/task_environment_handlers.go +++ b/apps/backend/internal/task/handlers/task_environment_handlers.go @@ -1,9 +1,14 @@ package handlers import ( + "errors" + "io" "net/http" "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/task/service" ) func (h *TaskHandlers) httpGetTaskEnvironment(c *gin.Context) { @@ -19,3 +24,67 @@ func (h *TaskHandlers) httpGetTaskEnvironment(c *gin.Context) { } c.JSON(http.StatusOK, env.ToAPI()) } + +// httpGetTaskEnvironmentLive returns the recorded TaskEnvironment row plus a +// real-time snapshot of the underlying container (when applicable). Designed +// to be polled from the Executor Settings popover so users see state changes +// without a page reload. +func (h *TaskHandlers) httpGetTaskEnvironmentLive(c *gin.Context) { + taskID := c.Param("id") + env, err := h.service.GetTaskEnvironmentByTaskID(c.Request.Context(), taskID) + if err != nil { + handleNotFound(c, h.logger, err, "task environment not found") + return + } + if env == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no environment for this task"}) + return + } + live, err := h.service.GetTaskEnvironmentLiveStatus(c.Request.Context(), taskID) + if err != nil { + h.logger.Warn("failed to fetch live container status", + zap.String("task_id", taskID), + zap.Error(err)) + } + resp := gin.H{"environment": env.ToAPI()} + if live != nil { + resp["container"] = live + } + c.JSON(http.StatusOK, resp) +} + +type resetEnvironmentRequest struct { + PushBranch bool `json:"push_branch"` +} + +func (h *TaskHandlers) httpResetTaskEnvironment(c *gin.Context) { + taskID := c.Param("id") + var body resetEnvironmentRequest + // The body is optional (an empty POST is valid: defaults to push_branch=false), + // but a malformed JSON payload should be rejected loudly rather than silently + // proceeding with a destructive reset. + if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body: " + err.Error()}) + return + } + + err := h.service.ResetTaskEnvironment(c.Request.Context(), taskID, service.ResetOptions{ + PushBranch: body.PushBranch, + }) + switch { + case err == nil: + c.JSON(http.StatusOK, gin.H{"success": true}) + case errors.Is(err, service.ErrNoEnvironment): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrSessionRunning): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + default: + // Log the underlying error for operators; respond with a generic + // message so internal details (paths, wrapped error chains, etc.) + // aren't leaked to clients. + h.logger.Error("reset task environment failed", + zap.String("task_id", taskID), + zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset task environment"}) + } +} diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index f0ba66a05f..d4c60a9e9a 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -71,6 +71,8 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api.GET("/tasks/:id/sessions", h.httpListTaskSessions) api.POST("/tasks/:id/sessions/ensure", h.httpEnsureTaskSession) api.GET("/tasks/:id/environment", h.httpGetTaskEnvironment) + api.GET("/tasks/:id/environment/live", h.httpGetTaskEnvironmentLive) + api.POST("/tasks/:id/environment/reset", h.httpResetTaskEnvironment) api.GET("/task-sessions/:id/turns", h.httpListSessionTurns) api.POST("/tasks", h.httpCreateTask) api.PATCH("/tasks/:id", h.httpUpdateTask) diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 04b321fc46..0824bb009c 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -112,32 +112,34 @@ type Repos struct { // Service provides task business logic type Service struct { - workspaces repository.WorkspaceRepository - tasks repository.TaskRepository - taskRepos repository.TaskRepoRepository - workflows repository.WorkflowRepository - messages repository.MessageRepository - turns repository.TurnRepository - sessions repository.SessionRepository - gitSnapshots repository.GitSnapshotRepository - repoEntities repository.RepositoryEntityRepository - executors repository.ExecutorRepository - environments repository.EnvironmentRepository - taskEnvironments repository.TaskEnvironmentRepository - reviews repository.ReviewRepository - eventBus bus.EventBus - logger *logger.Logger - discoveryConfig RepositoryDiscoveryConfig - worktreeCleanup WorktreeCleanup - executionStopper TaskExecutionStopper - gitArchiveCapture GitArchiveCapture - workflowStepCreator WorkflowStepCreator - workflowStepGetter WorkflowStepGetter - startStepResolver StartStepResolver - quickChatDir string // Directory for quick-chat workspaces (e.g., ~/.kandev/quick-chat) - branchFetcher *branchFetcher - remoteBranchLister RemoteBranchLister - repoCloneLocation RepoCloneLocation + workspaces repository.WorkspaceRepository + tasks repository.TaskRepository + taskRepos repository.TaskRepoRepository + workflows repository.WorkflowRepository + messages repository.MessageRepository + turns repository.TurnRepository + sessions repository.SessionRepository + gitSnapshots repository.GitSnapshotRepository + repoEntities repository.RepositoryEntityRepository + executors repository.ExecutorRepository + environments repository.EnvironmentRepository + taskEnvironments repository.TaskEnvironmentRepository + reviews repository.ReviewRepository + eventBus bus.EventBus + logger *logger.Logger + discoveryConfig RepositoryDiscoveryConfig + worktreeCleanup WorktreeCleanup + executionStopper TaskExecutionStopper + gitArchiveCapture GitArchiveCapture + workflowStepCreator WorkflowStepCreator + workflowStepGetter WorkflowStepGetter + startStepResolver StartStepResolver + quickChatDir string // Directory for quick-chat workspaces (e.g., ~/.kandev/quick-chat) + branchFetcher *branchFetcher + envDestroyer EnvironmentDestroyer + sessionRunningChecker SessionRunningChecker + remoteBranchLister RemoteBranchLister + repoCloneLocation RepoCloneLocation } // NewService creates a new task service diff --git a/apps/backend/internal/task/service/service_task_environments.go b/apps/backend/internal/task/service/service_task_environments.go index dd2e3de239..528da94268 100644 --- a/apps/backend/internal/task/service/service_task_environments.go +++ b/apps/backend/internal/task/service/service_task_environments.go @@ -2,12 +2,230 @@ package service import ( "context" + "errors" + "fmt" + + "go.uber.org/zap" "github.com/kandev/kandev/internal/task/models" ) +// EnvironmentDestroyer tears down the runtime resources recorded on a TaskEnvironment. +// Implemented by an adapter over the lifecycle.Manager and worktree.Manager. +// Worktree destruction preserves the underlying branch — user data that hasn't been +// pushed is never deleted by reset. +type EnvironmentDestroyer interface { + DestroyContainer(ctx context.Context, containerID string) error + DestroySandbox(ctx context.Context, sandboxID, executionID string) error + DestroyWorktree(ctx context.Context, worktreeID string) error + // PushEnvironmentBranch best-effort pushes the current branch of the environment's + // workspace to its upstream. Returns an error if the push fails; callers can decide + // whether to abort the reset on failure. + PushEnvironmentBranch(ctx context.Context, env *models.TaskEnvironment) error + // GetContainerLiveStatus returns a real-time snapshot of a Docker container, + // or nil when the executor type doesn't have a container layer. + GetContainerLiveStatus(ctx context.Context, containerID string) (*ContainerLiveStatus, error) +} + +// ContainerLiveStatus mirrors lifecycle.ContainerLiveStatus for the task service +// layer so the EnvironmentDestroyer interface doesn't import lifecycle types. +type ContainerLiveStatus struct { + ContainerID string `json:"container_id"` + State string `json:"state"` + Status string `json:"status"` + StartedAt string `json:"started_at,omitempty"` + FinishedAt string `json:"finished_at,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + Health string `json:"health,omitempty"` + Missing bool `json:"missing,omitempty"` +} + +// ResetOptions controls destructive behavior of ResetTaskEnvironment. +type ResetOptions struct { + // PushBranch runs `git push` on the environment's current branch before teardown. + // If the push fails, ResetTaskEnvironment aborts and the environment stays intact. + PushBranch bool +} + +// SessionRunningChecker reports whether a session has a live executor running. +// Used by ResetTaskEnvironment to block resets when the user still has an agent +// attached to the environment. +type SessionRunningChecker interface { + IsAnySessionRunningForTask(ctx context.Context, taskID string) (bool, error) +} + +// ErrSessionRunning is returned by ResetTaskEnvironment when at least one session +// on the task is still actively running and the caller must stop it first. +var ErrSessionRunning = errors.New("active session is running on this task; stop it before resetting the environment") + +// ErrNoEnvironment is returned when the task has no TaskEnvironment to reset. +var ErrNoEnvironment = errors.New("no environment exists for this task") + +// SetEnvironmentDestroyer wires the runtime-resource destroyer used by ResetTaskEnvironment. +func (s *Service) SetEnvironmentDestroyer(d EnvironmentDestroyer) { + s.envDestroyer = d +} + +// SetSessionRunningChecker wires a custom running-session guard. When unset, the +// service falls back to a default implementation over the existing session repo. +func (s *Service) SetSessionRunningChecker(c SessionRunningChecker) { + s.sessionRunningChecker = c +} + +// GetTaskEnvironmentLiveStatus returns a real-time snapshot of the task's +// container, plus the recorded TaskEnvironment row. Returns (nil, nil) when +// the task has no environment yet. Callers (e.g. Executor Settings popover) +// poll this endpoint to surface live state changes. +func (s *Service) GetTaskEnvironmentLiveStatus(ctx context.Context, taskID string) (*ContainerLiveStatus, error) { + env, err := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID) + if err != nil || env == nil { + return nil, err + } + if env.ContainerID == "" || s.envDestroyer == nil { + return nil, nil + } + return s.envDestroyer.GetContainerLiveStatus(ctx, env.ContainerID) +} + +// checkAnySessionRunning delegates to the configured checker or falls back to +// the default (session has an ExecutorRunning row). +func (s *Service) checkAnySessionRunning(ctx context.Context, taskID string) (bool, error) { + if s.sessionRunningChecker != nil { + return s.sessionRunningChecker.IsAnySessionRunningForTask(ctx, taskID) + } + return s.isAnySessionRunning(ctx, taskID) +} + +// isAnySessionRunning is the default SessionRunningChecker: a session is running +// if it has an ExecutorRunning row and is actively starting or processing a turn. +// Idle sessions can keep an executor row so terminals and resumed prompts have +// a live workspace, but Reset Environment must still be able to tear that +// workspace down once the agent is waiting for input. +func (s *Service) isAnySessionRunning(ctx context.Context, taskID string) (bool, error) { + sessions, err := s.sessions.ListTaskSessions(ctx, taskID) + if err != nil { + return false, err + } + for _, sess := range sessions { + if sess == nil { + continue + } + running, err := s.executors.GetExecutorRunningBySessionID(ctx, sess.ID) + if err != nil { + if errors.Is(err, models.ErrExecutorRunningNotFound) { + continue + } + return false, err + } + if running != nil && sessionBlocksEnvironmentReset(sess.State) { + return true, nil + } + } + return false, nil +} + +func sessionBlocksEnvironmentReset(state models.TaskSessionState) bool { + return state == models.TaskSessionStateStarting || state == models.TaskSessionStateRunning +} + // GetTaskEnvironmentByTaskID returns the active task environment for a task. // Returns nil if no environment exists yet. func (s *Service) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) { return s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID) } + +// ResetTaskEnvironment tears down the task's current environment (container/sandbox/worktree) +// and deletes the TaskEnvironment row, so the next session launch starts fresh. +// +// Guards: +// - No environment → ErrNoEnvironment +// - Any session on the task is actively running → ErrSessionRunning +// +// Cleanup is best-effort per resource. If any resource fails to destroy, the +// TaskEnvironment row is preserved so the user can retry. Success deletes the row. +// +// If opts.PushBranch is set, the branch is pushed before teardown; a failed push +// aborts the reset and leaves the environment intact so the user can investigate. +func (s *Service) ResetTaskEnvironment(ctx context.Context, taskID string, opts ResetOptions) error { + env, err := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID) + if err != nil { + return fmt.Errorf("lookup environment: %w", err) + } + if env == nil { + return ErrNoEnvironment + } + + // Fail closed: if the running-session check itself errors (DB hiccup, + // locked table) we cannot prove the task is idle and must abort rather + // than risk destroying a container or sandbox while an agent is still + // writing to it. + running, err := s.checkAnySessionRunning(ctx, taskID) + if err != nil { + return fmt.Errorf("check running sessions before reset: %w", err) + } + if running { + return ErrSessionRunning + } + + s.logger.Info("resetting task environment", + zap.String("task_id", taskID), + zap.String("env_id", env.ID), + zap.String("executor_type", env.ExecutorType), + zap.String("container_id", env.ContainerID), + zap.String("sandbox_id", env.SandboxID), + zap.String("worktree_id", env.WorktreeID), + zap.Bool("push_branch", opts.PushBranch)) + + if opts.PushBranch { + if s.envDestroyer == nil { + return fmt.Errorf("environment destroyer not configured; cannot push branch") + } + if err := s.envDestroyer.PushEnvironmentBranch(ctx, env); err != nil { + return fmt.Errorf("push branch before reset: %w", err) + } + } + + if err := s.teardownEnvironmentResources(ctx, env); err != nil { + return err + } + + if err := s.taskEnvironments.DeleteTaskEnvironment(ctx, env.ID); err != nil { + return fmt.Errorf("delete task environment row: %w", err) + } + s.logger.Info("task environment reset complete", + zap.String("task_id", taskID), + zap.String("env_id", env.ID)) + return nil +} + +// teardownEnvironmentResources destroys runtime resources (container/sandbox/worktree) +// recorded on a TaskEnvironment. Best-effort per resource: every resource is +// attempted even when an earlier one fails, and all failures are joined into a +// single error so a stuck container can't permanently orphan the worktree. +// On any error, the caller should preserve the row so the user can retry. +func (s *Service) teardownEnvironmentResources(ctx context.Context, env *models.TaskEnvironment) error { + if env.ContainerID == "" && env.SandboxID == "" && env.WorktreeID == "" { + return nil + } + if s.envDestroyer == nil { + return fmt.Errorf("environment destroyer not configured; cannot tear down resources") + } + + var errs []error + if env.ContainerID != "" { + if err := s.envDestroyer.DestroyContainer(ctx, env.ContainerID); err != nil { + errs = append(errs, fmt.Errorf("destroy container %s: %w", env.ContainerID, err)) + } + } + if env.SandboxID != "" { + if err := s.envDestroyer.DestroySandbox(ctx, env.SandboxID, ""); err != nil { + errs = append(errs, fmt.Errorf("destroy sandbox %s: %w", env.SandboxID, err)) + } + } + if env.WorktreeID != "" { + if err := s.envDestroyer.DestroyWorktree(ctx, env.WorktreeID); err != nil { + errs = append(errs, fmt.Errorf("destroy worktree %s: %w", env.WorktreeID, err)) + } + } + return errors.Join(errs...) +} diff --git a/apps/backend/internal/task/service/service_task_environments_test.go b/apps/backend/internal/task/service/service_task_environments_test.go new file mode 100644 index 0000000000..7403c02e40 --- /dev/null +++ b/apps/backend/internal/task/service/service_task_environments_test.go @@ -0,0 +1,303 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" +) + +type stubEnvRepo struct { + env *models.TaskEnvironment + deleted bool + getErr error + delErr error +} + +func (s *stubEnvRepo) CreateTaskEnvironment(context.Context, *models.TaskEnvironment) error { + return nil +} +func (s *stubEnvRepo) CreateTaskEnvironmentRepo(context.Context, *models.TaskEnvironmentRepo) error { + return nil +} +func (s *stubEnvRepo) ListTaskEnvironmentRepos(context.Context, string) ([]*models.TaskEnvironmentRepo, error) { + return nil, nil +} +func (s *stubEnvRepo) UpdateTaskEnvironmentRepo(context.Context, *models.TaskEnvironmentRepo) error { + return nil +} +func (s *stubEnvRepo) DeleteTaskEnvironmentRepo(context.Context, string) error { + return nil +} +func (s *stubEnvRepo) DeleteTaskEnvironmentReposByEnv(context.Context, string) error { + return nil +} +func (s *stubEnvRepo) GetTaskEnvironment(context.Context, string) (*models.TaskEnvironment, error) { + return s.env, s.getErr +} +func (s *stubEnvRepo) GetTaskEnvironmentByTaskID(context.Context, string) (*models.TaskEnvironment, error) { + return s.env, s.getErr +} +func (s *stubEnvRepo) UpdateTaskEnvironment(context.Context, *models.TaskEnvironment) error { + return nil +} +func (s *stubEnvRepo) DeleteTaskEnvironment(context.Context, string) error { + if s.delErr != nil { + return s.delErr + } + s.deleted = true + return nil +} +func (s *stubEnvRepo) DeleteTaskEnvironmentsByTask(context.Context, string) error { return nil } + +type stubDestroyer struct { + containerCalls []string + sandboxCalls []string + worktreeCalls []string + pushCalls int + containerErr error + sandboxErr error + worktreeErr error + pushErr error +} + +func (s *stubDestroyer) DestroyContainer(_ context.Context, id string) error { + s.containerCalls = append(s.containerCalls, id) + return s.containerErr +} +func (s *stubDestroyer) DestroySandbox(_ context.Context, id, _ string) error { + s.sandboxCalls = append(s.sandboxCalls, id) + return s.sandboxErr +} +func (s *stubDestroyer) DestroyWorktree(_ context.Context, id string) error { + s.worktreeCalls = append(s.worktreeCalls, id) + return s.worktreeErr +} +func (s *stubDestroyer) PushEnvironmentBranch(context.Context, *models.TaskEnvironment) error { + s.pushCalls++ + return s.pushErr +} +func (s *stubDestroyer) GetContainerLiveStatus(context.Context, string) (*ContainerLiveStatus, error) { + return nil, nil +} + +type stubRunningChecker struct { + running bool + err error +} + +func (s *stubRunningChecker) IsAnySessionRunningForTask(context.Context, string) (bool, error) { + return s.running, s.err +} + +func newResetTestService(t *testing.T, repo *stubEnvRepo) *Service { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("failed to create logger: %v", err) + } + return &Service{ + logger: log, + taskEnvironments: repo, + } +} + +func TestResetTaskEnvironment_NoEnvironment(t *testing.T) { + svc := newResetTestService(t, &stubEnvRepo{env: nil}) + err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{}) + if !errors.Is(err, ErrNoEnvironment) { + t.Fatalf("expected ErrNoEnvironment, got %v", err) + } +} + +func TestResetTaskEnvironment_SessionRunningBlocks(t *testing.T) { + repo := &stubEnvRepo{env: &models.TaskEnvironment{ID: "env-1", TaskID: "task-1", ContainerID: "c"}} + svc := newResetTestService(t, repo) + svc.SetSessionRunningChecker(&stubRunningChecker{running: true}) + svc.SetEnvironmentDestroyer(&stubDestroyer{}) + + err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{}) + if !errors.Is(err, ErrSessionRunning) { + t.Fatalf("expected ErrSessionRunning, got %v", err) + } + if repo.deleted { + t.Error("expected environment row to be preserved when session is running") + } +} + +func TestSessionBlocksEnvironmentReset(t *testing.T) { + tests := []struct { + state models.TaskSessionState + want bool + }{ + {models.TaskSessionStateCreated, false}, + {models.TaskSessionStateStarting, true}, + {models.TaskSessionStateRunning, true}, + {models.TaskSessionStateWaitingForInput, false}, + {models.TaskSessionStateCompleted, false}, + {models.TaskSessionStateFailed, false}, + {models.TaskSessionStateCancelled, false}, + } + + for _, tt := range tests { + if got := sessionBlocksEnvironmentReset(tt.state); got != tt.want { + t.Fatalf("sessionBlocksEnvironmentReset(%q) = %v, want %v", tt.state, got, tt.want) + } + } +} + +func TestResetTaskEnvironment_DestroysEachResourceTypeAndDeletesRow(t *testing.T) { + repo := &stubEnvRepo{env: &models.TaskEnvironment{ + ID: "env-1", + TaskID: "task-1", + ContainerID: "container-abc", + SandboxID: "sandbox-xyz", + WorktreeID: "wt-1", + }} + destroyer := &stubDestroyer{} + svc := newResetTestService(t, repo) + svc.SetSessionRunningChecker(&stubRunningChecker{running: false}) + svc.SetEnvironmentDestroyer(destroyer) + + if err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !repo.deleted { + t.Error("expected environment row to be deleted") + } + if len(destroyer.containerCalls) != 1 || destroyer.containerCalls[0] != "container-abc" { + t.Errorf("expected 1 container destroy call, got %v", destroyer.containerCalls) + } + if len(destroyer.sandboxCalls) != 1 || destroyer.sandboxCalls[0] != "sandbox-xyz" { + t.Errorf("expected 1 sandbox destroy call, got %v", destroyer.sandboxCalls) + } + if len(destroyer.worktreeCalls) != 1 || destroyer.worktreeCalls[0] != "wt-1" { + t.Errorf("expected 1 worktree destroy call, got %v", destroyer.worktreeCalls) + } +} + +func TestResetTaskEnvironment_ContainerDestroyFailurePreservesRow(t *testing.T) { + repo := &stubEnvRepo{env: &models.TaskEnvironment{ + ID: "env-1", + TaskID: "task-1", + ContainerID: "container-abc", + }} + destroyer := &stubDestroyer{containerErr: errors.New("docker unreachable")} + svc := newResetTestService(t, repo) + svc.SetSessionRunningChecker(&stubRunningChecker{running: false}) + svc.SetEnvironmentDestroyer(destroyer) + + err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{}) + if err == nil { + t.Fatal("expected error when container destroy fails") + } + if repo.deleted { + t.Error("expected environment row to be preserved when destroy fails") + } +} + +func TestResetTaskEnvironment_RunningCheckErrorFailsClosed(t *testing.T) { + repo := &stubEnvRepo{env: &models.TaskEnvironment{ + ID: "env-1", + TaskID: "task-1", + ContainerID: "container-abc", + }} + destroyer := &stubDestroyer{} + svc := newResetTestService(t, repo) + svc.SetSessionRunningChecker(&stubRunningChecker{err: errors.New("db locked")}) + svc.SetEnvironmentDestroyer(destroyer) + + err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{}) + if err == nil { + t.Fatal("expected error when running-session check fails") + } + if len(destroyer.containerCalls) != 0 { + t.Errorf("expected teardown to be skipped when guard errors, got %v", destroyer.containerCalls) + } + if repo.deleted { + t.Error("expected environment row to be preserved when guard errors") + } +} + +func TestResetTaskEnvironment_TeardownIsBestEffortAcrossResources(t *testing.T) { + repo := &stubEnvRepo{env: &models.TaskEnvironment{ + ID: "env-1", + TaskID: "task-1", + ContainerID: "container-abc", + WorktreeID: "wt-1", + }} + destroyer := &stubDestroyer{containerErr: errors.New("docker unreachable")} + svc := newResetTestService(t, repo) + svc.SetSessionRunningChecker(&stubRunningChecker{running: false}) + svc.SetEnvironmentDestroyer(destroyer) + + err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{}) + if err == nil { + t.Fatal("expected joined error when container destroy fails") + } + if len(destroyer.containerCalls) != 1 { + t.Errorf("expected container destroy attempted, got %v", destroyer.containerCalls) + } + if len(destroyer.worktreeCalls) != 1 { + t.Errorf("expected worktree destroy attempted even when container failed, got %v", destroyer.worktreeCalls) + } + if repo.deleted { + t.Error("expected environment row to be preserved when any destroy fails") + } +} + +func TestResetTaskEnvironment_PushBranchFailureAbortsResetBeforeTeardown(t *testing.T) { + repo := &stubEnvRepo{env: &models.TaskEnvironment{ + ID: "env-1", + TaskID: "task-1", + WorktreeID: "wt-1", + WorktreePath: "/tmp/worktree", + }} + destroyer := &stubDestroyer{pushErr: errors.New("remote rejected")} + svc := newResetTestService(t, repo) + svc.SetSessionRunningChecker(&stubRunningChecker{running: false}) + svc.SetEnvironmentDestroyer(destroyer) + + err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{PushBranch: true}) + if err == nil { + t.Fatal("expected error when push fails") + } + if destroyer.pushCalls != 1 { + t.Errorf("expected push to be attempted once, got %d", destroyer.pushCalls) + } + if len(destroyer.worktreeCalls) != 0 { + t.Error("expected teardown to be skipped when push fails") + } + if repo.deleted { + t.Error("expected environment row to be preserved when push fails") + } +} + +func TestPerformTaskCleanup_TearsDownTaskEnvironmentAndDeletesRow(t *testing.T) { + env := &models.TaskEnvironment{ + ID: "env-1", + TaskID: "task-1", + ContainerID: "container-abc", + } + repo := &stubEnvRepo{env: env} + destroyer := &stubDestroyer{} + svc := newResetTestService(t, repo) + svc.SetEnvironmentDestroyer(destroyer) + + errs := svc.performTaskCleanup(context.Background(), "task-1", nil, nil, taskEnvironmentCleanup{ + env: env, + deleteRow: true, + }, false) + + if len(errs) != 0 { + t.Fatalf("unexpected cleanup errors: %v", errs) + } + if len(destroyer.containerCalls) != 1 || destroyer.containerCalls[0] != "container-abc" { + t.Fatalf("expected container teardown, got %v", destroyer.containerCalls) + } + if !repo.deleted { + t.Fatal("expected task environment row to be deleted") + } +} diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 2c1ac3d191..8bed13cb78 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -24,6 +24,11 @@ type taskStopTarget struct { executionID string } +type taskEnvironmentCleanup struct { + env *models.TaskEnvironment + deleteRow bool +} + // Task operations // CreateTask creates a new task and publishes a task.created event. @@ -456,6 +461,7 @@ func (s *Service) ArchiveTask(ctx context.Context, id string) error { } } } + taskEnv := s.gatherTaskEnvironmentForCleanup(ctx, id) // 3. Set archived_at in DB if err := s.tasks.ArchiveTask(ctx, id); err != nil { @@ -476,8 +482,9 @@ func (s *Service) ArchiveTask(ctx context.Context, id string) error { // 6. Background: Stop agents and cleanup worktrees // Note: isEphemeral=false for archive to preserve quick-chat directories - if len(stopTargets) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 { - s.runAsyncTaskCleanup(id, sessions, worktrees, stopTargets, false, + envCleanup := taskEnvironmentCleanup{env: taskEnv, deleteRow: true} + if len(stopTargets) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 || taskEnv != nil { + s.runAsyncTaskCleanup(id, sessions, worktrees, stopTargets, envCleanup, false, "task archived", "failed to stop session on task archive", "task archive cleanup completed") } @@ -505,6 +512,7 @@ func (s *Service) DeleteTask(ctx context.Context, id string) error { } worktrees := s.gatherWorktreesForDelete(ctx, id) + taskEnv := s.gatherTaskEnvironmentForCleanup(ctx, id) // 3. Get active sessions for stopping agents (sync, fast) // Must query before delete since DB records will be gone @@ -531,10 +539,14 @@ func (s *Service) DeleteTask(ctx context.Context, id string) error { zap.String("task_id", id), zap.Duration("duration", time.Since(start))) - // 6. Stop agents and cleanup worktrees in the background. - hasCleanup := len(stopTargets) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 || task.IsEphemeral + // 6. Stop agents and cleanup worktrees in the background. Carry the + // envCleanup struct so the task environment row is reset alongside + // the worktrees (an extra task.taskEnv != nil branch keeps the + // cleanup running when only the env needs reclaiming). + envCleanup := taskEnvironmentCleanup{env: taskEnv, deleteRow: false} + hasCleanup := len(stopTargets) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 || task.IsEphemeral || taskEnv != nil if hasCleanup { - s.runAsyncTaskCleanup(id, sessions, worktrees, stopTargets, task.IsEphemeral, + s.runAsyncTaskCleanup(id, sessions, worktrees, stopTargets, envCleanup, task.IsEphemeral, "task deleted", "failed to stop session on task delete", "task cleanup completed") } @@ -567,11 +579,26 @@ func (s *Service) gatherWorktreesForDelete(ctx context.Context, taskID string) [ return worktrees } +func (s *Service) gatherTaskEnvironmentForCleanup(ctx context.Context, taskID string) *models.TaskEnvironment { + if s.taskEnvironments == nil { + return nil + } + env, err := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID) + if err != nil { + s.logger.Warn("failed to lookup task environment for cleanup", + zap.String("task_id", taskID), + zap.Error(err)) + return nil + } + return env +} + func (s *Service) runAsyncTaskCleanup( id string, sessions []*models.TaskSession, worktrees []*worktree.Worktree, stopTargets []taskStopTarget, + envCleanup taskEnvironmentCleanup, isEphemeral bool, stopReason, stopFailMsg, cleanupMsg string, ) { @@ -601,7 +628,7 @@ func (s *Service) runAsyncTaskCleanup( } } - cleanupErrors := s.performTaskCleanup(cleanupCtx, id, sessions, worktrees, isEphemeral) + cleanupErrors := s.performTaskCleanup(cleanupCtx, id, sessions, worktrees, envCleanup, isEphemeral) if len(cleanupErrors) > 0 { s.logger.Warn(cleanupMsg+" with errors", @@ -649,10 +676,14 @@ func (s *Service) performTaskCleanup( taskID string, sessions []*models.TaskSession, worktrees []*worktree.Worktree, + envCleanup taskEnvironmentCleanup, isEphemeral bool, ) []error { var errs []error + errs = append(errs, s.cleanupTaskEnvironment(ctx, taskID, envCleanup)...) + worktrees = excludeEnvironmentWorktree(worktrees, envCleanup.env) + // Cleanup worktrees if len(worktrees) > 0 { if cleaner, ok := s.worktreeCleanup.(WorktreeBatchCleaner); ok { @@ -705,6 +736,47 @@ func (s *Service) performTaskCleanup( return errs } +func (s *Service) cleanupTaskEnvironment( + ctx context.Context, + taskID string, + cleanup taskEnvironmentCleanup, +) []error { + if cleanup.env == nil { + return nil + } + if err := s.teardownEnvironmentResources(ctx, cleanup.env); err != nil { + s.logger.Warn("failed to teardown task environment during task cleanup", + zap.String("task_id", taskID), + zap.String("env_id", cleanup.env.ID), + zap.Error(err)) + return []error{fmt.Errorf("teardown task environment %s: %w", cleanup.env.ID, err)} + } + if cleanup.deleteRow { + if err := s.taskEnvironments.DeleteTaskEnvironment(ctx, cleanup.env.ID); err != nil { + s.logger.Warn("failed to delete task environment row during task cleanup", + zap.String("task_id", taskID), + zap.String("env_id", cleanup.env.ID), + zap.Error(err)) + return []error{fmt.Errorf("delete task environment row %s: %w", cleanup.env.ID, err)} + } + } + return nil +} + +func excludeEnvironmentWorktree(worktrees []*worktree.Worktree, env *models.TaskEnvironment) []*worktree.Worktree { + if env == nil || env.WorktreeID == "" || len(worktrees) == 0 { + return worktrees + } + filtered := worktrees[:0] + for _, wt := range worktrees { + if wt == nil || wt.ID == env.WorktreeID { + continue + } + filtered = append(filtered, wt) + } + return filtered +} + // ListTasks returns all tasks for a workflow func (s *Service) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) { tasks, err := s.tasks.ListTasks(ctx, workflowID) diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 579e4b41f4..6febd61c8d 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -332,6 +332,33 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio ACPSessionID: acpSessionID, } + var taskEnv *models.TaskEnvironment + if session.TaskEnvironmentID != "" { + env, envErr := s.taskEnvironments.GetTaskEnvironment(ctx, session.TaskEnvironmentID) + if envErr != nil { + s.logger.Warn("failed to get task environment for session", + zap.String("session_id", sessionID), + zap.String("task_environment_id", session.TaskEnvironmentID), + zap.Error(envErr)) + } else { + taskEnv = env + } + } + if taskEnv == nil && taskID != "" { + env, envErr := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID) + if envErr != nil { + s.logger.Warn("failed to get task environment by task", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(envErr)) + } else { + taskEnv = env + } + } + if taskEnv != nil { + applyTaskEnvironmentToWorkspaceInfo(info, taskEnv) + } + // Populate executor info for correct runtime selection and remote reconnection running, err := s.executors.GetExecutorRunningBySessionID(ctx, sessionID) if err != nil { @@ -347,6 +374,10 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio } else if running != nil { info.RuntimeName = running.Runtime info.AgentExecutionID = running.AgentExecutionID + mergePersistentWorkspaceMetadata(info, running.Metadata) + if running.ContainerID != "" { + ensureWorkspaceMetadata(info)[lifecycle.MetadataKeyContainerID] = running.ContainerID + } } if session.ExecutorID != "" { exec, err := s.executors.GetExecutor(ctx, session.ExecutorID) @@ -363,6 +394,44 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio return info, nil } +func applyTaskEnvironmentToWorkspaceInfo(info *lifecycle.WorkspaceInfo, env *models.TaskEnvironment) { + // Always align info.TaskEnvironmentID with the env we resolved against. + // When session.TaskEnvironmentID points to a stale/missing env, the + // caller falls back to GetTaskEnvironmentByTaskID and ends up with a + // different env.ID. Previously we only updated info.TaskEnvironmentID + // when it was empty, so the metadata + path here would come from env + // while the ID still pointed at the stale row — a mismatch downstream + // reconcilers and progress events would key off the wrong env. + info.TaskEnvironmentID = env.ID + if info.WorkspacePath == "" { + info.WorkspacePath = env.WorkspacePath + } + if env.ContainerID != "" { + ensureWorkspaceMetadata(info)[lifecycle.MetadataKeyContainerID] = env.ContainerID + } + if env.SandboxID != "" { + ensureWorkspaceMetadata(info)["sprite_name"] = env.SandboxID + } +} + +func mergePersistentWorkspaceMetadata(info *lifecycle.WorkspaceInfo, metadata map[string]interface{}) { + filtered := lifecycle.FilterPersistentMetadata(metadata) + if filtered == nil { + return + } + dst := ensureWorkspaceMetadata(info) + for k, v := range filtered { + dst[k] = v + } +} + +func ensureWorkspaceMetadata(info *lifecycle.WorkspaceInfo) map[string]interface{} { + if info.Metadata == nil { + info.Metadata = make(map[string]interface{}) + } + return info.Metadata +} + // GetWorkspaceInfoForEnvironment returns workspace information for a task environment. func (s *Service) GetWorkspaceInfoForEnvironment(ctx context.Context, taskEnvironmentID string) (*lifecycle.WorkspaceInfo, error) { if taskEnvironmentID == "" { diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index b1e4cd85ad..caf8202e87 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/kandev/kandev/internal/agent/lifecycle" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" ) @@ -171,6 +172,101 @@ func TestGetWorkspaceInfoForSession_ExecutorInfo(t *testing.T) { } } +func TestGetWorkspaceInfoForSession_IncludesEnvironmentReconnectMetadata(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + now := time.Now().UTC() + + if err := repo.CreateExecutor(ctx, &models.Executor{ + ID: "exec-1", + Name: "Docker", + Type: models.ExecutorTypeLocalDocker, + Status: "active", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to create executor: %v", err) + } + + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-123", + TaskID: "task-123", + ExecutorType: string(models.ExecutorTypeLocalDocker), + Status: models.TaskEnvironmentStatusReady, + WorkspacePath: "/host/repo", + ContainerID: "container-from-env", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to create task environment: %v", err) + } + + session := &models.TaskSession{ + ID: "session-1", + TaskID: "task-123", + TaskEnvironmentID: "env-123", + ExecutorID: "exec-1", + AgentProfileSnapshot: map[string]interface{}{ + "agent_name": "codex", + }, + State: models.TaskSessionStateCompleted, + StartedAt: now, + UpdatedAt: now, + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to create session: %v", err) + } + + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "er-1", + SessionID: "session-1", + TaskID: "task-123", + ExecutorID: "exec-1", + Runtime: "docker", + AgentExecutionID: "running-exec", + ContainerID: "container-from-running", + Metadata: map[string]interface{}{ + lifecycle.MetadataKeyAuthTokenSecret: "secret-token", + lifecycle.MetadataKeyBootstrapNonceSecret: "secret-nonce", + lifecycle.MetadataKeyImageTagOverride: "kandev:test", + "task_description": "drop me", + }, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to upsert executor running: %v", err) + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-1") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession returned error: %v", err) + } + + if info.WorkspacePath != "/host/repo" { + t.Fatalf("WorkspacePath = %q, want /host/repo", info.WorkspacePath) + } + if info.AgentExecutionID != "running-exec" { + t.Fatalf("AgentExecutionID = %q, want running-exec", info.AgentExecutionID) + } + if info.Metadata[lifecycle.MetadataKeyContainerID] != "container-from-running" { + t.Fatalf("container metadata = %v, want container-from-running", info.Metadata[lifecycle.MetadataKeyContainerID]) + } + if info.Metadata[lifecycle.MetadataKeyAuthTokenSecret] != "secret-token" { + t.Fatalf("auth secret metadata missing: %v", info.Metadata) + } + if info.Metadata[lifecycle.MetadataKeyBootstrapNonceSecret] != "secret-nonce" { + t.Fatalf("nonce secret metadata missing: %v", info.Metadata) + } + if info.Metadata[lifecycle.MetadataKeyImageTagOverride] != "kandev:test" { + t.Fatalf("image override metadata missing: %v", info.Metadata) + } + if _, ok := info.Metadata["task_description"]; ok { + t.Fatalf("launch-only metadata should not be retained: %v", info.Metadata) + } +} + func TestGetWorkspaceInfoForSession_NoExecutorRunning(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() @@ -205,6 +301,66 @@ func TestGetWorkspaceInfoForSession_NoExecutorRunning(t *testing.T) { } } +// TestGetWorkspaceInfoForSession_AlignsTaskEnvironmentIDOnFallback locks in +// the fix in applyTaskEnvironmentToWorkspaceInfo: when the session's stored +// TaskEnvironmentID points to a missing/stale row, GetWorkspaceInfoForSession +// falls back to GetTaskEnvironmentByTaskID. The previous implementation kept +// info.TaskEnvironmentID pointing at the stale ID while picking up +// metadata/workspace path from the fallback env — a downstream ID/metadata +// mismatch that broke reconciler keying. The fix always aligns the ID with +// the env we resolved against. +func TestGetWorkspaceInfoForSession_AlignsTaskEnvironmentIDOnFallback(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + now := time.Now().UTC() + + // Real env exists under a different ID than what the session points at. + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "real-env", + TaskID: "task-123", + Status: models.TaskEnvironmentStatusReady, + WorkspacePath: "/host/real-env", + ContainerID: "container-real", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to create task environment: %v", err) + } + + // Session stores a stale TaskEnvironmentID — the env it points at has + // been deleted or rolled over, but the session row still references it. + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-1", + TaskID: "task-123", + TaskEnvironmentID: "stale-env", + AgentProfileSnapshot: map[string]interface{}{ + "agent_name": "auggie", + }, + State: models.TaskSessionStateCompleted, + StartedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to create session: %v", err) + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-1") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession returned error: %v", err) + } + + if info.TaskEnvironmentID != "real-env" { + t.Fatalf("TaskEnvironmentID = %q, want real-env (alignment fix should overwrite the session's stale ID)", info.TaskEnvironmentID) + } + if info.WorkspacePath != "/host/real-env" { + t.Fatalf("WorkspacePath = %q, want /host/real-env (metadata must come from the resolved env, same as the ID)", info.WorkspacePath) + } + if got := info.Metadata["container_id"]; got != "container-real" { + t.Fatalf("metadata container_id = %v, want container-real", got) + } +} + func TestGetWorkspaceInfoForSession_SessionNotFound(t *testing.T) { svc, _, _ := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/worktree/config.go b/apps/backend/internal/worktree/config.go index 7a8a5154f7..e2bb40d7b2 100644 --- a/apps/backend/internal/worktree/config.go +++ b/apps/backend/internal/worktree/config.go @@ -140,6 +140,25 @@ func NormalizeBranchPrefix(prefix string) string { return trimmed } +// TaskBranchName returns the standard per-task branch name used by isolated +// executors. The random suffix keeps similarly titled tasks from colliding. +func TaskBranchName(taskTitle, taskID, prefix string) string { + return TaskBranchNameWithSuffix(taskTitle, taskID, prefix, SmallSuffix(3)) +} + +// TaskBranchNameWithSuffix is the deterministic form of TaskBranchName used by +// worktree naming, where the caller already generated the suffix. +func TaskBranchNameWithSuffix(taskTitle, taskID, prefix, suffix string) string { + sanitized := SanitizeForBranch(taskTitle, 20) + if sanitized == "" { + sanitized = SanitizeForBranch(taskID, 20) + } + if sanitized == "" { + sanitized = "task" + } + return NormalizeBranchPrefix(prefix) + sanitized + "-" + suffix +} + // ValidateBranchPrefix ensures a prefix contains only safe branch characters. func ValidateBranchPrefix(prefix string) error { trimmed := strings.TrimSpace(prefix) diff --git a/apps/backend/internal/worktree/manager.go b/apps/backend/internal/worktree/manager.go index e59d0810cd..21de729b18 100644 --- a/apps/backend/internal/worktree/manager.go +++ b/apps/backend/internal/worktree/manager.go @@ -686,15 +686,11 @@ func (m *Manager) buildWorktreeNames(req CreateRequest) (dirName, branchName str if req.TaskTitle != "" { // Use semantic naming: {sanitized-title}_{suffix} dirName = SemanticWorktreeName(req.TaskTitle, dirSuffix) - sanitizedTitle := SanitizeForBranch(req.TaskTitle, 20) - if sanitizedTitle == "" { - sanitizedTitle = SanitizeForBranch(req.TaskID, 20) - } - branchName = prefix + sanitizedTitle + "-" + branchSuffix + branchName = TaskBranchNameWithSuffix(req.TaskTitle, req.TaskID, prefix, branchSuffix) } else { // Fallback to task ID based naming dirName = req.TaskID + "_" + dirSuffix - branchName = prefix + SanitizeForBranch(req.TaskID, 20) + "-" + branchSuffix + branchName = TaskBranchNameWithSuffix("", req.TaskID, prefix, branchSuffix) } return dirName, branchName } diff --git a/apps/web/app/settings/executor/[id]/profile/[profileId]/page.tsx b/apps/web/app/settings/executor/[id]/profile/[profileId]/page.tsx index 88b2354aac..4e9fd1d935 100644 --- a/apps/web/app/settings/executor/[id]/profile/[profileId]/page.tsx +++ b/apps/web/app/settings/executor/[id]/profile/[profileId]/page.tsx @@ -18,6 +18,8 @@ import { } from "@kandev/ui/dialog"; import { IconPlus, IconTrash } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; +import { RequestIndicator } from "@/components/request-indicator"; +import { useToast } from "@/components/toast-provider"; import { useSecrets } from "@/hooks/domains/settings/use-secrets"; import { updateExecutorProfile, @@ -25,6 +27,11 @@ import { listScriptPlaceholders, } from "@/lib/api/domains/settings-api"; import type { ScriptPlaceholder } from "@/lib/api/domains/settings-api"; +import { + getSaveButtonLabel, + upsertExecutorProfile, + type SaveStatus, +} from "@/components/settings/profile-edit/profile-edit-page-chrome"; import { SpritesConnectionCard, SpritesInstancesCard, @@ -234,18 +241,20 @@ function EnvVarsCard({ function ProfileActions({ executorId, - saving, + saveStatus, nameValid, onSave, onRequestDelete, }: { executorId: string; - saving: boolean; + saveStatus: SaveStatus; nameValid: boolean; onSave: () => void; onRequestDelete: () => void; }) { const router = useRouter(); + const isSaving = saveStatus === "loading"; + const saveLabel = getSaveButtonLabel(saveStatus); return (
-
@@ -303,9 +321,10 @@ function DeleteProfileDialog({ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { const router = useRouter(); + const { toast } = useToast(); const executors = useAppStore((state) => state.executors.items); const setExecutors = useAppStore((state) => state.setExecutors); - const [saving, setSaving] = useState(false); + const [saveStatus, setSaveStatus] = useState("idle"); const [error, setError] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleting, setDeleting] = useState(false); @@ -317,24 +336,22 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { cleanup_script: string; env_vars: ProfileEnvVar[]; }) => { - setSaving(true); + setSaveStatus("loading"); setError(null); try { const updated = await updateExecutorProfile(executor.id, profile.id, data); - setExecutors( - executors.map((e: Executor) => - e.id === executor.id - ? { ...e, profiles: e.profiles?.map((p) => (p.id === updated.id ? updated : p)) } - : e, - ), - ); + setSaveStatus("success"); + toast({ title: "Profile saved", variant: "success" }); + setExecutors(upsertExecutorProfile(executors, executor, updated)); + window.setTimeout(() => setSaveStatus("idle"), 1500); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save profile"); - } finally { - setSaving(false); + const message = err instanceof Error ? err.message : "Failed to save profile"; + setError(message); + setSaveStatus("error"); + toast({ title: "Failed to save profile", description: message, variant: "error" }); } }, - [executor.id, profile.id, executors, setExecutors], + [executor, profile.id, executors, setExecutors, toast], ); const remove = useCallback(async () => { @@ -355,7 +372,7 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { } }, [executor.id, profile.id, executors, setExecutors, router]); - return { saving, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove }; + return { saveStatus, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove }; } function useProfileFormState(executor: Executor, profile: ExecutorProfile) { @@ -485,7 +502,7 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E {persistence.error &&

{persistence.error}

} persistence.setDeleteDialogOpen(true)} diff --git a/apps/web/app/settings/executors/[profileId]/page.tsx b/apps/web/app/settings/executors/[profileId]/page.tsx index ad4fa57969..70cc420b9d 100644 --- a/apps/web/app/settings/executors/[profileId]/page.tsx +++ b/apps/web/app/settings/executors/[profileId]/page.tsx @@ -9,6 +9,7 @@ import { useSecrets } from "@/hooks/domains/settings/use-secrets"; import { updateExecutorProfile, deleteExecutorProfile, + removeDockerContainer, fetchLocalGitIdentity, listScriptPlaceholders, } from "@/lib/api/domains/settings-api"; @@ -33,11 +34,15 @@ import { DockerSections, SpritesSections, } from "@/components/settings/profile-edit/profile-runtime-sections"; +import { useDockerProfileContainers } from "@/components/settings/profile-edit/docker-sections"; import { ProfileHeader, ProfileFormActions, DeleteProfileDialog, + upsertExecutorProfile, + type SaveStatus, } from "@/components/settings/profile-edit/profile-edit-page-chrome"; +import { useToast } from "@/components/toast-provider"; import type { Executor, ExecutorProfile, ProfileEnvVar } from "@/lib/types/http"; import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api"; @@ -196,9 +201,10 @@ export default function ProfileEditPage({ params }: { params: Promise<{ profileI function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { const router = useRouter(); + const { toast } = useToast(); const executors = useAppStore((state) => state.executors.items); const setExecutors = useAppStore((state) => state.setExecutors); - const [saving, setSaving] = useState(false); + const [saveStatus, setSaveStatus] = useState("idle"); const [error, setError] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleting, setDeleting] = useState(false); @@ -212,45 +218,47 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { cleanup_script: string; env_vars: ProfileEnvVar[]; }) => { - setSaving(true); + setSaveStatus("loading"); setError(null); try { const updated = await updateExecutorProfile(executor.id, profile.id, data); + setSaveStatus("success"); + toast({ title: "Profile saved", variant: "success" }); + setExecutors(upsertExecutorProfile(executors, executor, updated)); + window.setTimeout(() => setSaveStatus("idle"), 1500); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to save profile"; + setError(message); + setSaveStatus("error"); + toast({ title: "Failed to save profile", description: message, variant: "error" }); + } + }, + [executor, profile.id, executors, setExecutors, toast], + ); + + const remove = useCallback( + async (beforeDelete?: () => Promise) => { + setDeleting(true); + try { + await beforeDelete?.(); + await deleteExecutorProfile(executor.id, profile.id); setExecutors( executors.map((e: Executor) => e.id === executor.id - ? { ...e, profiles: e.profiles?.map((p) => (p.id === updated.id ? updated : p)) } + ? { ...e, profiles: e.profiles?.filter((p) => p.id !== profile.id) } : e, ), ); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save profile"); - } finally { - setSaving(false); + router.push(EXECUTORS_ROUTE); + } catch { + setDeleting(false); + setDeleteDialogOpen(false); } }, - [executor.id, profile.id, executors, setExecutors], + [executor.id, profile.id, executors, setExecutors, router], ); - const remove = useCallback(async () => { - setDeleting(true); - try { - await deleteExecutorProfile(executor.id, profile.id); - setExecutors( - executors.map((e: Executor) => - e.id === executor.id - ? { ...e, profiles: e.profiles?.filter((p) => p.id !== profile.id) } - : e, - ), - ); - router.push(EXECUTORS_ROUTE); - } catch { - setDeleting(false); - setDeleteDialogOpen(false); - } - }, [executor.id, profile.id, executors, setExecutors, router]); - - return { saving, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove }; + return { saveStatus, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove }; } function useProfileFormState(executor: Executor, profile: ExecutorProfile) { @@ -258,6 +266,8 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) { const [mcpPolicy, setMcpPolicy] = useState(profile.mcp_policy ?? ""); const [prepareScript, setPrepareScript] = useState(profile.prepare_script ?? ""); const [cleanupScript, setCleanupScript] = useState(profile.cleanup_script ?? ""); + const [dockerfile, setDockerfile] = useState(profile.config?.dockerfile ?? ""); + const [imageTag, setImageTag] = useState(profile.config?.image_tag ?? ""); const { envVarRows, addEnvVar, removeEnvVar, updateEnvVar } = useEnvVarRows(profile.env_vars); const [placeholders, setPlaceholders] = useState([]); const [spritesSecretId, setSpritesSecretId] = useState(() => @@ -295,6 +305,10 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) { setPrepareScript, cleanupScript, setCleanupScript, + dockerfile, + setDockerfile, + imageTag, + setImageTag, envVarRows, addEnvVar, removeEnvVar, @@ -334,7 +348,7 @@ function buildSaveConfig( } else { delete config.sprites_network_policy_rules; } - if (form.isSprites && form.remoteCredentials.length > 0) { + if (form.isRemote && form.remoteCredentials.length > 0) { config.remote_credentials = JSON.stringify(form.remoteCredentials); } else { delete config.remote_credentials; @@ -342,7 +356,7 @@ function buildSaveConfig( const nonNullEnvVars = Object.fromEntries( Object.entries(form.agentEnvVars).filter(([, v]) => v != null), ); - if (form.isSprites && Object.keys(nonNullEnvVars).length > 0) { + if (form.isRemote && Object.keys(nonNullEnvVars).length > 0) { config.remote_auth_secrets = JSON.stringify(nonNullEnvVars); } else { delete config.remote_auth_secrets; @@ -365,9 +379,27 @@ function buildSaveConfig( } else { delete config.git_user_email; } + applyDockerConfig(config, form); return config; } +function applyDockerConfig( + config: Record, + form: ReturnType, +): void { + if (!form.isDocker) return; + if (form.dockerfile.trim()) { + config.dockerfile = form.dockerfile; + } else { + delete config.dockerfile; + } + if (form.imageTag.trim()) { + config.image_tag = form.imageTag.trim(); + } else { + delete config.image_tag; + } +} + function ProfileEditSections({ executor, profile, @@ -389,7 +421,15 @@ function ProfileEditSections({ secrets={secrets} /> )} - {form.isDocker && } + {form.isDocker && ( + + )} {form.isRemote && ( { @@ -465,6 +506,18 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E }); }; + const handleDelete = (options?: { removeRelatedDockerContainers?: boolean }) => { + const beforeDelete = options?.removeRelatedDockerContainers + ? async () => { + await Promise.all( + relatedContainers.containers.map((container) => removeDockerContainer(container.id)), + ); + await relatedContainers.refresh(); + } + : undefined; + void persistence.remove(beforeDelete); + }; + return (
{persistence.error}

} persistence.setDeleteDialogOpen(true)} @@ -491,8 +544,9 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E
); diff --git a/apps/web/app/settings/executors/new/[type]/page.tsx b/apps/web/app/settings/executors/new/[type]/page.tsx index 3b8b0a3073..574015d2db 100644 --- a/apps/web/app/settings/executors/new/[type]/page.tsx +++ b/apps/web/app/settings/executors/new/[type]/page.tsx @@ -6,6 +6,7 @@ import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { Separator } from "@kandev/ui/separator"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; import { useSecrets } from "@/hooks/domains/settings/use-secrets"; import { @@ -27,7 +28,10 @@ import { rowsToEnvVars, } from "@/components/settings/profile-edit/env-vars-card"; import { ScriptCard } from "@/components/settings/profile-edit/script-card"; -import { DockerfileBuildCard } from "@/components/settings/profile-edit/docker-sections"; +import { + DockerfileBuildCard, + type DockerBuildSuccess, +} from "@/components/settings/profile-edit/docker-sections"; import { SpritesApiKeyCard } from "@/components/settings/profile-edit/sprites-api-key-card"; import { NetworkPoliciesCard } from "@/components/settings/profile-edit/sprites-sections"; import { @@ -144,13 +148,20 @@ function CreateProfileHeader({ function CreateFormActions({ saving, saveDisabled, + disabledReason, onSave, }: { saving: boolean; saveDisabled: boolean; + disabledReason: string | null; onSave: () => void; }) { const router = useRouter(); + const createButton = ( + + ); return (
- + {disabledReason ? ( + + + + {createButton} + + + {disabledReason} + + ) : ( + createButton + )}
); } @@ -170,6 +190,7 @@ function CreateFormActions({ type BuildProfileConfigInput = { isRemote: boolean; isSprites: boolean; + isDocker: boolean; networkPolicyRules: NetworkPolicyRule[]; remoteCredentials: string[]; agentEnvVars: Record; @@ -177,12 +198,15 @@ type BuildProfileConfigInput = { localGitIdentity: GitIdentityState; gitUserName: string; gitUserEmail: string; + dockerfile: string; + imageTag: string; }; function buildProfileConfig(input: BuildProfileConfigInput): Record | undefined { const { isRemote, isSprites, + isDocker, networkPolicyRules, remoteCredentials, agentEnvVars, @@ -190,18 +214,20 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record = {}; if (isSprites && networkPolicyRules.length > 0) { config.sprites_network_policy_rules = JSON.stringify(networkPolicyRules); } - if (isSprites && remoteCredentials.length > 0) { + if (isRemote && remoteCredentials.length > 0) { config.remote_credentials = JSON.stringify(remoteCredentials); } const nonNullEnvVars = Object.fromEntries( Object.entries(agentEnvVars).filter(([, v]) => v != null), ); - if (isSprites && Object.keys(nonNullEnvVars).length > 0) { + if (isRemote && Object.keys(nonNullEnvVars).length > 0) { config.remote_auth_secrets = JSON.stringify(nonNullEnvVars); } if (isRemote) { @@ -216,9 +242,25 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record 0 ? config : undefined; } +function applyDockerCreateConfig( + config: Record, + isDocker: boolean, + dockerfile: string, + imageTag: string, +): void { + if (!isDocker) return; + if (dockerfile.trim()) { + config.dockerfile = dockerfile; + } + if (imageTag.trim()) { + config.image_tag = imageTag.trim(); + } +} + function useDefaultScripts(executorType: string, setPrepareScript: (v: string) => void) { useEffect(() => { fetchDefaultScripts(executorType) @@ -305,7 +347,7 @@ function useCreateGitIdentityState(isRemote: boolean) { } function useCreateProfileFormState(executorType: string) { - const [name, setName] = useState(""); + const [name, setName] = useState(() => (executorType === "local_docker" ? "Docker" : "")); const [mcpPolicy, setMcpPolicy] = useState(""); const [prepareScript, setPrepareScript] = useState(""); const [cleanupScript, setCleanupScript] = useState(""); @@ -315,6 +357,7 @@ function useCreateProfileFormState(executorType: string) { const remoteAuth = useCreateRemoteAuthState(executorType); const [dockerfile, setDockerfile] = useState(""); const [imageTag, setImageTag] = useState(""); + const [builtDockerImage, setBuiltDockerImage] = useState(null); const flags = useCreateRemoteFlags(executorType); const gitIdentity = useCreateGitIdentityState(flags.isRemote); const mcpPolicyError = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]); @@ -335,6 +378,17 @@ function useCreateProfileFormState(executorType: string) { return vars; }, [envVarRows, flags.isSprites, spritesSecretId]); + const recordDockerBuildSuccess = useCallback((result: DockerBuildSuccess) => { + setBuiltDockerImage(result); + }, []); + + const dockerImageBuilt = + !flags.isDocker || + (Boolean(dockerfile.trim()) && + Boolean(imageTag.trim()) && + builtDockerImage?.dockerfile === dockerfile && + builtDockerImage?.imageTag === imageTag.trim()); + const prepareDesc = flags.isRemote ? "Runs inside the execution environment before the agent starts. Type {{ to see available placeholders." : "Runs on the host machine before the agent starts."; @@ -368,6 +422,8 @@ function useCreateProfileFormState(executorType: string) { setDockerfile, imageTag, setImageTag, + recordDockerBuildSuccess, + dockerImageBuilt, gitUserName: gitIdentity.gitUserName, setGitUserName: gitIdentity.setGitUserName, gitUserEmail: gitIdentity.gitUserEmail, @@ -393,6 +449,10 @@ function useCreateProfileSave( const handleSave = useCallback(async () => { if (!form.name.trim() || form.mcpPolicyError) return; + if (form.isDocker && !form.dockerImageBuilt) { + setError("Build this Docker image before creating the profile."); + return; + } if (form.isSprites && !form.spritesSecretId) { setError("Sprites API key is required."); return; @@ -406,6 +466,7 @@ function useCreateProfileSave( config: buildProfileConfig({ isRemote: form.isRemote, isSprites: form.isSprites, + isDocker: form.isDocker, networkPolicyRules: form.networkPolicyRules, remoteCredentials: form.remoteCredentials, agentEnvVars: form.agentEnvVars, @@ -413,6 +474,8 @@ function useCreateProfileSave( localGitIdentity: form.localGitIdentity, gitUserName: form.gitUserName, gitUserEmail: form.gitUserEmail, + dockerfile: form.dockerfile, + imageTag: form.imageTag, }), prepare_script: form.prepareScript, cleanup_script: form.cleanupScript, @@ -458,6 +521,7 @@ function CreateProfileSections({ onDockerfileChange={form.setDockerfile} imageTag={form.imageTag} onImageTagChange={form.setImageTag} + onBuildSuccess={form.recordDockerBuildSuccess} /> )} {form.isRemote && ( @@ -519,6 +583,23 @@ function CreateProfileSections({ ); } +function getCreateDisabledReason( + form: ReturnType, + spritesTokenMissing: boolean, + saving: boolean, +) { + if (saving) return "Creating profile..."; + if (!form.name.trim()) return "Enter a profile name."; + if (form.mcpPolicyError) return form.mcpPolicyError; + if (spritesTokenMissing) return "Add a Sprites API key before creating the profile."; + if (form.isDocker) { + if (!form.imageTag.trim()) return "Enter an image tag before creating the profile."; + if (!form.dockerfile.trim()) return "Add Dockerfile content before creating the profile."; + if (!form.dockerImageBuilt) return "Build this Docker image before creating the profile."; + } + return null; +} + function CreateProfileForm({ executorType, typeInfo, @@ -530,6 +611,7 @@ function CreateProfileForm({ const form = useCreateProfileFormState(executorType); const { saving, error, handleSave } = useCreateProfileSave(form, typeInfo.executorId); const spritesTokenMissing = form.isSprites && !form.spritesSecretId; + const disabledReason = getCreateDisabledReason(form, spritesTokenMissing, saving); return (
@@ -545,9 +627,8 @@ function CreateProfileForm({ {error &&

{error}

}
diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index f58ae17c24..9fd3a94fbd 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -115,6 +115,7 @@ export function KanbanCardBody({ )} diff --git a/apps/web/components/session/prepare-progress.test.tsx b/apps/web/components/session/prepare-progress.test.tsx new file mode 100644 index 0000000000..e50ce74fc7 --- /dev/null +++ b/apps/web/components/session/prepare-progress.test.tsx @@ -0,0 +1,120 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PrepareStepInfo } from "@/lib/state/slices/session-runtime/types"; + +let mockSteps: PrepareStepInfo[] = []; +let mockPrepareStatus: "preparing" | "completed" | "failed" = "preparing"; +let mockSessionState: string = "STARTING"; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + prepareProgress: { + bySessionId: { + "session-1": { + sessionId: "session-1", + status: mockPrepareStatus, + steps: mockSteps, + }, + }, + }, + taskSessions: { + items: { + "session-1": { + id: "session-1", + state: mockSessionState, + }, + }, + }, + sessionAgentctl: { + itemsBySessionId: {}, + }, + }), +})); + +import { PrepareProgress } from "./prepare-progress"; + +describe("PrepareProgress", () => { + afterEach(() => cleanup()); + + it("hides skipped steps that have no useful details", () => { + mockPrepareStatus = "preparing"; + mockSessionState = "STARTING"; + mockSteps = [ + { + name: "Uploading credentials", + status: "skipped", + }, + { + name: "Waiting for agent controller", + status: "completed", + }, + ]; + + render(); + + expect(screen.queryByText("Uploading credentials")).toBeNull(); + expect(screen.getByText("Waiting for agent controller")).toBeTruthy(); + }); + + it("keeps the fallback notice row visible because it carries a warning", () => { + mockPrepareStatus = "preparing"; + mockSessionState = "STARTING"; + mockSteps = [ + { + name: "Reconnecting cloud sandbox", + status: "skipped", + warning: + "Previous sandbox is no longer available — provisioning a fresh one for this branch.", + warningDetail: "Old sandbox could not be reached.", + output: "Old sandbox: kandev-old\nNew sandbox: kandev-new\nBranch: feature/foo", + }, + ]; + + render(); + + expect(screen.getByText("Reconnecting cloud sandbox")).toBeTruthy(); + expect( + screen.getByText( + "Previous sandbox is no longer available — provisioning a fresh one for this branch.", + ), + ).toBeTruthy(); + }); + + it("relabels the header when the only warning is the fallback notice", () => { + mockPrepareStatus = "completed"; + mockSessionState = "RUNNING"; + mockSteps = [ + { + name: "Reconnecting cloud sandbox", + status: "skipped", + warning: + "Previous sandbox is no longer available — provisioning a fresh one for this branch.", + }, + { name: "Creating cloud sandbox", status: "completed" }, + { name: "Waiting for agent controller", status: "completed" }, + ]; + + render(); + + expect(screen.getByText("Environment prepared on a fresh sandbox")).toBeTruthy(); + expect(screen.queryByText("Environment prepared with warnings")).toBeNull(); + }); + + it("uses the generic warnings header when warnings are unrelated to fallback", () => { + mockPrepareStatus = "completed"; + mockSessionState = "RUNNING"; + mockSteps = [ + { + name: "Uploading credentials", + status: "completed", + warning: "Some credentials skipped", + }, + ]; + + render(); + + expect(screen.getByText("Environment prepared with warnings")).toBeTruthy(); + expect(screen.queryByText("Environment prepared on a fresh sandbox")).toBeNull(); + }); +}); diff --git a/apps/web/components/session/prepare-progress.tsx b/apps/web/components/session/prepare-progress.tsx index bca7dd4098..00c27e03a5 100644 --- a/apps/web/components/session/prepare-progress.tsx +++ b/apps/web/components/session/prepare-progress.tsx @@ -12,6 +12,7 @@ import { } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { ExpandableRow } from "@/components/task/chat/messages/expandable-row"; +import { isFallbackNoticeStep } from "@/lib/prepare/summarize"; import { cn } from "@/lib/utils"; import type { PrepareStepInfo, @@ -283,7 +284,13 @@ function getHeaderLabel(status: EffectiveStatus, prepareState: SessionPrepareSta // `||` (not `??`) so an empty-string error also falls through to the name fallback. return failed?.error || `${failed?.name ?? "Environment prepared"} — step failed`; } - if (status === "completed_with_warnings") return "Environment prepared with warnings"; + if (status === "completed_with_warnings") { + const warningSteps = prepareState.steps.filter((s) => s.warning); + if (warningSteps.length === 1 && isFallbackNoticeStep(warningSteps[0])) { + return "Environment prepared on a fresh sandbox"; + } + return "Environment prepared with warnings"; + } return "Environment prepared"; } @@ -300,6 +307,15 @@ function Mono({ children }: { children: React.ReactNode }) { return {children}; } +function hasStepDetails(step: PrepareStepInfo): boolean { + return Boolean(step.command || step.output || step.error || step.warning || step.warningDetail); +} + +function isVisibleStep(step: PrepareStepInfo): boolean { + if (step.status === "skipped" && !hasStepDetails(step)) return false; + return step.name.trim() !== "" || hasStepDetails(step); +} + function SessionInfo({ sessionId }: { sessionId: string }) { const session = useAppStore((state) => state.taskSessions.items[sessionId]); if (!session) return null; @@ -350,13 +366,7 @@ export function PrepareProgress({ sessionId }: PrepareProgressProps) { if (!prepareState) return null; const hasSessionInfo = Boolean(session?.worktree_path || session?.worktree_branch); - const visibleSteps = prepareState.steps.filter( - (step) => - step.name.trim() !== "" || - Boolean(step.output) || - Boolean(step.error) || - Boolean(step.warning), - ); + const visibleSteps = prepareState.steps.filter(isVisibleStep); const headerLabel = getHeaderLabel(status, prepareState); const isErrorStatus = status === "failed" || status === "completed_with_error"; const headerClass = cn("text-xs", isErrorStatus ? "text-destructive" : "text-muted-foreground"); diff --git a/apps/web/components/settings/profile-edit/docker-sections.test.ts b/apps/web/components/settings/profile-edit/docker-sections.test.ts new file mode 100644 index 0000000000..e5d0094390 --- /dev/null +++ b/apps/web/components/settings/profile-edit/docker-sections.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { containerTaskLabel } from "./docker-sections"; +import type { DockerContainer } from "@/lib/api/domains/settings-api"; + +const taskID = "task-abc123"; + +function container(labels?: Record): DockerContainer { + return { + id: "container-1", + name: "kandev-agent-test", + image: "kandev/multi-agent:latest", + state: "running", + status: "Up 5 seconds", + labels, + }; +} + +describe("containerTaskLabel", () => { + it("prefers task title over task id", () => { + expect( + containerTaskLabel( + container({ + "kandev.task_id": taskID, + "kandev.task_title": "Fix Docker reuse", + }), + ), + ).toBe("Fix Docker reuse"); + }); + + it("falls back to task id for older containers", () => { + expect(containerTaskLabel(container({ "kandev.task_id": taskID }))).toBe(taskID); + }); + + it("falls back to task id when the task title label is empty", () => { + expect( + containerTaskLabel( + container({ + "kandev.task_id": taskID, + "kandev.task_title": " ", + }), + ), + ).toBe(taskID); + }); +}); diff --git a/apps/web/components/settings/profile-edit/docker-sections.tsx b/apps/web/components/settings/profile-edit/docker-sections.tsx index 0ea06be936..18eb023ef3 100644 --- a/apps/web/components/settings/profile-edit/docker-sections.tsx +++ b/apps/web/components/settings/profile-edit/docker-sections.tsx @@ -17,10 +17,27 @@ import { } from "@/lib/api/domains/settings-api"; import type { DockerContainer } from "@/lib/api/domains/settings-api"; -const DEFAULT_IMAGE_TAG = "kandev/agent:latest"; -const DEFAULT_DOCKERFILE = "FROM ubuntu:24.04\n"; +const DEFAULT_IMAGE_TAG = "kandev/multi-agent:latest"; +// Self-contained default that produces a working image: +// - node + npm/npx so ACP agents (Claude, Codex, OpenCode, Auggie) can be +// fetched on demand at runtime +// - git so the prepare script can clone the workspace into the container +// - ca-certificates + curl as a baseline for any extra tooling users add +// +// The kandev backend mounts the agentctl binary into /usr/local/bin/agentctl +// at container creation time, so users do NOT need to bake it in here. +const DEFAULT_DOCKERFILE = `FROM node:22-slim -type BuildStatus = "idle" | "building" | "success" | "failed"; +RUN apt-get update \\ + && apt-get install -y --no-install-recommends \\ + git ca-certificates curl \\ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace +`; + +export type BuildStatus = "idle" | "building" | "success" | "failed"; +export type DockerBuildSuccess = { dockerfile: string; imageTag: string }; /** Parse a Docker JSON stream line into a human-readable string. */ function parseDockerLine(raw: string): string | null { @@ -87,7 +104,7 @@ async function readDockerStream( return hasError; } -function useBuildStream() { +function useBuildStream(onBuildSuccess?: (result: DockerBuildSuccess) => void) { const [buildStatus, setBuildStatus] = useState("idle"); const [buildLog, setBuildLog] = useState(""); @@ -114,14 +131,18 @@ function useBuildStream() { return; } const hasError = await readDockerStream(reader, appendLog); - setBuildStatus(hasError ? "failed" : "success"); + const nextStatus = hasError ? "failed" : "success"; + setBuildStatus(nextStatus); + if (nextStatus === "success") { + onBuildSuccess?.({ dockerfile, imageTag: tag.trim() }); + } } catch (err) { setBuildStatus("failed"); const msg = err instanceof Error ? err.message : "Unknown error"; setBuildLog((prev) => prev + `\nBuild failed: ${msg}`); } }, - [appendLog], + [appendLog, onBuildSuccess], ); return { buildStatus, buildLog, runBuild }; @@ -132,6 +153,7 @@ type DockerfileBuildCardProps = { onDockerfileChange: (v: string) => void; imageTag: string; onImageTagChange: (v: string) => void; + onBuildSuccess?: (result: DockerBuildSuccess) => void; }; export function DockerfileBuildCard({ @@ -139,8 +161,9 @@ export function DockerfileBuildCard({ onDockerfileChange, imageTag, onImageTagChange, + onBuildSuccess, }: DockerfileBuildCardProps) { - const { buildStatus, buildLog, runBuild } = useBuildStream(); + const { buildStatus, buildLog, runBuild } = useBuildStream(onBuildSuccess); const logRef = useRef(null); useEffect(() => { @@ -237,7 +260,42 @@ function ContainersEmptyState({ loading }: { loading: boolean }) { ); } - return

No running containers.

; + return

No Docker containers.

; +} + +export function useDockerProfileContainers(profileId: string, enabled = true) { + const [containers, setContainers] = useState([]); + const [loading, setLoading] = useState(enabled); + + const refresh = useCallback(async () => { + if (!enabled) { + setContainers([]); + setLoading(false); + return; + } + setLoading(true); + try { + const result = await listDockerContainers({ + labels: { "kandev.executor_profile_id": profileId }, + }); + setContainers(result.containers ?? []); + } catch { + setContainers([]); + } finally { + setLoading(false); + } + }, [enabled, profileId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { containers, loading, refresh }; +} + +export function containerTaskLabel(container: DockerContainer) { + const title = container.labels?.["kandev.task_title"]?.trim(); + return title || container.labels?.["kandev.task_id"] || "Untracked"; } function ContainerRow({ @@ -253,9 +311,12 @@ function ContainerRow({ }) { const isLoading = actionLoading === container.id; return ( - + {container.name} {container.image} + + {containerTaskLabel(container)} + {container.status} @@ -296,26 +357,9 @@ function ContainerRow({ } export function DockerContainersCard({ profileId }: { profileId: string }) { - const [containers, setContainers] = useState([]); - const [loading, setLoading] = useState(true); + const { containers, loading, refresh } = useDockerProfileContainers(profileId); const [actionLoading, setActionLoading] = useState(null); - const refresh = useCallback(async () => { - setLoading(true); - try { - const result = await listDockerContainers({ labels: { "kandev.profile_id": profileId } }); - setContainers(result.containers ?? []); - } catch { - setContainers([]); - } finally { - setLoading(false); - } - }, [profileId]); - - useEffect(() => { - void refresh(); - }, [refresh]); - const handleAction = useCallback( async (id: string, action: (id: string) => Promise) => { setActionLoading(id); @@ -332,7 +376,7 @@ export function DockerContainersCard({ profileId }: { profileId: string }) { return ( - Running Containers + Docker Containers Docker containers created by this profile. @@ -344,6 +388,7 @@ export function DockerContainersCard({ profileId }: { profileId: string }) { Name Image + Task Status diff --git a/apps/web/components/settings/profile-edit/profile-edit-page-chrome.tsx b/apps/web/components/settings/profile-edit/profile-edit-page-chrome.tsx index ed14239a0b..3f3333202c 100644 --- a/apps/web/components/settings/profile-edit/profile-edit-page-chrome.tsx +++ b/apps/web/components/settings/profile-edit/profile-edit-page-chrome.tsx @@ -1,9 +1,12 @@ "use client"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import { IconTrash } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; +import { Checkbox } from "@kandev/ui/checkbox"; +import { Label } from "@kandev/ui/label"; import { Separator } from "@kandev/ui/separator"; import { Dialog, @@ -14,10 +17,40 @@ import { DialogTitle, } from "@kandev/ui/dialog"; import { EXECUTOR_ICON_MAP, getExecutorLabel } from "@/lib/executor-icons"; -import type { Executor } from "@/lib/types/http"; +import { RequestIndicator } from "@/components/request-indicator"; +import type { Executor, ExecutorProfile } from "@/lib/types/http"; const EXECUTORS_ROUTE = "/settings/executors"; const DefaultIcon = EXECUTOR_ICON_MAP.local; +export type SaveStatus = "idle" | "loading" | "success" | "error"; + +export function getSaveButtonLabel(status: SaveStatus) { + if (status === "success") return "Saved"; + if (status === "loading") return "Saving"; + return "Save Changes"; +} + +export function upsertExecutorProfile( + executors: Executor[], + executor: Executor, + updated: ExecutorProfile, +) { + let foundExecutor = false; + const replaceProfile = (profiles: ExecutorProfile[] = []) => { + const foundProfile = profiles.some((p) => p.id === updated.id); + if (!foundProfile) return [...profiles, updated]; + return profiles.map((p) => (p.id === updated.id ? updated : p)); + }; + + const next = executors.map((item) => { + if (item.id !== executor.id) return item; + foundExecutor = true; + return { ...item, profiles: replaceProfile(item.profiles ?? executor.profiles ?? []) }; + }); + + if (foundExecutor) return next; + return [...next, { ...executor, profiles: replaceProfile(executor.profiles ?? []) }]; +} function ExecutorTypeIcon({ type }: { type: string }) { const Icon = EXECUTOR_ICON_MAP[type] ?? DefaultIcon; @@ -62,17 +95,18 @@ export function ProfileHeader({ } export function ProfileFormActions({ - saving, + saveStatus, saveDisabled, onSave, onDelete, }: { - saving: boolean; + saveStatus: SaveStatus; saveDisabled: boolean; onSave: () => void; onDelete: () => void; }) { const router = useRouter(); + const saveLabel = getSaveButtonLabel(saveStatus); return (
-
@@ -100,26 +139,57 @@ export function DeleteProfileDialog({ onOpenChange, onDelete, deleting, + relatedDockerContainerCount = 0, }: { open: boolean; onOpenChange: (v: boolean) => void; - onDelete: () => void; + onDelete: (options?: { removeRelatedDockerContainers?: boolean }) => void; deleting: boolean; + relatedDockerContainerCount?: number; }) { + const [removeRelatedContainers, setRemoveRelatedContainers] = useState(null); + const hasRelatedContainers = relatedDockerContainerCount > 0; + const shouldRemoveRelatedContainers = hasRelatedContainers && (removeRelatedContainers ?? true); + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) setRemoveRelatedContainers(null); + onOpenChange(nextOpen); + }; + return ( - + Delete Profile Are you sure? This action cannot be undone. + {hasRelatedContainers && ( +
+

+ {relatedDockerContainerCount} related Docker{" "} + {relatedDockerContainerCount === 1 ? "container" : "containers"} will also be removed. +

+
+ setRemoveRelatedContainers(checked === true)} + /> + +
+
+ )} + ); +} + +function setMethodSelected(selectedIds: Set, methodId: string, selected: boolean) { + if (selected) { + selectedIds.add(methodId); + return; + } + selectedIds.delete(methodId); +} + function AuthStatusBadge({ choice, hasSecret }: { choice: AuthChoice; hasSecret: boolean }) { if (choice === "env" && hasSecret) { return ( diff --git a/apps/web/components/task-create-dialog-create-mode-selectors.tsx b/apps/web/components/task-create-dialog-create-mode-selectors.tsx index 05264e4a05..1793749aa4 100644 --- a/apps/web/components/task-create-dialog-create-mode-selectors.tsx +++ b/apps/web/components/task-create-dialog-create-mode-selectors.tsx @@ -22,6 +22,8 @@ type CreateModeSelectorsProps = { onAgentProfileChange: (v: string) => void; onExecutorProfileChange: (v: string) => void; workflowAgentLocked: boolean; + noCompatibleAgent: boolean; + executorProfileName: string | null; }; /** @@ -44,6 +46,8 @@ export function CreateModeSelectors(props: CreateModeSelectorsProps) { onExecutorProfileChange={props.onExecutorProfileChange} executorsLoading={props.executorsLoading} workflowAgentLocked={props.workflowAgentLocked} + noCompatibleAgent={props.noCompatibleAgent} + executorProfileName={props.executorProfileName} AgentSelectorComponent={AgentSelector} ExecutorProfileSelectorComponent={ExecutorProfileSelector} /> diff --git a/apps/web/components/task-create-dialog-footer.test.ts b/apps/web/components/task-create-dialog-footer.test.ts index 4517b86df0..21e8936837 100644 --- a/apps/web/components/task-create-dialog-footer.test.ts +++ b/apps/web/components/task-create-dialog-footer.test.ts @@ -34,6 +34,8 @@ function makeProps( workspaceId: "ws-1", effectiveWorkflowId: "wf-1", executorHint: null, + noCompatibleAgent: false, + executorProfileName: null, onCancel: () => {}, onUpdateWithoutAgent: () => {}, onCreateWithoutAgent: () => {}, @@ -98,6 +100,19 @@ describe("computeDisabledReason (start-task)", () => { it("flags missing agent profile for start-task button", () => { expect(computeDisabledReason(makeProps({ agentProfileId: "" }), KIND_START)).toBe(REASON_AGENT); }); + + it("flags no compatible agent for the selected executor profile", () => { + const reason = computeDisabledReason( + makeProps({ + agentProfileId: "", + noCompatibleAgent: true, + executorProfileName: "Docker (sandbox)", + }), + KIND_START, + ); + expect(reason).toContain("Docker (sandbox)"); + expect(reason).toContain("credentials"); + }); }); describe("computeDisabledReason (update)", () => { diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index 0e9a473463..2dd444fafb 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -227,6 +227,8 @@ export type TaskCreateDialogFooterProps = { workspaceId: string | null; effectiveWorkflowId: string | null; executorHint: string | null; + noCompatibleAgent: boolean; + executorProfileName: string | null; onCancel: () => void; onUpdateWithoutAgent: () => void; onCreateWithoutAgent: () => void; @@ -259,7 +261,8 @@ function computeBaseDisabled(props: TaskCreateDialogFooterProps) { !props.hasTitle || !props.hasRepositorySelection || !props.hasAllBranches || - missingCtx + missingCtx || + props.noCompatibleAgent ); } @@ -273,12 +276,18 @@ export const REASON_WORKFLOW = "Select a workflow"; export const REASON_AGENT = "Select an agent"; export const REASON_DESCRIPTION = "Add a session description"; +function noCompatibleAgentReason(executorProfileName: string | null): string { + const target = executorProfileName ? `“${executorProfileName}”` : "this executor"; + return `No compatible agent profile is configured for ${target}. Configure agent credentials in Settings → Executors.`; +} + function baseReason(props: TaskCreateDialogFooterProps): string | null { if (!props.hasTitle) return REASON_TITLE; if (!props.hasRepositorySelection) return REASON_REPO; if (!props.hasAllBranches) return REASON_BRANCH; if (props.isCreateMode && !props.workspaceId) return REASON_WORKSPACE; if (props.isCreateMode && !props.effectiveWorkflowId) return REASON_WORKFLOW; + if (props.noCompatibleAgent) return noCompatibleAgentReason(props.executorProfileName); return null; } @@ -287,6 +296,7 @@ function missingSessionDescription(props: TaskCreateDialogFooterProps): boolean } function sessionDefaultReason(props: TaskCreateDialogFooterProps): string | null { + if (props.noCompatibleAgent) return noCompatibleAgentReason(props.executorProfileName); if (!props.agentProfileId) return REASON_AGENT; if (missingSessionDescription(props)) return REASON_DESCRIPTION; return null; @@ -319,7 +329,13 @@ function computeFooterState(props: TaskCreateDialogFooterProps) { const blocked = Boolean(props.submitBlockedReason); const altDisabled = computeBaseDisabled(props) || blocked; const splitDisabled = altDisabled || !props.agentProfileId; - const defaultDisabled = (props.isSessionMode ? !props.agentProfileId : altDisabled) || blocked; + // Session mode previously only gated on missing agent — it ignored + // noCompatibleAgent, so a user who switched executor after picking an + // agent could still submit a known-incompatible combination. The reason + // text already surfaces noCompatibleAgentReason in this branch (see + // sessionDefaultReason), so the disable gate needs to match. + const sessionDisabled = !props.agentProfileId || props.noCompatibleAgent; + const defaultDisabled = (props.isSessionMode ? sessionDisabled : altDisabled) || blocked; const disabledReason = computeDisabledReason(props, resolveButtonKind(props, showStartTask)); diff --git a/apps/web/components/task-create-dialog-form-body.test.tsx b/apps/web/components/task-create-dialog-form-body.test.tsx index a49c504130..3a3de28e03 100644 --- a/apps/web/components/task-create-dialog-form-body.test.tsx +++ b/apps/web/components/task-create-dialog-form-body.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { WorkflowSection } from "./task-create-dialog-form-body"; +import { CreateEditSelectors, WorkflowSection } from "./task-create-dialog-form-body"; vi.mock("@/components/workflow-selector-row", () => ({ WorkflowSelectorRow: ({ selectedWorkflowId }: { selectedWorkflowId: string | null }) => ( @@ -37,3 +37,34 @@ describe("WorkflowSection", () => { expect(container.textContent).toBe(""); }); }); + +describe("CreateEditSelectors", () => { + it("links credential setup to the selected executor profile", () => { + const EmptySelector = () => ; + + render( + {}} + isCreatingSession={false} + executorProfileOptions={[]} + executorProfileId="exec-profile-1" + onExecutorProfileChange={() => {}} + executorsLoading={false} + AgentSelectorComponent={EmptySelector} + ExecutorProfileSelectorComponent={EmptySelector} + workflowAgentLocked={false} + noCompatibleAgent={true} + executorProfileName="Docker" + />, + ); + + expect(screen.getByRole("link", { name: /configure credentials/i }).getAttribute("href")).toBe( + "/settings/executors/exec-profile-1", + ); + }); +}); diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index 81ed95fc0f..071c083635 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -51,6 +51,8 @@ type CreateEditSelectorsProps = { triggerClassName?: string; }>; workflowAgentLocked: boolean; + noCompatibleAgent: boolean; + executorProfileName: string | null; }; type AgentColumnProps = Pick< @@ -63,8 +65,35 @@ type AgentColumnProps = Pick< | "isCreatingSession" | "AgentSelectorComponent" | "workflowAgentLocked" + | "noCompatibleAgent" + | "executorProfileName" + | "executorProfileId" >; +function NoCompatibleAgentState({ + executorProfileName, + executorProfileId, +}: { + executorProfileName: string | null; + executorProfileId: string; +}) { + const target = executorProfileName ? `“${executorProfileName}”` : "this executor"; + const href = executorProfileId + ? `/settings/executors/${executorProfileId}` + : "/settings/executors"; + return ( +
+ No compatible agent profiles for {target}. + + Configure credentials + +
+ ); +} + function AgentColumn({ agentProfiles, agentProfilesLoading, @@ -74,10 +103,16 @@ function AgentColumn({ isCreatingSession, AgentSelectorComponent, workflowAgentLocked, + noCompatibleAgent, + executorProfileName, + executorProfileId, }: AgentColumnProps) { if (agentProfiles.length === 0 && !agentProfilesLoading) { return ( -
+
No agents found. Add agent @@ -85,6 +120,14 @@ function AgentColumn({
); } + if (noCompatibleAgent && !agentProfilesLoading) { + return ( + + ); + } const placeholder = agentProfilesLoading ? "Loading agents..." : "Select agent"; return ( <> diff --git a/apps/web/components/task-create-dialog-options.test.tsx b/apps/web/components/task-create-dialog-options.test.tsx index bfe5caf201..784559752c 100644 --- a/apps/web/components/task-create-dialog-options.test.tsx +++ b/apps/web/components/task-create-dialog-options.test.tsx @@ -9,10 +9,17 @@ function exec(id: string, type: Executor["type"]): Executor { const WORKTREE_SINGLE = "A git worktree will be created from the base branch."; const WORKTREE_MULTI = "A git worktree will be created for each repository in a parent folder. The agent runs in that parent folder so it can see every worktree side by side."; +const DOCKER = + "A Docker container will be created from the selected base branch and checked out on a task branch."; const LOCAL = "The agent will run directly on the repository."; describe("computeExecutorHint", () => { - const executors = [exec("wt", "worktree"), exec("loc", "local")]; + const executors = [ + exec("wt", "worktree"), + exec("loc", "local"), + exec("docker", "local_docker"), + exec("remote-docker", "remote_docker"), + ]; it("returns the multi-repo worktree hint when more than one repo is selected", () => { expect(computeExecutorHint(executors, "wt", 2)).toBe(WORKTREE_MULTI); @@ -22,6 +29,11 @@ describe("computeExecutorHint", () => { expect(computeExecutorHint(executors, "wt", 1)).toBe(WORKTREE_SINGLE); }); + it("explains that Docker profiles create an isolated task branch", () => { + expect(computeExecutorHint(executors, "docker", 1)).toBe(DOCKER); + expect(computeExecutorHint(executors, "remote-docker", 1)).toBe(DOCKER); + }); + it("returns the local hint regardless of repoCount", () => { expect(computeExecutorHint(executors, "loc", 1)).toBe(LOCAL); expect(computeExecutorHint(executors, "loc", 5)).toBe(LOCAL); diff --git a/apps/web/components/task-create-dialog-options.tsx b/apps/web/components/task-create-dialog-options.tsx index be83b08389..0f21a48069 100644 --- a/apps/web/components/task-create-dialog-options.tsx +++ b/apps/web/components/task-create-dialog-options.tsx @@ -194,6 +194,9 @@ export function computeExecutorHint( } return "A git worktree will be created from the base branch."; } + if (selectedExecutor?.type === "local_docker" || selectedExecutor?.type === "remote_docker") { + return "A Docker container will be created from the selected base branch and checked out on a task branch."; + } if (selectedExecutor?.type === "local") return "The agent will run directly on the repository."; return null; } diff --git a/apps/web/components/task-create-dialog-prop-builders.test.ts b/apps/web/components/task-create-dialog-prop-builders.test.ts new file mode 100644 index 0000000000..a57f997beb --- /dev/null +++ b/apps/web/components/task-create-dialog-prop-builders.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { computeHasAllBranches } from "./task-create-dialog-prop-builders"; +import type { DialogFormState } from "@/components/task-create-dialog-types"; + +// Minimal fs stub for computeHasAllBranches. The function only reads +// `noRepository`, `useGitHubUrl`, `githubBranch`, and `repositories[].branch`, +// so we cast a partial through `unknown` to avoid having to materialise the +// full DialogFormState surface in tests. +function fsStub(overrides: { + noRepository?: boolean; + useGitHubUrl?: boolean; + githubBranch?: string; + repositories?: Array<{ branch?: string }>; +}): DialogFormState { + return { + noRepository: false, + useGitHubUrl: false, + githubBranch: "", + repositories: [], + ...overrides, + } as unknown as DialogFormState; +} + +describe("computeHasAllBranches", () => { + it("returns true when the task is in no-repository mode (short-circuits the rest)", () => { + // noRepository mode doesn't need a branch — the backend skips the + // worktree/clone step entirely, so it always satisfies hasAllBranches. + expect(computeHasAllBranches(fsStub({ noRepository: true, repositories: [] }))).toBe(true); + }); + + it("treats the GitHub-URL mode as branched iff githubBranch is non-empty", () => { + expect(computeHasAllBranches(fsStub({ useGitHubUrl: true, githubBranch: "" }))).toBe(false); + expect(computeHasAllBranches(fsStub({ useGitHubUrl: true, githubBranch: "main" }))).toBe(true); + }); + + it("requires every selected repository row to carry a branch", () => { + expect(computeHasAllBranches(fsStub({ repositories: [] }))).toBe(false); + expect(computeHasAllBranches(fsStub({ repositories: [{ branch: "main" }] }))).toBe(true); + expect( + computeHasAllBranches(fsStub({ repositories: [{ branch: "main" }, { branch: "" }] })), + ).toBe(false); + expect( + computeHasAllBranches( + fsStub({ repositories: [{ branch: "main" }, { branch: "feature/x" }] }), + ), + ).toBe(true); + }); +}); diff --git a/apps/web/components/task-create-dialog-prop-builders.ts b/apps/web/components/task-create-dialog-prop-builders.ts new file mode 100644 index 0000000000..f381f18101 --- /dev/null +++ b/apps/web/components/task-create-dialog-prop-builders.ts @@ -0,0 +1,106 @@ +/** + * Pure prop-assembly helpers for the task-create dialog. Extracted from + * task-create-dialog.tsx so the orchestrator stays under the per-file line + * cap — these are non-React, no-JSX projections from the setup hook's + * result + dialog props, so they belong outside the component module. + */ +// Both names are used only in type positions (interface + `typeof` in +// ReturnType<>), so the `import type` form makes the otherwise-circular +// dependency with task-create-dialog.tsx explicitly type-only — bundlers +// and analysis tools won't treat it as a real runtime cycle. +import type { + TaskCreateDialogProps, + useTaskCreateDialogSetup, +} from "@/components/task-create-dialog"; +import type { DialogFormBodyProps, DialogFormState } from "@/components/task-create-dialog-types"; + +export function computeHasAllBranches(fs: DialogFormState): boolean { + if (fs.noRepository) return true; + if (fs.useGitHubUrl) return !!fs.githubBranch; + return fs.repositories.length > 0 && fs.repositories.every((r) => !!r.branch); +} + +export function buildDialogFormBodyProps( + setup: ReturnType, + props: TaskCreateDialogProps, +): DialogFormBodyProps { + const { fs, computed, handlers } = setup; + const repoLocked = !!props.lockedFields?.repository; + return { + isSessionMode: setup.isSessionMode, + isCreateMode: setup.isCreateMode, + isEditMode: setup.isEditMode, + isTaskStarted: setup.isTaskStarted, + onTaskNameChange: handlers.handleTaskNameChange, + onRowRepositoryChange: handlers.handleRowRepositoryChange, + onRowBranchChange: handlers.handleRowBranchChange, + isPassthroughProfile: computed.isPassthroughProfile, + initialDescription: fs.currentDefaults.description, + hasDescription: fs.hasDescription, + workspaceId: props.workspaceId, + onJiraImport: setup.handleJiraImport, + onLinearImport: setup.handleLinearImport, + agentProfileOptions: computed.agentProfileOptions, + executorProfileOptions: computed.executorProfileOptions, + agentProfiles: setup.agentProfiles, + agentProfilesLoading: computed.agentProfilesLoading, + executorsLoading: computed.executorsLoading, + isCreatingSession: fs.isCreatingSession, + workflows: setup.workflows, + snapshots: setup.snapshots, + effectiveWorkflowId: computed.effectiveWorkflowId ?? null, + fs, + handleKeyDown: setup.handleKeyDown, + onAgentProfileChange: handlers.handleAgentProfileChange, + onExecutorProfileChange: handlers.handleExecutorProfileChange, + onWorkflowChange: handlers.handleWorkflowChange, + onToggleGitHubUrl: repoLocked ? undefined : handlers.handleToggleGitHubUrl, + onGitHubUrlChange: handlers.handleGitHubUrlChange, + onToggleFreshBranch: handlers.handleToggleFreshBranch, + onToggleNoRepository: repoLocked ? undefined : handlers.handleToggleNoRepository, + onWorkspacePathChange: handlers.handleWorkspacePathChange, + enhance: setup.enhance, + workflowAgentLocked: computed.workflowAgentLocked, + repositories: setup.repositories, + freshBranchAvailable: setup.freshBranchAvailable, + isLocalExecutor: computed.isLocalExecutor, + noCompatibleAgent: computed.noCompatibleAgent, + executorProfileName: computed.selectedExecutorProfileName, + extraFormSlot: props.extraFormSlot, + aboveDescriptionSlot: props.aboveDescriptionSlot, + bottomSlot: props.bottomSlot, + descriptionPlaceholder: props.descriptionPlaceholder, + workflowLocked: props.lockedFields?.workflow, + }; +} + +export function buildDialogFooterProps( + setup: ReturnType, + props: TaskCreateDialogProps, +) { + const { fs, computed, submitHandlers } = setup; + return { + isSessionMode: setup.isSessionMode, + isCreateMode: setup.isCreateMode, + isEditMode: setup.isEditMode, + isTaskStarted: setup.isTaskStarted, + isPassthroughProfile: computed.isPassthroughProfile, + isCreatingSession: fs.isCreatingSession, + isCreatingTask: fs.isCreatingTask, + hasTitle: fs.hasTitle, + hasDescription: fs.hasDescription, + hasRepositorySelection: computed.hasRepositorySelection, + hasAllBranches: computeHasAllBranches(fs), + agentProfileId: computed.effectiveAgentProfileId, + workspaceId: props.workspaceId, + effectiveWorkflowId: computed.effectiveWorkflowId ?? null, + executorHint: computed.executorHint, + noCompatibleAgent: computed.noCompatibleAgent, + executorProfileName: computed.selectedExecutorProfileName, + onCancel: submitHandlers.handleCancel, + onUpdateWithoutAgent: submitHandlers.handleUpdateWithoutAgent, + onCreateWithoutAgent: submitHandlers.handleCreateWithoutAgent, + onCreateWithPlanMode: submitHandlers.handleCreateWithPlanMode, + submitBlockedReason: props.submitBlockedReason, + }; +} diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 273e2cfa1b..2757148644 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -68,6 +68,8 @@ import { computeDialogDefaultStepId, computeSingleWorkflowFallbackId, } from "@/components/task-create-dialog-defaults"; +import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs"; +import { isAgentConfiguredOnExecutor } from "@/lib/agent-executor-compat"; export type { StepType, @@ -531,7 +533,6 @@ export function useDialogComputed({ // pill loads branches per-repo). Keep the computed value but always feed it // the URL branches when in URL mode. const branchOptions = useBranchOptions(fs.useGitHubUrl ? fs.githubBranches : []); - const agentProfileOptions = useAgentProfileOptions(agentProfiles); const allExecutorProfiles = useMemo(() => { return executors.flatMap((executor) => (executor.profiles ?? []).map((p) => ({ @@ -547,9 +548,23 @@ export function useDialogComputed({ // keep the full executor catalogue. const selectedRepoCount = fs.repositories.filter((r) => r.repositoryId || r.localPath).length; const isMultiRepoSelection = selectedRepoCount > 1; - const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles, { - disabledReasonFor: pickExecutorDisabledReason(fs.noRepository, isMultiRepoSelection), - }); + // `pickExecutorDisabledReason` came from main — folds two disable rules + // (no-repository disables worktree; multi-repo disables non-worktree) into + // one resolver. Plug it into our existing useExecutorProfileCompat wrapper + // so the downstream consumer still gets selectedExecutorProfile / + // noCompatibleAgent metadata. + // Use the effective agent ID (form value OR the workflow-locked override) + // so the compatibility gate catches the override case too — passing the + // raw fs.agentProfileId would let workflow-locked sessions slip past with + // an empty selection. + const exec = useExecutorProfileCompat( + allExecutorProfiles, + fs.executorProfileId, + effectiveAgentProfileId, + agentProfiles, + pickExecutorDisabledReason(fs.noRepository, isMultiRepoSelection), + ); + const agentProfileOptions = useAgentProfileOptions(exec.compatibleAgentProfiles); const executorHint = useExecutorHint(executors, fs.executorId, selectedRepoCount); const isLocalExecutor = useIsLocalExecutor(executors, fs.executorId); const { headerRepositoryOptions } = useRepositoryOptions(repositories, fs.discoveredRepositories); @@ -569,7 +584,7 @@ export function useDialogComputed({ hasRepositorySelection, branchOptions, agentProfileOptions, - executorProfileOptions, + executorProfileOptions: exec.executorProfileOptions, executorHint, isLocalExecutor, headerRepositoryOptions, @@ -578,6 +593,49 @@ export function useDialogComputed({ workflowAgentLocked, workflowAgentProfileId, effectiveAgentProfileId, + selectedExecutorProfileName: exec.selectedExecutorProfile?.name ?? null, + noCompatibleAgent: exec.noCompatibleAgent, + }; +} + +function useExecutorProfileCompat( + allExecutorProfiles: ExecutorProfile[], + selectedProfileId: string, + selectedAgentProfileId: string, + agentProfiles: DialogComputedArgs["agentProfiles"], + disabledReasonFor?: (profile: ExecutorProfile) => string | null, +) { + const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles, { + disabledReasonFor, + }); + const selectedExecutorProfile = useMemo( + () => allExecutorProfiles.find((p) => p.id === selectedProfileId) ?? null, + [allExecutorProfiles, selectedProfileId], + ); + const { specs: authSpecs, loaded: authLoaded } = useRemoteAuthSpecs(); + const compatibleAgentProfiles = useMemo(() => { + if (!selectedExecutorProfile || !authLoaded) return agentProfiles; + return agentProfiles.filter((ap) => + isAgentConfiguredOnExecutor(ap, selectedExecutorProfile, authSpecs), + ); + }, [agentProfiles, selectedExecutorProfile, authSpecs, authLoaded]); + // `noCompatibleAgent` gates the submit button. It must catch BOTH cases: + // 1. The selected executor has no compatible agents at all. + // 2. The user picked an agent that isn't compatible with the executor + // (e.g. switched executor after the agent was chosen). + // Previously this only checked case 1, so case 2 silently let the user + // submit with a known-incompatible combination. + const noCompatibleAgent = useMemo(() => { + if (!selectedExecutorProfile) return false; + if (compatibleAgentProfiles.length === 0) return true; + if (!selectedAgentProfileId) return false; + return !compatibleAgentProfiles.some((ap) => ap.id === selectedAgentProfileId); + }, [selectedExecutorProfile, compatibleAgentProfiles, selectedAgentProfileId]); + return { + selectedExecutorProfile, + compatibleAgentProfiles, + executorProfileOptions, + noCompatibleAgent, }; } diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index f35eae9f5d..08f99f5f24 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -7,7 +7,11 @@ import type { Task, } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; -import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; +import type { + KanbanMultiState, + WorkflowSnapshotData, + WorkflowsState, +} from "@/lib/state/slices/kanban/types"; import type { useRepositoryOptions, useBranchOptions, @@ -85,6 +89,10 @@ export type DialogComputedValues = { workflowAgentProfileId: string; /** User selection if any, else the workflow override; what footer/submit/passthrough should consult */ effectiveAgentProfileId: string; + /** Display name of the currently selected executor profile (null if none). */ + selectedExecutorProfileName: string | null; + /** True when an executor profile is selected and no agent profile is compatible with it. */ + noCompatibleAgent: boolean; }; export type DialogComputedArgs = { @@ -284,3 +292,77 @@ export type SubmitHandlersDeps = { */ transformDescriptionBeforeSubmit?: (description: string) => Promise | string; }; + +import type { JiraTicket } from "@/lib/types/jira"; +import type { LinearIssue } from "@/lib/types/linear"; +import type { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; + +/** + * Props shared by all dialog-body variants (CreateModeBody, SessionModeBody, + * DialogFormBody). Lives in this types module so task-create-dialog.tsx + * stays under the per-file line cap — the dialog file is already a thin + * orchestrator over many sibling modules and this is the largest type + * surface left in it. + */ +export type DialogFormBodyProps = { + isSessionMode: boolean; + isCreateMode: boolean; + isEditMode: boolean; + isTaskStarted: boolean; + isPassthroughProfile: boolean; + initialDescription: string; + hasDescription: boolean; + workspaceId: string | null; + onJiraImport?: (ticket: JiraTicket) => void; + onLinearImport?: (issue: LinearIssue) => void; + agentProfileOptions: ReturnType; + executorProfileOptions: Array<{ + value: string; + label: string; + renderLabel?: () => React.ReactNode; + }>; + agentProfiles: AgentProfileOption[]; + agentProfilesLoading: boolean; + executorsLoading: boolean; + isCreatingSession: boolean; + workflows: WorkflowsState["items"]; + snapshots: KanbanMultiState["snapshots"]; + effectiveWorkflowId: string | null; + fs: DialogFormState; + handleKeyDown: ReturnType; + onTaskNameChange: (v: string) => void; + onRowRepositoryChange: (key: string, value: string) => void; + onRowBranchChange: (key: string, value: string) => void; + onAgentProfileChange: (v: string) => void; + onExecutorProfileChange: (v: string) => void; + onWorkflowChange: (v: string) => void; + onToggleGitHubUrl?: () => void; + onGitHubUrlChange: (v: string) => void; + onToggleFreshBranch: (enabled: boolean) => void; + onToggleNoRepository?: () => void; + onWorkspacePathChange: (value: string) => void; + enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean }; + workflowAgentLocked: boolean; + /** Workspace repositories — driven into the chip row for repo + branch picks. */ + repositories: Repository[]; + /** Computed in the parent: single-row + local executor + not URL mode. */ + freshBranchAvailable: boolean; + /** + * True when the selected executor profile runs locally on the host. Used + * to lock the per-row branch pill (the user's checkout dictates the + * branch for local execution; fresh-branch mode unlocks it). + */ + isLocalExecutor: boolean; + noCompatibleAgent: boolean; + executorProfileName: string | null; + /** Optional render slot above the description editor. */ + aboveDescriptionSlot?: React.ReactNode; + /** Optional render slot inside the dialog body (rendered above the chip row). */ + extraFormSlot?: React.ReactNode; + /** Optional render slot at the bottom of the dialog body (above the footer). */ + bottomSlot?: React.ReactNode; + /** Optional override for the description placeholder. */ + descriptionPlaceholder?: string; + /** When true, hides the workflow picker so the enforced workflow can't be swapped. */ + workflowLocked?: boolean; +}; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index f1d6e3fed9..bf15108dbb 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -5,7 +5,6 @@ import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog"; import type { Task, Repository } from "@/lib/types/http"; -import type { AgentProfileOption } from "@/lib/state/slices"; import { SHORTCUTS } from "@/lib/keyboard/constants"; import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut"; @@ -18,7 +17,6 @@ import { WorkflowSection, DialogPromptSection, } from "@/components/task-create-dialog-form-body"; -import { useAgentProfileOptions } from "@/components/task-create-dialog-options"; import { AgentSelector, ExecutorProfileSelector, @@ -39,8 +37,13 @@ import { type DialogFormState, type TaskCreateDialogInitialValues, } from "@/components/task-create-dialog-state"; +import type { DialogFormBodyProps } from "@/components/task-create-dialog-types"; +import { + buildDialogFooterProps, + buildDialogFormBodyProps, +} from "@/components/task-create-dialog-prop-builders"; -interface TaskCreateDialogProps { +export interface TaskCreateDialogProps { open: boolean; onOpenChange: (open: boolean) => void; mode?: "create" | "edit" | "session"; @@ -98,73 +101,6 @@ interface TaskCreateDialogProps { submitBlockedReason?: string | null; } -type DialogFormBodyProps = { - isSessionMode: boolean; - isCreateMode: boolean; - isEditMode: boolean; - isTaskStarted: boolean; - isPassthroughProfile: boolean; - initialDescription: string; - hasDescription: boolean; - workspaceId: string | null; - onJiraImport?: (ticket: JiraTicket) => void; - onLinearImport?: (issue: LinearIssue) => void; - agentProfileOptions: ReturnType; - executorProfileOptions: Array<{ - value: string; - label: string; - renderLabel?: () => React.ReactNode; - }>; - agentProfiles: AgentProfileOption[]; - agentProfilesLoading: boolean; - executorsLoading: boolean; - isCreatingSession: boolean; - workflows: unknown[]; - snapshots: unknown; - effectiveWorkflowId: string | null; - fs: DialogFormState; - handleKeyDown: ReturnType; - onTaskNameChange: (v: string) => void; - onRowRepositoryChange: (key: string, value: string) => void; - onRowBranchChange: (key: string, value: string) => void; - onAgentProfileChange: (v: string) => void; - onExecutorProfileChange: (v: string) => void; - onWorkflowChange: (v: string) => void; - onToggleGitHubUrl?: () => void; - onGitHubUrlChange: (v: string) => void; - onToggleFreshBranch: (enabled: boolean) => void; - onToggleNoRepository?: () => void; - onWorkspacePathChange: (value: string) => void; - enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean }; - workflowAgentLocked: boolean; - /** Workspace repositories — driven into the chip row for repo + branch picks. */ - repositories: Repository[]; - /** Computed in the parent: single-row + local executor + not URL mode. */ - freshBranchAvailable: boolean; - /** - * True when the selected executor profile runs locally on the host. Used - * to lock the per-row branch pill (the user's checkout dictates the - * branch for local execution; fresh-branch mode unlocks it). - */ - isLocalExecutor: boolean; - /** Optional render slot above the description editor. */ - aboveDescriptionSlot?: React.ReactNode; - /** Optional render slot inside the dialog body (rendered above the chip row). */ - extraFormSlot?: React.ReactNode; - /** Optional render slot at the bottom of the dialog body (above the footer). */ - bottomSlot?: React.ReactNode; - /** Optional override for the description placeholder. */ - descriptionPlaceholder?: string; - /** When true, hides the workflow picker so the enforced workflow can't be swapped. */ - workflowLocked?: boolean; -}; - -function computeHasAllBranches(fs: DialogFormState): boolean { - if (fs.noRepository) return true; - if (fs.useGitHubUrl) return !!fs.githubBranch; - return fs.repositories.length > 0 && fs.repositories.every((r) => !!r.branch); -} - function CreateModeBody(props: DialogFormBodyProps) { const { isCreateMode, @@ -249,6 +185,8 @@ function CreateModeBody(props: DialogFormBodyProps) { onAgentProfileChange={onAgentProfileChange} onExecutorProfileChange={onExecutorProfileChange} workflowAgentLocked={workflowAgentLocked} + noCompatibleAgent={props.noCompatibleAgent} + executorProfileName={props.executorProfileName} /> {props.bottomSlot} @@ -438,7 +376,7 @@ function resolveSingleRowLocalPath(fs: DialogFormState, repositories: Repository return ""; } -function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { +export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { const { open, mode = "create", workspaceId, workflowId, defaultStepId } = props; const { editingTask, initialValues } = props; const isSessionMode = mode === "session"; @@ -536,97 +474,25 @@ function useGuardedSubmit( } export function TaskCreateDialog(props: TaskCreateDialogProps) { - const { open, onOpenChange, initialValues, workspaceId } = props; const setup = useTaskCreateDialogSetup(props); - const { fs, isSessionMode, isEditMode, isCreateMode, isTaskStarted } = setup; - const { sessionRepoName, workflows, agentProfiles, snapshots, repositories } = setup; - const { computed, handlers, handleKeyDown, freshBranchAvailable } = setup; - const repoLocked = !!props.lockedFields?.repository; - const { handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers; - const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers; - const { handleJiraImport, handleLinearImport, guardedHandleSubmit } = setup; return ( - + -
- + + - + diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 2f1a2a3360..3ea52b848f 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -23,6 +23,7 @@ import { formatPlanCommentsAsMarkdown, } from "@/lib/state/slices/comments/format"; import { usePlanActions } from "@/hooks/domains/kanban/use-plan-actions"; +import { useExecutorEnvironmentAvailability } from "@/hooks/domains/session/use-executor-environment-availability"; import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useToast } from "@/components/toast-provider"; import type { DiffComment } from "@/lib/diff/types"; @@ -338,6 +339,14 @@ type ChatInputAreaProps = { placeholderOverride?: string; }; +function useExecutorUnavailable(taskId: string | null, sessionId: string | null) { + const availability = useExecutorEnvironmentAvailability(taskId, Boolean(sessionId && taskId)); + return { + unavailable: availability.unavailable, + reason: availability.status?.label, + }; +} + export function ChatInputArea({ chatInputRef, clarificationKey, @@ -371,6 +380,7 @@ export function ChatInputArea({ chatInputRef, }); const hasClarification = !!panelState.pendingClarification; + const executor = useExecutorUnavailable(taskId, resolvedSessionId); const placeholder = isMoving ? "Switching agent..." : (placeholderOverride ?? @@ -405,6 +415,7 @@ export function ChatInputArea({ onPlanModeChange={handlePlanModeChange} isAgentBusy={isAgentBusy} isStarting={panelState.isStarting} + isPreparingEnvironment={panelState.isPreparingEnvironment} isMoving={isMoving} isSending={isSending} onCancel={handleCancelTurn} @@ -421,6 +432,8 @@ export function ChatInputArea({ hasAgentCommands={!!(panelState.agentCommands && panelState.agentCommands.length > 0)} isFailed={panelState.isFailed} needsRecovery={needsRecovery} + executorUnavailable={executor.unavailable} + executorUnavailableReason={executor.reason} contextItems={panelState.contextItems} planContextEnabled={panelState.planContextEnabled} contextFiles={panelState.contextFiles} diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index 93724ec8be..aad579f9d1 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -18,6 +18,8 @@ export type ChatInputEditorAreaProps = { handleSubmitWithReset: () => void; inputPlaceholder: string; isDisabled: boolean; + submitDisabled: boolean; + submitDisabledReason?: string; hasClarification: boolean; planModeEnabled: boolean; planModeAvailable: boolean; @@ -126,7 +128,7 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { const userContextCount = planContextEnabled ? Math.max(0, contextCount - 1) : contextCount; const hasContent = value.trim().length > 0 || userContextCount > 0; // Block submit while enhancing prompt, but keep editor editable for programmatic updates - const wrappedSubmit = isEnhancingPrompt ? () => {} : handleSubmitWithReset; + const wrappedSubmit = isEnhancingPrompt || p.submitDisabled ? () => {} : handleSubmitWithReset; const handleAttachFiles = useCallback(() => fileInputRef.current?.click(), [fileInputRef]); return (
@@ -167,7 +169,8 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { taskDescription={taskDescription} isAgentBusy={isAgentBusy} hasContent={hasContent} - isDisabled={isDisabled} + isDisabled={p.submitDisabled} + submitDisabledReason={p.submitDisabledReason} isSending={isSending} onCancel={onCancel} onSubmit={wrappedSubmit} diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index 54f88dd3e9..860f4db3ac 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -58,6 +58,11 @@ type ChatInputContainerProps = { onPlanModeChange: (enabled: boolean) => void; isAgentBusy: boolean; isStarting: boolean; + /** True only while a containerized executor is bootstrapping (Docker + * prepare, Sprites sandbox spin-up). Distinct from the brief STARTING + * state every session — including local quick-chat — passes through; + * see useSessionState. */ + isPreparingEnvironment?: boolean; isMoving?: boolean; isSending: boolean; onCancel: () => void; @@ -72,6 +77,8 @@ type ChatInputContainerProps = { hasAgentCommands?: boolean; isFailed?: boolean; needsRecovery?: boolean; + executorUnavailable?: boolean; + executorUnavailableReason?: string; contextItems?: ContextItem[]; planContextEnabled?: boolean; contextFiles?: ContextFile[]; @@ -89,11 +96,19 @@ function FailedSessionBanner({ onShowDialog, taskId, sessionId, + message = "This agent has stopped.", + detail, + resumeLabel = "Resume", + resumingLabel = "Resuming...", }: { showDialog: boolean; onShowDialog: (open: boolean) => void; taskId: string | null; sessionId: string | null; + message?: string; + detail?: string; + resumeLabel?: string; + resumingLabel?: string; }) { const [isResuming, setIsResuming] = useState(false); @@ -126,9 +141,10 @@ function FailedSessionBanner({ <>
-
+
- This agent has stopped. + {message} + {detail && ({detail})}
{sessionId && taskId && ( @@ -144,7 +160,7 @@ function FailedSessionBanner({ disabled={isResuming || !profileExists} > - {isResuming ? "Resuming..." : "Resume"} + {isResuming ? resumingLabel : resumeLabel} @@ -169,6 +185,28 @@ function FailedSessionBanner({ } type ContainerState = ReturnType; +type NormalizedChatInputProps = ChatInputContainerProps & { + isFailed: boolean; + hasAgentCommands: boolean; + submitKey: "enter" | "cmd_enter"; + planContextEnabled: boolean; + contextFiles: ContextFile[]; + contextItems: ContextItem[]; + showRequestChangesTooltip: boolean; +}; + +function normalizeChatInputProps(p: ChatInputContainerProps): NormalizedChatInputProps { + return { + ...p, + isFailed: p.isFailed ?? false, + hasAgentCommands: p.hasAgentCommands ?? false, + submitKey: p.submitKey ?? "cmd_enter", + planContextEnabled: p.planContextEnabled ?? false, + contextFiles: p.contextFiles ?? [], + contextItems: p.contextItems ?? [], + showRequestChangesTooltip: p.showRequestChangesTooltip ?? false, + }; +} function buildContextAreaProps( s: ContainerState, @@ -189,7 +227,7 @@ type EnhancePromptExtras = { function buildEditorAreaProps( s: ContainerState, - p: ChatInputContainerProps, + p: NormalizedChatInputProps, extras: EnhancePromptExtras = {}, ): ChatInputEditorAreaProps { return { @@ -199,21 +237,23 @@ function buildEditorAreaProps( handleSubmitWithReset: s.handleSubmitWithReset, inputPlaceholder: s.inputPlaceholder, isDisabled: s.isDisabled, + submitDisabled: s.submitDisabled, + submitDisabledReason: s.submitDisabledReason, hasClarification: s.hasClarification, planModeEnabled: p.planModeEnabled, planModeAvailable: p.planModeAvailable ?? true, mcpServers: p.mcpServers ?? [], - submitKey: p.submitKey ?? "cmd_enter", + submitKey: p.submitKey, setIsInputFocused: s.setIsInputFocused, sessionId: p.sessionId, taskId: p.taskId, onAddContextFile: p.onAddContextFile, onToggleContextFile: p.onToggleContextFile, - planContextEnabled: p.planContextEnabled ?? false, + planContextEnabled: p.planContextEnabled, handleAgentCommand: s.handleAgentCommand, addFiles: s.addFiles, fileInputRef: s.fileInputRef, - showRequestChangesTooltip: p.showRequestChangesTooltip ?? false, + showRequestChangesTooltip: p.showRequestChangesTooltip, isAgentBusy: p.isAgentBusy, onPlanModeChange: p.onPlanModeChange, taskTitle: p.taskTitle, @@ -223,7 +263,7 @@ function buildEditorAreaProps( contextCount: s.allItems.length, contextPopoverOpen: s.contextPopoverOpen, setContextPopoverOpen: s.setContextPopoverOpen, - contextFiles: p.contextFiles ?? [], + contextFiles: p.contextFiles, onImplementPlan: p.onImplementPlan, onEnhancePrompt: extras.onEnhancePrompt, isEnhancingPrompt: extras.isEnhancingPrompt, @@ -234,41 +274,35 @@ function buildEditorAreaProps( }; } +function buildStoppedBannerProps(p: ChatInputContainerProps) { + if (!p.executorUnavailable) return {}; + return { + message: "Executor environment is unavailable.", + detail: p.executorUnavailableReason, + resumeLabel: "Restart", + resumingLabel: "Restarting...", + }; +} + export const ChatInputContainer = forwardRef( function ChatInputContainer(props, ref) { - const { - sessionId, - taskId, - taskTitle, - taskDescription, - isAgentBusy, - isStarting, - isMoving = false, - isSending, - isFailed = false, - showRequestChangesTooltip = false, - } = props; + const { sessionId, taskId, taskTitle, taskDescription, isAgentBusy, isStarting, isSending } = + props; + const p = normalizeChatInputProps(props); + const isMoving = props.isMoving ?? false; + const executorUnavailable = props.executorUnavailable ?? false; const isBusyVisual = isStarting || isMoving; - const p = { - ...props, - isFailed: isFailed ?? false, - hasAgentCommands: props.hasAgentCommands ?? false, - submitKey: props.submitKey ?? "cmd_enter", - planContextEnabled: props.planContextEnabled ?? false, - contextFiles: props.contextFiles ?? [], - contextItems: props.contextItems ?? [], - showRequestChangesTooltip, - } as const; - const s = useChatInputContainer({ ref, sessionId, isSending, isStarting, + isPreparingEnvironment: props.isPreparingEnvironment ?? false, isMoving, isFailed: p.isFailed, needsRecovery: props.needsRecovery ?? false, + executorUnavailable, isAgentBusy, hasAgentCommands: p.hasAgentCommands, placeholder: props.placeholder, @@ -277,7 +311,7 @@ export const ChatInputContainer = forwardRef ); } @@ -317,11 +352,11 @@ export const ChatInputContainer = forwardRef ({ TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); +vi.mock("@/components/keyboard-shortcut-tooltip", () => ({ + KeyboardShortcutTooltip: ({ + children, + description, + }: { + children: React.ReactNode; + description?: string; + }) => ( + <> + {children} + {description ? {description} : null} + + ), +})); + import { ChatInputToolbar } from "./chat-input-toolbar"; function deferred() { @@ -95,3 +110,28 @@ describe("ChatInputToolbar cancel button", () => { expect(button.disabled).toBe(false); }); }); + +describe("ChatInputToolbar submit button", () => { + it("shows the setup-disabled reason while keeping the submit button disabled", () => { + render( + {}} + sessionId="s1" + taskId="t1" + taskDescription="" + isAgentBusy={false} + hasContent + isDisabled + submitDisabledReason="The agent is still being set up." + isSending={false} + onCancel={() => {}} + onSubmit={() => {}} + minimalToolbar + />, + ); + + expect((screen.getByTestId("submit-message-button") as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText("The agent is still being set up.")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx index a9fc7d43b2..d6376fb3a7 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.tsx @@ -3,7 +3,6 @@ import { memo, useCallback, useRef, useState, type ReactNode } from "react"; import { IconArrowUp, - IconChevronDown, IconChevronsLeft, IconDots, IconFileTextSpark, @@ -12,15 +11,8 @@ import { IconPlugConnected, IconPlugConnectedX, IconPaperclip, - IconPlus, - IconRocket, } from "@tabler/icons-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@kandev/ui/dropdown-menu"; + import { EnhancePromptButton } from "@/components/enhance-prompt-button"; import { GridSpinner } from "@/components/grid-spinner"; import { Button } from "@kandev/ui/button"; @@ -38,6 +30,7 @@ import { ModelSelector } from "@/components/task/model-selector"; import { ModeSelector } from "@/components/task/mode-selector"; import { ContextPopover } from "./context-popover"; import { ResetContextButton } from "./reset-context-button"; +import { ImplementPlanButton } from "./implement-plan-button"; import type { ContextFile } from "@/lib/state/context-files-store"; export type ChatInputToolbarProps = { @@ -53,6 +46,7 @@ export type ChatInputToolbarProps = { /** Whether the input has content to send (text, comments, or context) */ hasContent?: boolean; isDisabled: boolean; + submitDisabledReason?: string; isSending: boolean; onCancel: () => void | Promise; onSubmit: () => void; @@ -85,6 +79,7 @@ type SubmitButtonProps = { isAgentBusy: boolean; hasContent: boolean; isDisabled: boolean; + submitDisabledReason?: string; isSending: boolean; planModeEnabled: boolean; onCancel: () => void | Promise; @@ -92,7 +87,12 @@ type SubmitButtonProps = { submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; }; -function submitTooltipDescription(isAgentBusy: boolean, planModeEnabled: boolean) { +function submitTooltipDescription( + isAgentBusy: boolean, + planModeEnabled: boolean, + submitDisabledReason?: string, +) { + if (submitDisabledReason) return submitDisabledReason; if (isAgentBusy) return "Queue message"; if (planModeEnabled) return "Request plan changes"; return undefined; @@ -102,6 +102,7 @@ function SubmitButton({ isAgentBusy, hasContent, isDisabled, + submitDisabledReason, isSending, planModeEnabled, onCancel, @@ -116,6 +117,11 @@ function SubmitButton({ // take several seconds, and without this the user clicks repeatedly and // each click hits the backend. const [isCancelling, setIsCancelling] = useState(false); + const tooltipDescription = submitTooltipDescription( + isAgentBusy, + planModeEnabled, + submitDisabledReason, + ); // Re-entrancy guard via ref: `disabled={isCancelling}` already blocks DOM // clicks, but the ref makes the guard effective for any programmatic caller // and keeps `isCancelling` out of the useCallback deps so the handler @@ -164,25 +170,27 @@ function SubmitButton({ {showSendButton && ( - + + + )}
@@ -226,61 +234,6 @@ function PlanToggleButton({ ); } -function ImplementPlanButton({ onClick }: { onClick: (fresh: boolean) => void }) { - const primary = ( - - - - - Implement the plan in this session - - ); - return ( -
- {primary} - - - - - - onClick(true)} - className="cursor-pointer" - > - -
-
Implement in fresh agent
-
- Starts a new session with a clean context window -
-
-
-
-
-
- ); -} - function McpIndicator({ mcpServers }: { mcpServers: string[] }) { const hasMcp = mcpServers.length > 0; const tooltipText = hasMcp @@ -348,6 +301,7 @@ function ToolbarRightSection({ hasContent, onImplementPlan, isDisabled, + submitDisabledReason, isSending, onCancel, onSubmit, @@ -361,6 +315,7 @@ function ToolbarRightSection({ hasContent: boolean; onImplementPlan?: (fresh: boolean) => void; isDisabled: boolean; + submitDisabledReason?: string; isSending: boolean; onCancel: () => void; onSubmit: () => void; @@ -378,6 +333,7 @@ function ToolbarRightSection({ isAgentBusy={isAgentBusy} hasContent={hasContent} isDisabled={isDisabled} + submitDisabledReason={submitDisabledReason} isSending={isSending} planModeEnabled={planModeEnabled} onCancel={onCancel} @@ -493,13 +449,21 @@ function MinimalToolbar({ isAgentBusy, hasContent, isDisabled, + submitDisabledReason, isSending, onCancel, onSubmit, submitKey = "cmd_enter", }: Pick< ChatInputToolbarProps, - "isAgentBusy" | "hasContent" | "isDisabled" | "isSending" | "onCancel" | "onSubmit" | "submitKey" + | "isAgentBusy" + | "hasContent" + | "isDisabled" + | "submitDisabledReason" + | "isSending" + | "onCancel" + | "onSubmit" + | "submitKey" >) { const submitShortcut = submitKey === "enter" ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT; return ( @@ -508,6 +472,7 @@ function MinimalToolbar({ isAgentBusy={isAgentBusy} hasContent={hasContent ?? false} isDisabled={isDisabled} + submitDisabledReason={submitDisabledReason} isSending={isSending} planModeEnabled={false} onCancel={onCancel} @@ -545,6 +510,7 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp isAgentBusy={props.isAgentBusy} hasContent={props.hasContent} isDisabled={props.isDisabled} + submitDisabledReason={props.submitDisabledReason} isSending={props.isSending} onCancel={props.onCancel} onSubmit={props.onSubmit} @@ -615,6 +581,7 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp hasContent={props.hasContent ?? false} onImplementPlan={props.onImplementPlan} isDisabled={props.isDisabled} + submitDisabledReason={props.submitDisabledReason} isSending={props.isSending} onCancel={props.onCancel} onSubmit={props.onSubmit} diff --git a/apps/web/components/task/chat/implement-plan-button.tsx b/apps/web/components/task/chat/implement-plan-button.tsx new file mode 100644 index 0000000000..1ac7c6ed12 --- /dev/null +++ b/apps/web/components/task/chat/implement-plan-button.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { IconChevronDown, IconPlus, IconRocket } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; + +/** + * Split-button: primary "Implement" runs the plan inline in the current + * session; the dropdown caret offers "Implement in fresh agent" which starts + * a new session with a clean context window. `onClick(fresh)` is invoked + * with `false` for the primary path and `true` for the fresh-agent path. + */ +export function ImplementPlanButton({ onClick }: { onClick: (fresh: boolean) => void }) { + const primary = ( + + + + + Implement the plan in this session + + ); + return ( +
+ {primary} + + + + + + onClick(true)} + className="cursor-pointer" + > + +
+
Implement in fresh agent
+
+ Starts a new session with a clean context window +
+
+
+
+
+
+ ); +} diff --git a/apps/web/components/task/chat/use-chat-input-container.test.ts b/apps/web/components/task/chat/use-chat-input-container.test.ts new file mode 100644 index 0000000000..62b2d36092 --- /dev/null +++ b/apps/web/components/task/chat/use-chat-input-container.test.ts @@ -0,0 +1,57 @@ +import { createRef } from "react"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useChatInputContainer } from "./use-chat-input-container"; +import type { ChatInputContainerHandle } from "./chat-input-container"; + +function renderInputState(overrides: Partial[0]> = {}) { + return renderHook(() => + useChatInputContainer({ + ref: createRef(), + sessionId: "session-1", + isSending: false, + isStarting: false, + isPreparingEnvironment: false, + isMoving: false, + isFailed: false, + needsRecovery: false, + executorUnavailable: false, + isAgentBusy: false, + hasAgentCommands: true, + placeholder: undefined, + contextItems: [], + pendingClarification: null, + onClarificationResolved: undefined, + pendingCommentsByFile: undefined, + hasContextComments: false, + showRequestChangesTooltip: false, + onRequestChangesTooltipDismiss: undefined, + onSubmit: vi.fn(), + ...overrides, + }), + ); +} + +describe("useChatInputContainer", () => { + it("disables the editor while the session is still STARTING", () => { + // The editor must stay uneditable until the agent reaches RUNNING — if + // the user can press Cmd+Enter mid-startup, the backend rejects with + // "Failed to send message to agent" because the agent process isn't + // ready yet. This is the regression from earlier rounds where the e2e + // quick-chat suite kept failing on race conditions. + const { result } = renderInputState({ isStarting: true }); + + expect(result.current.isDisabled).toBe(true); + expect(result.current.submitDisabled).toBe(true); + expect(result.current.submitDisabledReason).toBeUndefined(); + }); + + it("surfaces the setup tooltip only while a container/sandbox is preparing", () => { + const { result } = renderInputState({ + isStarting: true, + isPreparingEnvironment: true, + }); + + expect(result.current.submitDisabledReason).toBe("The agent is still being set up."); + }); +}); diff --git a/apps/web/components/task/chat/use-chat-input-container.ts b/apps/web/components/task/chat/use-chat-input-container.ts index 0d68a8bd4a..5cf474e04c 100644 --- a/apps/web/components/task/chat/use-chat-input-container.ts +++ b/apps/web/components/task/chat/use-chat-input-container.ts @@ -16,9 +16,18 @@ type UseChatInputContainerParams = { sessionId: string | null; isSending: boolean; isStarting: boolean; + /** True only during a real Docker/Sprites prepare phase. Different from + * `isStarting`, which fires for every session that's transitioning + * through STARTING (including local quick-chat). Drives the "agent still + * being set up" submit-disabled tooltip so it only appears when a + * container/sandbox is genuinely bootstrapping; the disabled state + * itself is still gated on the broader `isStarting` to keep e2e + * Cmd+Enter from racing the not-yet-ready agent. */ + isPreparingEnvironment: boolean; isMoving: boolean; isFailed: boolean; needsRecovery: boolean; + executorUnavailable: boolean; isAgentBusy: boolean; hasAgentCommands: boolean; placeholder: string | undefined; @@ -59,6 +68,15 @@ function useInputHandle( ); } +function useSyncTipTapRef( + tiptapRef: React.RefObject, + inputRef: React.RefObject, +) { + useEffect(() => { + tiptapRef.current = inputRef.current; + }); +} + function getInputPlaceholder( placeholder: string | undefined, isAgentBusy: boolean, @@ -74,10 +92,12 @@ function getInputPlaceholder( function computeDerivedState(params: { isStarting: boolean; + isPreparingEnvironment: boolean; isMoving: boolean; isSending: boolean; isFailed: boolean; needsRecovery: boolean; + executorUnavailable: boolean; pendingClarification: Message | null | undefined; onClarificationResolved: (() => void) | undefined; pendingCommentsByFile: Record | undefined; @@ -87,12 +107,26 @@ function computeDerivedState(params: { isAgentBusy: boolean; hasAgentCommands: boolean; }) { + // STARTING is included so the editor itself stays uneditable until the + // session reaches RUNNING. Without this, the e2e suite's wait-for- + // contenteditable would fire mid-STARTING, the test would press Cmd+Enter, + // and the backend would reject with "Failed to send message to agent" + // before the agent process is ready to receive prompts. const isDisabled = params.isStarting || params.isMoving || params.isSending || params.isFailed || - params.needsRecovery; + params.needsRecovery || + params.executorUnavailable; + const submitDisabled = isDisabled; + // The "agent still being set up" tooltip is only meaningful while a + // container/sandbox is actively bootstrapping. The brief STARTING + // transition for local quick-chat sessions doesn't deserve its own + // tooltip — the editor is disabled, that's the signal. + const submitDisabledReason = params.isPreparingEnvironment + ? "The agent is still being set up." + : undefined; const hasClarification = !!(params.pendingClarification && params.onClarificationResolved); const hasPendingComments = !!( params.pendingCommentsByFile && Object.keys(params.pendingCommentsByFile).length > 0 @@ -107,6 +141,8 @@ function computeDerivedState(params: { ); return { isDisabled, + submitDisabled, + submitDisabledReason, hasClarification, hasPendingComments, hasContextZone, @@ -116,25 +152,11 @@ function computeDerivedState(params: { } export function useChatInputContainer(params: UseChatInputContainerParams) { - const { - ref, - sessionId, - isSending, - isStarting, - isMoving, - isFailed, - needsRecovery, - isAgentBusy, - hasAgentCommands, - placeholder, - contextItems, - pendingClarification, - onClarificationResolved, - pendingCommentsByFile, - showRequestChangesTooltip, - onRequestChangesTooltipDismiss, - onSubmit, - } = params; + const { ref, sessionId, isSending, isStarting, isPreparingEnvironment, isMoving } = params; + const { isFailed, needsRecovery, executorUnavailable, isAgentBusy, hasAgentCommands } = params; + const { placeholder, contextItems, pendingClarification, onClarificationResolved } = params; + const { pendingCommentsByFile, showRequestChangesTooltip } = params; + const { onRequestChangesTooltipDismiss, onSubmit } = params; const [isInputFocused, setIsInputFocused] = useState(false); const [showNewSessionDialog, setShowNewSessionDialog] = useState(false); @@ -159,9 +181,8 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { onSubmit, }); - useEffect(() => { - tiptapRef.current = inputRef.current; - }); + useSyncTipTapRef(tiptapRef, inputRef); + useInputHandle(ref, inputRef, getAttachments); // Auto-expand the input container as the user types more lines @@ -185,10 +206,12 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { const derived = computeDerivedState({ isStarting, + isPreparingEnvironment, isMoving, isSending, isFailed, needsRecovery, + executorUnavailable, pendingClarification, onClarificationResolved, pendingCommentsByFile, diff --git a/apps/web/components/task/executor-environment-info.tsx b/apps/web/components/task/executor-environment-info.tsx new file mode 100644 index 0000000000..e879ed9472 --- /dev/null +++ b/apps/web/components/task/executor-environment-info.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useMemo } from "react"; +import { IconCopy, IconLoader } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { toast } from "sonner"; + +import { + type ContainerLiveStatus, + type TaskEnvironment, +} from "@/lib/api/domains/task-environment-api"; +import { resolveExecutorEnvironmentStatus, type StatusTone } from "./executor-environment-status"; + +const TONE_CLASSES: Record = { + running: "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300", + stopped: "border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300", + warn: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", + error: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300", + neutral: "border-muted text-muted-foreground", +}; + +export function EnvironmentInfo({ + env, + container, + loading, +}: { + env: TaskEnvironment | null; + container: ContainerLiveStatus | null; + loading: boolean; +}) { + if (loading && !env) { + return ( +
+ +
+ ); + } + + if (!env) { + return ( +
+

No environment yet

+

+ An environment is created when you start a session on this task. +

+
+ ); + } + + return ( +
+
+ {formatExecutorType(env.executor_type)} + +
+ +
+ ); +} + +function StatusBadge({ + env, + container, +}: { + env: TaskEnvironment; + container: ContainerLiveStatus | null; +}) { + // For container-backed envs the live state is the source of truth; for the + // others fall back to the recorded TaskEnvironment.status. + const { label, tone } = resolveExecutorEnvironmentStatus(env, container); + const className = TONE_CLASSES[tone]; + return ( + + {label} + + ); +} + +function EnvironmentFields({ + env, + container, +}: { + env: TaskEnvironment; + container: ContainerLiveStatus | null; +}) { + const fields = useMemo(() => buildFields(env, container), [env, container]); + if (fields.length === 0) { + return

No resource details available.

; + } + return ( +
+ {fields.map((f) => ( + + ))} +
+ ); +} + +function Field({ label, value, copy }: { label: string; value: string; copy?: boolean }) { + return ( +
+
{label}
+
+ {value} + {copy && ( + + )} +
+
+ ); +} + +type FieldRow = { label: string; value: string; copy?: boolean }; + +function buildFields(env: TaskEnvironment, container: ContainerLiveStatus | null): FieldRow[] { + const rows: FieldRow[] = []; + + if (env.worktree_path) { + rows.push({ label: "Worktree", value: env.worktree_path, copy: true }); + } + if (env.worktree_branch) { + rows.push({ label: "Branch", value: env.worktree_branch, copy: true }); + } + + if (env.container_id) { + const short = env.container_id.slice(0, 12); + rows.push({ label: "Container", value: short, copy: true }); + // Use `sh` rather than `bash` — user-built images may only ship + // /bin/sh (busybox/alpine/etc.), and the bootstrap entrypoint already + // assumes sh-only. + rows.push({ + label: "Shell", + value: `docker exec -it ${short} sh`, + copy: true, + }); + if (container?.started_at && container.state === "running") { + rows.push({ label: "Uptime", value: formatUptime(container.started_at) }); + } + } + + if (env.sandbox_id) { + rows.push({ label: "Sprite", value: env.sandbox_id, copy: true }); + } + + return rows; +} + +function formatUptime(startedAt: string): string { + const startedMs = Date.parse(startedAt); + if (Number.isNaN(startedMs)) return startedAt; + const elapsedSec = Math.max(0, Math.floor((Date.now() - startedMs) / 1000)); + if (elapsedSec < 60) return `${elapsedSec}s`; + const min = Math.floor(elapsedSec / 60); + if (min < 60) return `${min}m`; + const hr = Math.floor(min / 60); + return `${hr}h ${min % 60}m`; +} + +function formatExecutorType(type: string): string { + switch (type) { + case "local_pc": + case "worktree": + return "Local (worktree)"; + case "local_docker": + return "Local Docker"; + case "sprites": + return "Sprites sandbox"; + case "remote_docker": + return "Remote Docker"; + default: + return type || "Unknown executor"; + } +} diff --git a/apps/web/components/task/executor-environment-status.test.ts b/apps/web/components/task/executor-environment-status.test.ts new file mode 100644 index 0000000000..5006572295 --- /dev/null +++ b/apps/web/components/task/executor-environment-status.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + getEnvironmentStatusSnapshot, + resolveExecutorEnvironmentStatus, +} from "./executor-environment-status"; +import type { ContainerLiveStatus, TaskEnvironment } from "@/lib/api/domains/task-environment-api"; + +const baseEnv: TaskEnvironment = { + id: "env-1", + task_id: "task-1", + repository_id: "repo-1", + executor_type: "local_docker", + executor_id: "local-docker", + executor_profile_id: "profile-1", + agent_execution_id: "exec-1", + control_port: 41001, + status: "ready", + created_at: "2026-05-02T00:00:00Z", + updated_at: "2026-05-02T00:00:00Z", +}; + +function container(overrides: Partial): ContainerLiveStatus { + return { + container_id: "container-1", + state: "running", + status: "running", + started_at: "2026-05-02T00:00:00Z", + ...overrides, + }; +} + +describe("resolveExecutorEnvironmentStatus", () => { + it("uses live container state ahead of recorded environment status", () => { + expect( + resolveExecutorEnvironmentStatus( + { ...baseEnv, status: "ready" }, + container({ state: "exited", status: "exited", exit_code: 137 }), + ), + ).toEqual({ label: "exited (137)", tone: "error" }); + }); + + it("shows paused containers as a warning state", () => { + expect(resolveExecutorEnvironmentStatus(baseEnv, container({ state: "paused" }))).toEqual({ + label: "paused", + tone: "warn", + }); + }); + + it("falls back to the persisted environment status when no live container exists", () => { + expect(resolveExecutorEnvironmentStatus({ ...baseEnv, status: "creating" }, null)).toEqual({ + label: "starting", + tone: "warn", + }); + }); +}); + +describe("getEnvironmentStatusSnapshot", () => { + it("uses a stable key for status transition notifications", () => { + expect(getEnvironmentStatusSnapshot(baseEnv, container({ state: "running" }))).toEqual({ + key: "running:running", + label: "running", + tone: "running", + }); + expect(getEnvironmentStatusSnapshot(null, null)).toEqual({ + key: "none", + label: "not created", + tone: "neutral", + }); + }); +}); diff --git a/apps/web/components/task/executor-environment-status.ts b/apps/web/components/task/executor-environment-status.ts new file mode 100644 index 0000000000..c9823b6ecd --- /dev/null +++ b/apps/web/components/task/executor-environment-status.ts @@ -0,0 +1,65 @@ +import type { ContainerLiveStatus, TaskEnvironment } from "@/lib/api/domains/task-environment-api"; + +export type StatusTone = "running" | "stopped" | "warn" | "error" | "neutral"; + +export type ExecutorEnvironmentStatus = { + label: string; + tone: StatusTone; +}; + +export type EnvironmentStatusSnapshot = ExecutorEnvironmentStatus & { + key: string; +}; + +export function getEnvironmentStatusSnapshot( + env: TaskEnvironment | null, + container: ContainerLiveStatus | null, +): EnvironmentStatusSnapshot { + if (!env) { + return { key: "none", label: "not created", tone: "neutral" }; + } + const status = resolveExecutorEnvironmentStatus(env, container); + return { ...status, key: `${status.tone}:${status.label}` }; +} + +export function resolveExecutorEnvironmentStatus( + env: TaskEnvironment, + container: ContainerLiveStatus | null, +): ExecutorEnvironmentStatus { + if (container) { + return resolveContainerStatus(container); + } + return resolveEnvStatus(env.status); +} + +const CONTAINER_STATUS_TONES: Record = { + paused: "warn", + restarting: "warn", + dead: "error", +}; + +function resolveContainerStatus(container: ContainerLiveStatus): ExecutorEnvironmentStatus { + if (container.missing) return { label: "missing", tone: "warn" }; + if (container.state === "running") { + return { label: container.status || "running", tone: "running" }; + } + if (container.state === "exited") { + return { + label: container.exit_code ? `exited (${container.exit_code})` : "exited", + tone: container.exit_code ? "error" : "stopped", + }; + } + const tone = CONTAINER_STATUS_TONES[container.state] ?? "neutral"; + return { label: container.state || "unknown", tone }; +} + +const ENV_STATUS_MAP: Record = { + ready: { label: "ready", tone: "running" }, + creating: { label: "starting", tone: "warn" }, + stopped: { label: "stopped", tone: "stopped" }, + failed: { label: "failed", tone: "error" }, +}; + +function resolveEnvStatus(status: string): ExecutorEnvironmentStatus { + return ENV_STATUS_MAP[status] ?? { label: status || "unknown", tone: "neutral" }; +} diff --git a/apps/web/components/task/executor-prepare-status.tsx b/apps/web/components/task/executor-prepare-status.tsx new file mode 100644 index 0000000000..8db9be7db3 --- /dev/null +++ b/apps/web/components/task/executor-prepare-status.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react"; + +import type { PrepareSummary } from "@/lib/prepare/summarize"; + +export function PrepareStatusSection({ summary }: { summary: PrepareSummary }) { + if (summary.phase === "idle" || summary.phase === "ready") return null; + if (summary.phase === "resuming") return ; + if (summary.phase === "preparing" || summary.phase === "preparing_fallback") { + return ; + } + if (summary.phase === "failed") return ; + return null; +} + +function ResumingRow() { + return ( +
+
+ + Resuming session +
+
Reconnecting to the existing environment…
+
+ ); +} + +function PreparingRow({ summary }: { summary: PrepareSummary }) { + const stepLabel = summary.current?.name ?? "Preparing..."; + const stepNumber = Math.min(summary.currentIndex + 1, summary.totalSteps); + return ( +
+
+ + Preparing environment +
+
+ {summary.totalSteps > 0 + ? `Step ${stepNumber} of ${summary.totalSteps}: ${stepLabel}` + : stepLabel} +
+ {summary.phase === "preparing_fallback" && summary.fallbackWarning && ( +
+ + {summary.fallbackWarning} +
+ )} +
+ ); +} + +function FailedRow({ summary }: { summary: PrepareSummary }) { + return ( +
+
+ + Environment preparation failed +
+ {summary.failedStep && ( +
Failed at: {summary.failedStep.name}
+ )} + {summary.failedStep?.error && ( +
+          {summary.failedStep.error}
+        
+ )} +
+ ); +} diff --git a/apps/web/components/task/executor-settings-button.test.tsx b/apps/web/components/task/executor-settings-button.test.tsx new file mode 100644 index 0000000000..3d3218ab27 --- /dev/null +++ b/apps/web/components/task/executor-settings-button.test.tsx @@ -0,0 +1,168 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SessionPrepareState } from "@/lib/state/slices/session-runtime/types"; + +// Constants declared *above* the vi.mock factories so the mocks can reference +// them without relying on vitest's hoisting capturing TDZ-undefined closures. +// The previous layout worked by accident (factories aren't invoked until the +// test imports the module under test, by which time the const initialization +// has run), but a refactor that imports the module earlier — for example, a +// shared test helper — would break the closure silently. +const SESSION_ID = "session-1"; +const TASK_ID = "task-1"; +const STEP_CREATE_SANDBOX = "Creating cloud sandbox"; +const PREPARE_STATUS_TESTID = "executor-prepare-status"; +const SETTINGS_BUTTON_TESTID = "executor-settings-button"; + +let mockPrepareState: SessionPrepareState | null = null; +let mockSessionState: string | null = null; +let mockEnv: { executor_type: string; sandbox_id?: string; container_id?: string } | null = null; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + prepareProgress: { + bySessionId: mockPrepareState ? { [mockPrepareState.sessionId]: mockPrepareState } : {}, + }, + taskSessions: { + items: mockSessionState + ? { [SESSION_ID]: { id: SESSION_ID, state: mockSessionState } } + : {}, + }, + }), +})); + +vi.mock("@/lib/api/domains/task-environment-api", () => ({ + fetchTaskEnvironmentLive: vi.fn().mockImplementation(async () => ({ + environment: mockEnv ?? { executor_type: "" }, + container: null, + })), + resetTaskEnvironment: vi.fn().mockResolvedValue({ success: true }), +})); + +vi.mock("./task-reset-env-confirm-dialog", () => ({ + TaskResetEnvConfirmDialog: () => null, +})); + +import { ExecutorSettingsButton } from "./executor-settings-button"; + +describe("ExecutorSettingsButton", () => { + afterEach(() => { + cleanup(); + mockPrepareState = null; + mockSessionState = null; + mockEnv = null; + }); + + it("shows the cloud icon when the executor type is sprites", async () => { + mockEnv = { executor_type: "sprites", sandbox_id: "kandev-abc" }; + + render(); + + // Wait a tick for the live fetch to resolve. + await Promise.resolve(); + await Promise.resolve(); + expect(await screen.findByTestId("executor-status-cloud-icon")).toBeTruthy(); + }); + + it("shows the container icon for both docker variants", async () => { + mockEnv = { executor_type: "local_docker", container_id: "abcdef" }; + + render(); + + await Promise.resolve(); + expect(await screen.findByTestId("executor-status-container-icon")).toBeTruthy(); + }); + + it("swaps to a spinner while the prepare progress is preparing", async () => { + mockEnv = { executor_type: "sprites", sandbox_id: "kandev-abc" }; + mockPrepareState = { + sessionId: SESSION_ID, + status: "preparing", + steps: [{ name: STEP_CREATE_SANDBOX, status: "running" }], + }; + + render(); + + expect(screen.getByTestId("executor-settings-button-spinner")).toBeTruthy(); + expect(screen.queryByTestId("executor-status-cloud-icon")).toBeNull(); + }); + + it("renders the preparing section with current step copy", async () => { + mockPrepareState = { + sessionId: SESSION_ID, + status: "preparing", + steps: [ + { name: STEP_CREATE_SANDBOX, status: "completed" }, + { name: "Uploading agent controller", status: "running" }, + { name: "Waiting for agent controller", status: "pending" }, + ], + }; + + render(); + + // Open the hover card by hovering the trigger (not clicking — it's a + // borderless info surface, not a button). + fireEvent.pointerEnter(screen.getByTestId(SETTINGS_BUTTON_TESTID)); + + expect(await screen.findByTestId(PREPARE_STATUS_TESTID)).toHaveProperty( + "dataset.phase", + "preparing", + ); + expect(screen.getByText(/Step 2 of 3: Uploading agent controller/)).toBeTruthy(); + }); + + it("renders the fallback warning callout when the missing-sandbox notice is present", async () => { + mockPrepareState = { + sessionId: SESSION_ID, + status: "preparing", + steps: [ + { + name: "Reconnecting cloud sandbox", + status: "skipped", + warning: + "Previous sandbox is no longer available — provisioning a fresh one for this branch.", + }, + { name: STEP_CREATE_SANDBOX, status: "running" }, + ], + }; + + render(); + fireEvent.pointerEnter(screen.getByTestId(SETTINGS_BUTTON_TESTID)); + + const status = await screen.findByTestId(PREPARE_STATUS_TESTID); + expect(status.dataset.phase).toBe("preparing_fallback"); + expect(screen.getByTestId("executor-prepare-fallback-warning")).toBeTruthy(); + }); + + it("renders the Resuming session row when session is STARTING with no prepare events", async () => { + mockSessionState = "STARTING"; + + render(); + + expect(screen.getByTestId("executor-settings-button-spinner")).toBeTruthy(); + fireEvent.pointerEnter(screen.getByTestId(SETTINGS_BUTTON_TESTID)); + + const status = await screen.findByTestId(PREPARE_STATUS_TESTID); + expect(status.dataset.phase).toBe("resuming"); + expect(screen.getByText("Resuming session")).toBeTruthy(); + expect(screen.getByText(/Reconnecting to the existing environment/)).toBeTruthy(); + }); + + it("hides the prepare-status section once preparation completes", async () => { + // The READY badge next to the executor name conveys ready-state; the + // dedicated "Environment ready · 12s" row is redundant noise. + mockPrepareState = { + sessionId: SESSION_ID, + status: "completed", + steps: [{ name: "Creating cloud sandbox", status: "completed" }], + durationMs: 12500, + }; + + render(); + fireEvent.pointerEnter(screen.getByTestId(SETTINGS_BUTTON_TESTID)); + + expect(screen.queryByTestId(PREPARE_STATUS_TESTID)).toBeNull(); + expect(screen.queryByText(/Environment ready/)).toBeNull(); + }); +}); diff --git a/apps/web/components/task/executor-settings-button.tsx b/apps/web/components/task/executor-settings-button.tsx new file mode 100644 index 0000000000..33344a870b --- /dev/null +++ b/apps/web/components/task/executor-settings-button.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { IconBox, IconLoader2, IconTrash } from "@tabler/icons-react"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; +import { Button } from "@kandev/ui/button"; + +import { getExecutorStatusIcon } from "@/lib/executor-icons"; +import { TaskResetEnvConfirmDialog } from "./task-reset-env-confirm-dialog"; +import type { StatusTone } from "./executor-environment-status"; +import { usePrepareSummary } from "@/hooks/domains/session/use-prepare-summary"; +import { useTaskEnvironment } from "@/hooks/domains/session/use-task-environment"; +import { isPreparingPhase } from "@/lib/prepare/summarize"; +import { PrepareStatusSection } from "./executor-prepare-status"; +import { EnvironmentInfo } from "./executor-environment-info"; + +type ExecutorSettingsButtonProps = { + taskId?: string | null; + sessionId?: string | null; + disabled?: boolean; +}; + +export function ExecutorSettingsButton({ + taskId, + sessionId, + disabled, +}: ExecutorSettingsButtonProps) { + const [open, setOpen] = useState(false); + const [resetDialogOpen, setResetDialogOpen] = useState(false); + const prepare = usePrepareSummary(sessionId ?? null); + const isPreparing = isPreparingPhase(prepare.phase); + // Promote the foreground polling cadence while preparing so the icon flips + // to "ready" without the user hovering over the trigger. + const { env, container, loading, isResetting, reset, status } = useTaskEnvironment( + taskId, + open || isPreparing, + ); + + const handleReset = useCallback( + async (opts: { pushBranch: boolean }) => { + const ok = await reset(opts); + if (ok) { + setResetDialogOpen(false); + setOpen(false); + } + }, + [reset], + ); + + if (!taskId) return null; + + const executorType = env?.executor_type ?? null; + const ariaLabel = computeAriaLabel(isPreparing, status); + + return ( + <> + + + {/* Real + + + + +
+ +
+
+
+ + + + ); +} + +function computeAriaLabel( + preparing: boolean, + status: { label: string; tone: StatusTone } | null, +): string { + if (preparing) return "Executor settings, preparing environment"; + if (status) return `Executor settings, environment ${status.label}`; + return "Executor settings"; +} + +function ExecutorButtonIcon({ + executorType, + preparing, +}: { + executorType: string | null; + preparing: boolean; +}) { + if (preparing) { + return ( + + ); + } + if (!executorType) { + return ; + } + const { Icon, testId } = getExecutorStatusIcon(executorType, false); + return ; +} + +const DOT_CLASSES: Record = { + running: "bg-green-500", + stopped: "bg-zinc-500", + warn: "bg-amber-500", + error: "bg-red-500", + neutral: "bg-muted-foreground", +}; + +function ExecutorStatusDot({ + status, + loading, +}: { + status: { label: string; tone: StatusTone } | null; + loading: boolean; +}) { + const tone = status?.tone ?? "neutral"; + const label = status?.label ?? "not created"; + return ( +