From a1309b596ec10f64437b1aeae171183182f23581 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 8 May 2026 13:54:39 -0400 Subject: [PATCH] feat(v0.5.3): LaunchOpts.SessionMode/TmuxPath; shouldRunProvisional gate --- internal/session/run.go | 74 +++++++++++++++++++++++++++-- internal/session/run_provisional.go | 20 ++++---- internal/session/run_test.go | 23 +++++++++ 3 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 internal/session/run_test.go diff --git a/internal/session/run.go b/internal/session/run.go index d4ec699..df37cc3 100644 --- a/internal/session/run.go +++ b/internal/session/run.go @@ -27,6 +27,29 @@ type LaunchOpts struct { // 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. @@ -159,9 +182,35 @@ func Run(opts LaunchOpts) error { // ---- Run the child ---- var childErr error - if opts.Shell { + 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, opts.EnvVars, opts.Agent) + } + case opts.Shell: childErr = shell.ExecShell(launchAbs, opts.EnvVars, opts.Slug, opts.Mode) - } else { + default: for _, line := range shell.BannerLines(opts.Mode, opts.Slug, opts.WsDir, opts.LaunchDir) { fmt.Println(line) } @@ -177,8 +226,9 @@ func Run(opts LaunchOpts) error { // (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)) { + // with an empty diff. Persistent (tmux) mode bypasses this gate via + // shouldRunProvisional. + if shouldRunProvisional(opts) && handleProvisional(opts, startManifest, childExitCode(childErr)) { return childErr } @@ -194,6 +244,14 @@ func Run(opts LaunchOpts) error { 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 @@ -245,6 +303,14 @@ func finalize(opts LaunchOpts, startManifest *Manifest, startTime, endTime time. sessionID, currentHostname(), agent, 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), diff --git a/internal/session/run_provisional.go b/internal/session/run_provisional.go index ca1611c..f6ffb87 100644 --- a/internal/session/run_provisional.go +++ b/internal/session/run_provisional.go @@ -10,18 +10,20 @@ import ( // conditions must hold: // // - NewlyCreated is true (so the workspace is ours to reclaim) -// - the child process exited non-zero (trust prompt rejected, Esc during -// startup, Ctrl+C mid-launch — confirmed empirically; a zero exit means -// the user entered the agent and exited cleanly, which is a legitimate -// workflow that must preserve the workspace even with no file changes) +// - the child process exited non-zero // - the manifest diff is empty (nothing to preserve) // -// Removing the workspace directory also removes every v0.4 state file inside -// .ctask/ (session.json, manifest-start.json, last-session-summary.json, -// write.lock), so no separate cleanup of those files is required. +// In persistent (tmux) mode the caller skips this gate entirely via +// shouldRunProvisional; see session.Run and v0.5.3-spec.md §7. The gate's +// UX assumption — "user hit Esc, agent exited non-zero before any work" — +// does not translate to tmux, where the polling loop typically reports a +// clean (zero) exit even when the user kills the session abruptly. // -// Returns true iff the workspace was removed (the caller must then skip the -// normal finalize path, since there is nothing to log or summarize). +// Removing the workspace directory also removes every v0.4 state file +// inside .ctask/, so no separate cleanup is required. +// +// Returns true iff the workspace was removed (the caller must then skip +// the normal finalize path, since there is nothing to log or summarize). func handleProvisional(opts LaunchOpts, startManifest *Manifest, childExitCode int) bool { if !opts.NewlyCreated { return false diff --git a/internal/session/run_test.go b/internal/session/run_test.go new file mode 100644 index 0000000..7641f30 --- /dev/null +++ b/internal/session/run_test.go @@ -0,0 +1,23 @@ +package session + +import "testing" + +func TestShouldRunProvisional(t *testing.T) { + cases := []struct { + name string + opts LaunchOpts + want bool + }{ + {"direct + newly created", LaunchOpts{NewlyCreated: true}, true}, + {"direct + not newly created", LaunchOpts{}, false}, + {"persistent + newly created", LaunchOpts{SessionMode: "persistent", NewlyCreated: true}, false}, + {"persistent + not newly created", LaunchOpts{SessionMode: "persistent"}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := shouldRunProvisional(c.opts); got != c.want { + t.Errorf("got %v, want %v", got, c.want) + } + }) + } +}