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:
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
@@ -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 == "~" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user