diff --git a/internal/session/run.go b/internal/session/run.go index 4a27c7f..56f3c83 100644 --- a/internal/session/run.go +++ b/internal/session/run.go @@ -6,6 +6,7 @@ import ( "path/filepath" "time" + "github.com/warrenronsiek/ctask/internal/lockfile" "github.com/warrenronsiek/ctask/internal/shell" ) @@ -29,78 +30,196 @@ func manifestStartPath(wsDir string) string { return filepath.Join(wsDir, ".ctask", "manifest-start.json") } -// Run launches an agent or shell session with pre/post manifest capture and session logging. -// Returns the child process error (for exit code propagation). -func Run(opts LaunchOpts) error { - startTime := time.Now().UTC().Truncate(time.Second) +// ctaskWriteLockPath returns the path to the workspace write lock file. +func ctaskWriteLockPath(wsDir string) string { + return filepath.Join(wsDir, ".ctask", "write.lock") +} - // Pre-session: capture start manifest - startManifest, err := CaptureManifest(opts.WsDir) +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: failed to capture start manifest: %v\n", err) - // Continue anyway -- never block the user + 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 } - if startManifest != nil { - mPath := manifestStartPath(opts.WsDir) - if err := WriteManifest(mPath, startManifest); err != nil { - fmt.Fprintf(os.Stderr, "[ctask] warning: failed to write start manifest: %v\n", err) + 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 } } - // Launch the session + // ---- 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 { - // Print banner before agent launch for _, line := range shell.BannerLines(opts.Mode, opts.Slug, opts.WsDir) { fmt.Println(line) } childErr = shell.ExecAgent(opts.Agent, opts.WsDir, opts.EnvVars) } - // Post-session: capture end manifest and log - endTime := time.Now().UTC().Truncate(time.Second) + if hb != nil { + hb.Stop() + } + // ---- Post-session: manifest + summary + cleanup ---- + endTime := time.Now().UTC().Truncate(time.Second) if startManifest != nil { - if logErr := captureAndLog(opts, startManifest, startTime, endTime); logErr != nil { - fmt.Fprintf(os.Stderr, "[ctask] warning: session logging failed: %v\n", logErr) - // Keep manifest-start.json for debugging - } else { - // Clean up manifest-start.json on success - os.Remove(manifestStartPath(opts.WsDir)) + 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 } -// captureAndLog captures the end manifest, diffs, and appends to session log. -func captureAndLog(opts LaunchOpts, startManifest *Manifest, startTime, endTime time.Time) error { +// 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: opts.Agent, + Agent: agent, Mode: opts.Mode, StartTime: startTime, EndTime: endTime, Diff: diff, } - // For shell sessions, record "shell" as agent - if opts.Shell { - info.Agent = "shell" + // 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 + } } - if err := AppendSessionLog(opts.WsDir, info); err != nil { - return fmt.Errorf("appending session log: %w", err) - } + summary := SummarizeFromDiff( + sessionID, currentHostname(), agent, opts.Mode, + startTime, endTime, diff, endManifest, + ) - return nil + 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 }