package session import ( "fmt" "os" "os/exec" "path/filepath" "time" "github.com/warrenronsiek/ctask/internal/lockfile" "github.com/warrenronsiek/ctask/internal/shell" "github.com/warrenronsiek/ctask/internal/workspace" ) // LaunchOpts configures a session launch. type LaunchOpts struct { WsDir string EnvVars map[string]string Agent string Mode string Slug string Shell bool // true = interactive shell, false = agent // LaunchDir is the workspace-relative launch directory (v0.5). Empty for // tasks and pre-v0.5 projects. When set, Run resolves the absolute path // via workspace.ResolveLaunch and uses it as the child's working dir; // 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. Force bool // NewlyCreated signals that this invocation just created WsDir (via // `ctask new`). When true and the session's manifest diff is empty, the // 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. func manifestStartPath(wsDir string) string { return filepath.Join(wsDir, ".ctask", "manifest-start.json") } // ctaskWriteLockPath returns the path to the workspace write lock file. func ctaskWriteLockPath(wsDir string) string { return filepath.Join(wsDir, ".ctask", "write.lock") } const ( sessionWriteLockTimeout = 2 * time.Second sessionWriteLockStaleAfter = 10 * time.Second ) // Run launches an agent or shell session. It sequences the full v0.4 // concurrency-protection lifecycle: // // 1. Layer 3: stale-workspace detection (warn if files changed outside a // ctask session; skipped if Force is true). // 2. Layer 1: active-session lease check (warn or auto-clean stale; // skipped if Force is true). // 3. Layer 2: write lock taken to install the new lease (unless coexisting // with an already-active lease, in which case no lease is written). // 4. Start manifest capture + write. // 5. Launch context banner (Layer 4 — prints the last-session summary). // 6. Heartbeat goroutine starts (Layer 1) when we own a lease. // 7. Agent/shell runs to completion. // 8. Heartbeat stops. // 9. End manifest + diff. // 10. Write lock taken to append log, write summary (Layer 4), remove // lease (Layer 1), remove start manifest. // // Returns the child process's error (for exit-code propagation). 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, ActiveLeaseHint: opts.ActiveLeaseHint, }) if err != nil { fmt.Fprintf(os.Stderr, "[ctask] Warning: preflight check failed: %v\n", err) // Continue anyway — preflight errors must never block the session. } if !preflight.Proceed { return nil } startTime := time.Now().UTC().Truncate(time.Second) // ---- Install a lease unless we're coexisting with an active one ---- // Lease creation is "important but non-fatal": if the lock times out, // the session runs without concurrency protection; make that visible. leasePath := LeasePath(opts.WsDir) ownLease := !preflight.ActiveLeaseFound if ownLease { lease := NewLease(startTime, opts.Agent, opts.Mode) skipped, lockErr := lockfile.WithLock( ctaskWriteLockPath(opts.WsDir), sessionWriteLockTimeout, sessionWriteLockStaleAfter, func() error { return WriteLease(leasePath, lease) }, ) if lockErr != nil { fmt.Fprintf(os.Stderr, "[ctask] Warning: could not write session lease: %v; this session will not be visible to concurrent ctask processes\n", lockErr) ownLease = false } if skipped { fmt.Fprintf(os.Stderr, "[ctask] Warning: could not acquire metadata lock, skipping write to %s; this session will not be visible to concurrent ctask processes\n", leasePath) ownLease = false } } // ---- Start manifest (important but non-fatal) ---- startManifest, err := CaptureManifest(opts.WsDir) if err != nil { fmt.Fprintf(os.Stderr, "[ctask] warning: failed to capture start manifest: %v; end-of-session diff will be skipped\n", err) } if startManifest != nil { mPath := manifestStartPath(opts.WsDir) skipped, lockErr := lockfile.WithLock( ctaskWriteLockPath(opts.WsDir), sessionWriteLockTimeout, sessionWriteLockStaleAfter, func() error { return WriteManifest(mPath, startManifest) }, ) if skipped { fmt.Fprintf(os.Stderr, "[ctask] Warning: could not acquire metadata lock, skipping write to %s; end-of-session diff will be skipped\n", mPath) startManifest = nil } else if lockErr != nil { fmt.Fprintf(os.Stderr, "[ctask] Warning: could not write start manifest: %v\n", lockErr) startManifest = nil } } // ---- Launch context banner (Layer 4 — best-effort read) ---- if summary, err := ReadSummary(SummaryPath(opts.WsDir)); err == nil && summary != nil { fmt.Fprint(os.Stderr, FormatLaunchContext(summary)) } // ---- Heartbeat (Layer 1) ---- var hb *Heartbeat if ownLease { hb = StartHeartbeat(leasePath, HeartbeatInterval) } // ---- Resolve launch directory (v0.5) ---- // Security violations (absolute path, ..-escape) abort the session // before we hand control to the child. Missing/non-dir falls back to // wsDir with a warning. launchAbs, launchWarn, launchErr := workspace.ResolveLaunch(opts.WsDir, opts.LaunchDir) if launchErr != nil { return fmt.Errorf("resolving launch_dir: %w", launchErr) } if launchWarn != "" { fmt.Fprintln(os.Stderr, launchWarn) } // ---- Run the child ---- var childErr error 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) default: for _, line := range shell.BannerLines(opts.Mode, opts.Slug, opts.WsDir, opts.LaunchDir) { fmt.Println(line) } childErr = shell.ExecAgent(opts.Agent, launchAbs, opts.EnvVars) } if hb != nil { hb.Stop() } // ---- Provisional-workspace cleanup ---- // If `ctask new` launched this session, the child exited non-zero // (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. Persistent (tmux) mode bypasses this gate via // shouldRunProvisional. if shouldRunProvisional(opts) && handleProvisional(opts, startManifest, childExitCode(childErr)) { return childErr } // ---- Post-session: manifest + summary + cleanup ---- endTime := time.Now().UTC().Truncate(time.Second) if startManifest != nil { if err := finalize(opts, startManifest, startTime, endTime, ownLease); err != nil { fmt.Fprintf(os.Stderr, "[ctask] warning: session finalize failed: %v\n", err) // Leave manifest-start.json for debugging on failure. } } 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 // failure) so provisional cleanup still kicks in — the child never ran. func childExitCode(err error) int { if err == nil { return 0 } if ee, ok := err.(*exec.ExitError); ok { return ee.ExitCode() } return -1 } // finalize runs all end-of-session metadata writes under one write-lock // acquisition: session log append (best-effort), last-session-summary.json // (best-effort), lease removal (best-effort, if we owned it), and // manifest-start.json cleanup. func finalize(opts LaunchOpts, startManifest *Manifest, startTime, endTime time.Time, ownLease bool) error { endManifest, err := CaptureManifest(opts.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, } // Pull the session ID from the lease (if we own one) so the summary // references the same ID used during the session. Fall back to a // constructed ID to keep the summary self-describing when leaseless. sessionID := NewSessionID(currentHostname(), os.Getpid(), startTime) if ownLease { if l, err := ReadLease(LeasePath(opts.WsDir)); err == nil && l != nil { sessionID = l.SessionID } } summary := SummarizeFromDiff( 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), sessionWriteLockTimeout, sessionWriteLockStaleAfter, func() error { if err := AppendSessionLog(opts.WsDir, info); err != nil { fmt.Fprintf(os.Stderr, "[ctask] warning: append session log failed: %v\n", err) } if err := WriteSummary(SummaryPath(opts.WsDir), summary); err != nil { return fmt.Errorf("write summary: %w", err) } if ownLease { if rmErr := os.Remove(LeasePath(opts.WsDir)); rmErr != nil && !os.IsNotExist(rmErr) { fmt.Fprintf(os.Stderr, "[ctask] warning: could not remove lease: %v\n", rmErr) } } if rmErr := os.Remove(manifestStartPath(opts.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, skipping session-end writes (summary and lease cleanup)\n") return nil } return lockErr }