342 lines
12 KiB
Go
342 lines
12 KiB
Go
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
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
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
|
|
}
|