feat(v0.4): sequence session.Run through Layers 1-4
This commit is contained in:
+152
-33
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user