feat(v0.5.3): LaunchOpts.SessionMode/TmuxPath; shouldRunProvisional gate

This commit is contained in:
2026-05-08 13:54:39 -04:00
parent 8b82af1598
commit a1309b596e
3 changed files with 104 additions and 13 deletions
+70 -4
View File
@@ -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),
+11 -9
View File
@@ -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
+23
View File
@@ -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)
}
})
}
}