feat(v0.5.3): LaunchOpts.SessionMode/TmuxPath; shouldRunProvisional gate
This commit is contained in:
+70
-4
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user