Files
ctask/internal/session/run.go
T
typebasedio b75b82e676 feat(v0.6): launch path carries ResolvedAgent (command + args + env)
LaunchOpts.Agent (string) and WorkspaceEntryOptions.Agent (string) are
replaced by *agent.Resolved, carrying Command, Args, and Env. The five
entry commands (new, resume, last, open, attach) each construct an
AgentSpec from the workspace metadata, apply --agent as a one-shot
agent.command override (Open Q 1 — keeps muscle memory for users
passing executable paths), call agent.Resolve, and pass the result
through. resolveEntryAgent centralises the resume/last/open/attach path.

shell.ExecAgent and shell.ExecTmuxAgent gain an args parameter; agent.env
is merged into the env map at the session.Run launch switch, AFTER
ctask's exported CTASK_* vars (per spec §5: agent.env wins on collision).
mergeAgentEnv is the centralised merge.

Lease, manifest, write lock, heartbeat, summary, and provisional
cleanup are unchanged. The Agent string fields on Lease, SessionSummary,
and SessionInfo continue to record the launched command for diagnostics.
2026-05-15 11:08:03 -04:00

399 lines
14 KiB
Go

package session
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/warrenronsiek/ctask/internal/agent"
"github.com/warrenronsiek/ctask/internal/lockfile"
"github.com/warrenronsiek/ctask/internal/shell"
"github.com/warrenronsiek/ctask/internal/workspace"
)
// execAgent is the test seam for the direct-mode child launch. Tests
// replace this to capture the (command, args, wsDir, env) tuple Run
// would have passed to shell.ExecAgent. Restored via t.Cleanup.
var execAgent = shell.ExecAgent
// mergeAgentEnv overlays agentEnv on top of base, returning a new map.
// Per v0.6 spec §5 ("merged into the agent's environment, after ctask's
// own exported vars"), agentEnv keys WIN on collision — the spec is
// explicit that user-supplied env vars take precedence. Callers that
// want a warning for shadowed CTASK_* keys must emit it themselves
// (ctask agents check surfaces this).
func mergeAgentEnv(base, agentEnv map[string]string) map[string]string {
out := make(map[string]string, len(base)+len(agentEnv))
for k, v := range base {
out[k] = v
}
for k, v := range agentEnv {
out[k] = v
}
return out
}
// leaseAgentCommand returns the command string recorded in leases and
// summaries for diagnostics. A nil ResolvedAgent yields "" (only happens
// in tests that do not exercise a launch).
func leaseAgentCommand(opts LaunchOpts) string {
if opts.ResolvedAgent != nil {
return opts.ResolvedAgent.Command
}
return ""
}
// LaunchOpts configures a session launch.
type LaunchOpts struct {
WsDir string
EnvVars map[string]string
// ResolvedAgent is the launch-ready agent (command + args + env),
// produced by the cmd layer via agent.Resolve. Required in agent mode;
// in shell mode it is still populated for lease/summary diagnostics.
ResolvedAgent *agent.Resolved
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, leaseAgentCommand(opts), 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)
}
// Agent-launch branches dereference ResolvedAgent; a nil here is an
// internal wiring bug (the cmd layer must always Resolve before Run).
if !opts.Shell && opts.ResolvedAgent == nil {
if hb != nil {
hb.Stop()
}
return fmt.Errorf("internal error: LaunchOpts.ResolvedAgent is nil in agent mode")
}
// ---- 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,
mergeAgentEnv(opts.EnvVars, opts.ResolvedAgent.Env),
opts.ResolvedAgent.Command, opts.ResolvedAgent.Args)
}
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 = execAgent(opts.ResolvedAgent.Command, opts.ResolvedAgent.Args, launchAbs,
mergeAgentEnv(opts.EnvVars, opts.ResolvedAgent.Env))
}
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)
agentCmd := leaseAgentCommand(opts)
if opts.Shell {
agentCmd = "shell"
}
info := &SessionInfo{
Agent: agentCmd,
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(), agentCmd, 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
}