diff --git a/cmd/attach.go b/cmd/attach.go new file mode 100644 index 0000000..39a7cf5 --- /dev/null +++ b/cmd/attach.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var attachCmd = &cobra.Command{ + Use: "attach ", + Short: "Attach to a workspace via tmux (always uses persistent session, regardless of CTASK_SESSION_MODE)", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: runAttach, +} + +var ( + attachAgent string + attachForce bool +) + +func init() { + attachCmd.Flags().StringVarP(&attachAgent, "agent", "a", "", "Override agent command") + attachCmd.Flags().BoolVar(&attachForce, "force", false, "Skip active-session and stale-workspace warnings (owner-create path only)") + attachCmd.ValidArgsFunction = completeWorkspaces(completionActive) + rootCmd.AddCommand(attachCmd) +} + +func runAttach(cmd *cobra.Command, args []string) error { + roots := config.SearchRoots() + // Active-only resolution per spec §9 (matches resume's completion filter). + ws := resolveOne(roots, args[0], false) + + // updated_at bump (existing v0.4 behavior). + now := time.Now().UTC().Truncate(time.Second) + ws.Meta.UpdatedAt = now + metaPath := filepath.Join(ws.Path, "task.yaml") + if err := workspace.WriteMetaLocked(metaPath, ws.Meta); err != nil { + return fmt.Errorf("updating metadata: %w", err) + } + + agent := attachAgent + if agent == "" { + agent = ws.Meta.Agent + } + + return runWorkspaceEntry(WorkspaceEntryOptions{ + WsPath: ws.Path, + WsRoot: ws.Root, + WsMeta: ws.Meta, + Agent: agent, + Shell: false, // attach defaults to agent + Force: attachForce, + AlwaysPersistent: true, // attach is always tmux, regardless of env + CommandName: "attach", + }) +} diff --git a/cmd/attach_test.go b/cmd/attach_test.go new file mode 100644 index 0000000..c6903b9 --- /dev/null +++ b/cmd/attach_test.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// Tests in this file mutate runWorkspaceEntry. Do not run with t.Parallel(). + +func TestAttachCommandRegistered(t *testing.T) { + var found *cobra.Command + for _, c := range rootCmd.Commands() { + if c.Name() == "attach" { + found = c + break + } + } + if found == nil { + t.Fatal("attach command not registered") + } +} + +func TestAttachRefusesDirectFlag(t *testing.T) { + for _, c := range rootCmd.Commands() { + if c.Name() != "attach" { + continue + } + if c.Flags().Lookup("direct") != nil { + t.Error("--direct flag must NOT exist on attach (always-tmux)") + } + if c.ValidArgsFunction == nil { + t.Error("attach must have ValidArgsFunction for tab completion") + } + return + } + t.Fatal("attach command not registered") +} + +// attach must call runWorkspaceEntry with AlwaysPersistent: true, +// Shell: false (defaults to agent), and CommandName: "attach". We use a +// fresh workspace fixture so resolveOne returns a real workspace, then +// stub runWorkspaceEntry to capture the opts. +func TestAttachForwardsToEntryHelperWithAlwaysPersistent(t *testing.T) { + root := t.TempDir() + wsDir := filepath.Join(root, "general", "2026-05-08_attach-fwd-demo") + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "attach-fwd-demo", Title: "attach-fwd-demo", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + t.Cleanup(func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }) + + var captured WorkspaceEntryOptions + orig := runWorkspaceEntry + runWorkspaceEntry = func(opts WorkspaceEntryOptions) error { + captured = opts + return nil + } + t.Cleanup(func() { runWorkspaceEntry = orig }) + + prevAgent, prevForce := attachAgent, attachForce + attachAgent, attachForce = "", false + t.Cleanup(func() { + attachAgent = prevAgent + attachForce = prevForce + }) + + if err := runAttach(attachCmd, []string{"attach-fwd-demo"}); err != nil { + t.Fatalf("runAttach: %v", err) + } + if !captured.AlwaysPersistent { + t.Error("attach must set AlwaysPersistent=true") + } + if captured.Shell { + t.Error("attach defaults to agent, Shell must be false") + } + if captured.CommandName != "attach" { + t.Errorf("CommandName: got %q, want %q", captured.CommandName, "attach") + } +} diff --git a/cmd/doctor.go b/cmd/doctor.go index 0795e8b..7dc9972 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/shell" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -174,7 +175,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { passed++ } else { fmt.Printf(" [FAIL] No workspaces found\n") - fmt.Printf(" Fix: create one with: ctask new \"my first task\"\n") + fmt.Printf(" Fix: create one with: %s new \"my first task\"\n", invocationName()) failed++ } @@ -203,6 +204,9 @@ func runDoctor(cmd *cobra.Command, args []string) error { // Check 8: CTASK_PROJECT_ROOT (v0.5). checkProjectRoot(&passed, &failed) + // Check 9: tmux availability for persistent session mode (v0.5.3). + checkTmux(&passed, &failed) + // Summary fmt.Println() fmt.Printf("%d checks passed, %d failed\n", passed, failed) @@ -254,3 +258,29 @@ func checkSeedDir(label, envValue, resolved, envName string, passed, failed *int fmt.Printf(" Fix: create the directory or unset %s to use built-in defaults.\n", envName) *failed++ } + +// checkTmux reports the three-state tmux check (v0.5.3): +// - CTASK_SESSION_MODE != "persistent" -> INFO (direct mode, tmux optional) +// - persistent + tmux on PATH + version OK -> two INFO lines +// - persistent + tmux missing or too old -> FAIL with install/update hint +func checkTmux(passed, failed *int) { + _ = passed + mode := config.ResolveSessionMode() + if mode != "persistent" { + fmt.Printf(" [INFO] Session mode: direct (tmux not required)\n") + return + } + fmt.Printf(" [INFO] Session mode: persistent\n") + tmuxPath, ver, err := shell.LookupTmux() + if err != nil { + fmt.Printf(" [FAIL] tmux not found on PATH or unsupported version: %v\n", err) + fmt.Printf(" Fix: install tmux 3.0+ (apt/brew/pacman/dnf), or unset CTASK_SESSION_MODE\n") + *failed++ + return + } + rawVer := ver.Raw + if rawVer == "" { + rawVer = "unknown version" + } + fmt.Printf(" [INFO] tmux found: %s (%s)\n", rawVer, tmuxPath) +} diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 4b50c4e..054de11 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -2,12 +2,31 @@ package cmd import ( "bytes" + "io" "os" + "os/exec" "path/filepath" "strings" "testing" ) +// captureStdout runs fn while capturing os.Stdout and returns the output. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + orig := os.Stdout + os.Stdout = w + defer func() { os.Stdout = orig }() + + fn() + w.Close() + data, _ := io.ReadAll(r) + return string(data) +} + // This file swaps process-global os.Stdout and env vars. Do not call // t.Parallel() in this file. @@ -161,3 +180,50 @@ func TestDoctorProjectRootSetButMissingFails(t *testing.T) { t.Errorf("expected FAIL line, got:\n%s", out) } } + +func TestCheckTmuxNotConfigured(t *testing.T) { + os.Unsetenv("CTASK_SESSION_MODE") + out := captureStdout(t, func() { + passed, failed := 0, 0 + checkTmux(&passed, &failed) + }) + if !strings.Contains(out, "Session mode: direct") { + t.Errorf("expected 'Session mode: direct' info line: %q", out) + } +} + +func TestCheckTmuxConfiguredAndPresent(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux not on PATH") + } + os.Setenv("CTASK_SESSION_MODE", "persistent") + defer os.Unsetenv("CTASK_SESSION_MODE") + out := captureStdout(t, func() { + passed, failed := 0, 0 + checkTmux(&passed, &failed) + }) + if !strings.Contains(out, "Session mode: persistent") { + t.Errorf("expected 'Session mode: persistent': %q", out) + } + if !strings.Contains(out, "tmux found") { + t.Errorf("expected 'tmux found' info line: %q", out) + } +} + +func TestCheckTmuxConfiguredAndMissing(t *testing.T) { + orig := os.Getenv("PATH") + defer os.Setenv("PATH", orig) + os.Setenv("PATH", "") + os.Setenv("CTASK_SESSION_MODE", "persistent") + defer os.Unsetenv("CTASK_SESSION_MODE") + + failed := 0 + passed := 0 + out := captureStdout(t, func() { checkTmux(&passed, &failed) }) + if failed != 1 { + t.Errorf("missing tmux must increment failed counter; got %d", failed) + } + if !strings.Contains(out, "tmux not found") { + t.Errorf("expected 'tmux not found' fail line: %q", out) + } +} diff --git a/cmd/entry.go b/cmd/entry.go new file mode 100644 index 0000000..11be00d --- /dev/null +++ b/cmd/entry.go @@ -0,0 +1,226 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/shell" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// WorkspaceEntryOptions captures everything the persistent-mode dispatcher +// needs to enter a workspace. Callers (runNew, runResume, runOpen, +// runAttach) populate this struct after they have resolved or created the +// workspace, then call runWorkspaceEntry. The helper handles preflight +// (when not pre-resolved), session-name computation, lease inspection, +// dispatch decision, and the fresh_remote confirmation prompt. +type WorkspaceEntryOptions struct { + WsPath string // absolute workspace directory + WsRoot string // top-level root (used for CTASK_ROOT env var) + WsMeta *workspace.TaskMeta // workspace metadata + Agent string + Shell bool // launch interactive shell (open / new --shell) + Force bool // bypass v0.4 Layer 1/3 prompts (owner-create only) + Direct bool // user passed --direct + AlwaysPersistent bool // ctask attach: ignore CTASK_SESSION_MODE + CommandName string // for hint rendering: "new" | "resume" | "open" | "attach" + TmuxPath string // pre-resolved tmux path; if empty in persistent mode, runWorkspaceEntry resolves + NewlyCreated bool // forwarded to LaunchOpts.NewlyCreated +} + +// runWorkspaceEntry is the test seam for the persistent-mode dispatcher. +// Production code calls defaultRunWorkspaceEntry; tests override this +// variable to capture invocations or simulate the dispatch outcome. +// +// Do NOT mark tests that override this in t.Parallel() — it is a package +// global. Each test must restore via t.Cleanup. +var runWorkspaceEntry = defaultRunWorkspaceEntry + +// dispatchDecision enumerates the three persistent-mode entry paths. +type dispatchDecision int + +const ( + dispatchOwnerCreate dispatchDecision = iota + dispatchPassive + dispatchAdopted +) + +// dispatchPersistent is the pure decision function — no I/O, no globals, +// trivially testable. +func dispatchPersistent(hasTmuxSession bool, leaseState session.LeaseState) dispatchDecision { + if !hasTmuxSession { + return dispatchOwnerCreate + } + if leaseState == session.LeaseStateFreshLocal { + return dispatchPassive + } + return dispatchAdopted +} + +func defaultRunWorkspaceEntry(opts WorkspaceEntryOptions) error { + mode := config.ResolveSessionMode() + persistent := opts.AlwaysPersistent || (mode == "persistent" && !opts.Direct) + + // Direct flag with persistent env: confirm if a tmux session exists. + if !persistent && mode == "persistent" && opts.Direct { + if err := confirmDirectBypass(opts); err != nil { + return err + } + } + + if !persistent { + return invokeDirectRun(opts) + } + + tmuxPath := opts.TmuxPath + if tmuxPath == "" { + var err error + tmuxPath, err = preflightPersistentEntry(opts.CommandName) + if err != nil { + return err + } + } + + absWs, _ := filepath.Abs(opts.WsPath) + sessionName := session.SessionName(opts.WsMeta.Category, opts.WsMeta.Slug, absWs) + hasSession := shell.HasSession(tmuxPath, sessionName) + leaseState := session.InspectLease(opts.WsPath) + + switch dispatchPersistent(hasSession, leaseState) { + case dispatchOwnerCreate: + return invokePersistentRun(opts, tmuxPath, sessionName) + case dispatchPassive: + return session.AttachExisting(tmuxPath, sessionName) + case dispatchAdopted: + if leaseState == session.LeaseStateFreshRemote { + if err := confirmFreshRemoteAdoption(opts.WsPath); err != nil { + return err + } + } + return invokePersistentAdoption(opts, tmuxPath, sessionName) + } + return fmt.Errorf("internal: unreachable persistent dispatch") +} + +func entryEnvVars(opts WorkspaceEntryOptions) map[string]string { + return config.EnvVars( + opts.WsMeta.Slug, opts.WsMeta.Mode, + opts.WsRoot, opts.WsPath, + opts.WsMeta.Category, workspace.EffectiveType(opts.WsMeta), + opts.WsMeta.LaunchDir, + ) +} + +func invokeDirectRun(opts WorkspaceEntryOptions) error { + return session.Run(session.LaunchOpts{ + WsDir: opts.WsPath, + EnvVars: entryEnvVars(opts), + Agent: opts.Agent, + Mode: opts.WsMeta.Mode, + Slug: opts.WsMeta.Slug, + Shell: opts.Shell, + LaunchDir: opts.WsMeta.LaunchDir, + Category: opts.WsMeta.Category, + Force: opts.Force, + NewlyCreated: opts.NewlyCreated, + ActiveLeaseHint: directModeTmuxHint(opts), + }) +} + +// directModeTmuxHint returns a Layer-1 prompt suggestion when ctask is +// about to enter direct mode on a workspace that already has a live tmux +// session — pointing the user at `ctask attach ` as the reattach +// path. Returns "" when no hint is appropriate (no tmux on PATH, no +// session for this workspace, or native Windows without WSL). +// +// This is a best-effort UX nudge: the lookup is silent on error so a +// missing/broken tmux never blocks the direct-mode path. +func directModeTmuxHint(opts WorkspaceEntryOptions) string { + if runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == "" { + return "" + } + tmuxPath, err := exec.LookPath("tmux") + if err != nil { + return "" + } + absWs, _ := filepath.Abs(opts.WsPath) + sessionName := session.SessionName(opts.WsMeta.Category, opts.WsMeta.Slug, absWs) + if !shell.HasSession(tmuxPath, sessionName) { + return "" + } + return fmt.Sprintf( + "Tip: a tmux session exists for this workspace.\nTo reattach instead of starting a second direct-mode session, run:\n %s attach %s", + invocationName(), opts.WsMeta.Slug) +} + +func invokePersistentRun(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error { + return session.Run(session.LaunchOpts{ + WsDir: opts.WsPath, + EnvVars: entryEnvVars(opts), + Agent: opts.Agent, + Mode: opts.WsMeta.Mode, + Slug: opts.WsMeta.Slug, + Shell: opts.Shell, + LaunchDir: opts.WsMeta.LaunchDir, + Category: opts.WsMeta.Category, + SessionMode: "persistent", + SessionName: sessionName, + TmuxPath: tmuxPath, + Force: opts.Force, + NewlyCreated: opts.NewlyCreated, + }) +} + +func invokePersistentAdoption(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error { + return session.AdoptExistingPersistentSession(tmuxPath, sessionName, opts.WsPath, session.LaunchOpts{ + WsDir: opts.WsPath, + EnvVars: entryEnvVars(opts), + Agent: opts.Agent, + Mode: opts.WsMeta.Mode, + Slug: opts.WsMeta.Slug, + Shell: opts.Shell, + LaunchDir: opts.WsMeta.LaunchDir, + Category: opts.WsMeta.Category, + SessionMode: "persistent", + SessionName: sessionName, + TmuxPath: tmuxPath, + }) +} + +// confirmDirectBypass is invoked when the user passes --direct under +// persistent mode. If a tmux session exists for the workspace, prompt for +// confirmation. Otherwise, print a one-line warning and proceed. +func confirmDirectBypass(opts WorkspaceEntryOptions) error { + // Native Windows / no WSL: no tmux can exist; silent proceed. + if runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == "" { + return nil + } + tmuxPath, err := exec.LookPath("tmux") + if err != nil { + fmt.Fprintln(os.Stderr, + "[ctask] warning: --direct bypassing persistent mode (no tmux session exists for this workspace)") + return nil + } + absWs, _ := filepath.Abs(opts.WsPath) + sessionName := session.SessionName(opts.WsMeta.Category, opts.WsMeta.Slug, absWs) + if !shell.HasSession(tmuxPath, sessionName) { + fmt.Fprintln(os.Stderr, + "[ctask] warning: --direct bypassing persistent mode (no tmux session exists for this workspace)") + return nil + } + fmt.Fprintf(os.Stderr, + "A persistent tmux session exists for this workspace:\n %s\n\n"+ + "Opening a direct-mode shell may create conflicting workspace activity.\n"+ + "The recommended path is:\n %s attach %s\n\n"+ + "Continue with --direct anyway? [y/N] ", + sessionName, invocationName(), opts.WsMeta.Slug) + if !session.ConfirmYN(os.Stdin, os.Stderr, "", false) { + return fmt.Errorf("canceled by user") + } + return nil +} diff --git a/cmd/entry_test.go b/cmd/entry_test.go new file mode 100644 index 0000000..e276009 --- /dev/null +++ b/cmd/entry_test.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "path/filepath" + "testing" + + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// dispatchPersistent is a pure decision function — table tests are the +// right shape. +func TestDispatchPersistentOwnerWhenNoTmuxSession(t *testing.T) { + got := dispatchPersistent(false, session.LeaseStateNone) + if got != dispatchOwnerCreate { + t.Errorf("got %v, want %v", got, dispatchOwnerCreate) + } +} + +func TestDispatchPersistentPassiveWhenFreshLocal(t *testing.T) { + got := dispatchPersistent(true, session.LeaseStateFreshLocal) + if got != dispatchPassive { + t.Errorf("got %v, want %v", got, dispatchPassive) + } +} + +func TestDispatchPersistentAdoptedWhenStaleNoneOrRemote(t *testing.T) { + for _, st := range []session.LeaseState{ + session.LeaseStateStale, + session.LeaseStateNone, + session.LeaseStateFreshRemote, + } { + got := dispatchPersistent(true, st) + if got != dispatchAdopted { + t.Errorf("state %v: got %v, want %v", st, got, dispatchAdopted) + } + } +} + +// SessionName is computed by callers — sanity check determinism. +func TestEntrySessionNameStable(t *testing.T) { + abs, _ := filepath.Abs("/tmp/x") + a := session.SessionName("projects", "demo", abs) + b := session.SessionName("projects", "demo", abs) + if a != b { + t.Errorf("not stable: %q vs %q", a, b) + } +} + +// runWorkspaceEntry must be injectable so per-command tests can capture +// the WorkspaceEntryOptions each command produces. This test installs a +// stub and verifies the wiring works end-to-end. +// +// Tests in this file mutate the package-level runWorkspaceEntry. Do not +// run with t.Parallel(). +func TestRunWorkspaceEntryIsInjectable(t *testing.T) { + var captured WorkspaceEntryOptions + orig := runWorkspaceEntry + runWorkspaceEntry = func(opts WorkspaceEntryOptions) error { + captured = opts + return nil + } + t.Cleanup(func() { runWorkspaceEntry = orig }) + + want := WorkspaceEntryOptions{ + WsPath: "/tmp/ws", + WsRoot: "/tmp", + WsMeta: &workspace.TaskMeta{Slug: "demo", Category: "projects", Mode: "local", Agent: "claude"}, + Agent: "claude", + Shell: true, + CommandName: "test", + } + if err := runWorkspaceEntry(want); err != nil { + t.Fatalf("runWorkspaceEntry: %v", err) + } + if captured.CommandName != "test" { + t.Errorf("CommandName: got %q", captured.CommandName) + } + if !captured.Shell { + t.Error("Shell should be true") + } + if captured.WsMeta == nil || captured.WsMeta.Slug != "demo" { + t.Errorf("WsMeta not propagated: %+v", captured.WsMeta) + } + if captured.WsPath != "/tmp/ws" || captured.WsRoot != "/tmp" { + t.Errorf("path/root not propagated: path=%q root=%q", captured.WsPath, captured.WsRoot) + } +} diff --git a/cmd/invocation.go b/cmd/invocation.go new file mode 100644 index 0000000..acba7f5 --- /dev/null +++ b/cmd/invocation.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "os" + "path/filepath" +) + +// invocationNameOverride lets tests fix the binary name surfaced in +// user-facing hint and refusal messages. Production code leaves it empty +// so the live os.Args[0] basename is used. +// +// Tests that assert on specific hint substrings (e.g. "ctask attach +// ") must set this to "ctask" via t.Cleanup; otherwise the test +// binary's name (e.g. "cmd.test") will surface in the hint and the +// substring will not match. Do not run such tests in t.Parallel — this +// is a package global. +var invocationNameOverride string + +// invocationName returns the binary name to render in user-facing +// command suggestions (" new --direct", +// " attach ", etc.). It returns the basename of os.Args[0] +// so the hint reads cleanly regardless of invocation form: `./ctask`, +// `.\ctask.exe`, `/usr/local/bin/ctask`, and an installed `ctask` on +// PATH all surface as `ctask` (or `ctask.exe` on Windows). The slight +// loss of paste-ability for explicit-path invocations (the user has to +// re-prepend their `./` or `.\`) is the trade for a clean, predictable +// hint that matches the canonical install case. +// +// Falls back to "ctask" when argv is empty (a degenerate state — should +// not happen in normal execution, but defensive against odd embeddings). +func invocationName() string { + if invocationNameOverride != "" { + return invocationNameOverride + } + if len(os.Args) == 0 || os.Args[0] == "" { + return "ctask" + } + return filepath.Base(os.Args[0]) +} diff --git a/cmd/last.go b/cmd/last.go index 8e80edd..c90c047 100644 --- a/cmd/last.go +++ b/cmd/last.go @@ -18,15 +18,17 @@ var lastCmd = &cobra.Command{ } var ( - lastShell bool - lastAgent string - lastForce bool + lastShell bool + lastAgent string + lastForce bool + lastDirect bool ) func init() { lastCmd.Flags().BoolVar(&lastShell, "shell", false, "Open shell instead of agent") lastCmd.Flags().StringVarP(&lastAgent, "agent", "a", "", "Override agent command") lastCmd.Flags().BoolVar(&lastForce, "force", false, "Skip active-session and stale-workspace warnings") + lastCmd.Flags().BoolVar(&lastDirect, "direct", false, "Bypass persistent session mode for this command") rootCmd.AddCommand(lastCmd) } @@ -42,5 +44,5 @@ func runLast(cmd *cobra.Command, args []string) error { os.Exit(1) } - return doResume(best.Meta.Slug, false, lastShell, lastForce, lastAgent) + return doResume(best.Meta.Slug, false, lastShell, lastForce, lastAgent, lastDirect) } diff --git a/cmd/new.go b/cmd/new.go index bce7eb2..e75b53a 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" - "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/shell" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -27,6 +26,7 @@ var ( newAgent string newNoLaunch bool newProject bool + newDirect bool ) func init() { @@ -36,6 +36,7 @@ func init() { newCmd.Flags().BoolVar(&newShell, "shell", false, "Open interactive shell instead of agent") newCmd.Flags().StringVarP(&newAgent, "agent", "a", "", "Command to exec as the agent") newCmd.Flags().BoolVar(&newNoLaunch, "no-launch", false, "Create workspace only, do not launch") + newCmd.Flags().BoolVar(&newDirect, "direct", false, "Bypass persistent session mode for this command") rootCmd.AddCommand(newCmd) } @@ -45,6 +46,13 @@ func runNew(cmd *cobra.Command, args []string) error { return nil } + // Persistent-mode preflight runs BEFORE workspace.Create — a missing + // tmux install must not leave a half-initialized workspace on disk. + tmuxPath, err := newPersistentPreflight(newDirect) + if err != nil { + return err + } + agent := newAgent if agent == "" { agent = config.ResolveAgent() @@ -119,16 +127,42 @@ func runNew(cmd *cobra.Command, args []string) error { return nil } - envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta), ws.Meta.LaunchDir) - - return session.Run(session.LaunchOpts{ - WsDir: ws.Path, - EnvVars: envVars, + // Re-set the workspace's root: workspace.Create returned ws but our + // computed `root` is what should be exported via CTASK_ROOT (it may + // differ from ws-derived defaults when --project + CTASK_PROJECT_ROOT + // are in play). + return runWorkspaceEntry(WorkspaceEntryOptions{ + WsPath: ws.Path, + WsRoot: root, + WsMeta: ws.Meta, Agent: agent, - Mode: ws.Meta.Mode, - Slug: ws.Meta.Slug, Shell: newShell, - LaunchDir: ws.Meta.LaunchDir, + Direct: newDirect, + CommandName: "new", + TmuxPath: tmuxPath, NewlyCreated: true, }) } + +// newPersistentPreflight runs the persistent-mode preflight for `ctask new`, +// returning the validated tmux path on success or "" when persistent mode +// is not in effect (or --direct was passed). When persistent mode IS in +// effect and --direct was passed, prints the bypass warning and returns +// ("", nil) — the workspace can still be created in direct mode. +// +// `new` is the only command where preflight runs *before* the workspace +// exists; a tmux failure must not leave a half-initialized directory on +// disk. (resume / last / open / attach run preflight inside +// runWorkspaceEntry, after their own resolution step.) +func newPersistentPreflight(directFlag bool) (string, error) { + mode := config.ResolveSessionMode() + if mode != "persistent" { + return "", nil + } + if directFlag { + fmt.Fprintln(os.Stderr, + "[ctask] warning: --direct bypassing persistent mode (no tmux session exists for this workspace)") + return "", nil + } + return preflightPersistentEntry("new") +} diff --git a/cmd/new_persistent_test.go b/cmd/new_persistent_test.go new file mode 100644 index 0000000..015acae --- /dev/null +++ b/cmd/new_persistent_test.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "os" + "strings" + "testing" +) + +// Tests in this file mutate package globals (isTTYCheck, runWorkspaceEntry). +// Do not run with t.Parallel(). + +func TestNewDirectModeSkipsPreflight(t *testing.T) { + os.Unsetenv("CTASK_SESSION_MODE") + // Direct mode (default): preflight is a no-op even when --direct is unset. + if _, err := newPersistentPreflight(false); err != nil { + t.Errorf("direct mode preflight should be no-op: %v", err) + } +} + +func TestNewDirectFlagUnderPersistentEmitsWarningAndProceeds(t *testing.T) { + os.Setenv("CTASK_SESSION_MODE", "persistent") + t.Cleanup(func() { os.Unsetenv("CTASK_SESSION_MODE") }) + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + orig := os.Stderr + os.Stderr = w + t.Cleanup(func() { os.Stderr = orig }) + + tmuxPath, err := newPersistentPreflight(true) // --direct + w.Close() + if err != nil { + t.Errorf("--direct under persistent should not error pre-create: %v", err) + } + if tmuxPath != "" { + t.Errorf("expected empty tmuxPath under --direct: got %q", tmuxPath) + } + buf := make([]byte, 4096) + n, _ := r.Read(buf) + if !strings.Contains(string(buf[:n]), "--direct bypassing") { + t.Errorf("expected bypass warning on stderr, got %q", string(buf[:n])) + } +} + +// The behavioral assertion that runNew forwards the workspace produced by +// workspace.Create into runWorkspaceEntry lives in cmd/entry_test.go +// (Task 10). It uses the entry seam directly; we don't duplicate that here. diff --git a/cmd/open.go b/cmd/open.go index 1df23b6..3993137 100644 --- a/cmd/open.go +++ b/cmd/open.go @@ -7,7 +7,6 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" - "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -20,23 +19,27 @@ var openCmd = &cobra.Command{ } var ( - openAll bool - openForce bool + openAll bool + openForce bool + openDirect bool ) func init() { openCmd.Flags().BoolVarP(&openAll, "all", "a", false, "Include archived workspaces in query resolution") openCmd.Flags().BoolVar(&openForce, "force", false, "Skip active-session and stale-workspace warnings") - // v0.5.2: completion offers active candidates only (see delete for rationale). + openCmd.Flags().BoolVar(&openDirect, "direct", false, "Bypass persistent session mode for this command") openCmd.ValidArgsFunction = completeWorkspaces(completionActive) rootCmd.AddCommand(openCmd) } func runOpen(cmd *cobra.Command, args []string) error { roots := config.SearchRoots() + // PRESERVED v0.5.2 behavior: open's archive resolution is opt-in via --all. + // resolveOne(roots, query, includeArchived) — distinct from resume's + // archived-inclusive-with-restore-hint behavior. ws := resolveOne(roots, args[0], openAll) - // Update updated_at + // updated_at bump (existing v0.4 behavior). now := time.Now().UTC().Truncate(time.Second) ws.Meta.UpdatedAt = now metaPath := filepath.Join(ws.Path, "task.yaml") @@ -44,16 +47,14 @@ func runOpen(cmd *cobra.Command, args []string) error { return fmt.Errorf("updating metadata: %w", err) } - envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, ws.Root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta), ws.Meta.LaunchDir) - - return session.Run(session.LaunchOpts{ - WsDir: ws.Path, - EnvVars: envVars, - Agent: ws.Meta.Agent, - Mode: ws.Meta.Mode, - Slug: ws.Meta.Slug, - Shell: true, // open always launches shell - LaunchDir: ws.Meta.LaunchDir, - Force: openForce, + return runWorkspaceEntry(WorkspaceEntryOptions{ + WsPath: ws.Path, + WsRoot: ws.Root, + WsMeta: ws.Meta, + Agent: ws.Meta.Agent, + Shell: true, // open always launches a shell + Force: openForce, + Direct: openDirect, + CommandName: "open", }) } diff --git a/cmd/open_persistent_test.go b/cmd/open_persistent_test.go new file mode 100644 index 0000000..13796d9 --- /dev/null +++ b/cmd/open_persistent_test.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// Tests in this file mutate runWorkspaceEntry. Do not run with t.Parallel(). + +func TestOpenDirectFlagRegistered(t *testing.T) { + if openCmd.Flags().Lookup("direct") == nil { + t.Error("--direct flag missing from `ctask open`") + } + if openCmd.Flags().Lookup("all") == nil { + t.Error("--all flag missing from `ctask open` (archive resolution must remain opt-in)") + } +} + +// Open must hit the shared entry helper with Shell: true and CommandName +// "open". Open's archive-inclusive lookup is gated by --all, so a bare-name +// resolution against an active workspace must succeed and the captured opts +// must reflect Shell=true. +func TestOpenForwardsToEntryHelperWithShellTrue(t *testing.T) { + root := t.TempDir() + wsDir := filepath.Join(root, "general", "2026-05-08_open-fwd-demo") + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "open-fwd-demo", Title: "open-fwd-demo", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + t.Cleanup(func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }) + + var captured WorkspaceEntryOptions + orig := runWorkspaceEntry + runWorkspaceEntry = func(opts WorkspaceEntryOptions) error { + captured = opts + return nil + } + t.Cleanup(func() { runWorkspaceEntry = orig }) + + // Reset openAll / openForce / openDirect to defaults explicitly + // since they are package globals shared across tests. + prevAll, prevForce, prevDirect := openAll, openForce, openDirect + openAll, openForce, openDirect = false, false, false + t.Cleanup(func() { + openAll = prevAll + openForce = prevForce + openDirect = prevDirect + }) + + if err := runOpen(openCmd, []string{"open-fwd-demo"}); err != nil { + t.Fatalf("runOpen: %v", err) + } + if captured.CommandName != "open" { + t.Errorf("CommandName: got %q, want %q", captured.CommandName, "open") + } + if !captured.Shell { + t.Error("open must set Shell=true") + } + if captured.WsMeta == nil || captured.WsMeta.Slug != "open-fwd-demo" { + t.Errorf("WsMeta not propagated: %+v", captured.WsMeta) + } +} diff --git a/cmd/persistent.go b/cmd/persistent.go new file mode 100644 index 0000000..3d46f03 --- /dev/null +++ b/cmd/persistent.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "runtime" + "time" + + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/shell" +) + +// isTTYCheck is the test seam for terminal detection. Tests override this +// package-level variable to control the TTY refusal path without depending +// on the real process's stdin/stdout state. Production callers go through +// defaultIsTTYCheck. +var isTTYCheck = defaultIsTTYCheck + +func defaultIsTTYCheck() bool { + return shell.IsTTY(os.Stdin) && shell.IsTTY(os.Stdout) +} + +// preflightPersistentEntry validates the host environment supports +// tmux-based persistent mode and returns the validated tmux binary path +// for callers to pass through `session.LaunchOpts.TmuxPath`. +// +// Order of checks: +// +// 1. Native Windows refusal — tmux is not supported; recommend WSL. +// 2. Nested tmux refusal — `$TMUX` is set in the parent process. +// 3. Non-TTY refusal — stdin or stdout is not a terminal. +// 4. shell.LookupTmux — handles ErrTmuxNotFound and ErrTmuxTooOld +// with platform-aware install hints. +// +// commandName ("new", "resume", "last", "open", "attach") is rendered into +// the bypass hint so each command tells the user the right form. +func preflightPersistentEntry(commandName string) (string, error) { + bin := invocationName() + bypass := " " + bin + " " + commandName + " --direct" + + if runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == "" { + return "", fmt.Errorf( + "ctask persistent mode requires tmux, which is not supported on native Windows.\n\n"+ + "Recommended:\n Run ctask from WSL and install tmux there:\n sudo apt install tmux\n\n"+ + "Or bypass persistent mode:\n%s", bypass) + } + if os.Getenv("TMUX") != "" { + return "", fmt.Errorf( + "ctask persistent mode cannot attach while already inside tmux.\n\n"+ + "Run ctask from outside tmux, or bypass persistent mode:\n%s", bypass) + } + if !isTTYCheck() { + return "", fmt.Errorf( + "ctask persistent mode requires an interactive terminal.\n\n"+ + "Over SSH, use:\n ssh -t ctask %s \n\n"+ + "Or bypass persistent mode:\n%s", commandName, bypass) + } + tmuxPath, ver, err := shell.LookupTmux() + if err != nil { + if errors.Is(err, shell.ErrTmuxNotFound) { + return "", fmt.Errorf( + "ctask is configured for persistent sessions, but tmux is not installed.\n\n"+ + "Install tmux:\n"+ + " Debian/Ubuntu/WSL: sudo apt install tmux\n"+ + " macOS: brew install tmux\n"+ + " Arch: sudo pacman -S tmux\n"+ + " Fedora: sudo dnf install tmux\n\n"+ + "Or bypass persistent mode for this command:\n%s\n\n"+ + "To disable persistent mode:\n unset CTASK_SESSION_MODE", bypass) + } + if errors.Is(err, shell.ErrTmuxTooOld) { + raw := ver.Raw + if raw == "" { + raw = "unknown version" + } + return "", fmt.Errorf( + "ctask persistent mode requires tmux 3.0 or newer (found: %s).\n\n"+ + "Update tmux:\n"+ + " Debian/Ubuntu/WSL: sudo apt install tmux (Debian 10+ ships 2.8; consider backports or a newer release)\n"+ + " macOS: brew upgrade tmux\n"+ + " Arch: sudo pacman -Syu tmux\n"+ + " Fedora: sudo dnf upgrade tmux\n\n"+ + "Or bypass persistent mode for this command:\n%s", raw, bypass) + } + return "", err + } + return tmuxPath, nil +} + +// confirmFreshRemoteAdoption prompts the user to confirm adoption of a +// workspace whose lease is fresh but from a different host. Refuses on +// non-TTY environments — silent overwrite of a fresh remote lease is +// never appropriate. +func confirmFreshRemoteAdoption(wsPath string) error { + l, _ := session.ReadLease(session.LeasePath(wsPath)) + hostStr := "unknown" + heartbeatAgo := "unknown" + if l != nil { + hostStr = l.Hostname + heartbeatAgo = session.FormatAgo(time.Since(l.LastHeartbeatAt)) + } + if !isTTYCheck() { + return fmt.Errorf( + "ctask refused to adopt persistent session: lease is fresh from another host (%s, last heartbeat %s ago); rerun in an interactive terminal to confirm", + hostStr, heartbeatAgo) + } + fmt.Fprintf(os.Stderr, + "[ctask] tmux session exists, but a fresh lease from another host is present:\n"+ + "[ctask] hostname: %s\n"+ + "[ctask] last heartbeat: %s ago\n"+ + "[ctask]\n"+ + "[ctask] This may indicate another machine is actively using this workspace\n"+ + "[ctask] (e.g., shared filesystem). Adopting will overwrite the remote lease\n"+ + "[ctask] and may create conflicting workspace activity.\n"+ + "[ctask]\n"+ + "Adopt anyway? [y/N] ", + hostStr, heartbeatAgo) + if !session.ConfirmYN(os.Stdin, os.Stderr, "", false) { + return fmt.Errorf("[ctask] adoption refused; wait for the remote session to end or unset CTASK_SESSION_MODE") + } + return nil +} diff --git a/cmd/persistent_test.go b/cmd/persistent_test.go new file mode 100644 index 0000000..01fa89e --- /dev/null +++ b/cmd/persistent_test.go @@ -0,0 +1,168 @@ +package cmd + +import ( + "errors" + "os" + "runtime" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/shell" +) + +// withTTYCheck swaps the package-level isTTYCheck for the duration of the +// test. Tests that exercise refusal paths must NOT run in parallel — they +// mutate a package global. +func withTTYCheck(t *testing.T, fn func() bool) { + t.Helper() + orig := isTTYCheck + isTTYCheck = fn + t.Cleanup(func() { isTTYCheck = orig }) +} + +// withInvocationName pins the binary name surfaced in user-facing hints +// to a fixed value (typically "ctask") for the duration of the test, so +// substring assertions against rendered hints stay stable regardless of +// the Go test binary's name. Must NOT run in parallel — mutates a +// package global. +func withInvocationName(t *testing.T, name string) { + t.Helper() + orig := invocationNameOverride + invocationNameOverride = name + t.Cleanup(func() { invocationNameOverride = orig }) +} + +func TestPreflightRefusesNativeWindows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("native-Windows refusal applies only on Windows") + } + had := os.Getenv("WSL_DISTRO_NAME") + os.Unsetenv("WSL_DISTRO_NAME") + t.Cleanup(func() { + if had != "" { + os.Setenv("WSL_DISTRO_NAME", had) + } + }) + _, err := preflightPersistentEntry("resume") + if err == nil { + t.Fatal("expected refusal on native Windows") + } + if !strings.Contains(err.Error(), "tmux") || !strings.Contains(err.Error(), "WSL") { + t.Errorf("expected tmux+WSL message: %v", err) + } +} + +func TestPreflightRefusesNestedTmux(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("nested-tmux check runs on Unix paths only in this test") + } + withTTYCheck(t, func() bool { return true }) + os.Setenv("TMUX", "/tmp/tmux-1000/default,1234,0") + t.Cleanup(func() { os.Unsetenv("TMUX") }) + _, err := preflightPersistentEntry("resume") + if err == nil { + t.Fatal("expected refusal when $TMUX is set") + } + if !strings.Contains(err.Error(), "already inside tmux") { + t.Errorf("expected nested-tmux message: %v", err) + } +} + +func TestPreflightRefusesNonTTY(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("focus this case on Unix; Windows TTY semantics covered by manual smoke") + } + os.Unsetenv("TMUX") + withTTYCheck(t, func() bool { return false }) + withInvocationName(t, "ctask") + _, err := preflightPersistentEntry("resume") + if err == nil { + t.Fatal("expected refusal when not a TTY") + } + if !strings.Contains(err.Error(), "interactive terminal") { + t.Errorf("expected interactive-terminal message: %v", err) + } + if !strings.Contains(err.Error(), "ssh -t") { + t.Errorf("error should mention ssh -t: %v", err) + } + if !strings.Contains(err.Error(), "ctask resume --direct") { + t.Errorf("error should mention command-specific bypass form: %v", err) + } +} + +func TestPreflightCommandNameRendersInHints(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("path-based check covered on Unix") + } + os.Unsetenv("TMUX") + withTTYCheck(t, func() bool { return false }) + withInvocationName(t, "ctask") + _, err := preflightPersistentEntry("attach") + if err == nil || !strings.Contains(err.Error(), "ctask attach --direct") { + t.Errorf("commandName must appear in bypass hint: %v", err) + } +} + +func TestPreflightTmuxNotFound(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PATH manipulation applies on Unix here") + } + os.Unsetenv("TMUX") + withTTYCheck(t, func() bool { return true }) + orig := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", orig) }) + os.Setenv("PATH", "") + _, err := preflightPersistentEntry("resume") + if err == nil { + t.Fatal("expected refusal when tmux missing") + } + if !strings.Contains(err.Error(), "tmux is not installed") { + t.Errorf("expected install-hint message: %v", err) + } +} + +// Confirm the helper returns the validated tmux path on the happy path so +// callers can pass it through LaunchOpts.TmuxPath without re-resolving. +func TestPreflightSuccessReturnsTmuxPath(t *testing.T) { + if _, _, err := shell.LookupTmux(); err != nil { + if errors.Is(err, shell.ErrTmuxNotFound) || errors.Is(err, shell.ErrTmuxTooOld) { + t.Skip("tmux not adequate on this host") + } + } + if runtime.GOOS == "windows" { + t.Skip("happy-path test on WSL/Linux only") + } + os.Unsetenv("TMUX") + withTTYCheck(t, func() bool { return true }) + tmuxPath, err := preflightPersistentEntry("resume") + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if tmuxPath == "" { + t.Error("expected non-empty tmuxPath on success") + } +} + +func TestConfirmFreshRemoteAdoptionRefusesOnNonTTY(t *testing.T) { + wsDir := t.TempDir() + // Write a fresh remote lease so the prompt has data to display. + other := "remote-host-xyz" + l := &session.Lease{ + SessionID: "x", Hostname: other, + StartedAt: time.Now().UTC(), LastHeartbeatAt: time.Now().UTC(), + } + if err := session.WriteLease(session.LeasePath(wsDir), l); err != nil { + t.Fatalf("WriteLease: %v", err) + } + + withTTYCheck(t, func() bool { return false }) + err := confirmFreshRemoteAdoption(wsDir) + if err == nil { + t.Fatal("expected refusal on non-TTY") + } + if !strings.Contains(err.Error(), other) { + t.Errorf("error should name the remote host: %v", err) + } +} diff --git a/cmd/resume.go b/cmd/resume.go index 62bb63b..b738dcd 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -8,7 +8,6 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" - "github.com/warrenronsiek/ctask/internal/session" "github.com/warrenronsiek/ctask/internal/shell" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -26,6 +25,7 @@ var ( resumeShell bool resumeAgent string resumeForce bool + resumeDirect bool ) func init() { @@ -33,36 +33,38 @@ func init() { resumeCmd.Flags().BoolVar(&resumeShell, "shell", false, "Open shell instead of agent") resumeCmd.Flags().StringVarP(&resumeAgent, "agent", "a", "", "Override agent command") resumeCmd.Flags().BoolVar(&resumeForce, "force", false, "Skip active-session and stale-workspace warnings") + resumeCmd.Flags().BoolVar(&resumeDirect, "direct", false, "Bypass persistent session mode for this command") resumeCmd.ValidArgsFunction = completeWorkspaces(completionActive) rootCmd.AddCommand(resumeCmd) } func runResume(cmd *cobra.Command, args []string) error { - return doResume(args[0], resumeContainer, resumeShell, resumeForce, resumeAgent) + return doResume(args[0], resumeContainer, resumeShell, resumeForce, resumeAgent, resumeDirect) } -// doResume is the shared resume logic used by both resume and last commands. -func doResume(query string, container, useShell, force bool, agentOverride string) error { +// doResume is the shared resume logic used by both `resume` and `last`. +// It preserves resume's existing archive-inclusive resolution and +// restore-hint behavior, then delegates to runWorkspaceEntry for the +// persistent-vs-direct decision and tmux dispatch. +func doResume(query string, container, useShell, force bool, agentOverride string, directFlag bool) error { if container { fmt.Println(shell.ContainerNotice()) return nil } roots := config.SearchRoots() - // v0.5.2: resolve archived-inclusive so we can give a helpful hint when - // the user resumes an archived workspace. resolveOne still handles - // not-found and ambiguity exactly as before — this only changes which - // workspaces are reachable, not how lookup failures are reported. + // resume resolves archived-inclusive so we can give a helpful hint when + // the user resumes an archived workspace (v0.5.2 behavior — preserved). ws := resolveOne(roots, query, true) if ws.Meta.Status == "archived" { fmt.Fprintf(os.Stderr, - "[ctask] error: workspace %q is archived\n\nTo restore it:\n ctask restore %s\n", - query, query) + "[ctask] error: workspace %q is archived\n\nTo restore it:\n %s restore %s\n", + query, invocationName(), query) return fmt.Errorf("workspace archived") } - // Update updated_at + // updated_at bump (existing v0.4 behavior). now := time.Now().UTC().Truncate(time.Second) ws.Meta.UpdatedAt = now metaPath := filepath.Join(ws.Path, "task.yaml") @@ -75,16 +77,14 @@ func doResume(query string, container, useShell, force bool, agentOverride strin agent = ws.Meta.Agent } - envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, ws.Root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta), ws.Meta.LaunchDir) - - return session.Run(session.LaunchOpts{ - WsDir: ws.Path, - EnvVars: envVars, - Agent: agent, - Mode: ws.Meta.Mode, - Slug: ws.Meta.Slug, - Shell: useShell, - LaunchDir: ws.Meta.LaunchDir, - Force: force, + return runWorkspaceEntry(WorkspaceEntryOptions{ + WsPath: ws.Path, + WsRoot: ws.Root, + WsMeta: ws.Meta, + Agent: agent, + Shell: useShell, + Force: force, + Direct: directFlag, + CommandName: "resume", }) } diff --git a/cmd/resume_persistent_test.go b/cmd/resume_persistent_test.go new file mode 100644 index 0000000..c7afd97 --- /dev/null +++ b/cmd/resume_persistent_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// Tests in this file mutate runWorkspaceEntry. Do not run with t.Parallel(). + +// TestRunResumeForwardsToEntryHelperWithCommandNameResume verifies that +// `doResume` constructs the correct WorkspaceEntryOptions: CommandName +// "resume", Shell mirrors --shell, Direct mirrors --direct. We stub +// runWorkspaceEntry to capture the opts and use the resume_test.go fixture +// pattern (writing task.yaml on disk) so resolveOne returns a real +// workspace. +func TestRunResumeForwardsToEntryHelperWithCommandNameResume(t *testing.T) { + root := t.TempDir() + wsDir := filepath.Join(root, "general", "2026-05-08_resume-fwd-demo") + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "resume-fwd-demo", Title: "resume-fwd-demo", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + t.Cleanup(func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }) + + var captured WorkspaceEntryOptions + orig := runWorkspaceEntry + runWorkspaceEntry = func(opts WorkspaceEntryOptions) error { + captured = opts + return nil + } + t.Cleanup(func() { runWorkspaceEntry = orig }) + + if err := doResume("resume-fwd-demo", false, true /*shell*/, false, "" /*agent*/, true /*direct*/); err != nil { + t.Fatalf("doResume: %v", err) + } + if captured.CommandName != "resume" { + t.Errorf("CommandName: got %q, want %q", captured.CommandName, "resume") + } + if !captured.Shell { + t.Error("Shell should mirror --shell=true") + } + if !captured.Direct { + t.Error("Direct should mirror --direct=true") + } + if captured.WsMeta == nil || captured.WsMeta.Slug != "resume-fwd-demo" { + t.Errorf("WsMeta not propagated: %+v", captured.WsMeta) + } + if captured.WsPath != wsDir { + t.Errorf("WsPath: got %q, want %q", captured.WsPath, wsDir) + } +} diff --git a/cmd/resume_test.go b/cmd/resume_test.go index 4a6b2c2..ecae37f 100644 --- a/cmd/resume_test.go +++ b/cmd/resume_test.go @@ -33,7 +33,7 @@ func callDoResumeArchived(t *testing.T, root, query string) (stderr string, err os.Stderr = errW defer func() { os.Stderr = prevStderr }() - err = doResume(query, false, false, false, "") + err = doResume(query, false, false, false, "", false) errW.Close() var buf bytes.Buffer buf.ReadFrom(errR) @@ -41,6 +41,12 @@ func callDoResumeArchived(t *testing.T, root, query string) (stderr string, err } func TestResumeArchivedWorkspaceShowsRestoreHint(t *testing.T) { + // Pin the binary name surfaced in user-facing hints so the substring + // assertion below ("ctask restore resume-archived") is stable across + // Go test binary naming. + invocationNameOverride = "ctask" + t.Cleanup(func() { invocationNameOverride = "" }) + root := t.TempDir() wsDir := filepath.Join(root, "general", "2026-04-22_resume-archived") os.MkdirAll(wsDir, 0755) diff --git a/cmd/root.go b/cmd/root.go index f167c5c..fcf5d8a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -var version = "0.5.2" +var version = "0.5.3" var rootCmd = &cobra.Command{ Use: "ctask", diff --git a/docs/commands.md b/docs/commands.md index bdad0f0..d9ec3d2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -470,3 +470,107 @@ The metadata write lock still serializes all ctask-owned file writes regardless | 1 | General error (multiple matches, not found, invalid args, doctor failure) | | 2 | Missing required argument | | 127 | Agent command not found | + +--- + +## Persistent Session Mode (tmux) + +ctask v0.5.3+ supports an opt-in persistent session mode where workspace entry +runs inside a deterministic per-workspace tmux session. Multiple terminals +(local + SSH) attach to the same session, the agent and shell state survive +terminal disconnection, and the v0.4 lifecycle protections continue to apply +with one ctask process owning the workspace lifecycle while terminal +connections come and go. + +### Enabling persistent mode + +```bash +export CTASK_SESSION_MODE=persistent # in ~/.bashrc or equivalent +``` + +When unset or set to `direct`, ctask behaves as in v0.5.2 (no behavior change). + +Persistent mode requires: +- tmux 3.0+ on PATH (install via `apt install tmux`, `brew install tmux`, `pacman -S tmux`, or `dnf install tmux`) +- An interactive terminal (over SSH, use `ssh -t`) +- Not running inside an existing tmux session +- A Unix-like host or WSL — native Windows is not supported (use WSL) + +### Three entry paths + +When you run a persistent-mode entry command (`new`, `resume`, `last`, `open`, or `attach`), ctask picks one of three paths: + +1. **Owner-create** — no tmux session exists for this workspace yet. The command behaves like the direct path but launches the agent inside a new tmux session named `ctask---`. +2. **Passive reattach** — a tmux session exists and a fresh local lease is heartbeating. The command attaches the user's terminal to the existing session and exits when the user detaches. No lease writes, no manifest, no finalize — the original ctask owner is still managing the workspace. +3. **Adopted reattach** — a tmux session exists but the lease is missing, stale, or from another host (the original owner died). The command transfers ownership to itself, captures a fresh start manifest, starts heartbeating, attaches the terminal, and runs finalize when the session ends. + +### `ctask attach ` + +`ctask attach` always uses tmux regardless of `CTASK_SESSION_MODE`. Useful when you have not enabled persistent mode globally but want tmux for one workspace, or when shell scripts need unambiguous behavior. + +```bash +ctask attach promptvolley-v3 +``` + +The same three paths apply. + +### `--direct` bypass flag + +`new`, `resume`, `last`, and `open` accept `--direct` to bypass persistent mode for one invocation. When a persistent tmux session exists for the workspace, ctask prompts: + +``` +A persistent tmux session exists for this workspace: + ctask-projects-promptvolley-v3-a8f3c2 + +Opening a direct-mode shell may create conflicting workspace activity. +The recommended path is: + ctask attach promptvolley-v3 + +Continue with --direct anyway? [y/N] +``` + +`--direct` is a no-op under direct mode (allows scripts to use it defensively). + +### Doctor + +`ctask doctor` reports: + +``` +[INFO] Session mode: persistent +[INFO] tmux found: tmux 3.4 (/usr/bin/tmux) +``` + +or, on misconfiguration: + +``` +[INFO] Session mode: persistent +[FAIL] tmux not found on PATH + Fix: install tmux 3.0+ (apt/brew/pacman/dnf), or unset CTASK_SESSION_MODE +``` + +### Workflow examples + +**Local development** + +```bash +export CTASK_SESSION_MODE=persistent + +ctask new --project promptvolley-v3 +# -> workspace created, tmux session ctask-projects-promptvolley-v3-a8f3c2 started, attached. + +# Detach with Ctrl-B d. Terminal returns; tmux session keeps running. + +ctask resume promptvolley-v3 +# -> passive reattach. Same Claude Code session, scrollback intact. +``` + +**Remote access via SSH** + +```bash +ssh -t warren-desktop # -t is required +ctask resume promptvolley-v3 # -> passive reattach (concurrent with desktop) +``` + +### Native Windows note + +Persistent mode is not supported on native Windows (PowerShell). Run ctask under WSL and install tmux there. diff --git a/docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-checklist.md b/docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-checklist.md new file mode 100644 index 0000000..a04b8c7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-checklist.md @@ -0,0 +1,677 @@ +# v0.5.3 Manual Smoke-Test Checklist (WSL + Native Windows) — v2 + +**Branch:** `feat/v0.5.3-persistent-session-mode` +**Build artifact for WSL:** `dist/ctask-linux-amd64` + +This v2 checklist fixes two issues from v1: + +1. **Three explicit terminals.** Each command labels which terminal it goes + in. Persistent mode locks the foreground terminal in a polling loop, so + subsequent commands MUST use a different terminal. +2. **Realistic "what you'll see" notes.** The `[ctask] created ...` banner + is painted-over by tmux's alternate-screen mode within ~50ms. Inside + tmux you only see a bash prompt and a status bar — that is correct. + +--- + +## Terminals you'll need + +Open all three at the start. They are referenced by label throughout. + +| Label | What | Used for | +|-------|------|----------| +| **WSL-A** | A WSL `debian-dev` terminal | The "foreground ctask" terminal: every `ctask new`/`resume`/`attach` invocation runs here. After tmux attach, this terminal is locked in the persistent-mode polling loop until the tmux session ends or you Ctrl-C. | +| **WSL-B** | A SECOND WSL `debian-dev` terminal | For `tmux ls`, `pgrep`, `kill`, and SECONDARY `ctask resume` invocations that exercise passive reattach concurrent with the WSL-A owner. Always run with the same `CTASK_ROOT` and `PATH` exports as WSL-A (see Setup S2). | +| **PS-C** | A Windows PowerShell 7 terminal | Only for the native-Windows refusal test in section 10. | + +> **Important:** If at any point in WSL-A you see your bash prompt but the +> shell appears unresponsive (won't run `ctask ls` etc), you're inside +> tmux's outer screen with the foreground `ctask` process still polling. +> Open WSL-B for next commands, or Ctrl-C in WSL-A to kill `ctask` (which +> is what you want for the adopted-reattach test). + +--- + +## Setup + +### S1. (WSL-A) Stage the binary + +`claude` is not installed in your `debian-dev` distro, so the smoke tests +substitute `bash` as the agent via `--agent bash`. The point of these +tests is the ctask + tmux dispatch — the agent is just "a thing that runs +inside tmux." + +Run in **WSL-A**: + +```bash +mkdir -p ~/.local/bin +cp /mnt/c/Users/Warren/claude_tasks/ctask/dist/ctask-linux-amd64 ~/.local/bin/ctask +chmod +x ~/.local/bin/ctask +export PATH="$HOME/.local/bin:$PATH" +ctask --version # Expect: ctask v0.5.3 +which tmux && tmux -V # Expect: /usr/bin/tmux + tmux 3.5a (or similar) +``` + +### S2. (WSL-A and WSL-B both) Set the smoke-test root and session mode + +Run in **BOTH WSL-A AND WSL-B** (a fresh shell doesn't inherit env vars, and +`CTASK_SESSION_MODE` is the **only** trigger for persistent mode — without it +in WSL-B, the secondary `ctask resume` in section P1 falls back to direct mode +and hits the v0.4 "Continue anyway?" lease prompt instead of passive reattach): + +```bash +export PATH="$HOME/.local/bin:$PATH" +export CTASK_ROOT=/tmp/ctask-053-smoke +export CTASK_SESSION_MODE=persistent +mkdir -p "$CTASK_ROOT" +``` + +This keeps your real workspaces untouched — cleanup is one `rm -rf` later. + +--- + +## Owner-create path (Step 3) + +### O1. (WSL-A) Create the project under persistent mode + +`CTASK_SESSION_MODE=persistent` is already exported in both terminals from S2. + +Run in **WSL-A**: + +```bash +ctask new --project --agent bash ctask-053-smoke +``` + +**What you'll see (and the expected reality, not the misleading v1 text):** + +The `YYYY-MM-DD` shown below is **today's local-time date** (v0.5.1 — the +workspace directory uses `time.Now()`, not UTC). If you're running this +checklist on 2026-05-14, the path will read `2026-05-14_ctask-053-smoke`, +not `2026-05-08_ctask-053-smoke` (which was the day the checklist was +written). + +- For ~50ms, the outer terminal will print three lines (you may not have + time to read them — tmux clears the screen on attach): + - `[ctask] created projects/YYYY-MM-DD_ctask-053-smoke` + - `[ctask] local :: ctask-053-smoke` + - `[ctask] /tmp/ctask-053-smoke/projects/YYYY-MM-DD_ctask-053-smoke` + - `[ctask] project dir: ctask-053-smoke/` +- Then tmux's alternate screen takes over. You see: + - A bash prompt like `warren@DESKTOP-VGJVN77:/tmp/ctask-053-smoke/projects/YYYY-MM-DD_ctask-053-smoke/ctask-053-smoke$` + (the path includes the project subdir — that's the v0.5 `launch_dir`). + - A green status bar at the bottom showing something like + `[ctask-projects-ctask-053-smoke-XXXXXX:bash*]` on the left, with + your hostname and time on the right. **tmux truncates long session + names**: you may see only `[ctask-pro0:bash*]` when the bar is narrow + — that's normal, not a bug. + +**This is the expected screen.** No "[ctask] adopting orphaned..." line +appears (that's adoption, not owner-create). The banner you see flashing +will be in the outer-terminal scrollback after you fully exit tmux — +checking it now is not necessary. + +PASS / FAIL: ___ + +### O2. (Inside tmux in WSL-A) Detach without exiting + +Press `Ctrl-b d` (the default tmux prefix is Ctrl-b, then `d` for detach). + +**What you'll see:** + +- The tmux full-screen alternate display closes. Your outer WSL-A terminal + is restored. You'll see something like: + - The `[ctask] created projects/...` line and the banner lines printed + earlier (they're in scrollback). + - A line like `[detached (from session ctask-projects-ctask-053-smoke-XXXXXX)]`. +- **The shell prompt in WSL-A does NOT return.** The cursor sits on a new + line and your typed input is ignored. This is correct: the foreground + `ctask new` process is now in its persistent-mode polling loop, waiting + for the tmux session to actually end. +- This is why you need WSL-B for the next steps. Do NOT Ctrl-C in WSL-A + yet — that would kill the heartbeat and create an orphan, which we + exercise later. + +PASS / FAIL: ___ + +### O3. (WSL-B) Verify the tmux session is alive + +Switch to **WSL-B**, then run: + +```bash +tmux ls +``` + +**Expected:** A line containing `ctask-projects-ctask-053-smoke-XXXXXX` +followed by `(1 windows) ...`. The line may include `(attached)` if a +client is still connected (WSL-A's `ctask new` is still attaching to the +session at this point), or no marker at all if no client is connected. +**tmux does not emit a literal `(detached)` token** — the absence of +`(attached)` is how you know nothing is attached. + +PASS / FAIL: ___ + +--- + +## Passive reattach (Step 4) + +### P1. (WSL-B) Reattach via resume + +WSL-A is still locked in the polling loop. The reattach happens from a +DIFFERENT process. Run in **WSL-B**: + +```bash +ctask resume ctask-053-smoke +``` + +**What you'll see (passive reattach behavior):** + +- Sub-second attach. +- The same bash prompt as before. Scrollback inside tmux is intact (you + can scroll up via `Ctrl-b [` then PageUp). +- **No `[ctask] adopting orphaned ...` line.** That's the discriminator + between passive reattach and adoption. +- **No new banner.** Passive reattach does not print one — only owner-create + and adoption do. + +PASS / FAIL: ___ + +### P2. (Inside tmux in WSL-B) Detach again + +Press `Ctrl-b d` in WSL-B. + +**What you'll see (passive-reattach detach behavior — different from O2):** + +- The tmux full-screen alternate display closes; WSL-B's outer terminal is + restored. +- **WSL-B's shell prompt returns immediately.** Unlike WSL-A's owner-create + path, passive reattach does NOT enter a polling loop on detach. + `AttachExisting` (`internal/session/attach.go`) only calls + `shell.AttachSession` — no `PollSessionEnd`, no finalize. Passive viewers + detach freely because the **owner** is the one responsible for finalize + when the session ends. +- The owner in WSL-A is still locked in its polling loop on the same tmux + session; that's correct. + +PASS / FAIL: ___ + +--- + +## Adopted reattach (Step 5) + +The previous owner (the WSL-A `ctask new` process) needs to "die" while +the tmux session itself stays alive. Two clean ways to do this: + +- **Easiest:** Press Ctrl-C in WSL-A. This SIGINT-kills the foreground + ctask process; its heartbeat stops; the tmux session keeps running. +- **Equivalent via pgrep+kill from a third place:** what v1 said. We use + Ctrl-C here to keep things simple. + +### A1. (WSL-A) Kill the owner ctask + +Switch to **WSL-A** (still showing `[detached (from session ...)]` and a +non-responsive cursor) and press `Ctrl-C`. + +**What you'll see:** + +- WSL-A's shell prompt finally returns. + +(No Ctrl-C needed in WSL-B — passive reattach already exited cleanly after +the P2 detach; WSL-B has been at its shell prompt since then.) + +### A2. (WSL-A) Verify the tmux session survived + +```bash +tmux ls +``` + +**Expected:** still shows `ctask-projects-ctask-053-smoke-XXXXXX: 1 windows ...`. +No `(attached)` marker now (both clients have detached). As noted in O3, +tmux does not print a literal `(detached)` token; absence of `(attached)` +is the signal. + +### A3. (WSL-A) Wait for the lease to go stale + +The freshness threshold is 60 seconds (`internal/session/heartbeat.go:15`). + +```bash +date; sleep 65; date +``` + +### A4. (WSL-A) Reattach — adoption should fire + +```bash +ctask resume ctask-053-smoke +``` + +**What you'll see (the discriminating line):** + +``` +[ctask] adopting orphaned persistent session (previous owner exited without finalizing) +``` + +— printed to stderr BEFORE tmux's alternate screen takes over. Then +you're inside tmux with the same bash session. + +PASS / FAIL: ___ + +### A5. (Inside tmux in WSL-A) End the session + +Type at the bash prompt: + +```bash +exit +``` + +That exits bash. With no remaining processes, tmux's window has nothing +to display, and the session ends. tmux's alternate screen closes; WSL-A's +outer terminal is restored. + +Wait ~3 seconds (the polling cadence) — the foreground `ctask resume` +detects session end and runs adoption finalize. WSL-A's prompt returns. + +### A6. (WSL-A) Inspect the summary + +The workspace directory uses **today's local-time date** (v0.5.1). Use a +glob so this works on any day: + +```bash +WS=$(ls -d "$CTASK_ROOT"/projects/*_ctask-053-smoke 2>/dev/null | head -1) +echo "$WS" # expect: /tmp/ctask-053-smoke/projects/YYYY-MM-DD_ctask-053-smoke +cat "$WS/.ctask/last-session-summary.json" | python3 -m json.tool +``` + +**Expected fields present:** + +```json +"end_reason": "tmux_session_ended", +"detected_via": "polling", +"session_ownership": "adopted", +"adopted_from_orphan_at": "T..." +``` + +(Other fields like session_id, hostname, files_added etc. will also be +present — those are unchanged from v0.4.) + +PASS / FAIL: ___ + +--- + +## Non-TTY refusal (Step 6) + +### T1. (WSL-A) Pipe stdin to break the TTY check + +Run in **WSL-A** (now back at a normal prompt): + +```bash +echo "" | ctask resume ctask-053-smoke +``` + +**Expected:** A multi-line refusal message containing **(substring match, +not exact)** all three of: +- `ctask persistent mode requires an interactive terminal` +- `ssh -t ctask resume ` +- `resume --direct` + +Wording around these phrases (headings like "Over SSH, use:", a trailing +period, etc.) is incidental — PASS as long as all three substrings are +present. + +> **Note on the bypass hint:** the binary name in the printed bypass +> line reflects `basename(os.Args[0])`. On this WSL setup that resolves +> to `ctask` (so the line reads `ctask resume --direct`, +> matching the third substring). On Windows when running a local build +> as `.\ctask.exe` it resolves to `ctask.exe` (line reads +> `ctask.exe resume --direct`). Both forms satisfy the +> "resume --direct" substring above. + +Exit code: 1 (`echo $?` to verify). + +PASS / FAIL: ___ + +### T2. (Optional, WSL-A) SSH-without-t equivalent + +**Skip with N/A if `ssh localhost` fails with "Connection refused"** — +that just means sshd isn't running on this WSL distro (the common case). +The test only exercises the same non-TTY refusal path that T1 already +covers, via a different no-TTY transport; not running it does not gate +v0.5.3 sign-off. + +```bash +ssh localhost -- bash -lc "PATH=\$HOME/.local/bin:\$PATH CTASK_SESSION_MODE=persistent CTASK_ROOT=$CTASK_ROOT ctask resume ctask-053-smoke" +``` + +**Expected:** Same refusal message. (No `-t` means no TTY allocation.) + +PASS / FAIL / N/A: ___ + +--- + +## Nested tmux refusal (Step 7) + +Run in **WSL-A**: + +```bash +TMUX=fake-value-not-a-real-tmux ctask resume ctask-053-smoke +``` + +**Expected:** Refusal containing: +- `ctask persistent mode cannot attach while already inside tmux` +- `ctask resume --direct` + +PASS / FAIL: ___ + +--- + +## --direct confirmation (Step 8) + +### D1. (WSL-A) Recreate a tmux session for the workspace + +You killed the previous session in A5 by `exit`-ing bash. Recreate it +with the easiest path: `ctask resume`. Run in **WSL-A**: + +```bash +ctask resume --agent bash ctask-053-smoke +``` + +(Inside tmux now.) Detach with `Ctrl-b d`. WSL-A is locked in polling +loop again — that's fine. + +In **WSL-B** verify: + +```bash +tmux ls # Expected: ctask-projects-ctask-053-smoke-... listed +``` + +### D2. (WSL-B) Try opening with --direct + +While the tmux session exists, run in **WSL-B**: + +```bash +ctask open --direct ctask-053-smoke +``` + +**Expected (interactive Y/N prompt):** + +``` +A persistent tmux session exists for this workspace: + ctask-projects-ctask-053-smoke-XXXXXX + +Opening a direct-mode shell may create conflicting workspace activity. +The recommended path is: + ctask attach ctask-053-smoke + +Continue with --direct anyway? [y/N] +``` + +Type `n` then Enter. Expected: exits with `Error: canceled by user`. + +### D3. (WSL-B) Confirm --direct then bypass + +```bash +ctask open --direct ctask-053-smoke +``` + +Type `y` then Enter. Expected: the --direct prompt is bypassed, then the +v0.4 active-session warning fires (an existing tmux session means there's +also an active lease). At the second prompt you can answer either way — +the goal is to confirm the --direct prompt fired and was acceptable. + +If you answered `y` to the second prompt, you'll have a non-tmux bash +shell open in WSL-B. `exit` to close it. + +### D4. (WSL-A) End the tmux session for cleanup + +The tmux session from D1 is still running and WSL-A is still locked in +its polling loop. Two ways to end it: + +**Option 1 (clean):** From **WSL-B**: + +```bash +ctask attach ctask-053-smoke # rejoins the tmux session via the always-tmux path +exit # exits bash inside tmux → session ends +``` + +WSL-A's polling loop notices the session end after ~3s; finalize runs; +WSL-A's prompt returns. + +**Option 2 (lazy):** Press Ctrl-C in WSL-A. Then in **WSL-B**: + +```bash +tmux kill-session -t $(tmux ls | grep ctask-053-smoke | head -1 | cut -d: -f1) +``` + +PASS / FAIL: ___ + +--- + +## tmux missing — workspace NOT created (Step 9) + +This is the most important refusal: a missing tmux must not leave a +half-initialized workspace on disk. + +### M1. (WSL-A) Hide tmux from PATH (carefully) + +We need `~/.local/bin/ctask` reachable but `tmux` NOT findable on PATH. + +On modern Debian / Ubuntu / WSL distros (the "usrmerge" change), `/bin` is +a symlink to `/usr/bin`, so listing `/bin` on PATH still surfaces tmux. +The cleanest fix is to set PATH to only `~/.local/bin`. + +A separate gotcha: once PATH is reduced this far, the `which` utility +itself is no longer reachable (it lives in `/usr/bin`). Use the POSIX +shell built-in `command -v` instead — it's a bash built-in, so it works +regardless of PATH. + +```bash +ORIG_PATH=$PATH +export PATH="$HOME/.local/bin" # only ctask reachable; nothing else +command -v tmux && echo "ERROR: tmux still on PATH" # expect: NO output (no path, no ERROR) +command -v ctask # expect: /home//.local/bin/ctask +``` + +ctask is a pure-Go binary that does not shell out to anything except tmux, +so a PATH containing only its own directory is sufficient for the M2 / M3 +refusal-path check. The earlier `PATH=$HOME/.local/bin:/bin` form and the +`which` invocation were both checklist bugs — neither correctly verifies +that tmux is hidden on a usrmerge system with a minimal PATH. + +If `command -v tmux` still prints a path (e.g., `~/.local/bin/tmux`), some +copy of tmux remains reachable — remove or rename that file for the +duration of this section. + +### M2. (WSL-A) Try `ctask new` with persistent mode + +```bash +ctask new --project --agent bash ctask-053-no-tmux +``` + +**Expected (refusal BEFORE workspace.Create):** + +``` +Error: ctask is configured for persistent sessions, but tmux is not installed. + +Install tmux: + Debian/Ubuntu/WSL: sudo apt install tmux + macOS: brew install tmux + ... + +Or bypass persistent mode for this command: + ctask new --direct + +To disable persistent mode: + unset CTASK_SESSION_MODE +``` + +(The binary name in the bypass line is `basename(os.Args[0])` — in WSL +with an installed `~/.local/bin/ctask` on PATH, the shell-resolved +absolute path basenames to `ctask`, so the printed line matches the form +shown above.) + +### M3. (WSL-A) Restore PATH + +Restore PATH **before** the directory-listing verification — under the +minimal PATH from M1, `/usr/bin/ls` isn't reachable and the verification +silently fails (bash prints "ls: command not found" to stderr, which a +naive `2>/dev/null` would swallow). + +```bash +export PATH="$ORIG_PATH" +command -v tmux # Expected: an absolute path to tmux, + # e.g. /usr/bin/tmux OR /bin/tmux — both are + # the same file on usrmerge systems where + # /bin is a symlink to /usr/bin. +``` + +### M4. (WSL-A) Verify NO workspace was created + +```bash +ls "$CTASK_ROOT/projects/" +``` + +**Expected:** only the `YYYY-MM-DD_ctask-053-smoke` directory created +earlier in this run, NO `ctask-053-no-tmux` and NO +`YYYY-MM-DD_ctask-053-no-tmux`. (If you ran the older version of this +step under the minimal PATH and got "no output", that was the +`ls`-not-on-PATH bug — it does not mean the workspace was created +silently. Redo after the PATH restore.) + +PASS / FAIL: ___ + +--- + +## Native Windows refusal (Step 10) + +Run in **PS-C** (Windows PowerShell 7, NOT WSL). + +> **Important:** run **each numbered block below as its own input** — +> press Enter after each one and let PowerShell complete it before +> typing the next. Do NOT paste all five at once. With PSReadLine / +> Oh-My-Posh, multi-line paste is parsed as a single script block, and +> the execution order can confuse the user (PowerShell will happily run +> `.\ctask.exe` before `cd` has taken effect, producing "term not +> recognized" plus "go.mod not found" errors). + +### Step 10a. cd into the repo + +```powershell +cd C:\Users\Warren\claude_tasks\ctask +``` + +Verify with `Get-Location` — expect the path to end in `\ctask`. + +### Step 10b. Build the Windows binary + +```powershell +go build -o ctask.exe . +``` + +Expect no output (success). The repo's `go.mod` lives at the root, so +this only works after step 10a. + +### Step 10c. Set the persistent-mode env vars + +```powershell +$env:CTASK_SESSION_MODE = "persistent" +$env:WSL_DISTRO_NAME = $null +``` + +### Step 10d. Trigger the native-Windows refusal + +```powershell +.\ctask.exe new --no-launch ctask-053-windows +``` + +**Expected:** + +``` +Error: ctask persistent mode requires tmux, which is not supported on native Windows. + +Recommended: + Run ctask from WSL and install tmux there: + sudo apt install tmux + +Or bypass persistent mode: + ctask.exe new --direct +``` + +The bypass line reflects `basename(os.Args[0])` — running this section as +`.\ctask.exe` means the printed binary name is `ctask.exe`. (Running an +installed `ctask` would print `ctask.exe` too on Windows, since the OS +resolves it to the same `.exe`.) + +NO workspace created (verify under `%USERPROFILE%\ai-workspaces\` — +typical location for default Windows installs; the `ctask-053-windows` +directory should not exist). + +### Step 10e. --direct under persistent on Windows + +```powershell +.\ctask.exe new --no-launch --direct ctask-053-win-direct +``` + +**Expected:** workspace created with one warning line: +`[ctask] warning: --direct bypassing persistent mode (no tmux session exists for this workspace)` + +### Step 10f. Cleanup PS-C state + +```powershell +$leftover = Get-ChildItem -Path "$env:USERPROFILE\ai-workspaces\general" -Filter "*ctask-053-win-direct*" -ErrorAction SilentlyContinue +if ($leftover) { Remove-Item -Recurse -Force $leftover.FullName } +$env:CTASK_SESSION_MODE = $null +Remove-Item ctask.exe -ErrorAction SilentlyContinue +``` + +PASS / FAIL: ___ + +--- + +## Doctor output (Step 11) + +Run in **WSL-A**: + +```bash +unset CTASK_SESSION_MODE +ctask doctor 2>&1 | grep -E "Session mode|tmux" +``` + +**Expected:** `[INFO] Session mode: direct (tmux not required)` + +```bash +CTASK_SESSION_MODE=persistent ctask doctor 2>&1 | grep -E "Session mode|tmux" +``` + +**Expected (two lines):** +``` +[INFO] Session mode: persistent +[INFO] tmux found: tmux 3.5a (/usr/bin/tmux) +``` + +PASS / FAIL: ___ + +--- + +## Cleanup (Step 12) + +In **WSL-A** (or WSL-B, doesn't matter): + +```bash +unset CTASK_SESSION_MODE +# Kill any leftover tmux sessions from smoke testing: +tmux ls 2>/dev/null | grep '^ctask-' | cut -d: -f1 | xargs -r -I{} tmux kill-session -t {} +# Wipe the smoke-test root: +rm -rf "$CTASK_ROOT" +unset CTASK_ROOT +# Optionally remove the WSL-staged binary: +rm -f ~/.local/bin/ctask +``` + +In **PS-C** (already cleaned up at end of section 10). + +--- + +## Reporting back + +When complete, paste me PASS/FAIL for each labeled section. If everything +passes, I'll merge `feat/v0.5.3-persistent-session-mode` to `main` and +you can `just install` and `git tag v0.5.3`. If anything fails, paste the +exact output and I'll investigate. diff --git a/docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-log.md b/docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-log.md new file mode 100644 index 0000000..1a7cdc2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-log.md @@ -0,0 +1,206 @@ +# v0.5.3 Smoke Test Log + +Captured during execution of `docs/superpowers/plans/2026-05-08-v0.5.3-implementation.md`, +Task 17. Per user constraint #5: each smoke step must record its result. + +Date: 2026-05-08 +Branch: `feat/v0.5.3-persistent-session-mode` + +## Step 1 — Build for both platforms + +| Build | Result | +|-------|--------| +| `go build ./...` (Windows host) | PASS — no errors | +| `just build-linux` (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) | PASS — `dist/ctask-linux-amd64` produced; `file` reports "ELF 64-bit LSB executable, x86-64, statically linked" | + +## Step 2 — Run full test suite + +`go test ./... -count=1` on Windows host: + +``` +? github.com/warrenronsiek/ctask [no test files] +ok github.com/warrenronsiek/ctask/cmd ~1.93s +ok github.com/warrenronsiek/ctask/internal/config ~0.24s +ok github.com/warrenronsiek/ctask/internal/lockfile ~1.00s +ok github.com/warrenronsiek/ctask/internal/seed ~0.25s +ok github.com/warrenronsiek/ctask/internal/session ~1.80s +ok github.com/warrenronsiek/ctask/internal/shell ~0.29s +ok github.com/warrenronsiek/ctask/internal/workspace ~0.97s +``` + +`go vet ./...` clean. + +WSL has no Go toolchain installed, so `go test` cannot be run inside WSL +to exercise the Unix-only `t.Skip` paths in `cmd/persistent_test.go`. The +cross-compile success validates that the Unix-targeted code compiles; the +Unix-specific behaviors are exercised by the manual steps below. + +## Step 3 — WSL smoke test: owner-create + +NOT EXECUTED in this automated session. Reason: requires interactive TTY +and a `claude` agent binary inside the WSL distro. Manual verification by +the user is required: + +```bash +export CTASK_SESSION_MODE=persistent +ctask new --project ctask-053-smoke +# Expected: tmux session ctask-projects-ctask-053-smoke- started, attached. +# Detach with Ctrl-B d, then `tmux ls` should show the session. +``` + +## Step 4 — WSL smoke test: passive reattach + +NOT EXECUTED. Reason: same as Step 3. Manual verification: + +```bash +ctask resume ctask-053-smoke # Expected: passive reattach, scrollback intact +``` + +## Step 5 — WSL smoke test: adopted reattach + +NOT EXECUTED. Reason: same as Step 3. Manual verification: + +```bash +pgrep -f 'ctask resume ctask-053-smoke' | xargs -r kill -9 +ctask resume ctask-053-smoke +# Expected: "[ctask] adopting orphaned persistent session..." line. +# After session ends, .ctask/last-session-summary.json should contain: +# end_reason: tmux_session_ended +# session_ownership: adopted +# adopted_from_orphan_at: +``` + +The `internal/session/adopt_test.go` unit tests already validate the +race guard, UpdatedAt bump, attach error propagation, and summary field +population using stubbed seams; the smoke test is a TTY+tmux integration +check. + +## Step 6 — WSL smoke test: TTY refusal + +NOT EXECUTED. Reason: requires SSH-in-localhost with -t/no-t variants. +Manual verification: + +```bash +ssh localhost ctask resume ctask-053-smoke # Expected: refuse with TTY hint +ssh -t localhost ctask resume ctask-053-smoke # Expected: proceed +``` + +The `cmd/persistent_test.go::TestPreflightRefusesNonTTY` test already +covers the refusal logic on Unix using the `isTTYCheck` test seam. + +## Step 7 — WSL smoke test: nested tmux refusal + +NOT EXECUTED. Reason: requires interactive tmux. Manual verification: + +```bash +tmux new -d -s outer +tmux send-keys -t outer 'ctask resume ctask-053-smoke' Enter +tmux attach -t outer +# Expected: "cannot attach while already inside tmux" +``` + +The `cmd/persistent_test.go::TestPreflightRefusesNestedTmux` unit test +covers this code path on Unix via `$TMUX` env var manipulation. + +## Step 8 — WSL smoke test: tmux missing + +NOT EXECUTED in this session (would require `apt remove tmux` to validate). +The `cmd/persistent_test.go::TestPreflightTmuxNotFound` unit test covers +this path on Unix by emptying `$PATH` before invoking the helper. + +## Step 9 — WSL smoke test: --direct confirmation + +NOT EXECUTED. Reason: requires existing tmux session and interactive Y/N. +Manual verification per the plan. + +## Step 10 — Native Windows smoke test + +EXECUTED: + +``` +$env:CTASK_SESSION_MODE = "persistent"; $env:WSL_DISTRO_NAME = $null +.\ctask.exe new --no-launch native-win-test +``` + +Result: + +``` +Error: ctask persistent mode requires tmux, which is not supported on native Windows. + +Recommended: + Run ctask from WSL and install tmux there: + sudo apt install tmux + +Or bypass persistent mode: + ctask new --direct +``` + +PASS — refusal happens before workspace.Create, no half-initialized workspace +on disk. + +Also tested `--direct` bypass on native Windows (with persistent mode set): + +``` +$env:CTASK_SESSION_MODE = "persistent" +.\ctask.exe new --no-launch --direct native-win-test-direct +``` + +Result: + +``` +[ctask] created general/2026-05-08_native-win-test-direct +[ctask] warning: --direct bypassing persistent mode (no tmux session exists for this workspace) +``` + +PASS — `--direct` bypass proceeds with workspace creation, prints the +expected warning, and the resulting workspace was successfully cleaned up. + +## Step 11 — Doctor check + +EXECUTED on both platforms. + +Windows host (no env), via `go test ./cmd/ -run TestCheckTmux`: + +- `[INFO] Session mode: direct (tmux not required)` — PASS +- `[INFO] Session mode: persistent` + `[INFO] tmux found: ...` — PASS (tmux present in Windows PATH) +- `[FAIL] tmux not found ...` (PATH cleared) — PASS + +WSL `dist/ctask-linux-amd64` direct invocation: + +``` +$ ctask doctor # default (direct) +[INFO] Session mode: direct (tmux not required) + +$ CTASK_SESSION_MODE=persistent ctask doctor # persistent +[INFO] Session mode: persistent +[INFO] tmux found: tmux 3.5a (/usr/bin/tmux) +``` + +PASS on both platforms. + +## Step 12 — Cleanup + +`native-win-test-direct` workspace removed (force-removed under +`%USERPROFILE%\ai-workspaces\general\`). No other smoke-test workspaces +were created in this automated run. + +## Step 13 — Tag the release + +NOT EXECUTED. Per user constraint, tagging is left to the user after they +complete the interactive smoke steps. + +--- + +## Summary + +- All Windows-side automated checks PASS. +- All cross-platform unit tests PASS (Windows host). +- The Linux ELF binary builds, runs, and reports correct doctor state under WSL. +- The remaining smoke tests (steps 3-9) require interactive TTY + a real + `claude` agent and must be run manually by the user. The unit-test layer + covers the underlying refusal/dispatch logic on both platforms via the + `isTTYCheck` and `runWorkspaceEntry` seams; the smoke tests close the loop + on the TTY+tmux integration that CI cannot exercise. + +Branch is ready for manual smoke verification. After steps 3-9 are confirmed, +the user can `git tag v0.5.3` and proceed with `just install`. diff --git a/internal/config/config.go b/internal/config/config.go index aebcc07..94b12af 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "path/filepath" "runtime" @@ -148,6 +149,25 @@ func defaultSeedDir(leaf string) string { return filepath.Join(home, ".config", "ctask", leaf) } +// ResolveSessionMode returns "direct" or "persistent" based on CTASK_SESSION_MODE. +// Default (unset/empty) is "direct". Any other value falls back to "direct" +// after printing a stderr warning. Used by entry commands (new, resume, last, +// open) to dispatch between the standard session.Run path and the tmux-backed +// persistent path. +func ResolveSessionMode() string { + v := os.Getenv("CTASK_SESSION_MODE") + switch v { + case "", "direct": + return "direct" + case "persistent": + return "persistent" + default: + fmt.Fprintf(os.Stderr, + "[ctask] warning: CTASK_SESSION_MODE=%q is not recognized; using direct mode\n", v) + return "direct" + } +} + // expandPath expands a leading ~/ and resolves to an absolute path when possible. func expandPath(p string) string { if strings.HasPrefix(p, "~/") || p == "~" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1814897..c88e66f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,9 +1,11 @@ package config import ( + "io" "os" "path/filepath" "runtime" + "strings" "testing" ) @@ -191,3 +193,61 @@ func TestEnvVarsLaunchDirEmpty(t *testing.T) { t.Errorf("CTASK_LAUNCH_DIR: got %q, want empty", got) } } + +func TestResolveSessionModeDefault(t *testing.T) { + os.Unsetenv("CTASK_SESSION_MODE") + if got := ResolveSessionMode(); got != "direct" { + t.Errorf("default: got %q, want %q", got, "direct") + } +} + +func TestResolveSessionModeEmpty(t *testing.T) { + os.Setenv("CTASK_SESSION_MODE", "") + defer os.Unsetenv("CTASK_SESSION_MODE") + if got := ResolveSessionMode(); got != "direct" { + t.Errorf("empty: got %q, want %q", got, "direct") + } +} + +func TestResolveSessionModeDirect(t *testing.T) { + os.Setenv("CTASK_SESSION_MODE", "direct") + defer os.Unsetenv("CTASK_SESSION_MODE") + if got := ResolveSessionMode(); got != "direct" { + t.Errorf("direct: got %q, want %q", got, "direct") + } +} + +func TestResolveSessionModePersistent(t *testing.T) { + os.Setenv("CTASK_SESSION_MODE", "persistent") + defer os.Unsetenv("CTASK_SESSION_MODE") + if got := ResolveSessionMode(); got != "persistent" { + t.Errorf("persistent: got %q, want %q", got, "persistent") + } +} + +func TestResolveSessionModeUnknownFallsBackWithWarning(t *testing.T) { + os.Setenv("CTASK_SESSION_MODE", "garbage") + defer os.Unsetenv("CTASK_SESSION_MODE") + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + origStderr := os.Stderr + os.Stderr = w + defer func() { os.Stderr = origStderr }() + + got := ResolveSessionMode() + w.Close() + out, _ := io.ReadAll(r) + + if got != "direct" { + t.Errorf("unknown: got %q, want %q (fallback)", got, "direct") + } + if !strings.Contains(string(out), "garbage") { + t.Errorf("warning should echo bad value: %q", out) + } + if !strings.Contains(string(out), "not recognized") { + t.Errorf("warning should say 'not recognized': %q", out) + } +} diff --git a/internal/session/adopt.go b/internal/session/adopt.go new file mode 100644 index 0000000..3e35f3e --- /dev/null +++ b/internal/session/adopt.go @@ -0,0 +1,206 @@ +package session + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/warrenronsiek/ctask/internal/lockfile" + "github.com/warrenronsiek/ctask/internal/shell" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// adoptAttacher and adoptPoll are test seams that *wrap* shell primitives +// rather than hand-rolling tmux invocations. Production calls go through +// shell.AttachSession / shell.PollSessionEnd. Tests override these +// variables; do not run such tests with t.Parallel(). +var ( + adoptAttacher = shell.AttachSession + adoptPoll = shell.PollSessionEnd +) + +// AdoptExistingPersistentSession is the adopted-reattach path. It is +// invoked when a tmux session for the workspace exists but the lease is +// missing, stale, or fresh-but-from-another-host (the cmd-layer dispatcher +// has already prompted for confirmation in the fresh_remote case before +// calling here — see cmd/persistent.go::confirmFreshRemoteAdoption). +// +// Eight-step flow (see v0.5.3-spec.md §3 path C): +// 1. Print one diagnostic line announcing the adoption. +// 2. Acquire write lock; under lock, re-read lease state. If now +// fresh-local (concurrent adopter raced ahead), release the lock and +// fall through to AttachExisting — the race winner owns the lease. +// Otherwise, remove the orphaned lease, write a new lease for this +// process, and bump task.yaml.UpdatedAt. +// 3. Capture a fresh start manifest. Without it, finalize has no +// reliable diff baseline. +// 4. Start the heartbeat goroutine. +// 5. shell.AttachSession (returns nil on clean exit, error on non-zero). +// 6. Polling loop until tmux reports session gone — runs ONLY after a +// successful attach. +// 7. Stop the heartbeat goroutine. +// 8. finalize with SessionOwnership="adopted" and AdoptedFromOrphanAt set. +// +// On attach failure (step 5 returns error), steps 6-8 are skipped and the +// error is returned — the user sees the underlying tmux failure. +func AdoptExistingPersistentSession(tmuxPath, sessionName, wsDir string, opts LaunchOpts) error { + fmt.Fprintln(os.Stderr, + "[ctask] adopting orphaned persistent session (previous owner exited without finalizing)") + + startTime := time.Now().UTC().Truncate(time.Second) + adoptedAt := startTime + leasePath := LeasePath(wsDir) + + var raced bool + skipped, lockErr := lockfile.WithLock( + ctaskWriteLockPath(wsDir), + sessionWriteLockTimeout, sessionWriteLockStaleAfter, + func() error { + // Re-check under lock. If a concurrent adopter beat us to it, + // fall through to passive reattach. No lease writes, no + // task.yaml.UpdatedAt bump on this branch. + if InspectLease(wsDir) == LeaseStateFreshLocal { + raced = true + return nil + } + if _, rmErr := CleanupStaleLease(leasePath, StaleLeaseAfter); rmErr != nil { + fmt.Fprintf(os.Stderr, + "[ctask] warning: failed to remove orphaned lease: %v\n", rmErr) + } + lease := NewLease(startTime, opts.Agent, opts.Mode) + if err := WriteLease(leasePath, lease); err != nil { + return fmt.Errorf("writing lease: %w", err) + } + // Adoption-only: bump task.yaml.UpdatedAt. Passive reattach + // (and the race fall-through above) leave it untouched. + metaPath := filepath.Join(wsDir, "task.yaml") + if meta, err := workspace.ReadMeta(metaPath); err == nil && meta != nil { + meta.UpdatedAt = startTime + if err := workspace.WriteMeta(metaPath, meta); err != nil { + fmt.Fprintf(os.Stderr, + "[ctask] warning: failed to update task.yaml on adoption: %v\n", err) + } + } + return nil + }, + ) + if skipped { + fmt.Fprintf(os.Stderr, + "[ctask] Warning: could not acquire metadata lock; falling through to passive reattach\n") + return AttachExisting(tmuxPath, sessionName) + } + if lockErr != nil { + return fmt.Errorf("adoption lease write: %w", lockErr) + } + if raced { + return AttachExisting(tmuxPath, sessionName) + } + + // Step 3: fresh start manifest — load-bearing for finalize's diff baseline. + startManifest, err := CaptureManifest(wsDir) + if err != nil { + fmt.Fprintf(os.Stderr, + "[ctask] warning: failed to capture start manifest during adoption: %v; finalize diff will be skipped\n", err) + } else { + mPath := manifestStartPath(wsDir) + if _, lockErr := lockfile.WithLock( + ctaskWriteLockPath(wsDir), + sessionWriteLockTimeout, sessionWriteLockStaleAfter, + func() error { return WriteManifest(mPath, startManifest) }, + ); lockErr != nil { + fmt.Fprintf(os.Stderr, + "[ctask] warning: failed to write start manifest: %v\n", lockErr) + startManifest = nil + } + } + + // Step 4: heartbeat. + hb := StartHeartbeat(leasePath, HeartbeatInterval) + + // Step 5: attach. Non-zero exit is a real failure; surface it + // and skip steps 6-8 (polling and finalize). Stop the heartbeat first + // so we don't leak the goroutine. + if attachErr := adoptAttacher(tmuxPath, sessionName); attachErr != nil { + hb.Stop() + return attachErr + } + + // Step 6: poll until session ends. + adoptPoll(tmuxPath, sessionName, shell.PollInterval) + + // Step 7: stop heartbeat. + hb.Stop() + + // Step 8: finalize with adoption fields. + endTime := time.Now().UTC().Truncate(time.Second) + if startManifest != nil { + if err := finalizeAdopted(opts, wsDir, startManifest, startTime, endTime, adoptedAt); err != nil { + fmt.Fprintf(os.Stderr, "[ctask] warning: adoption finalize failed: %v\n", err) + } + } + return nil +} + +// finalizeAdopted is the adoption-specific finalize path. Mirrors +// session.Run's finalize() but stamps SessionOwnership = "adopted" and +// AdoptedFromOrphanAt onto the summary. +func finalizeAdopted(opts LaunchOpts, wsDir string, startManifest *Manifest, startTime, endTime, adoptedAt time.Time) error { + endManifest, err := CaptureManifest(wsDir) + if err != nil { + return fmt.Errorf("capturing end manifest: %w", err) + } + diff := DiffManifests(startManifest, endManifest) + + agent := opts.Agent + if opts.Shell { + agent = "shell" + } + info := &SessionInfo{ + Agent: agent, + Mode: opts.Mode, + StartTime: startTime, + EndTime: endTime, + Diff: diff, + } + + sessionID := NewSessionID(currentHostname(), os.Getpid(), startTime) + if l, err := ReadLease(LeasePath(wsDir)); err == nil && l != nil { + sessionID = l.SessionID + } + + summary := SummarizeFromDiff( + sessionID, currentHostname(), agent, opts.Mode, + startTime, endTime, diff, endManifest, + ) + summary.EndReason = "tmux_session_ended" + summary.DetectedVia = "polling" + summary.SessionOwnership = "adopted" + summary.AdoptedFromOrphanAt = &adoptedAt + + skipped, lockErr := lockfile.WithLock( + ctaskWriteLockPath(wsDir), + sessionWriteLockTimeout, sessionWriteLockStaleAfter, + func() error { + if err := AppendSessionLog(wsDir, info); err != nil { + fmt.Fprintf(os.Stderr, "[ctask] warning: append session log failed: %v\n", err) + } + if err := WriteSummary(SummaryPath(wsDir), summary); err != nil { + return fmt.Errorf("write summary: %w", err) + } + if rmErr := os.Remove(LeasePath(wsDir)); rmErr != nil && !os.IsNotExist(rmErr) { + fmt.Fprintf(os.Stderr, "[ctask] warning: could not remove lease: %v\n", rmErr) + } + if rmErr := os.Remove(manifestStartPath(wsDir)); rmErr != nil && !os.IsNotExist(rmErr) { + fmt.Fprintf(os.Stderr, "[ctask] warning: could not remove start manifest: %v\n", rmErr) + } + return nil + }, + ) + if skipped { + fmt.Fprintf(os.Stderr, + "[ctask] Warning: could not acquire metadata lock at adoption finalize; skipping summary write\n") + return nil + } + return lockErr +} diff --git a/internal/session/adopt_test.go b/internal/session/adopt_test.go new file mode 100644 index 0000000..f8e67af --- /dev/null +++ b/internal/session/adopt_test.go @@ -0,0 +1,224 @@ +package session + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// adoptionFixture wires a temp workspace with a stale lease, a task.yaml +// with a known initial UpdatedAt, and the test seams (adoptAttacher, +// adoptPoll) overridden to no-ops. Tests using this fixture must not run +// in parallel — the seams are package globals. +type adoptionFixture struct { + wsDir string + staleLease *Lease + initialUpdate time.Time + attachCalls int +} + +func newAdoptionFixture(t *testing.T) *adoptionFixture { + t.Helper() + ws := t.TempDir() + + // task.yaml with a known UpdatedAt so we can assert the adoption bump. + initial := time.Now().UTC().Add(-2 * time.Hour).Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "demo-id", Slug: "demo", Title: "demo", + CreatedAt: initial, UpdatedAt: initial, + Status: "active", Category: "projects", Type: "project", + Mode: "local", Agent: "claude", + } + if err := workspace.WriteMeta(filepath.Join(ws, "task.yaml"), meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + + stale := &Lease{ + SessionID: "host-old-1-20260101000000", + PID: 99999, + Hostname: currentHostname(), + Username: "u", + Agent: "claude", + Mode: "local", + StartedAt: time.Now().UTC().Add(-1 * time.Hour), + LastHeartbeatAt: time.Now().UTC().Add(-1 * time.Hour), + Terminal: "test", + } + if err := WriteLease(LeasePath(ws), stale); err != nil { + t.Fatalf("WriteLease: %v", err) + } + + return &adoptionFixture{wsDir: ws, staleLease: stale, initialUpdate: initial} +} + +// stubSeams installs no-op replacements for adoptAttacher, adoptPoll, and +// the `attacher` used by AttachExisting (which is on the race fall-through +// path). Tests must call t.Cleanup(restore). +func (fx *adoptionFixture) stubSeams(t *testing.T) { + t.Helper() + origA := adoptAttacher + adoptAttacher = func(_, _ string) error { fx.attachCalls++; return nil } + origP := adoptPoll + adoptPoll = func(_, _ string, _ time.Duration) {} + // AttachExisting's seam — race fall-through routes through it. + origE := attacher + attacher = func(_, _ string) error { return nil } + t.Cleanup(func() { + adoptAttacher = origA + adoptPoll = origP + attacher = origE + }) +} + +func defaultAdoptionOpts(wsDir string) LaunchOpts { + return LaunchOpts{ + WsDir: wsDir, + Agent: "claude", + Mode: "local", + Slug: "demo", + Category: "projects", + SessionMode: "persistent", + SessionName: "ctask-projects-demo-abc123", + TmuxPath: "/usr/bin/tmux", + } +} + +func TestAdoptionReplacesLeaseAndCapturesStartManifest(t *testing.T) { + fx := newAdoptionFixture(t) + fx.stubSeams(t) + + opts := defaultAdoptionOpts(fx.wsDir) + if err := AdoptExistingPersistentSession(opts.TmuxPath, opts.SessionName, fx.wsDir, opts); err != nil { + t.Fatalf("Adopt: %v", err) + } + if fx.attachCalls != 1 { + t.Errorf("expected attach call, got %d", fx.attachCalls) + } + + got, err := ReadLease(LeasePath(fx.wsDir)) + if err == nil && got != nil && got.SessionID == fx.staleLease.SessionID { + t.Error("lease still has stale SessionID — adoption did not replace it") + } + // finalize removes the lease at end-of-session; either nil-or-different is acceptable. + + mfPath := filepath.Join(fx.wsDir, ".ctask", "manifest-start.json") + if _, err := os.Stat(mfPath); err == nil { + t.Errorf("expected start manifest removed by finalize at %s", mfPath) + } + + summary, err := ReadSummary(SummaryPath(fx.wsDir)) + if err != nil { + t.Fatalf("ReadSummary: %v", err) + } + if summary == nil { + t.Fatal("summary not written") + } + if summary.SessionOwnership != "adopted" { + t.Errorf("SessionOwnership: got %q, want %q", summary.SessionOwnership, "adopted") + } + if summary.AdoptedFromOrphanAt == nil { + t.Error("AdoptedFromOrphanAt must be set on adopted reattach") + } + if summary.EndReason != "tmux_session_ended" { + t.Errorf("EndReason: got %q", summary.EndReason) + } + if summary.DetectedVia != "polling" { + t.Errorf("DetectedVia: got %q", summary.DetectedVia) + } +} + +// Successful adoption must bump task.yaml.UpdatedAt. +func TestAdoptionBumpsUpdatedAtOnSuccess(t *testing.T) { + fx := newAdoptionFixture(t) + fx.stubSeams(t) + + opts := defaultAdoptionOpts(fx.wsDir) + if err := AdoptExistingPersistentSession(opts.TmuxPath, opts.SessionName, fx.wsDir, opts); err != nil { + t.Fatalf("Adopt: %v", err) + } + meta, err := workspace.ReadMeta(filepath.Join(fx.wsDir, "task.yaml")) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if !meta.UpdatedAt.After(fx.initialUpdate) { + t.Errorf("UpdatedAt must be bumped on adoption: got %v, initial %v", + meta.UpdatedAt, fx.initialUpdate) + } +} + +// Race-guard fall-through to passive reattach must NOT bump UpdatedAt. +func TestAdoptionRaceGuardFallsThroughAndDoesNotBumpUpdatedAt(t *testing.T) { + fx := newAdoptionFixture(t) + fx.stubSeams(t) + + // Simulate concurrent adopter winning by writing a fresh local lease. + freshLease := &Lease{ + SessionID: "race-winner-1-x", PID: os.Getpid(), Hostname: currentHostname(), + Username: "u", Agent: "claude", Mode: "local", + StartedAt: time.Now().UTC(), LastHeartbeatAt: time.Now().UTC(), + Terminal: "test", + } + if err := WriteLease(LeasePath(fx.wsDir), freshLease); err != nil { + t.Fatalf("WriteLease: %v", err) + } + + opts := defaultAdoptionOpts(fx.wsDir) + if err := AdoptExistingPersistentSession(opts.TmuxPath, opts.SessionName, fx.wsDir, opts); err != nil { + t.Fatalf("Adopt (race fall-through): %v", err) + } + + // Race winner's lease must be intact. + got, err := ReadLease(LeasePath(fx.wsDir)) + if err != nil { + t.Fatalf("ReadLease: %v", err) + } + if got.SessionID != "race-winner-1-x" { + t.Errorf("race-winner lease overwritten: got SessionID %q", got.SessionID) + } + + // No summary on fall-through — adoption did not run. + if summary, _ := ReadSummary(SummaryPath(fx.wsDir)); summary != nil { + t.Error("fall-through to passive must not write a summary") + } + + // UpdatedAt must NOT be bumped on the fall-through path. + meta, err := workspace.ReadMeta(filepath.Join(fx.wsDir, "task.yaml")) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if !meta.UpdatedAt.Equal(fx.initialUpdate) { + t.Errorf("UpdatedAt must NOT be bumped on race fall-through: got %v, initial %v", + meta.UpdatedAt, fx.initialUpdate) + } +} + +// A non-zero attach error from the seam propagates as an error. +// Adoption must not silently swallow attach failures. +func TestAdoptionPropagatesAttachError(t *testing.T) { + fx := newAdoptionFixture(t) + + wantErr := errors.New("tmux attach-session exited 5") + origA := adoptAttacher + adoptAttacher = func(_, _ string) error { return wantErr } + origP := adoptPoll + adoptPoll = func(_, _ string, _ time.Duration) { + t.Fatal("polling must not run after attach failure") + } + t.Cleanup(func() { + adoptAttacher = origA + adoptPoll = origP + }) + + opts := defaultAdoptionOpts(fx.wsDir) + err := AdoptExistingPersistentSession(opts.TmuxPath, opts.SessionName, fx.wsDir, opts) + if err == nil { + t.Fatal("expected error when attach fails") + } + if !errors.Is(err, wantErr) { + t.Errorf("expected wrapped attach error, got %v", err) + } +} diff --git a/internal/session/attach.go b/internal/session/attach.go new file mode 100644 index 0000000..a312a58 --- /dev/null +++ b/internal/session/attach.go @@ -0,0 +1,29 @@ +package session + +import ( + "github.com/warrenronsiek/ctask/internal/shell" +) + +// attacher is the test seam used by AttachExisting. Production code calls +// shell.AttachSession directly; tests override this variable to capture +// invocations or simulate failures. Do not run tests that override this +// variable in parallel — it is a package global. +var attacher = shell.AttachSession + +// AttachExisting is the passive-reattach path. It is invoked when a tmux +// session for the workspace already exists and the lease is fresh and local +// (the original ctask owner is alive and heartbeating). +// +// AttachExisting performs no Preflight, writes no lease, captures no +// manifest, starts no heartbeat, prints no banner, and runs no finalize. +// It connects the user's terminal to the existing tmux session via +// shell.AttachSession and returns when the user detaches or the session +// ends. shell.AttachSession's contract handles failure-mode classification: +// nil on clean exit, wrapped error on non-zero exit. +// +// If the session disappeared between the dispatcher's HasSession check +// and this call, AttachSession returns an error and the user can retry — +// the next invocation will hit the owner-create path. +func AttachExisting(tmuxPath, name string) error { + return attacher(tmuxPath, name) +} diff --git a/internal/session/attach_test.go b/internal/session/attach_test.go new file mode 100644 index 0000000..ce0a248 --- /dev/null +++ b/internal/session/attach_test.go @@ -0,0 +1,43 @@ +package session + +import ( + "errors" + "testing" +) + +// Tests in this file mutate the package-level `attacher` variable and must +// not be run with t.Parallel(). + +func TestAttachExistingDelegatesToAttacher(t *testing.T) { + called := 0 + orig := attacher + attacher = func(tmuxPath, name string) error { + called++ + if name != "ctask-test-abc" { + t.Errorf("name: got %q", name) + } + if tmuxPath != "/usr/bin/tmux" { + t.Errorf("tmuxPath: got %q", tmuxPath) + } + return nil + } + t.Cleanup(func() { attacher = orig }) + + if err := AttachExisting("/usr/bin/tmux", "ctask-test-abc"); err != nil { + t.Fatalf("AttachExisting: %v", err) + } + if called != 1 { + t.Errorf("expected 1 call, got %d", called) + } +} + +func TestAttachExistingPropagatesAttachError(t *testing.T) { + want := errors.New("attach failed") + orig := attacher + attacher = func(_, _ string) error { return want } + t.Cleanup(func() { attacher = orig }) + + if err := AttachExisting("/usr/bin/tmux", "ctask-x"); !errors.Is(err, want) { + t.Errorf("expected %v, got %v", want, err) + } +} diff --git a/internal/session/lease.go b/internal/session/lease.go index dc33d0f..4da4e83 100644 --- a/internal/session/lease.go +++ b/internal/session/lease.go @@ -159,7 +159,15 @@ func CleanupStaleLease(path string, staleAfter time.Duration) (*Lease, error) { // FormatActiveWarning renders the human-readable warning printed when a // fresh active lease is detected on session start. -func FormatActiveWarning(l *Lease, now time.Time) string { +// +// hint, when non-empty, is rendered between the "may cause conflicts" line +// and the "Continue anyway?" prompt. Each non-empty line of hint is +// indented with two spaces to match the rest of the block; callers should +// supply plain text (no leading indent, no trailing newline required). +// Used to surface "a tmux session exists; ctask attach is the +// reattach path" when ctask is about to enter direct mode on a workspace +// that already has a persistent tmux session. +func FormatActiveWarning(l *Lease, now time.Time, hint string) string { startedAgo := now.Sub(l.StartedAt) lastSeenAgo := now.Sub(l.LastHeartbeatAt) @@ -174,6 +182,16 @@ func FormatActiveWarning(l *Lease, now time.Time) string { fmt.Fprintf(&b, " Last seen: %s ago\n", FormatAgoShort(lastSeenAgo)) b.WriteString("\n") b.WriteString(" Opening a second session may cause conflicts.\n") + if hint != "" { + b.WriteString("\n") + for _, line := range strings.Split(strings.TrimRight(hint, "\n"), "\n") { + if line == "" { + b.WriteString("\n") + continue + } + fmt.Fprintf(&b, " %s\n", line) + } + } b.WriteString(" Continue anyway? [y/N] ") return b.String() } diff --git a/internal/session/lease_inspect.go b/internal/session/lease_inspect.go new file mode 100644 index 0000000..71beaca --- /dev/null +++ b/internal/session/lease_inspect.go @@ -0,0 +1,66 @@ +package session + +import ( + "errors" + "os" + "time" +) + +// LeaseState classifies the lease found at a workspace's session.json. It is +// the input to the persistent-mode dispatcher: missing/stale/remote -> adopt; +// fresh local -> passive reattach. +type LeaseState int + +const ( + // LeaseStateNone: lease file missing or unparseable. + LeaseStateNone LeaseState = iota + // LeaseStateFreshLocal: lease parses, last_heartbeat_at < StaleLeaseAfter, + // hostname matches the current host. + LeaseStateFreshLocal + // LeaseStateStale: lease parses, last_heartbeat_at >= StaleLeaseAfter. + LeaseStateStale + // LeaseStateFreshRemote: lease parses, last_heartbeat_at < StaleLeaseAfter, + // hostname differs from the current host. + LeaseStateFreshRemote +) + +func (s LeaseState) String() string { + switch s { + case LeaseStateNone: + return "none" + case LeaseStateFreshLocal: + return "fresh_local" + case LeaseStateStale: + return "stale" + case LeaseStateFreshRemote: + return "fresh_remote" + default: + return "unknown" + } +} + +// InspectLease reads /.ctask/session.json and classifies the result. +// Reuses the existing 60-second freshness threshold (StaleLeaseAfter) — the +// persistent-mode dispatcher must agree with v0.4 lease semantics. +func InspectLease(wsDir string) LeaseState { + l, err := ReadLease(LeasePath(wsDir)) + if err != nil { + // Missing or corrupt -> none. (CleanupStaleLease handles removal of + // corrupt files when sessions actually start; for a read-only + // inspection we just classify and return.) + if errors.Is(err, os.ErrNotExist) { + return LeaseStateNone + } + return LeaseStateNone + } + if l == nil { + return LeaseStateNone + } + if !IsFresh(l, time.Now(), StaleLeaseAfter) { + return LeaseStateStale + } + if l.Hostname != currentHostname() { + return LeaseStateFreshRemote + } + return LeaseStateFreshLocal +} diff --git a/internal/session/lease_inspect_test.go b/internal/session/lease_inspect_test.go new file mode 100644 index 0000000..caa08a3 --- /dev/null +++ b/internal/session/lease_inspect_test.go @@ -0,0 +1,106 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func writeLeaseAt(t *testing.T, wsDir string, l *Lease) { + t.Helper() + if err := WriteLease(LeasePath(wsDir), l); err != nil { + t.Fatalf("WriteLease: %v", err) + } +} + +func TestInspectLeaseNoneWhenMissing(t *testing.T) { + ws := t.TempDir() + if got := InspectLease(ws); got != LeaseStateNone { + t.Errorf("missing: got %v, want %v", got, LeaseStateNone) + } +} + +func TestInspectLeaseNoneWhenCorrupt(t *testing.T) { + ws := t.TempDir() + if err := os.MkdirAll(filepath.Join(ws, ".ctask"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(LeasePath(ws), []byte("not json"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + if got := InspectLease(ws); got != LeaseStateNone { + t.Errorf("corrupt: got %v, want %v", got, LeaseStateNone) + } +} + +func TestInspectLeaseFreshLocal(t *testing.T) { + ws := t.TempDir() + host := currentHostname() + l := &Lease{ + SessionID: "test", + PID: os.Getpid(), + Hostname: host, + Username: "u", + Agent: "claude", + Mode: "local", + StartedAt: time.Now().UTC(), + LastHeartbeatAt: time.Now().UTC(), + Terminal: "test", + } + writeLeaseAt(t, ws, l) + if got := InspectLease(ws); got != LeaseStateFreshLocal { + t.Errorf("fresh local: got %v, want %v", got, LeaseStateFreshLocal) + } +} + +func TestInspectLeaseStale(t *testing.T) { + ws := t.TempDir() + host := currentHostname() + l := &Lease{ + SessionID: "test", + PID: os.Getpid(), + Hostname: host, + Username: "u", + Agent: "claude", + Mode: "local", + StartedAt: time.Now().UTC().Add(-10 * time.Minute), + LastHeartbeatAt: time.Now().UTC().Add(-10 * time.Minute), + Terminal: "test", + } + writeLeaseAt(t, ws, l) + if got := InspectLease(ws); got != LeaseStateStale { + t.Errorf("stale: got %v, want %v", got, LeaseStateStale) + } +} + +func TestInspectLeaseFreshRemote(t *testing.T) { + ws := t.TempDir() + other := "some-other-host-that-is-not-this-one" + if other == currentHostname() { + other = "different-" + other + } + l := &Lease{ + SessionID: "test", + PID: 1, + Hostname: other, + Username: "u", + Agent: "claude", + Mode: "local", + StartedAt: time.Now().UTC(), + LastHeartbeatAt: time.Now().UTC(), + Terminal: "test", + } + writeLeaseAt(t, ws, l) + if got := InspectLease(ws); got != LeaseStateFreshRemote { + t.Errorf("fresh remote: got %v, want %v", got, LeaseStateFreshRemote) + } +} + +func TestInspectLeaseStringerCoverage(t *testing.T) { + for _, s := range []LeaseState{LeaseStateNone, LeaseStateFreshLocal, LeaseStateStale, LeaseStateFreshRemote} { + if s.String() == "" { + t.Errorf("LeaseState %d has empty String()", s) + } + } +} diff --git a/internal/session/lease_test.go b/internal/session/lease_test.go index 3f4dc92..e4ca084 100644 --- a/internal/session/lease_test.go +++ b/internal/session/lease_test.go @@ -251,7 +251,7 @@ func TestFormatActiveWarning(t *testing.T) { LastHeartbeatAt: lastSeen, } - got := FormatActiveWarning(lease, now) + got := FormatActiveWarning(lease, now, "") for _, want := range []string{ "[ctask] This workspace has an active session:", @@ -270,6 +270,45 @@ func TestFormatActiveWarning(t *testing.T) { t.Errorf("FormatActiveWarning missing %q in:\n%s", want, got) } } + if strings.Contains(got, "Tip:") || strings.Contains(got, "ctask attach") { + t.Errorf("FormatActiveWarning should not render a hint when none supplied:\n%s", got) + } +} + +func TestFormatActiveWarningWithHint(t *testing.T) { + now := time.Date(2026, 4, 21, 16, 45, 10, 0, time.UTC) + lease := &Lease{ + SessionID: "warren-desktop-12345-20260421143022", + Hostname: "warren-desktop", + Agent: "claude", + StartedAt: now.Add(-time.Hour), + LastHeartbeatAt: now.Add(-15 * time.Second), + } + hint := "Tip: a tmux session exists for this workspace.\nTo reattach instead of starting a second direct-mode session, run:\n ctask attach ctask-053-smoke" + + got := FormatActiveWarning(lease, now, hint) + + for _, want := range []string{ + "[ctask] This workspace has an active session:", + "Opening a second session may cause conflicts.", + " Tip: a tmux session exists for this workspace.", + " To reattach instead of starting a second direct-mode session, run:", + " ctask attach ctask-053-smoke", + "Continue anyway? [y/N]", + } { + if !strings.Contains(got, want) { + t.Errorf("FormatActiveWarning with hint missing %q in:\n%s", want, got) + } + } + + // Hint must appear between the conflict warning and the y/N prompt. + conflictIdx := strings.Index(got, "Opening a second session may cause conflicts.") + tipIdx := strings.Index(got, "Tip: a tmux session exists") + promptIdx := strings.Index(got, "Continue anyway? [y/N]") + if !(conflictIdx < tipIdx && tipIdx < promptIdx) { + t.Errorf("hint placement wrong: conflict=%d tip=%d prompt=%d\nOutput:\n%s", + conflictIdx, tipIdx, promptIdx, got) + } } func TestFormatStaleCleanupNoticeRenders(t *testing.T) { diff --git a/internal/session/run.go b/internal/session/run.go index d4ec699..9e1e7eb 100644 --- a/internal/session/run.go +++ b/internal/session/run.go @@ -27,6 +27,29 @@ type LaunchOpts struct { // a security violation (absolute path or .. escape) aborts the session. LaunchDir string + // SessionMode is "direct" (default) or "persistent". In persistent mode, + // Run dispatches to shell.ExecTmuxAgent / ExecTmuxShell. The heartbeat + // continues throughout. handleProvisional is bypassed via + // shouldRunProvisional. + SessionMode string + + // SessionName is the deterministic tmux session name (computed by the + // cmd-layer dispatcher via session.SessionName). Required when + // SessionMode == "persistent"; empty otherwise. + SessionName string + + // Category mirrors Workspace.Meta.Category for symmetry; not directly + // consumed by Run today but populated by the dispatcher for any future + // session-name regeneration paths and for clarity in logs. + Category string + + // TmuxPath is the validated tmux binary path returned by + // preflightPersistentEntry / shell.LookupTmux. Required when + // SessionMode == "persistent"; empty otherwise. Run does NOT call + // exec.LookPath itself — the cmd-layer preflight is the single source + // of truth. + TmuxPath string + // Force suppresses both the active-session warning (Layer 1) and the // stale-workspace warning (Layer 3). It does NOT disable the metadata // write lock or the session summary. Used for scripted/automated runs. @@ -37,6 +60,12 @@ type LaunchOpts struct { // workspace is treated as provisional and removed on exit — see // handleProvisional. Set to false by resume/open/last. NewlyCreated bool + + // ActiveLeaseHint, when non-empty, is forwarded to PreflightOpts so the + // Layer-1 "Continue anyway?" prompt can render a contextual suggestion. + // Populated by the cmd-layer dispatcher when about to enter direct mode + // on a workspace that already has a live tmux session. + ActiveLeaseHint string } // manifestStartPath returns the path to the start manifest file. @@ -76,10 +105,11 @@ const ( func Run(opts LaunchOpts) error { // ---- Preflight (Layers 3 + 1) ---- preflight, err := PreflightFull(PreflightOpts{ - WsDir: opts.WsDir, - Force: opts.Force, - In: os.Stdin, - Out: os.Stderr, + WsDir: opts.WsDir, + Force: opts.Force, + In: os.Stdin, + Out: os.Stderr, + ActiveLeaseHint: opts.ActiveLeaseHint, }) if err != nil { fmt.Fprintf(os.Stderr, "[ctask] Warning: preflight check failed: %v\n", err) @@ -159,9 +189,35 @@ func Run(opts LaunchOpts) error { // ---- Run the child ---- var childErr error - if opts.Shell { + switch { + case opts.SessionMode == "persistent": + // Persistent mode: tmux owns the foreground process. Banner prints + // from here (tmux paints over it within ~50ms of attach — accepted). + if !opts.Shell { + for _, line := range shell.BannerLines(opts.Mode, opts.Slug, opts.WsDir, opts.LaunchDir) { + fmt.Println(line) + } + } + if opts.TmuxPath == "" { + if hb != nil { + hb.Stop() + } + return fmt.Errorf("internal error: LaunchOpts.TmuxPath is empty in persistent mode") + } + if opts.SessionName == "" { + if hb != nil { + hb.Stop() + } + return fmt.Errorf("internal error: LaunchOpts.SessionName is empty in persistent mode") + } + if opts.Shell { + childErr = shell.ExecTmuxShell(opts.TmuxPath, opts.SessionName, launchAbs, opts.EnvVars) + } else { + childErr = shell.ExecTmuxAgent(opts.TmuxPath, opts.SessionName, launchAbs, opts.EnvVars, opts.Agent) + } + case opts.Shell: childErr = shell.ExecShell(launchAbs, opts.EnvVars, opts.Slug, opts.Mode) - } else { + default: for _, line := range shell.BannerLines(opts.Mode, opts.Slug, opts.WsDir, opts.LaunchDir) { fmt.Println(line) } @@ -177,8 +233,9 @@ func Run(opts LaunchOpts) error { // (canceled before real work), and no files changed, remove the workspace // entirely and skip finalize. A zero child exit means the user entered // the agent and exited normally — those workspaces are preserved even - // with an empty diff. - if handleProvisional(opts, startManifest, childExitCode(childErr)) { + // with an empty diff. Persistent (tmux) mode bypasses this gate via + // shouldRunProvisional. + if shouldRunProvisional(opts) && handleProvisional(opts, startManifest, childExitCode(childErr)) { return childErr } @@ -194,6 +251,14 @@ func Run(opts LaunchOpts) error { return childErr } +// shouldRunProvisional reports whether the provisional-workspace cleanup +// gate should run for the given opts. Persistent mode unconditionally +// disables the gate (see v0.5.3-spec.md §7); direct mode runs the gate +// only when the workspace was created by this invocation. +func shouldRunProvisional(opts LaunchOpts) bool { + return opts.SessionMode != "persistent" && opts.NewlyCreated +} + // childExitCode extracts the exit code from the error returned by cmd.Run(). // Returns 0 when err is nil (clean exit). Returns the reported code for // *exec.ExitError. Returns -1 for any other error (agent not found, OS-level @@ -245,6 +310,14 @@ func finalize(opts LaunchOpts, startManifest *Manifest, startTime, endTime time. sessionID, currentHostname(), agent, opts.Mode, startTime, endTime, diff, endManifest, ) + if opts.SessionMode == "persistent" { + summary.EndReason = "tmux_session_ended" + summary.DetectedVia = "polling" + summary.SessionOwnership = "created" + } else { + summary.EndReason = "child_exited" + summary.DetectedVia = "child_exit" + } skipped, lockErr := lockfile.WithLock( ctaskWriteLockPath(opts.WsDir), diff --git a/internal/session/run_preflight.go b/internal/session/run_preflight.go index 049e531..3fcfa1a 100644 --- a/internal/session/run_preflight.go +++ b/internal/session/run_preflight.go @@ -15,6 +15,13 @@ type PreflightOpts struct { Force bool In io.Reader Out io.Writer + + // ActiveLeaseHint, when non-empty, is rendered inside the Layer-1 + // "Continue anyway?" prompt — between the conflict warning and the + // y/N line. The cmd-layer dispatcher populates it (e.g., when + // entering direct mode on a workspace that has a live tmux session, + // suggest `ctask attach ` as the reattach path). + ActiveLeaseHint string } // PreflightResult reports whether the session should proceed and whether an @@ -98,7 +105,7 @@ func runActiveLeaseCheck(opts PreflightOpts) (bool, bool, error) { if opts.Force { return true, true, nil } - fmt.Fprint(opts.Out, FormatActiveWarning(existing, time.Now())) + fmt.Fprint(opts.Out, FormatActiveWarning(existing, time.Now(), opts.ActiveLeaseHint)) if !ConfirmYN(opts.In, opts.Out, "", false) { return false, false, nil } diff --git a/internal/session/run_provisional.go b/internal/session/run_provisional.go index ca1611c..f6ffb87 100644 --- a/internal/session/run_provisional.go +++ b/internal/session/run_provisional.go @@ -10,18 +10,20 @@ import ( // conditions must hold: // // - NewlyCreated is true (so the workspace is ours to reclaim) -// - the child process exited non-zero (trust prompt rejected, Esc during -// startup, Ctrl+C mid-launch — confirmed empirically; a zero exit means -// the user entered the agent and exited cleanly, which is a legitimate -// workflow that must preserve the workspace even with no file changes) +// - the child process exited non-zero // - the manifest diff is empty (nothing to preserve) // -// Removing the workspace directory also removes every v0.4 state file inside -// .ctask/ (session.json, manifest-start.json, last-session-summary.json, -// write.lock), so no separate cleanup of those files is required. +// In persistent (tmux) mode the caller skips this gate entirely via +// shouldRunProvisional; see session.Run and v0.5.3-spec.md §7. The gate's +// UX assumption — "user hit Esc, agent exited non-zero before any work" — +// does not translate to tmux, where the polling loop typically reports a +// clean (zero) exit even when the user kills the session abruptly. // -// Returns true iff the workspace was removed (the caller must then skip the -// normal finalize path, since there is nothing to log or summarize). +// Removing the workspace directory also removes every v0.4 state file +// inside .ctask/, so no separate cleanup is required. +// +// Returns true iff the workspace was removed (the caller must then skip +// the normal finalize path, since there is nothing to log or summarize). func handleProvisional(opts LaunchOpts, startManifest *Manifest, childExitCode int) bool { if !opts.NewlyCreated { return false diff --git a/internal/session/run_test.go b/internal/session/run_test.go new file mode 100644 index 0000000..7641f30 --- /dev/null +++ b/internal/session/run_test.go @@ -0,0 +1,23 @@ +package session + +import "testing" + +func TestShouldRunProvisional(t *testing.T) { + cases := []struct { + name string + opts LaunchOpts + want bool + }{ + {"direct + newly created", LaunchOpts{NewlyCreated: true}, true}, + {"direct + not newly created", LaunchOpts{}, false}, + {"persistent + newly created", LaunchOpts{SessionMode: "persistent", NewlyCreated: true}, false}, + {"persistent + not newly created", LaunchOpts{SessionMode: "persistent"}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := shouldRunProvisional(c.opts); got != c.want { + t.Errorf("got %v, want %v", got, c.want) + } + }) + } +} diff --git a/internal/session/sessionname.go b/internal/session/sessionname.go new file mode 100644 index 0000000..1ca74db --- /dev/null +++ b/internal/session/sessionname.go @@ -0,0 +1,68 @@ +package session + +import ( + "crypto/sha256" + "encoding/hex" + "path/filepath" + "runtime" + "strings" +) + +// SessionName returns a stable tmux session name for the given workspace. +// Format: ctask---. +// +// category and slug are sanitized to [A-Za-z0-9_-]; characters outside that +// set become '_'; runs of '_' collapse; both are lowercased. slug is +// truncated to 30 characters maximum (after sanitization). hash6 is the +// first six lowercase-hex characters of sha256(canonical absolute path). +// +// On Windows the path is lowercased before hashing to match +// config.searchRootKey conventions for case-insensitive filesystems. +func SessionName(category, slug, absWsPath string) string { + cat := sanitizeNameComponent(category) + sl := sanitizeNameComponent(slug) + if len(sl) > 30 { + sl = sl[:30] + sl = strings.TrimRight(sl, "_") + } + clean := filepath.Clean(absWsPath) + if runtime.GOOS == "windows" { + clean = strings.ToLower(clean) + } + sum := sha256.Sum256([]byte(clean)) + hash := hex.EncodeToString(sum[:])[:6] + return "ctask-" + cat + "-" + sl + "-" + hash +} + +// sanitizeNameComponent replaces every char outside [A-Za-z0-9_-] with '_', +// collapses runs of '_', trims leading/trailing '_' and '-', and lowercases. +func sanitizeNameComponent(s string) string { + if s == "" { + return "_" + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r + ('a' - 'A')) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '_' || r == '-': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + out := b.String() + for strings.Contains(out, "__") { + out = strings.ReplaceAll(out, "__", "_") + } + out = strings.Trim(out, "_-") + if out == "" { + return "_" + } + return out +} diff --git a/internal/session/sessionname_test.go b/internal/session/sessionname_test.go new file mode 100644 index 0000000..b25dc2d --- /dev/null +++ b/internal/session/sessionname_test.go @@ -0,0 +1,84 @@ +package session + +import ( + "runtime" + "strings" + "testing" +) + +func TestSessionNameStableAcrossRuns(t *testing.T) { + a := SessionName("projects", "promptvolley-v3", "/home/warren/ai-workspaces/projects/promptvolley-v3") + b := SessionName("projects", "promptvolley-v3", "/home/warren/ai-workspaces/projects/promptvolley-v3") + if a != b { + t.Errorf("not stable: %q vs %q", a, b) + } +} + +func TestSessionNamePrefixAndShape(t *testing.T) { + got := SessionName("projects", "promptvolley-v3", "/abs/path/promptvolley-v3") + if !strings.HasPrefix(got, "ctask-projects-promptvolley-v3-") { + t.Errorf("unexpected prefix: %q", got) + } + parts := strings.Split(got, "-") + hash := parts[len(parts)-1] + if len(hash) != 6 { + t.Errorf("expected 6-char hash suffix, got %q (full %q)", hash, got) + } + for _, c := range hash { + ok := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') + if !ok { + t.Errorf("hash must be lowercase hex: %q", hash) + break + } + } +} + +func TestSessionNameSanitizesUnsafeCharacters(t *testing.T) { + got := SessionName("My Cat/Egory", "weird name with spaces & punct!", "/abs/path/x") + if strings.ContainsAny(got, " /&!?") { + t.Errorf("sanitization missed unsafe chars: %q", got) + } + if !strings.HasPrefix(got, "ctask-my_cat_egory-") { + t.Errorf("category not lowercased+sanitized: %q", got) + } +} + +func TestSessionNameSlugTruncatedAt30(t *testing.T) { + long := strings.Repeat("a", 50) + got := SessionName("projects", long, "/abs/path/x") + parts := strings.Split(got, "-") + if len(parts) < 4 { + t.Fatalf("unexpected shape: %q", got) + } + slugTokens := parts[2 : len(parts)-1] + slug := strings.Join(slugTokens, "-") + if len(slug) > 30 { + t.Errorf("slug not truncated to 30: got %d chars (%q)", len(slug), slug) + } +} + +func TestSessionNameDifferentPathsCollideOnSlugButNotOverall(t *testing.T) { + a := SessionName("projects", "demo", "/path/one/demo") + b := SessionName("projects", "demo", "/path/two/demo") + if a == b { + t.Errorf("hash should differ on path: both %q", a) + } +} + +func TestSessionNameWindowsPathCaseInsensitive(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only path canonicalization") + } + a := SessionName("projects", "demo", "C:\\Users\\Warren\\X") + b := SessionName("projects", "demo", "c:\\users\\warren\\x") + if a != b { + t.Errorf("Windows paths must be case-insensitive: %q vs %q", a, b) + } +} + +func TestSessionNameRunsOfUnderscoresCollapsed(t *testing.T) { + got := SessionName("a b", "x y", "/abs/x") + if strings.Contains(got, "__") { + t.Errorf("runs of _ must collapse: %q", got) + } +} diff --git a/internal/session/summary.go b/internal/session/summary.go index ef95ed6..5656573 100644 --- a/internal/session/summary.go +++ b/internal/session/summary.go @@ -26,6 +26,25 @@ type SessionSummary struct { FilesModified []string `json:"files_modified"` FilesDeleted []string `json:"files_deleted"` NotesUpdated bool `json:"notes_updated"` + + // EndReason is "child_exited" for direct mode, "tmux_session_ended" for + // persistent mode (both owner-create and adopted reattach). Optional; + // pre-v0.5.3 summaries omit it. + EndReason string `json:"end_reason,omitempty"` + + // DetectedVia distinguishes the mechanism that observed session end: + // "child_exit" for direct mode, "polling" for persistent mode. + DetectedVia string `json:"detected_via,omitempty"` + + // SessionOwnership is "created" if this ctask process originated the + // session (owner-create) or "adopted" if it took over an orphaned + // persistent session. Omitted in direct mode. + SessionOwnership string `json:"session_ownership,omitempty"` + + // AdoptedFromOrphanAt records the moment adoption took place. Set only + // for adopted reattach. + AdoptedFromOrphanAt *time.Time `json:"adopted_from_orphan_at,omitempty"` + // EndManifest captures the workspace file list at end-of-session so the // next session can diff current state against it (Layer 3). This field // is ctask-internal; it is not part of the public summary format shown diff --git a/internal/session/summary_test.go b/internal/session/summary_test.go index 82efe7c..4e8f1c6 100644 --- a/internal/session/summary_test.go +++ b/internal/session/summary_test.go @@ -169,3 +169,64 @@ func TestFormatLaunchContextRendersChanged(t *testing.T) { } } } + +func TestSummaryNewFieldsRoundTrip(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, ".ctask", "last-session-summary.json") + + adoptedAt := time.Now().UTC().Truncate(time.Second) + s := &SessionSummary{ + SessionID: "host-1-20260508140000", + Hostname: "host", + Agent: "claude", + Mode: "local", + StartedAt: adoptedAt, + EndedAt: adoptedAt.Add(10 * time.Minute), + DurationSeconds: 600, + EndReason: "tmux_session_ended", + DetectedVia: "polling", + SessionOwnership: "adopted", + AdoptedFromOrphanAt: &adoptedAt, + } + if err := WriteSummary(path, s); err != nil { + t.Fatalf("WriteSummary: %v", err) + } + got, err := ReadSummary(path) + if err != nil { + t.Fatalf("ReadSummary: %v", err) + } + if got.EndReason != "tmux_session_ended" { + t.Errorf("EndReason: got %q", got.EndReason) + } + if got.DetectedVia != "polling" { + t.Errorf("DetectedVia: got %q", got.DetectedVia) + } + if got.SessionOwnership != "adopted" { + t.Errorf("SessionOwnership: got %q", got.SessionOwnership) + } + if got.AdoptedFromOrphanAt == nil || !got.AdoptedFromOrphanAt.Equal(adoptedAt) { + t.Errorf("AdoptedFromOrphanAt: got %v, want %v", got.AdoptedFromOrphanAt, adoptedAt) + } +} + +func TestSummaryNewFieldsOmittedWhenEmpty(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, ".ctask", "last-session-summary.json") + + s := &SessionSummary{ + SessionID: "x", Hostname: "h", Agent: "claude", Mode: "local", + StartedAt: time.Now().UTC(), EndedAt: time.Now().UTC(), + } + if err := WriteSummary(path, s); err != nil { + t.Fatalf("WriteSummary: %v", err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + for _, key := range []string{"end_reason", "detected_via", "session_ownership", "adopted_from_orphan_at"} { + if strings.Contains(string(body), key) { + t.Errorf("expected %q to be omitted; body:\n%s", key, body) + } + } +} diff --git a/internal/shell/tmux.go b/internal/shell/tmux.go new file mode 100644 index 0000000..4271e69 --- /dev/null +++ b/internal/shell/tmux.go @@ -0,0 +1,230 @@ +package shell + +import ( + "errors" + "fmt" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "time" +) + +// Version describes a parsed tmux version. +type Version struct { + Major int // 0 if unparseable + Raw string // trimmed output of `tmux -V`, e.g. "tmux 3.4-rc" +} + +// parseTmuxVersion extracts the major version from `tmux -V` output. +// Major == 0 signals unparseable input (caller proceeds with a warning +// rather than blocking). +func parseTmuxVersion(raw string) Version { + line := strings.TrimSpace(raw) + parts := strings.Fields(line) + if len(parts) < 2 || parts[0] != "tmux" { + return Version{Raw: line} + } + tok := parts[1] + if idx := strings.IndexByte(tok, '-'); idx >= 0 { + tok = tok[:idx] + } + majorStr := tok + if idx := strings.IndexByte(tok, '.'); idx >= 0 { + majorStr = tok[:idx] + } + n, err := strconv.Atoi(majorStr) + if err != nil || n <= 0 { + return Version{Raw: line} + } + return Version{Major: n, Raw: line} +} + +// tmuxArgs builds the argv passed to `tmux` for new-session creation. +// Keys in env are emitted in sorted order so output is deterministic for +// testing. Empty values are skipped: tmux 3.0+ accepts `-e VAR=` to set an +// empty value, but we instead omit the variable so it inherits from the +// child (matching the v0.5 contract that empty CTASK_LAUNCH_DIR means "no +// project subdir"). The trailing `--` ensures the agent's own flags do not +// confuse tmux's argument parser. +func tmuxArgs(sessionName, launchAbs string, env map[string]string, command string, commandArgs ...string) []string { + args := []string{"new-session", "-d", "-s", sessionName, "-c", launchAbs} + keys := make([]string, 0, len(env)) + for k, v := range env { + if v == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + args = append(args, "-e", k+"="+env[k]) + } + args = append(args, "--", command) + args = append(args, commandArgs...) + return args +} + +// ErrTmuxNotFound is returned by LookupTmux when tmux is not on PATH. +var ErrTmuxNotFound = errors.New("tmux not found on PATH") + +// ErrTmuxTooOld is returned by LookupTmux when the installed tmux is older +// than MinTmuxMajor. +var ErrTmuxTooOld = errors.New("tmux version too old (requires 3.0+)") + +// MinTmuxMajor is the minimum tmux major version supported by ctask +// persistent mode. tmux 3.0 introduced `new-session -e VAR=VAL`, which is +// the only safe way to pass per-session env vars to non-POSIX shells. +const MinTmuxMajor = 3 + +// LookupTmux locates tmux on PATH and validates its version. +// - ErrTmuxNotFound — PATH lookup failed; returned path is "". +// - ErrTmuxTooOld — version < MinTmuxMajor; path and Version still populated +// so callers can render the discovered version in errors. +// - other error — `tmux -V` failed to execute (returned path populated). +// - nil error with Version{Major:0} — version unparseable (custom builds, +// snapshots); caller may proceed with a warning. +func LookupTmux() (string, Version, error) { + path, err := exec.LookPath("tmux") + if err != nil { + return "", Version{}, ErrTmuxNotFound + } + out, err := exec.Command(path, "-V").CombinedOutput() + if err != nil { + return path, Version{Raw: strings.TrimSpace(string(out))}, fmt.Errorf("running tmux -V: %w", err) + } + v := parseTmuxVersion(string(out)) + if v.Major == 0 { + return path, v, nil + } + if v.Major < MinTmuxMajor { + return path, v, ErrTmuxTooOld + } + return path, v, nil +} + +// HasSession reports whether tmux currently has a session named `name`. +// Discards stdout/stderr (stderr is chatty on miss with "no server running", +// which is a normal expected outcome). +func HasSession(tmuxPath, name string) bool { + cmd := exec.Command(tmuxPath, "has-session", "-t", name) + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() == nil +} + +// PollInterval is the production cadence for PollSessionEnd. Below the +// 30-second heartbeat interval so finalize lag after session end is +// bounded; above 1s to keep CPU and exec overhead negligible. +const PollInterval = 3 * time.Second + +// NewSession runs `tmux new-session -d -s name -c launchAbs -e VAR=VAL ... +// -- command [args...]`. Stdout discarded (empty in -d mode); stderr is +// surfaced so config errors / server start failures are visible. +func NewSession(tmuxPath, name, launchAbs string, env map[string]string, command string, args ...string) error { + argv := tmuxArgs(name, launchAbs, env, command, args...) + cmd := exec.Command(tmuxPath, argv...) + cmd.Stdout = nil + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("tmux new-session: %w", err) + } + return nil +} + +// AttachSession runs `tmux attach-session -t name` with the user's terminal +// wired through. Returns: +// - nil on clean exit. tmux exits 0 for *both* normal user detach +// (Ctrl-B d) and clean session end while attached — they are +// indistinguishable to the foreground process. The polling loop in the +// caller is what subsequently detects session-end vs detach. +// - an error wrapping the exit code on non-zero exit. Likely causes: +// missing TTY, nested tmux, session disappeared between has-session +// and attach-session, ~/.tmux.conf parse error. +// - an error wrapping any non-ExitError failure (process couldn't start +// at all). +func AttachSession(tmuxPath, name string) error { + cmd := exec.Command(tmuxPath, "attach-session", "-t", name) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return classifyAttachError(cmd.Run()) +} + +// classifyAttachError converts a `tmux attach-session` cmd.Run() result +// into an error following the AttachSession contract. Exposed for testing +// without invoking tmux. +func classifyAttachError(err error) error { + if err == nil { + return nil + } + if exitErr, ok := err.(*exec.ExitError); ok { + return fmt.Errorf( + "tmux attach-session exited %d (likely cause: missing TTY, nested tmux, session disappeared, or ~/.tmux.conf error): %w", + exitErr.ExitCode(), err) + } + return fmt.Errorf("tmux attach-session: %w", err) +} + +// PollSessionEnd blocks until tmux reports the named session is gone. +// Production callers use PollInterval. Internally delegates to +// pollSessionEndWith for testability. +func PollSessionEnd(tmuxPath, name string, interval time.Duration) { + pollSessionEndWith(tmuxPath, name, interval, HasSession) +} + +// pollSessionEndWith is the test seam for PollSessionEnd: callers can +// inject a fake HasSession to exercise the loop without invoking tmux. +func pollSessionEndWith(tmuxPath, name string, interval time.Duration, hs func(string, string) bool) { + for hs(tmuxPath, name) { + time.Sleep(interval) + } +} + +// ExecTmuxAgent orchestrates the three-call pattern for agent mode: +// NewSession -> AttachSession -> PollSessionEnd. +// +// AttachSession failures abort early — the polling loop is meaningful only +// after a successful attach (otherwise we'd block waiting for a session +// the user never connected to). +func ExecTmuxAgent(tmuxPath, sessionName, launchAbs string, env map[string]string, agent string) error { + if err := NewSession(tmuxPath, sessionName, launchAbs, env, agent); err != nil { + return err + } + if err := AttachSession(tmuxPath, sessionName); err != nil { + return err + } + PollSessionEnd(tmuxPath, sessionName, PollInterval) + return nil +} + +// ExecTmuxShell is the shell-mode counterpart to ExecTmuxAgent. The shell +// command comes from DefaultShell(); no PROMPT/PS1 override is wired +// through tmux — tmux handles its own status line and the user's tmux +// config governs prompt customization. +func ExecTmuxShell(tmuxPath, sessionName, launchAbs string, env map[string]string) error { + shellCmd := DefaultShell() + if err := NewSession(tmuxPath, sessionName, launchAbs, env, shellCmd); err != nil { + return err + } + if err := AttachSession(tmuxPath, sessionName); err != nil { + return err + } + PollSessionEnd(tmuxPath, sessionName, PollInterval) + return nil +} + +// IsTTY reports whether the given file (typically os.Stdin or os.Stdout) +// is a terminal. tmux attach-session requires both stdin and stdout to be +// TTYs. On Windows this is best-effort via os.Stat + ModeCharDevice. +func IsTTY(f *os.File) bool { + if f == nil { + return false + } + info, err := f.Stat() + if err != nil { + return false + } + return (info.Mode() & os.ModeCharDevice) != 0 +} diff --git a/internal/shell/tmux_test.go b/internal/shell/tmux_test.go new file mode 100644 index 0000000..5b6405d --- /dev/null +++ b/internal/shell/tmux_test.go @@ -0,0 +1,171 @@ +package shell + +import ( + "errors" + "os" + "os/exec" + "reflect" + "runtime" + "strings" + "testing" + "time" +) + +func TestParseTmuxVersionMajor(t *testing.T) { + cases := []struct { + raw string + wantMajor int + }{ + {"tmux 3.4\n", 3}, + {"tmux 3.4-rc\n", 3}, + {"tmux 3.0\n", 3}, + {"tmux 2.8\n", 2}, + {"tmux 3\n", 3}, + {"tmux next-3.5\n", 0}, // unparseable -> Major == 0 (caller proceeds with warning) + {"\n", 0}, + {"random gibberish", 0}, + } + for _, c := range cases { + got := parseTmuxVersion(c.raw) + if got.Major != c.wantMajor { + t.Errorf("parseTmuxVersion(%q).Major = %d, want %d", c.raw, got.Major, c.wantMajor) + } + if got.Raw == "" && c.raw != "\n" && strings.TrimSpace(c.raw) != "" { + t.Errorf("parseTmuxVersion(%q).Raw should preserve trimmed input", c.raw) + } + } +} + +func TestTmuxArgsConstruction(t *testing.T) { + env := map[string]string{ + "CTASK_TASK": "demo", + "CTASK_MODE": "local", + "CTASK_ROOT": "/tmp/root", + "CTASK_WORKSPACE": "/tmp/root/projects/demo", + "CTASK_CATEGORY": "projects", + "CTASK_TYPE": "project", + "CTASK_LAUNCH_DIR": "demo", + } + got := tmuxArgs("ctask-demo-abcdef", "/tmp/root/projects/demo/demo", env, "claude") + want := []string{ + "new-session", "-d", "-s", "ctask-demo-abcdef", + "-c", "/tmp/root/projects/demo/demo", + "-e", "CTASK_CATEGORY=projects", + "-e", "CTASK_LAUNCH_DIR=demo", + "-e", "CTASK_MODE=local", + "-e", "CTASK_ROOT=/tmp/root", + "-e", "CTASK_TASK=demo", + "-e", "CTASK_TYPE=project", + "-e", "CTASK_WORKSPACE=/tmp/root/projects/demo", + "--", "claude", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("tmuxArgs:\n got: %v\nwant: %v", got, want) + } +} + +func TestTmuxArgsOmitsEmptyEnvValues(t *testing.T) { + env := map[string]string{ + "CTASK_TASK": "demo", + "CTASK_LAUNCH_DIR": "", // empty value + } + got := tmuxArgs("name", "/dir", env, "shell") + for i, a := range got { + if a == "-e" && i+1 < len(got) { + if strings.HasSuffix(got[i+1], "=") { + t.Errorf("empty env value at index %d: %q", i, got[i+1]) + } + } + } +} + +func TestLookupTmuxNotFound(t *testing.T) { + orig := os.Getenv("PATH") + t.Cleanup(func() { os.Setenv("PATH", orig) }) + os.Setenv("PATH", "") + + _, _, err := LookupTmux() + if err == nil { + t.Fatal("expected error when tmux not on PATH") + } + if !errors.Is(err, ErrTmuxNotFound) { + t.Errorf("expected ErrTmuxNotFound, got %v", err) + } +} + +func TestLookupTmuxRunsVersionParser(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux not on PATH") + } + path, ver, err := LookupTmux() + if err != nil { + if errors.Is(err, ErrTmuxNotFound) { + t.Fatalf("LookupTmux returned ErrTmuxNotFound but tmux is on PATH: %v", err) + } + if errors.Is(err, ErrTmuxTooOld) { + if path == "" { + t.Error("ErrTmuxTooOld should still return discovered path") + } + return + } + t.Fatalf("unexpected error: %v", err) + } + if path == "" { + t.Error("expected non-empty path") + } + if ver.Major <= 0 { + t.Errorf("expected positive major version, got %d", ver.Major) + } + if ver.Raw == "" { + t.Error("expected non-empty Raw") + } +} + +func TestPollSessionEndExitsAfterFalse(t *testing.T) { + // Sequence: true, true, false -> loop must exit on the third call. + calls := 0 + hs := func(_, _ string) bool { + calls++ + return calls < 3 + } + pollSessionEndWith("/usr/bin/tmux", "ctask-x", 1*time.Millisecond, hs) + if calls < 3 { + t.Errorf("expected at least 3 calls before exit, got %d", calls) + } +} + +func TestClassifyAttachErrorNilOnNil(t *testing.T) { + if classifyAttachError(nil) != nil { + t.Error("nil input should produce nil") + } +} + +func TestClassifyAttachErrorWrapsExitError(t *testing.T) { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd.exe", "/c", "exit 5") + } else { + cmd = exec.Command("sh", "-c", "exit 5") + } + err := cmd.Run() + if err == nil { + t.Fatal("setup: expected sh/cmd to exit non-zero") + } + got := classifyAttachError(err) + if got == nil { + t.Fatal("expected wrapped error for non-zero exit") + } + if !strings.Contains(got.Error(), "exited 5") { + t.Errorf("expected 'exited 5' in message: %v", got) + } +} + +func TestClassifyAttachErrorWrapsNonExitError(t *testing.T) { + got := classifyAttachError(errors.New("startup failure")) + if got == nil { + t.Fatal("expected error") + } + if !strings.Contains(got.Error(), "startup failure") { + t.Errorf("expected wrapped underlying message: %v", got) + } +} diff --git a/notes.md b/notes.md index a1ceee0..537c60e 100644 --- a/notes.md +++ b/notes.md @@ -1,18 +1,14 @@ # ctask — Session Handoff Notes -Last touched: 2026-05-07 (after v0.5.2 ship). Pause before starting v0.6. +Last touched: 2026-05-09. **v0.5.2 is shipped on `main` and installed locally as `v0.5.2`. v0.5.3 implementation is complete on branch `feat/v0.5.3-persistent-session-mode` (20 commits) but is NOT yet merged or installed — it is awaiting the user's manual WSL smoke verification.** ## Where we are -**v0.5.1 is shipped on `main` and installed locally.** v0.5 added nested project structure (project subdir scaffolding, `launch_dir`-driven cd into the subdir, default discovery including `$CTASK_ROOT/projects/`). v0.5.1 is a tiny follow-up that fixes a UTC-date confusion surfaced during v0.5 smoke testing. - -- Version string: `v0.5.1` (see `cmd/root.go`) -- Branch: `main` -- Remote: none (local-only, intentional — see `CLAUDE.md`) -- Tests: all pass across 7 packages (`go test ./... -count=1`) -- `go vet ./...` clean, `go build ./...` clean -- Installed at `%LOCALAPPDATA%\ctask\bin\ctask.exe` via `just install` — reflects both v0.5 and v0.5.1 -- `ctask doctor` now reports 5 pass/fail checks + 2 seed-directory checks + 1 `CTASK_PROJECT_ROOT` check (all three-state) +- **`main`:** v0.5.2 (workspace retrieval + cross-workspace context). Installed binary at `%LOCALAPPDATA%\ctask\bin\ctask.exe` is `v0.5.2`. +- **`feat/v0.5.3-persistent-session-mode`:** v0.5.3 (persistent session mode via tmux). 20 commits. All automated tests + `go vet` + cross-compile (`just build-linux` produces a static ELF) green on the Windows host. The Linux binary at `dist/ctask-linux-amd64` runs and reports `ctask v0.5.3` under WSL `debian-dev`. +- **Pending action:** the user runs the manual smoke checklist at `docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-checklist.md` (v2 — explicit terminals, corrected expectations) and reports PASS/FAIL per section. On all-PASS we merge the branch to `main`, `just install` to refresh the installed binary, and optionally `git tag v0.5.3`. +- Remote: none (local-only, intentional — see `CLAUDE.md`). +- `ctask doctor` reports 5 pass/fail + 2 seed-directory + 1 `CTASK_PROJECT_ROOT` check (all three-state). Once v0.5.3 lands, doctor adds an INFO line for `Session mode: direct|persistent` plus an INFO/FAIL line for tmux when persistent. ### What v0.4 delivered (still true, unchanged) @@ -136,8 +132,9 @@ Covered in v0.4.1 notes. The exit-code gate (`childExitCode != 0 && startManifes ## Tree state at pause -- `main` clean with respect to v0.4, v0.4.1, v0.5, v0.5.1. -- Installed `ctask.exe` is **v0.5.1** — no reinstall needed unless source changes. +- `main` clean with respect to v0.4, v0.4.1, v0.5, v0.5.1, v0.5.2. Latest tip is `e448eff docs(v0.5.2): record v0.5.2 completion in notes.md`. +- HEAD is on `feat/v0.5.3-persistent-session-mode` (20 commits ahead of main, 0 behind). Branch was created cleanly from `main` at the start of v0.5.3 work. +- Installed `ctask.exe` is **v0.5.2** — DO NOT reinstall yet. Wait until v0.5.3 has passed manual smoke + been merged. - Untracked files (do NOT touch without asking): - `.claude/settings.local.json` (modified — Claude Code local settings) - `bugfix-provisional-workspace.md` (spec for the 2026-04-22 initial provisional fix; may be deleted or archived) @@ -145,10 +142,46 @@ Covered in v0.4.1 notes. The exit-code gate (`childExitCode != 0 && startManifes - `docs/superpowers/plans/2026-04-21-v0.4-implementation.md` (v0.4 plan — executed) - `docs/superpowers/plans/2026-04-22-v0.4.1-patch.md` (v0.4.1 plan — executed) - `docs/superpowers/plans/2026-04-22-v0.5-implementation.md` (v0.5 plan — executed) - - `v0.4-spec.md`, `v0.4.1-patch-spec.md`, `v0.5-spec.md` (specs the implementations followed) + - `v0.4-spec.md`, `v0.4.1-patch-spec.md`, `v0.5-spec.md`, `v0.5.3-spec.md` (specs the implementations followed) +- Files committed ON the v0.5.3 branch (already tracked, not in the untracked list above): + - `docs/superpowers/plans/2026-05-08-v0.5.3-implementation.md` — the executed plan + - `docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-log.md` — automated portions of Task 17 (cross-compile, version, native-Windows refusal, doctor on both platforms — all PASS) + - `docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-checklist.md` — manual checklist v2 for the user to run ## How to resume +### Completing the v0.5.3 ship (current pending action) + +The v0.5.3 branch is ready — only manual WSL smoke verification stands between +it and `main`. + +```powershell +cd C:\Users\Warren\claude_tasks\ctask +git checkout feat/v0.5.3-persistent-session-mode +just test # all green on Windows +just build-linux # produces dist/ctask-linux-amd64 (static ELF) +``` + +Then the user runs through `docs/superpowers/plans/2026-05-08-v0.5.3-smoke-test-checklist.md` +in WSL (three terminals: WSL-A, WSL-B, PS-C) and reports PASS/FAIL per section. + +After all-PASS: + +```powershell +git checkout main +git merge --no-ff feat/v0.5.3-persistent-session-mode +just install # refresh installed binary to v0.5.3 +ctask --version # expect: ctask v0.5.3 +git branch -d feat/v0.5.3-persistent-session-mode +git tag v0.5.3 # optional +``` + +If anything fails: capture the exact output (especially around the +adopted-reattach summary fields and the tmux session-name hash) and feed +it back to the next session. + +### General resume (when on main after a ship) + ```powershell cd C:\Users\Warren\claude_tasks\ctask just test # go test ./... -count=1 @@ -242,6 +275,22 @@ ctask list --projects - **Cobra adds the `completion` subcommand lazily on first `Execute()`.** A test that calls `rootCmd.Find("completion")` before any `Execute()` returns "unknown command". For unit tests, prefer the `rootCmd.GenXxxCompletion(...)` generators directly. For end-to-end, one `SetArgs(...)` + `Execute()` per test — running multiple `Execute()` calls in succession with different shell args has state issues. - **`notes` uses `SilenceErrors: true`** so the `[ctask] no notes.md found in workspace "X"` stderr line is the only diagnostic the user/agent sees. Don't set `SilenceErrors: false` and add a `[ctask]` prefix to the returned error message — Cobra would then print both, doubling the message. +### From v0.5.3 (new — don't unlearn) [pending merge to main] + +- **`CTASK_SESSION_MODE` is the only persistent-mode trigger.** No flag promotes a single command to persistent. `ctask attach` is the inverse — it always uses tmux regardless of env. Don't add a `--persistent` flag; the existing `direct` ↔ `persistent` ↔ `attach` triangle covers every use case. +- **tmux command construction lives in exactly one place per operation** — `internal/shell/tmux.go`. `AttachExisting` and `AdoptExistingPersistentSession` use shell primitives via test seams (`adoptAttacher`, `adoptPoll`, `attacher`); they do NOT hand-roll their own `exec.Command("tmux", ...)` calls. If you find a fresh `exec.Command("tmux", ...)` outside `internal/shell/tmux.go`, that's drift — fix it. +- **`session.Run` never calls `exec.LookPath("tmux")`.** The cmd-layer preflight (`cmd/persistent.go::preflightPersistentEntry`) is the single source of truth for tmux discovery; the validated path flows through `LaunchOpts.TmuxPath`. `Run` errors if `TmuxPath == ""` in persistent mode. +- **The persistent-mode dispatcher is `cmd/entry.go::dispatchPersistent(hasTmuxSession, leaseState)` — a pure function.** Three outcomes: `dispatchOwnerCreate`, `dispatchPassive`, `dispatchAdopted`. The cmd-layer `runWorkspaceEntry` is a package-level variable (test seam); per-command tests stub it to assert each entry command produces the right `WorkspaceEntryOptions`. Don't move the decision into the session package — it depends on cmd-layer prompts (fresh_remote confirmation, --direct bypass). +- **Session names are deterministic via `session.SessionName(category, slug, absWsPath)`.** Format: `ctask---`. On Windows the path is lowercased before hashing to match `searchRootKey`. Don't change the algorithm — name stability across runs is what makes passive reattach work without state. tmux's status bar truncates the name aggressively (e.g., `[ctask-pro0:bash*]`) — that's a tmux display thing, not a ctask bug. +- **`AdoptExistingPersistentSession` bumps `task.yaml.UpdatedAt` ONLY on successful adoption, not on the race-guard fall-through.** The `TestAdoptionBumpsUpdatedAtOnSuccess` and `TestAdoptionRaceGuardFallsThroughAndDoesNotBumpUpdatedAt` tests enforce both branches. +- **A fresh remote lease (`LeaseStateFreshRemote`) is NEVER silently overwritten.** `cmd/persistent.go::confirmFreshRemoteAdoption` prompts on TTY, refuses on non-TTY (with the remote hostname in the error). Don't drop the prompt or relax the non-TTY refusal. +- **`shouldRunProvisional(opts)` gates `handleProvisional` and is `false` in persistent mode.** The "user hit Esc → empty diff → reclaim workspace" UX assumption does not translate to tmux, where the polling loop typically reports clean exit even on abrupt session kills. The four-row table test `TestShouldRunProvisional` enforces this. +- **`finalize` stamps `EndReason` / `DetectedVia` / `SessionOwnership` based on `opts.SessionMode`** — direct: `child_exited` / `child_exit`; persistent owner-create: `tmux_session_ended` / `polling` / `created`; adopted: same plus `adopted` and `AdoptedFromOrphanAt`. These fields are `omitempty` so pre-v0.5.3 summaries continue to round-trip. +- **The tmux polling cadence is 3s (`shell.PollInterval`).** Below the 30s heartbeat interval so finalize lag is bounded; above 1s so exec overhead is negligible. Don't lower below 1s without measuring impact. +- **Native Windows refuses persistent mode with a WSL recommendation.** The check is `runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == ""`. WSL sets `WSL_DISTRO_NAME` automatically, so WSL paths pass. Don't replace this with a `runtime.GOOS == "linux"` allowlist — that breaks macOS. +- **`ctask new` runs the persistent preflight BEFORE `workspace.Create`.** A missing tmux must not leave a half-initialized workspace on disk. The `cmd/new.go` ordering is load-bearing — don't move the preflight after creation. +- **The `[ctask] adopting orphaned persistent session...` line is the discriminator** between passive reattach and adoption in user-visible output. Don't suppress it; the manual smoke test relies on it. + ## Open follow-ups (NOT in v0.4/v0.4.1/v0.5, deferred) ### Potentially worth doing @@ -316,3 +365,33 @@ For the v0.4 surface: - **v0.5.1:** Do not switch the directory prefix / ID back to UTC. The `TestCreateDirectoryPrefixUsesLocalDate` test enforces local time. - **v0.5.1:** Do not remove `.Local()` from the `ctask info` Created/Updated/Archived formatting. `TestInfoFormatsTimestampsInLocalZone` enforces local display. - **v0.5.1:** Do not change *stored* timestamps (task.yaml, session logs, lease, manifest, summary) to local time. UTC storage is deliberate — only display converts. +- **v0.5.3:** Do not call `exec.Command("tmux", ...)` outside `internal/shell/tmux.go`. The single-construction-site rule is what makes passive reattach and adoption stay in sync. Test seams (`adoptAttacher`, `adoptPoll`, `attacher`) wrap the primitives — they don't replace them. +- **v0.5.3:** Do not move tmux discovery into `session.Run`. `cmd/persistent.go::preflightPersistentEntry` is the single source of truth; the validated path flows through `LaunchOpts.TmuxPath`. +- **v0.5.3:** Do not enable provisional cleanup in persistent mode. `shouldRunProvisional` returns false on `SessionMode == "persistent"` — the gate's UX assumption doesn't translate to tmux. +- **v0.5.3:** Do not silently overwrite a fresh remote lease. `confirmFreshRemoteAdoption` prompts on TTY and refuses on non-TTY. The non-TTY refusal carries the remote hostname so the user can disambiguate. +- **v0.5.3:** Do not run the persistent preflight after `workspace.Create` in `cmd/new.go`. Pre-create ordering prevents half-initialized workspaces when tmux is missing. +- **v0.5.3:** Do not change the `SessionName` algorithm. Name stability across processes is what makes passive reattach work without state. Windows path lowercasing matches `searchRootKey`. + +## What v0.5.3 delivered + +Persistent session mode is in. Key user-facing surfaces: + +- New env var `CTASK_SESSION_MODE` (`direct` | `persistent`); `direct` is the default and requires no setup. +- `ctask attach ` — always-tmux entry command. Defaults to launching the agent. +- `--direct` flag on `new` / `resume` / `last` / `open` to bypass persistent mode for one invocation, with confirmation when a tmux session already exists. +- `ctask doctor` now reports tmux presence and version when persistent mode is configured. + +Architecture notes: + +- tmux is invoked via a three-call pattern (`has-session`, `new-session -d`, `attach-session`) with a 3-second polling loop to detect session end. The polling cadence is below the 30-second heartbeat interval, so finalize lag is bounded. +- Session names are deterministic: `ctask---`, where the hash is the first 6 hex chars of `sha256(canonical absolute workspace path)`. On Windows the path is lowercased before hashing. +- Three entry paths (owner-create, passive reattach, adopted reattach) are picked based on tmux session existence and `InspectLease` four-state classification. +- Adoption transfers ownership under the metadata write lock with a re-check race guard. The previous lease is replaced, `task.yaml.UpdatedAt` is bumped, a fresh start manifest is captured, and finalize stamps `session_ownership: "adopted"` plus `adopted_from_orphan_at`. +- The v0.4 four-layer concurrency model is preserved verbatim. Layer 3 is selectively skipped on reattach paths because no reliable end_manifest baseline exists from a previous orphaned owner. +- Provisional cleanup is bypassed in persistent mode — the gate's UX assumption ("Esc on prompt -> empty diff") does not translate to tmux. +- `last-session-summary.json` gains four optional fields (`end_reason`, `detected_via`, `session_ownership`, `adopted_from_orphan_at`); pre-v0.5.3 summaries continue to load. + +Out of scope (deferred to future releases): +- Native Windows persistent mode (PSmux is a candidate; not committed). +- Config file (`~/.config/ctask/config.yaml`) — env var remains the only config surface until v0.6. +- `switch-client` for nested-tmux entry, `tmux wait-for` / `set-hook`-based detection, banner injection inside tmux, `ctask sessions` listing command.