diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go
index a051c50b9e..c669c9a51f 100644
--- a/apps/backend/cmd/kandev/adapters.go
+++ b/apps/backend/cmd/kandev/adapters.go
@@ -127,6 +127,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
WorktreePath: worktreePath,
WorktreeBranch: worktreeBranch,
Metadata: execution.Metadata,
+ PrepareResult: execution.PrepareResult,
}, nil
}
diff --git a/apps/backend/internal/agent/lifecycle/default_scripts.go b/apps/backend/internal/agent/lifecycle/default_scripts.go
index ca02db9b10..5d21dedd54 100644
--- a/apps/backend/internal/agent/lifecycle/default_scripts.go
+++ b/apps/backend/internal/agent/lifecycle/default_scripts.go
@@ -21,6 +21,9 @@ const defaultLocalPrepareScript = `#!/bin/bash
# Runs before launching the local agent runtime.
# The script executes with working directory set to {{workspace.path}}.
# Use {{repository.path}} when you need the canonical repository root path.
+
+# ---- Repository setup (if configured) ----
+{{repository.setup_script}}
`
const defaultWorktreePrepareScript = `#!/bin/bash
@@ -28,6 +31,9 @@ const defaultWorktreePrepareScript = `#!/bin/bash
# Runs after the worktree has already been created/reused by Kandev.
# The script executes with working directory set to {{worktree.path}}.
# Use {{repository.path}} if you need to run commands in the main repository.
+
+# ---- Repository setup (if configured) ----
+{{repository.setup_script}}
`
const defaultDockerPrepareScript = `#!/bin/bash
diff --git a/apps/backend/internal/agent/lifecycle/env_preparer.go b/apps/backend/internal/agent/lifecycle/env_preparer.go
index c7a457e921..edee188546 100644
--- a/apps/backend/internal/agent/lifecycle/env_preparer.go
+++ b/apps/backend/internal/agent/lifecycle/env_preparer.go
@@ -2,6 +2,7 @@ package lifecycle
import (
"context"
+ "strings"
"time"
"github.com/kandev/kandev/internal/agent/executor"
@@ -21,20 +22,21 @@ const (
// EnvPrepareRequest contains the parameters for environment preparation.
type EnvPrepareRequest struct {
- TaskID string
- SessionID string
- TaskTitle string
- ExecutionID string
- ExecutorType executor.Name
- WorkspacePath string
- RepositoryPath string
- RepositoryID string
- UseWorktree bool
- SetupScript string
- BaseBranch string
- CheckoutBranch string
- WorktreeID string
- WorktreeBranch string
+ TaskID string
+ SessionID string
+ TaskTitle string
+ ExecutionID string
+ ExecutorType executor.Name
+ WorkspacePath string
+ RepositoryPath string
+ RepositoryID string
+ UseWorktree bool
+ SetupScript string
+ RepoSetupScript string // Repository-level setup script (e.g. "make install")
+ BaseBranch string
+ CheckoutBranch string
+ WorktreeID string
+ WorktreeBranch string
WorktreeBranchPrefix string
PullBeforeWorktree bool
@@ -48,6 +50,7 @@ type EnvPrepareRequest struct {
// PrepareStep represents a single step in the preparation process.
type PrepareStep struct {
Name string `json:"name"`
+ Command string `json:"command,omitempty"`
Status PrepareStepStatus `json:"status"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
@@ -83,6 +86,53 @@ type EnvironmentPreparer interface {
Prepare(ctx context.Context, req *EnvPrepareRequest, onProgress PrepareProgressCallback) (*EnvPrepareResult, error)
}
+// MaxStepOutputBytes is the maximum size of a single step's Output field
+// when persisting to session metadata.
+const MaxStepOutputBytes = 10 * 1024
+
+// SerializePrepareResult converts a prepare result into a map suitable for
+// storage in session metadata. Used by both the synchronous persistence path
+// (persistLaunchState) and the async event handler (handlePrepareCompleted).
+func SerializePrepareResult(result *EnvPrepareResult) map[string]interface{} {
+ status := "completed"
+ if !result.Success {
+ status = "failed"
+ }
+ steps := make([]map[string]interface{}, 0, len(result.Steps))
+ for _, step := range result.Steps {
+ output := step.Output
+ if len(output) > MaxStepOutputBytes {
+ // Truncate at a valid UTF-8 boundary to avoid splitting multi-byte runes.
+ output = strings.ToValidUTF8(output[:MaxStepOutputBytes], "") + "\n... (truncated)"
+ }
+ entry := map[string]interface{}{
+ "name": step.Name, "status": string(step.Status),
+ "output": output, "command": step.Command,
+ }
+ if step.Error != "" {
+ entry["error"] = step.Error
+ }
+ if step.Warning != "" {
+ entry["warning"] = step.Warning
+ }
+ if step.WarningDetail != "" {
+ entry["warning_detail"] = step.WarningDetail
+ }
+ if step.StartedAt != nil {
+ entry["started_at"] = step.StartedAt.Format(time.RFC3339Nano)
+ }
+ if step.EndedAt != nil {
+ entry["ended_at"] = step.EndedAt.Format(time.RFC3339Nano)
+ }
+ steps = append(steps, entry)
+ }
+ return map[string]interface{}{
+ "status": status, "steps": steps,
+ "error_message": result.ErrorMessage,
+ "duration_ms": result.Duration.Milliseconds(),
+ }
+}
+
// PreparerRegistry maps executor types to environment preparers.
type PreparerRegistry struct {
preparers map[executor.Name]EnvironmentPreparer
diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_local.go b/apps/backend/internal/agent/lifecycle/env_preparer_local.go
index 8e43a76147..6ed7bcb6a5 100644
--- a/apps/backend/internal/agent/lifecycle/env_preparer_local.go
+++ b/apps/backend/internal/agent/lifecycle/env_preparer_local.go
@@ -1,11 +1,13 @@
package lifecycle
import (
+ "bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
+ "sync"
"time"
"go.uber.org/zap"
@@ -71,6 +73,7 @@ func (p *LocalPreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, onP
// Step 2: Checkout branch (if specified)
if effectiveBranch != "" {
step = beginStep("Checkout branch")
+ step.Command = fmt.Sprintf("git fetch origin %s && git checkout %s", effectiveBranch, effectiveBranch)
reportProgress(onProgress, step, stepIdx, totalSteps)
output, err := checkoutBranch(ctx, workspacePath, effectiveBranch)
if err != nil {
@@ -89,22 +92,7 @@ func (p *LocalPreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, onP
// Step 3: Run setup script (if provided)
if resolvedScript != "" {
- step = beginStep("Run setup script")
- reportProgress(onProgress, step, stepIdx, totalSteps)
- output, err := runSetupScript(ctx, resolvedScript, workspacePath, req.Env)
- if err != nil {
- completeStepError(&step, err.Error())
- step.Output = output
- steps = append(steps, step)
- reportProgress(onProgress, step, stepIdx, totalSteps)
- p.logger.Warn("setup script failed", zap.String("task_id", req.TaskID), zap.Error(err))
- // Setup script failure is non-fatal — log and continue
- } else {
- step.Output = output
- completeStepSuccess(&step)
- steps = append(steps, step)
- reportProgress(onProgress, step, stepIdx, totalSteps)
- }
+ steps = runSetupScriptStep(ctx, req, workspacePath, resolvedScript, stepIdx, totalSteps, onProgress, steps, p.logger)
}
return &EnvPrepareResult{
@@ -137,15 +125,65 @@ func checkoutBranch(ctx context.Context, workDir, branch string) (string, error)
return outStr, nil
}
-// runSetupScript executes a setup script in the given working directory.
-func runSetupScript(ctx context.Context, script, workDir string, env map[string]string) (string, error) {
+// setupScriptStreamInterval is the minimum gap between streaming-output callbacks
+// while a setup script runs. Chatty scripts (e.g. npm install with progress bars)
+// can emit hundreds of writes per second; throttling keeps WS event volume sane
+// while still showing live output.
+const setupScriptStreamInterval = 100 * time.Millisecond
+
+// runSetupScript executes a setup script in the given working directory,
+// streaming combined stdout/stderr to onOutput (if non-nil) as it runs.
+// Returns the full accumulated output (trimmed) and any execution error.
+func runSetupScript(ctx context.Context, script, workDir string, env map[string]string, onOutput func(current string)) (string, error) {
cmd := exec.CommandContext(ctx, "sh", "-c", script)
if workDir != "" {
cmd.Dir = workDir
}
cmd.Env = buildEnvSlice(env)
- out, err := cmd.CombinedOutput()
- return strings.TrimSpace(string(out)), err
+
+ w := newStreamingWriter(onOutput, setupScriptStreamInterval)
+ cmd.Stdout = w
+ cmd.Stderr = w
+
+ err := cmd.Run()
+ return strings.TrimSpace(w.String()), err
+}
+
+// streamingWriter accumulates writes into a buffer and invokes onFlush with the
+// current snapshot at most once per minGap. It's safe for concurrent Write calls
+// (both stdout and stderr write through the same writer).
+type streamingWriter struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+ onFlush func(current string)
+ lastFlush time.Time
+ minGap time.Duration
+}
+
+func newStreamingWriter(onFlush func(current string), minGap time.Duration) *streamingWriter {
+ return &streamingWriter{onFlush: onFlush, minGap: minGap}
+}
+
+func (w *streamingWriter) Write(p []byte) (int, error) {
+ // Hold the lock through the flush so concurrent stdout+stderr writes
+ // can't both observe `now-lastFlush >= minGap` and race on the captured
+ // `step.Output` shared by the caller's callback.
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ n, err := w.buf.Write(p)
+ now := time.Now()
+ if w.onFlush != nil && now.Sub(w.lastFlush) >= w.minGap {
+ w.lastFlush = now
+ w.onFlush(strings.TrimSpace(w.buf.String()))
+ }
+ return n, err
+}
+
+// String returns the full accumulated output.
+func (w *streamingWriter) String() string {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.buf.String()
}
// buildEnvSlice converts a map to os.Environ format (KEY=VALUE).
@@ -162,6 +200,19 @@ func buildEnvSlice(env map[string]string) []string {
return result
}
+// setupScriptDisplayCommand returns a short, user-facing command string for the
+// "Run setup script" step. Prefers the explicit profile script, then the
+// repository-level script. Falls back to empty (the step still shows its name).
+func setupScriptDisplayCommand(req *EnvPrepareRequest) string {
+ if s := strings.TrimSpace(req.SetupScript); s != "" {
+ return s
+ }
+ if s := strings.TrimSpace(req.RepoSetupScript); s != "" {
+ return s
+ }
+ return ""
+}
+
// Helper functions for step lifecycle
func beginStep(name string) PrepareStep {
diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_setup_script_test.go b/apps/backend/internal/agent/lifecycle/env_preparer_setup_script_test.go
new file mode 100644
index 0000000000..01eef1f3a0
--- /dev/null
+++ b/apps/backend/internal/agent/lifecycle/env_preparer_setup_script_test.go
@@ -0,0 +1,221 @@
+package lifecycle
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// TestRunSetupScript_StreamsOutputWhileRunning verifies that the streaming
+// callback fires with partial output as the script runs, not only once at the
+// end. This is what makes the UI show `npm install` progress live instead of
+// sitting on a silent spinner.
+func TestRunSetupScript_StreamsOutputWhileRunning(t *testing.T) {
+ // Script emits three lines with 200ms gaps. With the 100ms stream interval,
+ // we expect at least two intermediate flushes before completion.
+ script := `echo one; sleep 0.2; echo two; sleep 0.2; echo three`
+
+ var mu sync.Mutex
+ var snapshots []string
+ cb := func(current string) {
+ mu.Lock()
+ defer mu.Unlock()
+ snapshots = append(snapshots, current)
+ }
+
+ output, err := runSetupScript(context.Background(), script, t.TempDir(), nil, cb)
+ if err != nil {
+ t.Fatalf("runSetupScript error: %v", err)
+ }
+ if output != "one\ntwo\nthree" {
+ t.Fatalf("unexpected final output: %q", output)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(snapshots) < 2 {
+ t.Fatalf("expected at least 2 streaming snapshots, got %d: %v", len(snapshots), snapshots)
+ }
+ // Snapshots are monotonically growing — each should be a prefix of the next.
+ for i := 1; i < len(snapshots); i++ {
+ if !strings.HasPrefix(snapshots[i], strings.TrimSpace(snapshots[i-1])) {
+ t.Fatalf("snapshot %d (%q) is not a growth of snapshot %d (%q)", i, snapshots[i], i-1, snapshots[i-1])
+ }
+ }
+ // First snapshot should contain "one" (seen at ~0ms after first echo).
+ if !strings.Contains(snapshots[0], "one") {
+ t.Fatalf("first snapshot should contain 'one', got %q", snapshots[0])
+ }
+}
+
+// TestRunSetupScript_CapturesOutputOnFailure verifies that when the script
+// exits non-zero, both the output and the error are returned so the UI can
+// display what the failing command printed.
+func TestRunSetupScript_CapturesOutputOnFailure(t *testing.T) {
+ script := `echo installing; echo "ENOENT: package-lock.json" 1>&2; exit 1`
+
+ output, err := runSetupScript(context.Background(), script, t.TempDir(), nil, nil)
+ if err == nil {
+ t.Fatal("expected error for non-zero exit, got nil")
+ }
+ if !strings.Contains(output, "installing") {
+ t.Fatalf("output missing stdout line: %q", output)
+ }
+ if !strings.Contains(output, "ENOENT") {
+ t.Fatalf("output missing stderr line: %q", output)
+ }
+}
+
+// TestRunSetupScript_NilCallbackIsSafe verifies callers can skip streaming.
+func TestRunSetupScript_NilCallbackIsSafe(t *testing.T) {
+ output, err := runSetupScript(context.Background(), `echo hi`, t.TempDir(), nil, nil)
+ if err != nil {
+ t.Fatalf("runSetupScript error: %v", err)
+ }
+ if output != "hi" {
+ t.Fatalf("unexpected output: %q", output)
+ }
+}
+
+// TestLocalPreparer_StreamsSetupScriptProgress verifies the LocalPreparer wires
+// the streaming callback into PrepareProgressCallback — the setup script step
+// is reported multiple times with growing Output before completion.
+func TestLocalPreparer_StreamsSetupScriptProgress(t *testing.T) {
+ isolateGitEnv(t)
+ log := newTestLocalLogger()
+ preparer := NewLocalPreparer(log)
+ repoDir := initGitRepo(t)
+
+ req := &EnvPrepareRequest{
+ TaskID: "task-1",
+ RepositoryPath: repoDir,
+ SetupScript: `echo first; sleep 0.2; echo second; sleep 0.2; echo third`,
+ }
+
+ var mu sync.Mutex
+ var setupEvents []PrepareStep
+ cb := func(step PrepareStep, _, _ int) {
+ if step.Name != "Run setup script" {
+ return
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ setupEvents = append(setupEvents, step)
+ }
+
+ result, err := preparer.Prepare(context.Background(), req, cb)
+ if err != nil {
+ t.Fatalf("Prepare() error: %v", err)
+ }
+ if !result.Success {
+ t.Fatalf("Prepare() failed: %s", result.ErrorMessage)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ // Expect: 1 begin (running, no output) + >=1 streaming update (running, partial output) + 1 final (completed, full output).
+ if len(setupEvents) < 3 {
+ t.Fatalf("expected >=3 setup step events (begin + stream + complete), got %d", len(setupEvents))
+ }
+
+ first := setupEvents[0]
+ last := setupEvents[len(setupEvents)-1]
+ if first.Status != PrepareStepRunning {
+ t.Errorf("first setup event should be running, got %q", first.Status)
+ }
+ if first.Output != "" {
+ t.Errorf("first setup event should have no output yet, got %q", first.Output)
+ }
+ if last.Status != PrepareStepCompleted {
+ t.Errorf("last setup event should be completed, got %q", last.Status)
+ }
+ if !strings.Contains(last.Output, "first") || !strings.Contains(last.Output, "third") {
+ t.Errorf("final output should contain all lines, got %q", last.Output)
+ }
+
+ // At least one intermediate event should have partial (but non-empty) output.
+ sawPartial := false
+ for _, ev := range setupEvents[1 : len(setupEvents)-1] {
+ if ev.Status == PrepareStepRunning && ev.Output != "" {
+ sawPartial = true
+ break
+ }
+ }
+ if !sawPartial {
+ t.Errorf("expected at least one intermediate streaming event with partial output; events: %+v", setupEvents)
+ }
+}
+
+// TestLocalPreparer_SetupScriptFailurePreservesOutputAndError verifies that
+// a failing setup script marks the step as failed and keeps both the output
+// and error attached — this is what lets the UI display the failure banner
+// with the tail of stdout/stderr.
+func TestLocalPreparer_SetupScriptFailurePreservesOutputAndError(t *testing.T) {
+ isolateGitEnv(t)
+ log := newTestLocalLogger()
+ preparer := NewLocalPreparer(log)
+ repoDir := initGitRepo(t)
+
+ req := &EnvPrepareRequest{
+ TaskID: "task-1",
+ RepositoryPath: repoDir,
+ SetupScript: `echo running install; echo "fatal: not a git repository" 1>&2; exit 1`,
+ }
+
+ result, err := preparer.Prepare(context.Background(), req, nil)
+ if err != nil {
+ t.Fatalf("Prepare() error: %v (setup script failure should be non-fatal)", err)
+ }
+ // Per design, setup script failure is non-fatal — overall result succeeds
+ // so the agent can still start and the user can retry.
+ if !result.Success {
+ t.Fatalf("Prepare() should succeed even when setup script fails: %s", result.ErrorMessage)
+ }
+ if len(result.Steps) != 2 {
+ t.Fatalf("expected 2 steps (validate + setup), got %d", len(result.Steps))
+ }
+ setup := result.Steps[1]
+ if setup.Name != "Run setup script" {
+ t.Fatalf("second step should be setup script, got %q", setup.Name)
+ }
+ if setup.Status != PrepareStepFailed {
+ t.Errorf("setup step should be failed, got %q", setup.Status)
+ }
+ if setup.Error == "" {
+ t.Errorf("setup step should have an error message")
+ }
+ if !strings.Contains(setup.Output, "running install") {
+ t.Errorf("setup step output should contain stdout, got %q", setup.Output)
+ }
+ if !strings.Contains(setup.Output, "fatal: not a git repository") {
+ t.Errorf("setup step output should contain stderr, got %q", setup.Output)
+ }
+}
+
+// TestStreamingWriter_ThrottlesToMinGap verifies the writer does not fire the
+// callback more often than minGap — burst writes should coalesce.
+func TestStreamingWriter_ThrottlesToMinGap(t *testing.T) {
+ var mu sync.Mutex
+ var calls int
+ w := newStreamingWriter(func(_ string) {
+ mu.Lock()
+ calls++
+ mu.Unlock()
+ }, 50*time.Millisecond)
+
+ // Burst 10 writes in a tight loop — throttled callback should fire once
+ // (or at most twice if the test scheduler stalls across the 50ms window).
+ for i := 0; i < 10; i++ {
+ if _, err := w.Write([]byte("x")); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if calls > 2 {
+ t.Errorf("expected <=2 throttled callback invocations for a tight burst, got %d", calls)
+ }
+}
diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_test.go b/apps/backend/internal/agent/lifecycle/env_preparer_test.go
new file mode 100644
index 0000000000..b6404cac29
--- /dev/null
+++ b/apps/backend/internal/agent/lifecycle/env_preparer_test.go
@@ -0,0 +1,90 @@
+package lifecycle
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSerializePrepareResult(t *testing.T) {
+ t.Run("success result", func(t *testing.T) {
+ now := time.Now()
+ twoSecsAgo := now.Add(-2 * time.Second)
+ result := &EnvPrepareResult{
+ Success: true,
+ Steps: []PrepareStep{
+ {Name: "Validate repository", Status: PrepareStepCompleted, Command: "git status", Output: "On branch main"},
+ {Name: "Run setup script", Status: PrepareStepCompleted, Command: "npm install", Output: "added 100 packages", StartedAt: &twoSecsAgo, EndedAt: &now},
+ },
+ Duration: 3 * time.Second,
+ }
+
+ serialized := SerializePrepareResult(result)
+
+ require.Equal(t, "completed", serialized["status"])
+ require.Equal(t, int64(3000), serialized["duration_ms"])
+
+ steps, ok := serialized["steps"].([]map[string]interface{})
+ require.True(t, ok)
+ require.Len(t, steps, 2)
+
+ require.Equal(t, "Validate repository", steps[0]["name"])
+ require.Equal(t, string(PrepareStepCompleted), steps[0]["status"])
+ require.Equal(t, "git status", steps[0]["command"])
+ require.Equal(t, "On branch main", steps[0]["output"])
+
+ require.Equal(t, "Run setup script", steps[1]["name"])
+ require.NotEmpty(t, steps[1]["started_at"])
+ require.NotEmpty(t, steps[1]["ended_at"])
+ })
+
+ t.Run("failure result", func(t *testing.T) {
+ result := &EnvPrepareResult{
+ Success: false,
+ ErrorMessage: "setup script failed",
+ Steps: []PrepareStep{
+ {Name: "Run setup script", Status: PrepareStepFailed, Command: "npm install", Output: "ERR! not found"},
+ },
+ Duration: 1 * time.Second,
+ }
+
+ serialized := SerializePrepareResult(result)
+
+ require.Equal(t, "failed", serialized["status"])
+ require.Equal(t, "setup script failed", serialized["error_message"])
+ })
+
+ t.Run("truncates long output at UTF-8 boundary", func(t *testing.T) {
+ // Create output with a multi-byte emoji near the truncation boundary
+ longOutput := strings.Repeat("a", MaxStepOutputBytes-2) + "\U0001F600" // 4-byte emoji
+
+ result := &EnvPrepareResult{
+ Success: true,
+ Steps: []PrepareStep{{Name: "test", Status: PrepareStepCompleted, Output: longOutput}},
+ }
+
+ serialized := SerializePrepareResult(result)
+ steps := serialized["steps"].([]map[string]interface{})
+ output := steps[0]["output"].(string)
+
+ require.True(t, strings.HasSuffix(output, "\n... (truncated)"))
+ // The output should be valid UTF-8 (no partial runes)
+ require.True(t, strings.ToValidUTF8(output, "") == output, "output should be valid UTF-8")
+ })
+
+ t.Run("omits timestamps when nil", func(t *testing.T) {
+ result := &EnvPrepareResult{
+ Success: true,
+ Steps: []PrepareStep{{Name: "test", Status: PrepareStepCompleted}},
+ }
+
+ serialized := SerializePrepareResult(result)
+ steps := serialized["steps"].([]map[string]interface{})
+ _, hasStarted := steps[0]["started_at"]
+ _, hasEnded := steps[0]["ended_at"]
+ require.False(t, hasStarted, "should not include started_at when nil")
+ require.False(t, hasEnded, "should not include ended_at when nil")
+ })
+}
diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go b/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go
index 597cc3b746..2168e2d438 100644
--- a/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go
+++ b/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go
@@ -2,6 +2,7 @@ package lifecycle
import (
"context"
+ "fmt"
"path/filepath"
"time"
@@ -44,37 +45,110 @@ func (p *WorktreePreparer) Prepare(ctx context.Context, req *EnvPrepareRequest,
stepIdx := 0
// Step 1: Validate repository path
+ steps, ok := p.validateWorktreeRequest(req, stepIdx, totalSteps, onProgress, steps)
+ if !ok {
+ return &EnvPrepareResult{Success: false, Steps: steps, ErrorMessage: steps[len(steps)-1].Error, Duration: time.Since(start)}, nil
+ }
+ stepIdx++
+
+ // Steps 2 (and optional sync): Create worktree (with optional pre-sync).
+ wt, steps, stepIdx, err := p.createWorktreeWithSync(ctx, req, stepIdx, totalSteps, onProgress, steps)
+ if err != nil {
+ return &EnvPrepareResult{Success: false, Steps: steps, ErrorMessage: err.Error(), Duration: time.Since(start)}, nil
+ }
+
+ workspacePath := wt.Path
+ mainRepoGitDir := filepath.Join(req.RepositoryPath, ".git")
+
+ // Step 3 (optional): Fetch PR branch is handled inside worktree.Manager.Create
+ // via CreateRequest.CheckoutBranch. If it was set and failed, Create() would have
+ // returned an error above. We report a success step here for UI visibility.
+ if req.CheckoutBranch != "" {
+ step := beginStep("Fetch PR branch")
+ step.Command = fmt.Sprintf("git fetch origin %s", req.CheckoutBranch)
+ reportProgress(onProgress, step, stepIdx, totalSteps)
+ if wt.FetchWarning != "" {
+ step.Warning = wt.FetchWarning
+ step.WarningDetail = wt.FetchWarningDetail
+ }
+ completeStepSuccess(&step)
+ steps = append(steps, step)
+ reportProgress(onProgress, step, stepIdx, totalSteps)
+ stepIdx++
+ }
+
+ // Step N: Run setup script (if provided)
+ resolvedScript := resolvePreparerSetupScript(req, workspacePath)
+ if resolvedScript != "" {
+ totalSteps++
+ steps = runSetupScriptStep(ctx, req, workspacePath, resolvedScript, stepIdx, totalSteps, onProgress, steps, p.logger)
+ }
+
+ return &EnvPrepareResult{
+ Success: true,
+ Steps: steps,
+ WorkspacePath: workspacePath,
+ Duration: time.Since(start),
+ WorktreeID: wt.ID,
+ WorktreeBranch: wt.Branch,
+ MainRepoGitDir: mainRepoGitDir,
+ }, nil
+}
+
+// validateWorktreeRequest runs the "Validate repository" step.
+// Returns the updated steps and ok=true on success, ok=false when a failure step was appended.
+func (p *WorktreePreparer) validateWorktreeRequest(
+ req *EnvPrepareRequest,
+ stepIdx, totalSteps int,
+ onProgress PrepareProgressCallback,
+ steps []PrepareStep,
+) ([]PrepareStep, bool) {
step := beginStep("Validate repository")
reportProgress(onProgress, step, stepIdx, totalSteps)
if req.RepositoryPath == "" {
completeStepError(&step, "no repository path provided")
steps = append(steps, step)
reportProgress(onProgress, step, stepIdx, totalSteps)
- return &EnvPrepareResult{Success: false, Steps: steps, ErrorMessage: step.Error, Duration: time.Since(start)}, nil
+ return steps, false
}
if p.worktreeMgr == nil {
completeStepError(&step, "worktree manager not available")
steps = append(steps, step)
reportProgress(onProgress, step, stepIdx, totalSteps)
- return &EnvPrepareResult{Success: false, Steps: steps, ErrorMessage: step.Error, Duration: time.Since(start)}, nil
+ return steps, false
}
completeStepSuccess(&step)
steps = append(steps, step)
reportProgress(onProgress, step, stepIdx, totalSteps)
- stepIdx++
+ return steps, true
+}
+// createWorktreeWithSync creates the worktree (with optional sync-base-branch step).
+// Returns the created worktree, updated steps, the next step index after creation, and any error.
+func (p *WorktreePreparer) createWorktreeWithSync(
+ ctx context.Context,
+ req *EnvPrepareRequest,
+ stepIdx, totalSteps int,
+ onProgress PrepareProgressCallback,
+ steps []PrepareStep,
+) (*worktree.Worktree, []PrepareStep, int, error) {
var syncStep *PrepareStep
syncStepIndex := -1
if req.PullBeforeWorktree {
s := beginStep("Sync base branch")
+ if req.BaseBranch != "" {
+ s.Command = fmt.Sprintf("git fetch origin %s && git pull origin %s", req.BaseBranch, req.BaseBranch)
+ } else {
+ s.Command = "git fetch origin && git pull"
+ }
reportProgress(onProgress, s, stepIdx, totalSteps)
syncStep = &s
syncStepIndex = stepIdx
stepIdx++
}
- // Step 2: Create worktree
- step = beginStep("Create worktree")
+ step := beginStep("Create worktree")
+ step.Command = "git worktree add"
reportProgress(onProgress, step, stepIdx, totalSteps)
createReq := worktree.CreateRequest{
@@ -99,82 +173,42 @@ func (p *WorktreePreparer) Prepare(ctx context.Context, req *EnvPrepareRequest,
}
wt, err := p.worktreeMgr.Create(ctx, createReq)
- if syncStep != nil {
- if syncStep.Status == PrepareStepRunning {
- if err != nil {
- completeStepError(syncStep, "sync interrupted by worktree creation failure")
- } else {
- completeStepSuccess(syncStep)
- }
- }
- steps = append(steps, *syncStep)
- reportProgress(onProgress, *syncStep, syncStepIndex, totalSteps)
- }
+ steps = finalizeSyncStep(syncStep, syncStepIndex, totalSteps, onProgress, steps, err)
if err != nil {
completeStepError(&step, err.Error())
steps = append(steps, step)
reportProgress(onProgress, step, stepIdx, totalSteps)
- p.logger.Error("worktree creation failed",
- zap.String("task_id", req.TaskID),
- zap.Error(err))
- return &EnvPrepareResult{Success: false, Steps: steps, ErrorMessage: err.Error(), Duration: time.Since(start)}, nil
+ p.logger.Error("worktree creation failed", zap.String("task_id", req.TaskID), zap.Error(err))
+ return nil, steps, stepIdx, err
}
completeStepSuccess(&step)
steps = append(steps, step)
reportProgress(onProgress, step, stepIdx, totalSteps)
- stepIdx++
+ return wt, steps, stepIdx + 1, nil
+}
- workspacePath := wt.Path
- mainRepoGitDir := filepath.Join(req.RepositoryPath, ".git")
-
- // Step 3 (optional): Fetch PR branch is handled inside worktree.Manager.Create
- // via CreateRequest.CheckoutBranch. If it was set and failed, Create() would have
- // returned an error above. We report a success step here for UI visibility.
- if req.CheckoutBranch != "" {
- step = beginStep("Fetch PR branch")
- reportProgress(onProgress, step, stepIdx, totalSteps)
- if wt.FetchWarning != "" {
- step.Warning = wt.FetchWarning
- step.WarningDetail = wt.FetchWarningDetail
- }
- completeStepSuccess(&step)
- steps = append(steps, step)
- reportProgress(onProgress, step, stepIdx, totalSteps)
- stepIdx++
+// finalizeSyncStep closes out the optional sync step after worktree.Create returns.
+func finalizeSyncStep(
+ syncStep *PrepareStep,
+ syncStepIndex, totalSteps int,
+ onProgress PrepareProgressCallback,
+ steps []PrepareStep,
+ createErr error,
+) []PrepareStep {
+ if syncStep == nil {
+ return steps
}
-
- // Step N: Run setup script (if provided)
- resolvedScript := resolvePreparerSetupScript(req, workspacePath)
- if resolvedScript != "" {
- totalSteps++
- step = beginStep("Run setup script")
- reportProgress(onProgress, step, stepIdx, totalSteps)
- output, scriptErr := runSetupScript(ctx, resolvedScript, workspacePath, req.Env)
- if scriptErr != nil {
- completeStepError(&step, scriptErr.Error())
- step.Output = output
- steps = append(steps, step)
- reportProgress(onProgress, step, stepIdx, totalSteps)
- p.logger.Warn("setup script failed", zap.String("task_id", req.TaskID), zap.Error(scriptErr))
- // Setup script failure is non-fatal
+ if syncStep.Status == PrepareStepRunning {
+ if createErr != nil {
+ completeStepError(syncStep, "sync interrupted by worktree creation failure")
} else {
- step.Output = output
- completeStepSuccess(&step)
- steps = append(steps, step)
- reportProgress(onProgress, step, stepIdx, totalSteps)
+ completeStepSuccess(syncStep)
}
}
-
- return &EnvPrepareResult{
- Success: true,
- Steps: steps,
- WorkspacePath: workspacePath,
- Duration: time.Since(start),
- WorktreeID: wt.ID,
- WorktreeBranch: wt.Branch,
- MainRepoGitDir: mainRepoGitDir,
- }, nil
+ steps = append(steps, *syncStep)
+ reportProgress(onProgress, *syncStep, syncStepIndex, totalSteps)
+ return steps
}
func applySyncProgressEvent(step *PrepareStep, event worktree.SyncProgressEvent) {
diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go
index a4f92c449f..5ce4c00c26 100644
--- a/apps/backend/internal/agent/lifecycle/event_types.go
+++ b/apps/backend/internal/agent/lifecycle/event_types.go
@@ -42,18 +42,21 @@ type ACPSessionCreatedPayload struct {
// PrepareProgressEventPayload is the payload for environment preparation progress events.
type PrepareProgressEventPayload struct {
- TaskID string `json:"task_id"`
- SessionID string `json:"session_id"`
- ExecutionID string `json:"execution_id"`
- StepName string `json:"step_name"`
- StepIndex int `json:"step_index"`
- TotalSteps int `json:"total_steps"`
- Status string `json:"status"`
- Output string `json:"output,omitempty"`
- Error string `json:"error,omitempty"`
- Warning string `json:"warning,omitempty"`
- WarningDetail string `json:"warning_detail,omitempty"`
- Timestamp string `json:"timestamp"`
+ TaskID string `json:"task_id"`
+ SessionID string `json:"session_id"`
+ ExecutionID string `json:"execution_id"`
+ StepName string `json:"step_name"`
+ StepCommand string `json:"step_command,omitempty"`
+ StepIndex int `json:"step_index"`
+ TotalSteps int `json:"total_steps"`
+ Status string `json:"status"`
+ Output string `json:"output,omitempty"`
+ Error string `json:"error,omitempty"`
+ Warning string `json:"warning,omitempty"`
+ WarningDetail string `json:"warning_detail,omitempty"`
+ StartedAt *time.Time `json:"started_at,omitempty"`
+ EndedAt *time.Time `json:"ended_at,omitempty"`
+ Timestamp string `json:"timestamp"`
}
// GetSessionID returns the session ID for this event (used by event routing).
diff --git a/apps/backend/internal/agent/lifecycle/manager_execution.go b/apps/backend/internal/agent/lifecycle/manager_execution.go
index 848f89e5ae..3a4d342dc0 100644
--- a/apps/backend/internal/agent/lifecycle/manager_execution.go
+++ b/apps/backend/internal/agent/lifecycle/manager_execution.go
@@ -73,7 +73,7 @@ func (m *Manager) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID
}
if info.WorkspacePath == "" {
- return nil, fmt.Errorf("no workspace path available for session %s", sessionID)
+ return nil, fmt.Errorf("%w: session %s has no workspace path yet", ErrSessionWorkspaceNotReady, sessionID)
}
m.logger.Info("creating execution for task session",
diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go
index 43b8a7ec6e..80d6e9cb07 100644
--- a/apps/backend/internal/agent/lifecycle/manager_launch.go
+++ b/apps/backend/internal/agent/lifecycle/manager_launch.go
@@ -110,16 +110,52 @@ func (m *Manager) buildAgentCommand(req *LaunchRequest, profileInfo *AgentProfil
// For worktree executors, workspace resolution is handled by the WorktreePreparer.
// For tasks without repositories, creates a workspace directory in ~/.kandev/quick-chat/.
// Returns workspacePath, mainRepoGitDir, worktreeID, worktreeBranch.
-func (m *Manager) launchResolveWorkspacePath(ctx context.Context, req *LaunchRequest) (workspacePath, mainRepoGitDir, worktreeID, worktreeBranch string) {
- if req.UseWorktree {
- // Worktree executors: preparer handles worktree creation.
- // Return empty path; it will be populated from preparer results.
+// resolveResumeWorktreePath resolves workspace path for worktree resume using the provider.
+func (m *Manager) resolveResumeWorktreePath(ctx context.Context, req *LaunchRequest) (string, string, string, string) {
+ ws := m.resolveWorkspaceFromProvider(ctx, req)
+ if ws == "" {
+ m.logger.Warn("could not resolve workspace path for worktree resume",
+ zap.String("session_id", req.SessionID))
return "", "", "", ""
}
+ var mainRepoGitDir string
+ if req.RepositoryPath != "" {
+ mainRepoGitDir = filepath.Join(req.RepositoryPath, ".git")
+ }
+ return ws, mainRepoGitDir, req.WorktreeID, ""
+}
+
+// resolveWorkspaceFromProvider looks up the workspace path from the info provider.
+func (m *Manager) resolveWorkspaceFromProvider(ctx context.Context, req *LaunchRequest) string {
+ if m.workspaceInfoProvider == nil {
+ return ""
+ }
+ info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, req.TaskID, req.SessionID)
+ if err != nil || info.WorkspacePath == "" {
+ return ""
+ }
+ m.logger.Debug("resolved workspace from provider for resume",
+ zap.String("session_id", req.SessionID),
+ zap.String("path", info.WorkspacePath))
+ return info.WorkspacePath
+}
+
+func (m *Manager) launchResolveWorkspacePath(ctx context.Context, req *LaunchRequest) (workspacePath, mainRepoGitDir, worktreeID, worktreeBranch string) {
+ if req.UseWorktree && req.ACPSessionID == "" {
+ return "", "", "", ""
+ }
+ if req.UseWorktree && req.ACPSessionID != "" {
+ return m.resolveResumeWorktreePath(ctx, req)
+ }
workspacePath = req.WorkspacePath
if req.RepositoryPath != "" && workspacePath == "" {
workspacePath = req.RepositoryPath
}
+ if workspacePath == "" && req.ACPSessionID != "" {
+ if resolved := m.resolveWorkspaceFromProvider(ctx, req); resolved != "" {
+ return resolved, "", "", ""
+ }
+ }
// For tasks without repositories (e.g., quick chat), create a workspace in ~/.kandev/quick-chat/
// These directories are cleaned up when the ephemeral task is deleted (see task service performTaskCleanup).
if workspacePath == "" && req.SessionID != "" && m.dataDir != "" {
@@ -204,6 +240,7 @@ func (m *Manager) newProgressCallback(taskID, sessionID string) PrepareProgressC
TaskID: taskID,
SessionID: sessionID,
StepName: step.Name,
+ StepCommand: step.Command,
StepIndex: stepIndex,
TotalSteps: totalSteps,
Status: string(step.Status),
@@ -211,6 +248,8 @@ func (m *Manager) newProgressCallback(taskID, sessionID string) PrepareProgressC
Error: step.Error,
Warning: step.Warning,
WarningDetail: step.WarningDetail,
+ StartedAt: step.StartedAt,
+ EndedAt: step.EndedAt,
})
}
}
@@ -310,6 +349,7 @@ func (m *Manager) runEnvironmentPreparer(
return nil
}
+ repoSetupScript, _ := req.Metadata[MetadataKeyRepoSetupScript].(string)
prepReq := &EnvPrepareRequest{
TaskID: req.TaskID,
SessionID: req.SessionID,
@@ -321,6 +361,7 @@ func (m *Manager) runEnvironmentPreparer(
UseWorktree: req.UseWorktree,
WorktreeID: req.WorktreeID,
SetupScript: req.SetupScript,
+ RepoSetupScript: repoSetupScript,
BaseBranch: req.BaseBranch,
CheckoutBranch: req.CheckoutBranch,
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
@@ -418,8 +459,15 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
workspacePath, mainRepoGitDir, worktreeID, worktreeBranch := m.launchResolveWorkspacePath(ctx, req)
// 4b. Run environment preparation (if preparer registered for this executor type).
- // For worktree executors, the preparer creates the worktree and returns workspace metadata.
- prepResult := m.runEnvironmentPreparer(ctx, req, workspacePath)
+ // Skip on resume (ACPSessionID set) — workspace was already prepared during initial launch.
+ var prepResult *EnvPrepareResult
+ if req.ACPSessionID == "" {
+ prepResult = m.runEnvironmentPreparer(ctx, req, workspacePath)
+ } 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 {
if err := m.launchApplyPrepareResult(req, prepResult, &workspacePath, &mainRepoGitDir, &worktreeID, &worktreeBranch); err != nil {
return nil, err
@@ -441,8 +489,8 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
return nil, err
}
// Publish PrepareCompleted for CreateInstance only if no preparer ran
- // (preparers publish their own completion above).
- if prepResult == nil {
+ // 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,
@@ -458,6 +506,8 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
if req.ACPSessionID != "" {
execution.ACPSessionID = req.ACPSessionID
}
+ // Carry prepare result back to caller for synchronous persistence.
+ execution.PrepareResult = prepResult
if req.PreviousExecutionID != "" {
execution.isResumedSession = true
}
diff --git a/apps/backend/internal/agent/lifecycle/manager_launch_test.go b/apps/backend/internal/agent/lifecycle/manager_launch_test.go
index 22555e8df4..70e632d8f0 100644
--- a/apps/backend/internal/agent/lifecycle/manager_launch_test.go
+++ b/apps/backend/internal/agent/lifecycle/manager_launch_test.go
@@ -1,12 +1,14 @@
package lifecycle
import (
+ "context"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agent/agents"
+ "github.com/kandev/kandev/internal/agent/executor"
)
// resumeTestAgent is a minimal agent with a BuildCommand that respects the
@@ -71,3 +73,52 @@ func TestBuildAgentCommand_ResumeFlag(t *testing.T) {
require.Contains(t, cmds.initial, "sess-456")
})
}
+
+// trackingPreparer records whether Prepare was called.
+type trackingPreparer struct {
+ called bool
+}
+
+func (p *trackingPreparer) Name() string { return "tracking" }
+
+func (p *trackingPreparer) Prepare(_ context.Context, _ *EnvPrepareRequest, _ PrepareProgressCallback) (*EnvPrepareResult, error) {
+ p.called = true
+ return &EnvPrepareResult{Success: true, WorkspacePath: "/tmp/ws"}, nil
+}
+
+func TestRunEnvironmentPreparer_CalledOnFreshLaunch(t *testing.T) {
+ mgr := newTestManager()
+ preparer := &trackingPreparer{}
+ mgr.preparerRegistry = NewPreparerRegistry(mgr.logger)
+ mgr.preparerRegistry.Register(executor.NameStandalone, preparer)
+
+ req := &LaunchRequest{
+ TaskID: "task-1",
+ SessionID: "session-1",
+ ExecutorType: string(executor.NameStandalone),
+ RepositoryPath: "/tmp/repo",
+ }
+
+ result := mgr.runEnvironmentPreparer(context.Background(), req, "/tmp/repo")
+ require.True(t, preparer.called, "preparer should be called on fresh launch")
+ require.NotNil(t, result)
+ require.True(t, result.Success)
+}
+
+func TestRunEnvironmentPreparer_SkippedWithoutRepoPath(t *testing.T) {
+ mgr := newTestManager()
+ preparer := &trackingPreparer{}
+ mgr.preparerRegistry = NewPreparerRegistry(mgr.logger)
+ mgr.preparerRegistry.Register(executor.NameStandalone, preparer)
+
+ req := &LaunchRequest{
+ TaskID: "task-1",
+ SessionID: "session-1",
+ ExecutorType: string(executor.NameStandalone),
+ // No RepositoryPath — preparer should be skipped
+ }
+
+ result := mgr.runEnvironmentPreparer(context.Background(), req, "")
+ require.False(t, preparer.called, "preparer should be skipped when no repository path")
+ require.Nil(t, result)
+}
diff --git a/apps/backend/internal/agent/lifecycle/preparer_script.go b/apps/backend/internal/agent/lifecycle/preparer_script.go
index 7011090f3c..db1f71269f 100644
--- a/apps/backend/internal/agent/lifecycle/preparer_script.go
+++ b/apps/backend/internal/agent/lifecycle/preparer_script.go
@@ -2,14 +2,21 @@ package lifecycle
import (
"bytes"
+ "context"
"os/exec"
"path/filepath"
"strings"
+ "go.uber.org/zap"
+
"github.com/kandev/kandev/internal/agent/executor"
+ "github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/scriptengine"
)
+// prepareScriptLogger is a package-level logger for setup script resolution.
+var prepareScriptLogger = logger.Default().WithFields(zap.String("component", "prepare-script"))
+
func resolvePreparerSetupScript(req *EnvPrepareRequest, workspacePath string) string {
script := strings.TrimSpace(req.SetupScript)
if script == "" {
@@ -20,8 +27,9 @@ func resolvePreparerSetupScript(req *EnvPrepareRequest, workspacePath string) st
}
metadata := map[string]any{
- MetadataKeyRepositoryPath: req.RepositoryPath,
- MetadataKeyBaseBranch: req.BaseBranch,
+ MetadataKeyRepositoryPath: req.RepositoryPath,
+ MetadataKeyBaseBranch: req.BaseBranch,
+ MetadataKeyRepoSetupScript: req.RepoSetupScript,
}
if req.WorktreeID != "" {
metadata[MetadataKeyWorktreeID] = req.WorktreeID
@@ -46,7 +54,30 @@ func resolvePreparerSetupScript(req *EnvPrepareRequest, workspacePath string) st
req.BaseBranch,
))
- return resolver.Resolve(script)
+ resolved := resolver.Resolve(script)
+ if isScriptEffectivelyEmpty(resolved) {
+ prepareScriptLogger.Warn("setup script is comment-only after resolution, skipping",
+ zap.String("task_id", req.TaskID),
+ zap.String("executor_type", string(req.ExecutorType)),
+ zap.Bool("use_worktree", req.UseWorktree),
+ zap.Bool("has_explicit_script", strings.TrimSpace(req.SetupScript) != ""))
+ return ""
+ }
+ return resolved
+}
+
+// isScriptEffectivelyEmpty returns true when a script contains only a shebang,
+// comments, and/or blank lines — i.e. no executable commands. This avoids
+// running (and displaying) the default template scripts that are pure comments.
+func isScriptEffectivelyEmpty(script string) bool {
+ for _, line := range strings.Split(script, "\n") {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+ return false
+ }
+ return true
}
func defaultPreparerSetupScript(req *EnvPrepareRequest) string {
@@ -71,3 +102,37 @@ func getGitRemoteURL(repoPath string) (string, error) {
}
return strings.TrimSpace(out.String()), nil
}
+
+// runSetupScriptStep executes the resolved setup script as a named prepare step,
+// appends the completed step to steps, and returns the updated slice.
+// Setup script failures are non-fatal: the error is recorded in the step and
+// logged as a warning, but preparation continues.
+func runSetupScriptStep(
+ ctx context.Context,
+ req *EnvPrepareRequest,
+ workspacePath, resolvedScript string,
+ stepIdx, totalSteps int,
+ onProgress PrepareProgressCallback,
+ steps []PrepareStep,
+ log *logger.Logger,
+) []PrepareStep {
+ step := beginStep("Run setup script")
+ step.Command = setupScriptDisplayCommand(req)
+ reportProgress(onProgress, step, stepIdx, totalSteps)
+ output, err := runSetupScript(ctx, resolvedScript, workspacePath, req.Env, func(current string) {
+ step.Output = current
+ reportProgress(onProgress, step, stepIdx, totalSteps)
+ })
+ step.Output = output
+ if err != nil {
+ completeStepError(&step, err.Error())
+ steps = append(steps, step)
+ reportProgress(onProgress, step, stepIdx, totalSteps)
+ log.Warn("setup script failed", zap.String("task_id", req.TaskID), zap.Error(err))
+ } else {
+ completeStepSuccess(&step)
+ steps = append(steps, step)
+ reportProgress(onProgress, step, stepIdx, totalSteps)
+ }
+ return steps
+}
diff --git a/apps/backend/internal/agent/lifecycle/preparer_script_test.go b/apps/backend/internal/agent/lifecycle/preparer_script_test.go
index 2a9062143b..13a0dc69cc 100644
--- a/apps/backend/internal/agent/lifecycle/preparer_script_test.go
+++ b/apps/backend/internal/agent/lifecycle/preparer_script_test.go
@@ -7,21 +7,71 @@ import (
"github.com/kandev/kandev/internal/agent/executor"
)
-func TestResolvePreparerSetupScript_LocalFallback(t *testing.T) {
+func TestResolvePreparerSetupScript_LocalFallbackCommentOnly(t *testing.T) {
req := &EnvPrepareRequest{
ExecutorType: executor.NameStandalone,
RepositoryPath: "/tmp/my-repo",
}
+ got := resolvePreparerSetupScript(req, "/tmp/my-repo")
+ if got != "" {
+ t.Fatalf("expected comment-only default script to be treated as empty, got %q", got)
+ }
+}
+
+func TestIsScriptEffectivelyEmpty(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want bool
+ }{
+ {"empty", "", true},
+ {"shebang only", "#!/bin/bash\n", true},
+ {"shebang and comments", "#!/bin/bash\n# comment\n# another\n", true},
+ {"blank lines and comments", "\n# comment\n\n# more\n\n", true},
+ {"has command", "#!/bin/bash\necho hello\n", false},
+ {"command after comments", "#!/bin/bash\n# setup\napt-get install git\n", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := isScriptEffectivelyEmpty(tt.input)
+ if got != tt.want {
+ t.Fatalf("isScriptEffectivelyEmpty(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestResolvePreparerSetupScript_LocalWithRepoSetupScript(t *testing.T) {
+ req := &EnvPrepareRequest{
+ ExecutorType: executor.NameStandalone,
+ RepositoryPath: "/tmp/my-repo",
+ RepoSetupScript: "make install",
+ }
+
got := resolvePreparerSetupScript(req, "/tmp/my-repo")
if got == "" {
- t.Fatal("expected default local setup script")
+ t.Fatal("expected non-empty script when repo setup script is set")
}
- if strings.Contains(got, "{{repository.path}}") {
- t.Fatalf("expected repository.path placeholder to be resolved, got %q", got)
+ if !strings.Contains(got, "make install") {
+ t.Fatalf("expected repo setup script in resolved output, got %q", got)
}
- if !strings.Contains(got, "set to /tmp/my-repo.") {
- t.Fatalf("expected resolved workspace path in script comment, got %q", got)
+}
+
+func TestResolvePreparerSetupScript_WorktreeWithRepoSetupScript(t *testing.T) {
+ req := &EnvPrepareRequest{
+ ExecutorType: executor.NameStandalone,
+ UseWorktree: true,
+ RepositoryPath: "/tmp/my-repo",
+ RepoSetupScript: "npm ci",
+ }
+
+ got := resolvePreparerSetupScript(req, "/tmp/worktrees/wt-1")
+ if got == "" {
+ t.Fatal("expected non-empty script when repo setup script is set")
+ }
+ if !strings.Contains(got, "npm ci") {
+ t.Fatalf("expected repo setup script in resolved output, got %q", got)
}
}
diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go
index 8b4acb8e09..6d0e77a2a6 100644
--- a/apps/backend/internal/agent/lifecycle/types.go
+++ b/apps/backend/internal/agent/lifecycle/types.go
@@ -40,6 +40,10 @@ type AgentExecution struct {
ErrorMessage string
Metadata map[string]interface{}
+ // PrepareResult carries the environment preparation result back to the caller
+ // so it can be persisted synchronously before UpdateTaskSession clobbers metadata.
+ PrepareResult *EnvPrepareResult `json:"-"`
+
// agentctl client for this execution
agentctl *agentctl.Client
diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go
index 0aa8b00779..ee5daac0b8 100644
--- a/apps/backend/internal/orchestrator/event_handlers.go
+++ b/apps/backend/internal/orchestrator/event_handlers.go
@@ -18,6 +18,7 @@ const (
agentEventCompleted = "completed"
agentEventError = "error"
agentEventToolCall = "tool_call"
+ agentEventFailed = "failed"
)
// buildTaskEventPayload builds the standard map payload for TaskUpdated events.
diff --git a/apps/backend/internal/orchestrator/event_handlers_git.go b/apps/backend/internal/orchestrator/event_handlers_git.go
index 110c6d9082..dc091d2d9d 100644
--- a/apps/backend/internal/orchestrator/event_handlers_git.go
+++ b/apps/backend/internal/orchestrator/event_handlers_git.go
@@ -282,29 +282,18 @@ func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.C
"efficiency": data.ContextEfficiency,
}
- // Persist to database asynchronously. Read the session inside the goroutine
- // to get the latest metadata (avoids race with setSessionPlanMode etc.).
+ // Persist to database asynchronously using json_set to atomically set one
+ // key without clobbering other metadata keys (plan_mode, prepare_result).
go func() {
- session, err := s.repo.GetTaskSession(context.Background(), data.TaskSessionID)
- if err != nil {
- s.logger.Debug("no task session for context window update",
- zap.String("session_id", data.TaskSessionID),
- zap.Error(err))
- return
- }
- if session.Metadata == nil {
- session.Metadata = make(map[string]interface{})
- }
- session.Metadata["context_window"] = contextWindowData
- if err := s.repo.UpdateSessionMetadata(context.Background(), session.ID, session.Metadata); err != nil {
+ if err := s.repo.SetSessionMetadataKey(context.Background(), data.TaskSessionID, "context_window", contextWindowData); err != nil {
s.logger.Error("failed to update session with context window",
zap.String("task_id", data.TaskID),
- zap.String("session_id", session.ID),
+ zap.String("session_id", data.TaskSessionID),
zap.Error(err))
} else {
s.logger.Debug("persisted context window to session",
zap.String("task_id", data.TaskID),
- zap.String("session_id", session.ID))
+ zap.String("session_id", data.TaskSessionID))
}
}()
diff --git a/apps/backend/internal/orchestrator/event_handlers_prepare.go b/apps/backend/internal/orchestrator/event_handlers_prepare.go
new file mode 100644
index 0000000000..88d56079c0
--- /dev/null
+++ b/apps/backend/internal/orchestrator/event_handlers_prepare.go
@@ -0,0 +1,48 @@
+package orchestrator
+
+import (
+ "context"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/kandev/kandev/internal/agent/lifecycle"
+ "github.com/kandev/kandev/internal/events"
+ "github.com/kandev/kandev/internal/events/bus"
+)
+
+// subscribePrepareEvents subscribes to environment preparation events.
+func (s *Service) subscribePrepareEvents() {
+ if s.eventBus == nil {
+ return
+ }
+ if _, err := s.eventBus.Subscribe(events.ExecutorPrepareCompleted, s.handlePrepareCompleted); err != nil {
+ s.logger.Error("failed to subscribe to executor.prepare.completed events", zap.Error(err))
+ }
+}
+
+// handlePrepareCompleted persists prepare_result in session metadata using
+// json_set to atomically set one key without clobbering others.
+func (s *Service) handlePrepareCompleted(_ context.Context, event *bus.Event) error {
+ payload, ok := event.Data.(*lifecycle.PrepareCompletedEventPayload)
+ if !ok {
+ return nil
+ }
+
+ pr := lifecycle.SerializePrepareResult(&lifecycle.EnvPrepareResult{
+ Success: payload.Success,
+ Steps: payload.Steps,
+ ErrorMessage: payload.ErrorMessage,
+ Duration: time.Duration(payload.DurationMs) * time.Millisecond,
+ })
+ if err := s.repo.SetSessionMetadataKey(context.Background(), payload.SessionID, "prepare_result", pr); err != nil {
+ s.logger.Warn("failed to persist prepare_result",
+ zap.String("session_id", payload.SessionID), zap.Error(err))
+ }
+
+ s.logger.Info("environment preparation completed",
+ zap.String("session_id", payload.SessionID),
+ zap.Bool("success", payload.Success),
+ zap.Int("steps", len(payload.Steps)))
+ return nil
+}
diff --git a/apps/backend/internal/orchestrator/event_handlers_prepare_test.go b/apps/backend/internal/orchestrator/event_handlers_prepare_test.go
new file mode 100644
index 0000000000..b913ae88ac
--- /dev/null
+++ b/apps/backend/internal/orchestrator/event_handlers_prepare_test.go
@@ -0,0 +1,82 @@
+package orchestrator
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/kandev/kandev/internal/agent/lifecycle"
+ "github.com/kandev/kandev/internal/events/bus"
+)
+
+func TestHandlePrepareCompleted_PersistsViaJsonSet(t *testing.T) {
+ repo := setupTestRepo(t)
+ seedSession(t, repo, "task-1", "sess-1", "step1")
+
+ svc := &Service{logger: testLogger(), repo: repo}
+
+ event := &bus.Event{
+ Type: "executor.prepare.completed",
+ Data: &lifecycle.PrepareCompletedEventPayload{
+ SessionID: "sess-1",
+ Success: true,
+ Steps: []lifecycle.PrepareStep{{Name: "step1", Status: lifecycle.PrepareStepCompleted}},
+ },
+ Timestamp: time.Now(),
+ }
+ err := svc.handlePrepareCompleted(context.Background(), event)
+ require.NoError(t, err)
+
+ session, err := repo.GetTaskSession(context.Background(), "sess-1")
+ require.NoError(t, err)
+ require.NotNil(t, session.Metadata["prepare_result"], "prepare_result should be persisted")
+}
+
+func TestHandlePrepareCompleted_PersistsFailure(t *testing.T) {
+ repo := setupTestRepo(t)
+ seedSession(t, repo, "task-1", "sess-1", "step1")
+
+ svc := &Service{logger: testLogger(), repo: repo}
+
+ event := &bus.Event{
+ Type: "executor.prepare.completed",
+ Data: &lifecycle.PrepareCompletedEventPayload{
+ SessionID: "sess-1",
+ Success: false,
+ ErrorMessage: "setup script exited with code 1",
+ Steps: []lifecycle.PrepareStep{
+ {Name: "step1", Status: lifecycle.PrepareStepCompleted},
+ {Name: "step2", Status: lifecycle.PrepareStepFailed, Output: "npm ERR! missing"},
+ },
+ },
+ Timestamp: time.Now(),
+ }
+ err := svc.handlePrepareCompleted(context.Background(), event)
+ require.NoError(t, err)
+
+ session, err := repo.GetTaskSession(context.Background(), "sess-1")
+ require.NoError(t, err)
+
+ pr, ok := session.Metadata["prepare_result"].(map[string]interface{})
+ require.True(t, ok, "prepare_result should be a map")
+ require.Equal(t, "failed", pr["status"])
+ require.Equal(t, "setup script exited with code 1", pr["error_message"])
+
+ steps, ok := pr["steps"].([]interface{})
+ require.True(t, ok)
+ require.Len(t, steps, 2)
+}
+
+func TestHandlePrepareCompleted_HandlesWrongType(t *testing.T) {
+ svc := &Service{logger: testLogger()}
+
+ event := &bus.Event{
+ Type: "executor.prepare.completed",
+ Data: "wrong type",
+ Timestamp: time.Now(),
+ }
+ err := svc.handlePrepareCompleted(context.Background(), event)
+ require.NoError(t, err)
+}
diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go
index 664b8a0482..24462a6ab4 100644
--- a/apps/backend/internal/orchestrator/event_handlers_streaming.go
+++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go
@@ -311,7 +311,7 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle.
// Handle all status updates (running, complete, error, cancelled, pending, in_progress)
switch payload.Data.ToolStatus {
- case "running", agentEventComplete, agentEventCompleted, "success", agentEventError, "failed", "cancelled", "pending", "in_progress":
+ case "running", agentEventComplete, agentEventCompleted, "success", agentEventError, agentEventFailed, "cancelled", "pending", "in_progress":
if err := s.messageCreator.UpdateToolCallMessage(
ctx,
payload.TaskID,
@@ -334,7 +334,7 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle.
// Update session state for completion events
// Allow tool completions to wake session from WAITING_FOR_INPUT
if payload.Data.ToolStatus == agentEventComplete || payload.Data.ToolStatus == agentEventCompleted ||
- payload.Data.ToolStatus == "success" || payload.Data.ToolStatus == agentEventError || payload.Data.ToolStatus == "failed" {
+ payload.Data.ToolStatus == "success" || payload.Data.ToolStatus == agentEventError || payload.Data.ToolStatus == agentEventFailed {
s.updateTaskSessionState(ctx, payload.TaskID, payload.SessionID, models.TaskSessionStateRunning, "", true)
}
}
diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go
index af1c67f94d..b7581511db 100644
--- a/apps/backend/internal/orchestrator/event_handlers_workflow.go
+++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go
@@ -403,7 +403,9 @@ func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID strin
// resolveStepPlanMode determines whether plan mode should be active for a step.
// Returns false for passthrough sessions, steps without enable_plan_mode, or when the agent
-// doesn't support MCP. Also clears plan mode on the session when entering a non-plan-mode step.
+// doesn't support MCP. Plan mode is only cleared by explicit on_exit/on_turn_complete
+// disable_plan_mode actions, not automatically when entering a non-plan-mode step.
+// This preserves user-initiated plan mode across workflow transitions.
func (s *Service) resolveStepPlanMode(ctx context.Context, session *models.TaskSession, step *wfmodels.WorkflowStep, isPassthrough bool) bool {
hasPlanMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode)
@@ -415,10 +417,6 @@ func (s *Service) resolveStepPlanMode(ctx context.Context, session *models.TaskS
hasPlanMode = false
}
- // For ACP sessions, auto-disable plan mode when entering a step that doesn't explicitly enable it.
- if !isPassthrough && !hasPlanMode {
- s.clearSessionPlanMode(ctx, session)
- }
return hasPlanMode
}
@@ -543,7 +541,7 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo
if len(step.Events.OnEnter) == 0 {
s.setSessionWaitingForInput(ctx, taskID, sessionID, session)
- s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID)
+ s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID, session)
return
}
@@ -552,7 +550,7 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo
if step.HasOnEnterAction(wfmodels.OnEnterResetAgentContext) {
if !s.resetAgentContext(ctx, taskID, session, step.Name) {
s.setSessionWaitingForInput(ctx, taskID, sessionID, session)
- s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID)
+ s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID, session)
return
}
}
@@ -584,7 +582,7 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo
zap.String("step_name", step.Name),
zap.Error(err))
s.setSessionWaitingForInput(ctx, taskID, sessionID, session)
- s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID)
+ s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID, session)
}
case hasAutoStart:
@@ -605,7 +603,7 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo
default:
s.setSessionWaitingForInput(ctx, taskID, sessionID, session)
- s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID)
+ s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID, session)
}
}
@@ -805,15 +803,11 @@ func (s *Service) resetAgentContext(ctx context.Context, taskID string, session
return false
}
- // Clear the stored ACP session ID in the database so future resumes use session/new.
- // Use targeted metadata update to avoid overwriting other fields.
- if session.Metadata != nil {
- delete(session.Metadata, "acp_session_id")
- if updateErr := s.repo.UpdateSessionMetadata(ctx, sessionID, session.Metadata); updateErr != nil {
- s.logger.Warn("failed to clear ACP session ID from session metadata",
- zap.String("session_id", sessionID),
- zap.Error(updateErr))
- }
+ // Clear the stored ACP session ID using json_set to avoid clobbering other keys.
+ if updateErr := s.repo.SetSessionMetadataKey(ctx, sessionID, "acp_session_id", ""); updateErr != nil {
+ s.logger.Warn("failed to clear ACP session ID from session metadata",
+ zap.String("session_id", sessionID),
+ zap.Error(updateErr))
}
return true
}
@@ -866,17 +860,17 @@ func (s *Service) clearSessionPlanMode(ctx context.Context, session *models.Task
// setSessionPlanMode sets or clears plan mode in session metadata.
// Uses targeted metadata update to avoid overwriting other session fields.
func (s *Service) setSessionPlanMode(ctx context.Context, session *models.TaskSession, enabled bool) {
+ // Update in-memory struct for callers that read session.Metadata.
if session.Metadata == nil {
session.Metadata = make(map[string]interface{})
}
-
if enabled {
session.Metadata["plan_mode"] = true
} else {
delete(session.Metadata, "plan_mode")
}
-
- if err := s.repo.UpdateSessionMetadata(ctx, session.ID, session.Metadata); err != nil {
+ // Persist using json_set to atomically set one key without clobbering others.
+ if err := s.repo.SetSessionMetadataKey(ctx, session.ID, "plan_mode", enabled); err != nil {
s.logger.Warn("failed to update session plan mode",
zap.String("session_id", session.ID),
zap.Bool("enabled", enabled),
@@ -906,7 +900,9 @@ func (s *Service) processTurnCompleteActions(ctx context.Context, session *model
}
// publishSessionWaitingEvent publishes a session state change event for WAITING_FOR_INPUT.
-func (s *Service) publishSessionWaitingEvent(ctx context.Context, taskID, sessionID, stepID string) {
+// An optional preloaded session avoids re-reading from DB (which can miss recent writes
+// on the read-only WAL connection).
+func (s *Service) publishSessionWaitingEvent(ctx context.Context, taskID, sessionID, stepID string, preloadedSession ...*models.TaskSession) {
if s.eventBus == nil {
return
}
@@ -918,7 +914,13 @@ func (s *Service) publishSessionWaitingEvent(ctx context.Context, taskID, sessio
}
// Include agent_profile_id and session metadata so the frontend can
// identify the agent (e.g. MCP support) without waiting for SSR hydration.
- if session, err := s.repo.GetTaskSession(ctx, sessionID); err == nil {
+ var session *models.TaskSession
+ if len(preloadedSession) > 0 && preloadedSession[0] != nil {
+ session = preloadedSession[0]
+ } else if s, err := s.repo.GetTaskSession(ctx, sessionID); err == nil {
+ session = s
+ }
+ if session != nil {
if session.AgentProfileID != "" {
eventData["agent_profile_id"] = session.AgentProfileID
}
diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go
index 307032373b..5068b5bcce 100644
--- a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go
+++ b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go
@@ -161,8 +161,8 @@ func TestHandleTaskMovedWithSession(t *testing.T) {
// Set plan_mode on the session to verify on_exit clears it
session, _ := repo.GetTaskSession(ctx, "s1")
- session.Metadata = map[string]interface{}{"plan_mode": true}
_ = repo.UpdateTaskSession(ctx, session)
+ _ = repo.UpdateSessionMetadata(ctx, session.ID, map[string]interface{}{"plan_mode": true})
stepGetter := newMockStepGetter()
stepGetter.steps["step1"] = &wfmodels.WorkflowStep{
@@ -350,8 +350,8 @@ func TestHandleTaskMovedWithSession(t *testing.T) {
// Set agent execution ID on the session
session, _ := repo.GetTaskSession(ctx, "s1")
session.AgentExecutionID = "exec-123"
- session.Metadata = map[string]interface{}{"acp_session_id": "old-acp"}
_ = repo.UpdateTaskSession(ctx, session)
+ _ = repo.UpdateSessionMetadata(ctx, session.ID, map[string]interface{}{"acp_session_id": "old-acp"})
stepGetter := newMockStepGetter()
stepGetter.steps["step1"] = &wfmodels.WorkflowStep{
@@ -387,7 +387,7 @@ func TestHandleTaskMovedWithSession(t *testing.T) {
// Verify acp_session_id was cleared
updated, _ := repo.GetTaskSession(ctx, "s1")
if updated.Metadata != nil {
- if _, hasACP := updated.Metadata["acp_session_id"]; hasACP {
+ if acp, _ := updated.Metadata["acp_session_id"].(string); acp != "" {
t.Error("expected acp_session_id to be cleared from session metadata")
}
}
@@ -403,8 +403,8 @@ func TestProcessStepExitAndEnter(t *testing.T) {
// Set plan_mode on session — on_exit should clear it
session, _ := repo.GetTaskSession(ctx, "s1")
- session.Metadata = map[string]interface{}{"plan_mode": true}
_ = repo.UpdateTaskSession(ctx, session)
+ _ = repo.UpdateSessionMetadata(ctx, session.ID, map[string]interface{}{"plan_mode": true})
stepGetter := newMockStepGetter()
stepGetter.steps["step1"] = &wfmodels.WorkflowStep{
@@ -426,7 +426,7 @@ func TestProcessStepExitAndEnter(t *testing.T) {
updated, _ := repo.GetTaskSession(ctx, "s1")
if updated.Metadata != nil {
- if _, hasPlanMode := updated.Metadata["plan_mode"]; hasPlanMode {
+ if pm, _ := updated.Metadata["plan_mode"].(bool); pm {
t.Error("expected plan_mode to be cleared by on_exit")
}
}
diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_test.go
index 485cc4b4f1..8d5bd0e5d7 100644
--- a/apps/backend/internal/orchestrator/event_handlers_workflow_test.go
+++ b/apps/backend/internal/orchestrator/event_handlers_workflow_test.go
@@ -62,7 +62,8 @@ func TestPublishSessionWaitingEvent(t *testing.T) {
t.Fatalf("failed to get session: %v", err)
}
session.AgentProfileID = "profile-auggie"
- session.Metadata = map[string]any{"plan_mode": true}
+ _ = repo.UpdateTaskSession(ctx, session)
+ _ = repo.UpdateSessionMetadata(ctx, session.ID, map[string]any{"plan_mode": true})
if err := repo.UpdateTaskSession(ctx, session); err != nil {
t.Fatalf("failed to update session: %v", err)
}
diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go
index 7b3a49da43..4c72371a03 100644
--- a/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go
+++ b/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go
@@ -187,8 +187,8 @@ func TestProcessOnTurnComplete(t *testing.T) {
// Set plan_mode in session metadata
session, _ := repo.GetTaskSession(ctx, "s1")
- session.Metadata = map[string]interface{}{"plan_mode": true}
_ = repo.UpdateTaskSession(ctx, session)
+ _ = repo.UpdateSessionMetadata(ctx, session.ID, map[string]interface{}{"plan_mode": true})
stepGetter := newMockStepGetter()
stepGetter.steps["step1"] = &wfmodels.WorkflowStep{
@@ -217,7 +217,7 @@ func TestProcessOnTurnComplete(t *testing.T) {
// Verify plan_mode was cleared
updatedSession, _ := repo.GetTaskSession(ctx, "s1")
if updatedSession.Metadata != nil {
- if _, hasPlanMode := updatedSession.Metadata["plan_mode"]; hasPlanMode {
+ if pm, _ := updatedSession.Metadata["plan_mode"].(bool); pm {
t.Error("expected plan_mode to be cleared from session metadata")
}
}
@@ -326,14 +326,14 @@ func TestProcessOnEnter(t *testing.T) {
}
})
- t.Run("no plan mode clears it", func(t *testing.T) {
+ t.Run("plan mode persists when entering step without enable_plan_mode", func(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
- // Set plan_mode in session metadata
+ // Set plan_mode in session metadata (simulates user-initiated plan mode)
session, _ := repo.GetTaskSession(ctx, "s1")
- session.Metadata = map[string]interface{}{"plan_mode": true}
_ = repo.UpdateTaskSession(ctx, session)
+ _ = repo.UpdateSessionMetadata(ctx, session.ID, map[string]interface{}{"plan_mode": true})
taskRepo := newMockTaskRepo()
svc := createTestService(repo, newMockStepGetter(), taskRepo)
@@ -346,11 +346,12 @@ func TestProcessOnEnter(t *testing.T) {
session, _ = repo.GetTaskSession(ctx, "s1")
svc.processOnEnter(ctx, "t1", session, step, "test task")
+ // Plan mode should persist — only explicit on_exit/on_turn_complete
+ // disable_plan_mode actions should clear it.
updated, _ := repo.GetTaskSession(ctx, "s1")
- if updated.Metadata != nil {
- if _, hasPlanMode := updated.Metadata["plan_mode"]; hasPlanMode {
- t.Error("expected plan_mode to be cleared from session metadata")
- }
+ pm, _ := updated.Metadata["plan_mode"].(bool)
+ if !pm {
+ t.Error("expected plan_mode to persist in session metadata")
}
})
@@ -422,8 +423,8 @@ func TestSetSessionPlanMode(t *testing.T) {
// First enable
session, _ := repo.GetTaskSession(ctx, "s1")
- session.Metadata = map[string]interface{}{"plan_mode": true}
_ = repo.UpdateTaskSession(ctx, session)
+ _ = repo.UpdateSessionMetadata(ctx, session.ID, map[string]interface{}{"plan_mode": true})
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
session, _ = repo.GetTaskSession(ctx, "s1")
@@ -431,7 +432,7 @@ func TestSetSessionPlanMode(t *testing.T) {
updated, _ := repo.GetTaskSession(ctx, "s1")
if updated.Metadata != nil {
- if _, hasPlanMode := updated.Metadata["plan_mode"]; hasPlanMode {
+ if pm, _ := updated.Metadata["plan_mode"].(bool); pm {
t.Error("expected plan_mode to be removed from metadata")
}
}
@@ -480,8 +481,8 @@ func TestProcessOnExit(t *testing.T) {
// Set plan_mode in session metadata
session, _ := repo.GetTaskSession(ctx, "s1")
- session.Metadata = map[string]interface{}{"plan_mode": true}
_ = repo.UpdateTaskSession(ctx, session)
+ _ = repo.UpdateSessionMetadata(ctx, session.ID, map[string]interface{}{"plan_mode": true})
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
@@ -498,7 +499,7 @@ func TestProcessOnExit(t *testing.T) {
updated, _ := repo.GetTaskSession(ctx, "s1")
if updated.Metadata != nil {
- if _, hasPlanMode := updated.Metadata["plan_mode"]; hasPlanMode {
+ if pm, _ := updated.Metadata["plan_mode"].(bool); pm {
t.Error("expected plan_mode to be cleared from session metadata")
}
}
@@ -510,8 +511,8 @@ func TestProcessOnExit(t *testing.T) {
// Set plan_mode in session metadata
session, _ := repo.GetTaskSession(ctx, "s1")
- session.Metadata = map[string]interface{}{"plan_mode": true}
_ = repo.UpdateTaskSession(ctx, session)
+ _ = repo.UpdateSessionMetadata(ctx, session.ID, map[string]interface{}{"plan_mode": true})
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{isPassthrough: true})
@@ -560,7 +561,7 @@ func TestProcessOnEnterPassthrough(t *testing.T) {
session, _ = repo.GetTaskSession(ctx, "s1")
if session.Metadata != nil {
- if _, hasPlanMode := session.Metadata["plan_mode"]; hasPlanMode {
+ if pm, _ := session.Metadata["plan_mode"].(bool); pm {
t.Error("expected plan_mode NOT to be set for passthrough session")
}
}
@@ -572,8 +573,8 @@ func TestProcessOnEnterPassthrough(t *testing.T) {
// Set plan_mode in session metadata
session, _ := repo.GetTaskSession(ctx, "s1")
- session.Metadata = map[string]interface{}{"plan_mode": true}
_ = repo.UpdateTaskSession(ctx, session)
+ _ = repo.UpdateSessionMetadata(ctx, session.ID, map[string]interface{}{"plan_mode": true})
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{isPassthrough: true})
@@ -635,7 +636,7 @@ func TestProcessOnEnterResetAgentContext(t *testing.T) {
// Verify acp_session_id was cleared from session metadata
updated, _ := repo.GetTaskSession(ctx, "s1")
if updated.Metadata != nil {
- if _, hasACP := updated.Metadata["acp_session_id"]; hasACP {
+ if acp, _ := updated.Metadata["acp_session_id"].(string); acp != "" {
t.Error("expected acp_session_id to be cleared from session metadata")
}
}
diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go
index 9be774f367..016ba7ec46 100644
--- a/apps/backend/internal/orchestrator/executor/executor.go
+++ b/apps/backend/internal/orchestrator/executor/executor.go
@@ -7,6 +7,7 @@ import (
"sync"
"time"
+ "github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
@@ -279,6 +280,7 @@ type LaunchAgentResponse struct {
WorktreePath string
WorktreeBranch string
Metadata map[string]interface{}
+ PrepareResult *lifecycle.EnvPrepareResult `json:"-"` // Carried from lifecycle.Launch for synchronous persistence
}
// TaskExecution tracks an active task execution
@@ -294,6 +296,8 @@ type TaskExecution struct {
// Worktree info for the agent
WorktreePath string
WorktreeBranch string
+ // PrepareResult carries the env preparation result for deferred persistence
+ PrepareResult *lifecycle.EnvPrepareResult
}
// FromTaskSession converts a models.TaskSession to TaskExecution
diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go
index 26ec7eafce..71c34526fe 100644
--- a/apps/backend/internal/orchestrator/executor/executor_execute.go
+++ b/apps/backend/internal/orchestrator/executor/executor_execute.go
@@ -455,6 +455,7 @@ func (e *Executor) finalizeLaunch(ctx context.Context, task *v1.Task, session *m
SessionID: sessionID,
WorktreePath: resp.WorktreePath,
WorktreeBranch: resp.WorktreeBranch,
+ PrepareResult: resp.PrepareResult,
}
if startAgent {
diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go
index 45323fd543..79b2bf81b4 100644
--- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go
+++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go
@@ -478,6 +478,9 @@ func (m *mockRepository) UpdateSessionReviewStatus(ctx context.Context, sessionI
func (m *mockRepository) UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error {
return nil
}
+func (m *mockRepository) SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error {
+ return nil
+}
// Task Session Worktree operations
func (m *mockRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) {
diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go
index ae31083aca..1321560fc3 100644
--- a/apps/backend/internal/orchestrator/executor/executor_resume.go
+++ b/apps/backend/internal/orchestrator/executor/executor_resume.go
@@ -166,6 +166,16 @@ func (e *Executor) persistLaunchState(ctx context.Context, taskID, sessionID str
session.ErrorMessage = ""
session.UpdatedAt = now
+ // Merge prepare result into session metadata synchronously so it survives
+ // the UpdateTaskSession write (which would otherwise clobber it if the async
+ // handlePrepareCompleted event handler hasn't run yet).
+ if resp.PrepareResult != nil && resp.PrepareResult.Success {
+ if session.Metadata == nil {
+ session.Metadata = make(map[string]interface{})
+ }
+ session.Metadata["prepare_result"] = buildPrepareResultMetadata(resp.PrepareResult)
+ }
+
if err := e.repo.UpdateTaskSession(ctx, session); err != nil {
e.logger.Error("failed to update agent session after launch",
zap.String("task_id", taskID),
@@ -182,8 +192,12 @@ func (e *Executor) persistLaunchState(ctx context.Context, taskID, sessionID str
}
}
-// persistWorktreeAssociation creates a TaskSessionWorktree record if the response contains
-// a new worktree not already tracked by the session.
+// buildPrepareResultMetadata serializes a prepare result for storage in session metadata.
+// Uses lifecycle.SerializePrepareResult which is shared with the event handler.
+func buildPrepareResultMetadata(result *lifecycle.EnvPrepareResult) map[string]interface{} {
+ return lifecycle.SerializePrepareResult(result)
+}
+
func (e *Executor) persistWorktreeAssociation(ctx context.Context, taskID string, session *models.TaskSession, repositoryID string, resp *LaunchAgentResponse) {
if resp.WorktreeID == "" {
return
diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go
index bbcba1ce26..34ee2489a6 100644
--- a/apps/backend/internal/orchestrator/service.go
+++ b/apps/backend/internal/orchestrator/service.go
@@ -134,6 +134,7 @@ type sessionExecutorStore interface {
ClearSessionExecutionID(ctx context.Context, id string) error
UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error
UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error
+ SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error
// Executor running state
ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error)
UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error
@@ -543,6 +544,9 @@ func (s *Service) Start(ctx context.Context) error {
// Subscribe to clarification events (cancel-and-resume flow)
s.subscribeClarificationEvents()
+ // Subscribe to prepare events (persist result in session metadata)
+ s.subscribePrepareEvents()
+
s.logger.Info("orchestrator service started successfully")
return nil
}
diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go
index 71df46a377..9f6adcff53 100644
--- a/apps/backend/internal/orchestrator/task_operations.go
+++ b/apps/backend/internal/orchestrator/task_operations.go
@@ -452,6 +452,17 @@ func (s *Service) postLaunchStart(ctx context.Context, taskID string, execution
s.setSessionPlanMode(ctx, session, true)
}
}
+
+ // Persist prepare_result using SetSessionMetadataKey (json_set) which
+ // atomically sets ONE key without touching others. This avoids the
+ // read-modify-write race where UpdateSessionMetadata clobbers plan_mode.
+ if execution.PrepareResult != nil && execution.PrepareResult.Success {
+ pr := lifecycle.SerializePrepareResult(execution.PrepareResult)
+ if err := s.repo.SetSessionMetadataKey(ctx, execution.SessionID, "prepare_result", pr); err != nil {
+ s.logger.Warn("failed to persist prepare_result",
+ zap.String("session_id", execution.SessionID), zap.Error(err))
+ }
+ }
}
go s.ensureSessionPRWatch(context.Background(), taskID, execution.SessionID, execution.WorktreeBranch)
}
diff --git a/apps/backend/internal/orchestrator/workflow_store.go b/apps/backend/internal/orchestrator/workflow_store.go
index 2794f6bfef..8b06ec0463 100644
--- a/apps/backend/internal/orchestrator/workflow_store.go
+++ b/apps/backend/internal/orchestrator/workflow_store.go
@@ -127,14 +127,11 @@ func (s *workflowStore) ApplyTransition(ctx context.Context, taskID, sessionID,
}
func (s *workflowStore) PersistData(ctx context.Context, sessionID string, data map[string]any) error {
+ // Read existing workflow_data to merge new keys into it.
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
return fmt.Errorf("load session for data persist: %w", err)
}
-
- if session.Metadata == nil {
- session.Metadata = make(map[string]interface{})
- }
existing, _ := session.Metadata["workflow_data"].(map[string]interface{})
if existing == nil {
existing = make(map[string]interface{})
@@ -142,9 +139,9 @@ func (s *workflowStore) PersistData(ctx context.Context, sessionID string, data
for k, v := range data {
existing[k] = v
}
- session.Metadata["workflow_data"] = existing
-
- if err := s.repo.UpdateSessionMetadata(ctx, sessionID, session.Metadata); err != nil {
+ // Use SetSessionMetadataKey (json_set) to atomically set workflow_data
+ // without clobbering other metadata keys (plan_mode, prepare_result).
+ if err := s.repo.SetSessionMetadataKey(ctx, sessionID, "workflow_data", existing); err != nil {
return fmt.Errorf("persist workflow data: %w", err)
}
return nil
diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go
index 665773a7cd..afd8497af6 100644
--- a/apps/backend/internal/task/handlers/process_handlers_test.go
+++ b/apps/backend/internal/task/handlers/process_handlers_test.go
@@ -404,6 +404,9 @@ func (m *mockRepository) UpdateSessionReviewStatus(ctx context.Context, sessionI
func (m *mockRepository) UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error {
return nil
}
+func (m *mockRepository) SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error {
+ return nil
+}
// Task Plan operations
func (m *mockRepository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error {
diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go
index 1db479ace0..c26f20e8cd 100644
--- a/apps/backend/internal/task/repository/interface.go
+++ b/apps/backend/internal/task/repository/interface.go
@@ -109,6 +109,7 @@ type SessionRepository interface {
SetSessionPrimary(ctx context.Context, sessionID string) error
UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error
UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error
+ SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error
}
// SessionWorktreeRepository handles the task session↔worktree association.
diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go
index 4b8f4e6544..0d408d7361 100644
--- a/apps/backend/internal/task/repository/sqlite/session.go
+++ b/apps/backend/internal/task/repository/sqlite/session.go
@@ -328,14 +328,13 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
return r.scanTaskSession(ctx, row, fmt.Sprintf("no active agent session for task: %s", taskID))
}
-// UpdateTaskSession updates an existing agent session
+// UpdateTaskSession updates an existing agent session.
+// Note: metadata is NOT updated here to prevent clobbering concurrent writes
+// from UpdateSessionMetadata callers (e.g. setSessionPlanMode, persistPrepareResult).
+// Use UpdateSessionMetadata for metadata changes.
func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.TaskSession) error {
session.UpdatedAt = time.Now().UTC()
- metadataJSON, err := json.Marshal(session.Metadata)
- if err != nil {
- return fmt.Errorf("failed to serialize agent session metadata: %w", err)
- }
agentProfileSnapshotJSON, err := json.Marshal(session.AgentProfileSnapshot)
if err != nil {
return fmt.Errorf("failed to serialize agent profile snapshot: %w", err)
@@ -358,13 +357,13 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task
agent_execution_id = ?, container_id = ?, agent_profile_id = ?, executor_id = ?, executor_profile_id = ?, environment_id = ?,
repository_id = ?, base_branch = ?, base_commit_sha = ?,
agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?,
- state = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ?,
+ state = ?, error_message = ?, completed_at = ?, updated_at = ?,
is_primary = ?, review_status = ?, is_passthrough = ?, task_environment_id = ?
WHERE id = ?
`), session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID,
session.RepositoryID, session.BaseBranch, session.BaseCommitSHA,
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
- string(session.State), session.ErrorMessage, string(metadataJSON), session.CompletedAt, session.UpdatedAt,
+ string(session.State), session.ErrorMessage, session.CompletedAt, session.UpdatedAt,
dialect.BoolToInt(session.IsPrimary), session.ReviewStatus,
dialect.BoolToInt(session.IsPassthrough), session.TaskEnvironmentID,
session.ID)
@@ -423,6 +422,29 @@ func (r *Repository) UpdateSessionMetadata(ctx context.Context, sessionID string
return nil
}
+// SetSessionMetadataKey atomically sets a single key in the session's metadata
+// using SQLite's json_set. Unlike UpdateSessionMetadata (which does a full
+// replacement), this preserves all other metadata keys.
+func (r *Repository) SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error {
+ valueJSON, err := json.Marshal(value)
+ if err != nil {
+ return fmt.Errorf("failed to serialize metadata value: %w", err)
+ }
+ now := time.Now().UTC()
+ path := "$." + key
+ result, err := r.db.ExecContext(ctx, r.db.Rebind(`
+ UPDATE task_sessions SET metadata = json_set(CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END, ?, json(?)), updated_at = ? WHERE id = ?
+ `), path, string(valueJSON), now, sessionID)
+ if err != nil {
+ return err
+ }
+ rows, _ := result.RowsAffected()
+ if rows == 0 {
+ return fmt.Errorf("agent session not found: %s", sessionID)
+ }
+ return nil
+}
+
// ClearSessionExecutionID clears the agent_execution_id for a session.
// This is used when a stale execution ID needs to be removed (e.g., after a failed resume on startup).
func (r *Repository) ClearSessionExecutionID(ctx context.Context, id string) error {
diff --git a/apps/web/components/session/prepare-progress.tsx b/apps/web/components/session/prepare-progress.tsx
index 56299bb35b..bca7dd4098 100644
--- a/apps/web/components/session/prepare-progress.tsx
+++ b/apps/web/components/session/prepare-progress.tsx
@@ -6,16 +6,46 @@ import {
IconX,
IconLoader2,
IconAlertTriangle,
- IconChevronDown,
- IconChevronRight,
+ IconTerminal2,
+ IconFolder,
+ IconGitBranch,
} from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
-import type { PrepareStepInfo } from "@/lib/state/slices/session-runtime/types";
+import { ExpandableRow } from "@/components/task/chat/messages/expandable-row";
+import { cn } from "@/lib/utils";
+import type {
+ PrepareStepInfo,
+ SessionPrepareState,
+} from "@/lib/state/slices/session-runtime/types";
+
+const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
+function stripAnsi(s: string): string {
+ return s.replace(ANSI_RE, "");
+}
+
+function formatStepDuration(startedAt?: string, endedAt?: string): string | null {
+ if (!startedAt || !endedAt) return null;
+ const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
+ if (ms < 0 || Number.isNaN(ms)) return null;
+ if (ms < 1000) return `${ms}ms`;
+ const secs = Math.round(ms / 1000);
+ if (secs < 60) return `${secs}s`;
+ const mins = Math.floor(secs / 60);
+ const remSecs = secs % 60;
+ return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`;
+}
type PrepareProgressProps = {
sessionId: string;
};
+type EffectiveStatus =
+ | "preparing"
+ | "failed"
+ | "completed"
+ | "completed_with_error"
+ | "completed_with_warnings";
+
function StepIcon({ status, hasWarning }: { status: string; hasWarning?: boolean }) {
if (status === "completed" && hasWarning) {
return
+ {blockCommand}
+
+ )}
+ {step.output && (
+
+ {stripAnsi(step.output)}
+
+ )}
+
- {step.output}
-
- )}
- {step.warning && (
-
- {step.warning}
+
+ {warning}
+ {warningDetail && (
+ <>
+
+ {detailExpanded && (
+
+ {warningDetail}
- {step.warningDetail && (
- <>
-
- {detailExpanded && (
-
- {step.warningDetail}
-
- )}
- >
- )}
-
+ )}
+ >
+ )}
+
+ {step.error}
+
+ )}
+ >
+ );
+}
+
+function StepRow({ step }: { step: PrepareStepInfo }) {
+ const inlineCommand = step.command && isInlineCommand(step.command) ? step.command : undefined;
+ const blockCommand = step.command && !isInlineCommand(step.command) ? step.command : undefined;
+ const hasExpandable = Boolean(step.output) || Boolean(blockCommand);
+ const [detailsExpanded, setDetailsExpanded] = useState(
+ step.status === "running" || step.status === "failed",
+ );
+ const duration = formatStepDuration(step.startedAt, step.endedAt);
+
+ const nameClass = cn(
+ "text-muted-foreground",
+ step.status === "completed" && !step.warning && "line-through text-muted-foreground/60",
+ );
+
+ return (
+ {inlineCommand}
)}
- {step.error && (
-
- {step.error}
-
+ {duration && {duration}}
+ {hasExpandable && (
+
)}
{children};
+}
+
+function SessionInfo({ sessionId }: { sessionId: string }) {
+ const session = useAppStore((state) => state.taskSessions.items[sessionId]);
+ if (!session) return null;
+
+ const { worktree_id, worktree_path, worktree_branch, base_branch } = session;
+ const isWorktree = Boolean(worktree_id);
+
+ // Nothing useful to show for sessions without workspace info.
+ if (!worktree_path && !worktree_branch) return null;
+
+ return (
+
- {step.warningDetail}
-
- )}
-