103f2cd33e
LaunchOpts gains LaunchDir. session.Run resolves it via workspace.ResolveLaunch, prints any fallback warning, and passes the absolute path as the child process's working directory. Security violations (absolute paths, .. escape) abort the session. The banner gains a 'project dir: <name>/' line when launch_dir is set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
276 lines
9.5 KiB
Go
276 lines
9.5 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
|
|
|
|
// 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
|
|
if opts.Shell {
|
|
childErr = shell.ExecShell(launchAbs, opts.EnvVars, opts.Slug, opts.Mode)
|
|
} else {
|
|
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.
|
|
if 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
|
|
}
|
|
|
|
// 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,
|
|
)
|
|
|
|
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
|
|
}
|