Merge branch 'feat/v0.5.3-persistent-session-mode' into main

v0.5.3: persistent session mode via tmux.

- CTASK_SESSION_MODE env var: direct (default) | persistent
- ctask attach: always-tmux entry command
- --direct flag on new/resume/last/open to bypass persistent mode per invocation
- Deterministic session names: ctask-<category>-<slug>-<sha256_6>
- Three entry paths: owner-create, passive reattach, adopted reattach
- Adoption transfers ownership under metadata write lock with race-guard re-check
- v0.4 four-layer concurrency model preserved; Layer 3 selectively skipped on reattach
- Provisional cleanup bypassed in persistent mode (gate UX assumption doesn't translate)
- last-session-summary.json gains 4 optional fields (end_reason, detected_via,
  session_ownership, adopted_from_orphan_at)
- Native Windows refuses persistent mode with WSL recommendation; --direct bypass works
- Doctor reports session mode + tmux presence/version when persistent
- v0.4 lease prompt now suggests 'ctask attach <slug>' when a tmux session exists
- User-facing command suggestions use filepath.Base(os.Args[0])
- 21 commits including the polish patch (c204d87)
This commit is contained in:
2026-05-14 18:25:13 -04:00
42 changed files with 3854 additions and 87 deletions
+61
View File
@@ -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 <workspace>",
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",
})
}
+102
View File
@@ -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")
}
}
+31 -1
View File
@@ -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)
}
+66
View File
@@ -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)
}
}
+226
View File
@@ -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 <slug>` 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
}
+88
View File
@@ -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)
}
}
+39
View File
@@ -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
// <slug>") 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 ("<name> new <workspace> --direct",
// "<name> attach <slug>", 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])
}
+6 -4
View File
@@ -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)
}
+43 -9
View File
@@ -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")
}
+49
View File
@@ -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.
+17 -16
View File
@@ -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",
})
}
+84
View File
@@ -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)
}
}
+123
View File
@@ -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 + " <workspace> --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 <host> ctask %s <workspace>\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
}
+168
View File
@@ -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 <workspace> --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 <workspace> --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)
}
}
+22 -22
View File
@@ -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",
})
}
+73
View File
@@ -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)
}
}
+7 -1
View File
@@ -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)
+1 -1
View File
@@ -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",
+104
View File
@@ -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-<category>-<slug>-<hash6>`.
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 <workspace>`
`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.
@@ -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": "<today>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 <host> ctask resume <workspace>`
- `resume <workspace> --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 <workspace> --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 <workspace> --direct`). Both forms satisfy the
> "resume <workspace> --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 <workspace> --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/<you>/.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 <workspace> --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 <workspace> --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.
@@ -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-<hash> 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: <timestamp>
```
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 <workspace> --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`.
+20
View File
@@ -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 == "~" {
+60
View File
@@ -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)
}
}
+206
View File
@@ -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
}
+224
View File
@@ -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)
}
}
+29
View File
@@ -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)
}
+43
View File
@@ -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)
}
}
+19 -1
View File
@@ -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 <slug> 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()
}
+66
View File
@@ -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 <wsDir>/.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
}
+106
View File
@@ -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)
}
}
}
+40 -1
View File
@@ -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) {
+81 -8
View File
@@ -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),
+8 -1
View File
@@ -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 <slug>` 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
}
+11 -9
View File
@@ -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
+23
View File
@@ -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)
}
})
}
}
+68
View File
@@ -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>-<slug>-<hash6>.
//
// 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
}
+84
View File
@@ -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)
}
}
+19
View File
@@ -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
+61
View File
@@ -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)
}
}
}
+230
View File
@@ -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
}
+171
View File
@@ -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)
}
}
+92 -13
View File
@@ -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-<sanitized-category>-<sanitized-slug-truncated-30>-<sha256_6>`. 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 <workspace>` — 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-<category>-<slug>-<hash6>`, 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.