02dcdcc215
When `ctask new` launched the agent or shell and the child exited without touching any files (e.g. the user canceled at Claude Code's trust prompt), the workspace was left behind as empty clutter. Now, if the invocation just created the workspace and the manifest diff is empty (zero added, modified, deleted), the workspace directory is removed and finalize is skipped. resume/open/last are unaffected (NewlyCreated defaults to false), and --no-launch is unaffected (session.Run is never called). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
241 lines
8.1 KiB
Go
241 lines
8.1 KiB
Go
package session
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/warrenronsiek/ctask/internal/lockfile"
|
|
"github.com/warrenronsiek/ctask/internal/shell"
|
|
)
|
|
|
|
// 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
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ---- Run the child ----
|
|
var childErr error
|
|
if opts.Shell {
|
|
childErr = shell.ExecShell(opts.WsDir, opts.EnvVars, opts.Slug, opts.Mode)
|
|
} else {
|
|
for _, line := range shell.BannerLines(opts.Mode, opts.Slug, opts.WsDir) {
|
|
fmt.Println(line)
|
|
}
|
|
childErr = shell.ExecAgent(opts.Agent, opts.WsDir, opts.EnvVars)
|
|
}
|
|
|
|
if hb != nil {
|
|
hb.Stop()
|
|
}
|
|
|
|
// ---- Provisional-workspace cleanup ----
|
|
// If `ctask new` launched this session and the child exited without
|
|
// changing any files, remove the workspace entirely and skip finalize.
|
|
// Nothing to log, nothing to summarize, and the .ctask state files die
|
|
// with the directory.
|
|
if handleProvisional(opts, startManifest) {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|