diff --git a/cmd/new.go b/cmd/new.go index 09b3f3a..1853674 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -122,11 +122,12 @@ func runNew(cmd *cobra.Command, args []string) error { envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta)) return session.Run(session.LaunchOpts{ - WsDir: ws.Path, - EnvVars: envVars, - Agent: agent, - Mode: ws.Meta.Mode, - Slug: ws.Meta.Slug, - Shell: newShell, + WsDir: ws.Path, + EnvVars: envVars, + Agent: agent, + Mode: ws.Meta.Mode, + Slug: ws.Meta.Slug, + Shell: newShell, + NewlyCreated: true, }) } diff --git a/internal/session/run.go b/internal/session/run.go index 56f3c83..5e7727f 100644 --- a/internal/session/run.go +++ b/internal/session/run.go @@ -23,6 +23,12 @@ type LaunchOpts struct { // stale-workspace warning (Layer 3). It does NOT disable the metadata // write lock or the session summary. Used for scripted/automated runs. Force bool + + // NewlyCreated signals that this invocation just created WsDir (via + // `ctask new`). When true and the session's manifest diff is empty, the + // workspace is treated as provisional and removed on exit — see + // handleProvisional. Set to false by resume/open/last. + NewlyCreated bool } // manifestStartPath returns the path to the start manifest file. @@ -146,6 +152,15 @@ func Run(opts LaunchOpts) error { hb.Stop() } + // ---- Provisional-workspace cleanup ---- + // If `ctask new` launched this session and the child exited without + // changing any files, remove the workspace entirely and skip finalize. + // Nothing to log, nothing to summarize, and the .ctask state files die + // with the directory. + if handleProvisional(opts, startManifest) { + return childErr + } + // ---- Post-session: manifest + summary + cleanup ---- endTime := time.Now().UTC().Truncate(time.Second) if startManifest != nil { diff --git a/internal/session/run_provisional.go b/internal/session/run_provisional.go new file mode 100644 index 0000000..b89f557 --- /dev/null +++ b/internal/session/run_provisional.go @@ -0,0 +1,43 @@ +package session + +import ( + "fmt" + "os" +) + +// handleProvisional removes a workspace that was created by this `ctask new` +// invocation but saw no real work. "No real work" is defined strictly as an +// empty manifest diff — zero added, zero modified, zero deleted. No other +// heuristic is used. +// +// 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. +// +// 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) bool { + if !opts.NewlyCreated { + return false + } + // If the start manifest failed to capture we cannot prove the diff is + // empty, so err on the side of keeping the workspace. + if startManifest == nil { + return false + } + endManifest, err := CaptureManifest(opts.WsDir) + if err != nil { + return false + } + diff := DiffManifests(startManifest, endManifest) + if len(diff.Added) != 0 || len(diff.Modified) != 0 || len(diff.Deleted) != 0 { + return false + } + + if rmErr := os.RemoveAll(opts.WsDir); rmErr != nil { + fmt.Fprintf(os.Stderr, "[ctask] warning: could not remove provisional workspace: %v\n", rmErr) + return false + } + fmt.Fprintln(os.Stderr, "[ctask] launch canceled with no changes; removed provisional workspace") + return true +} diff --git a/internal/session/run_provisional_test.go b/internal/session/run_provisional_test.go new file mode 100644 index 0000000..7aa3c7f --- /dev/null +++ b/internal/session/run_provisional_test.go @@ -0,0 +1,130 @@ +package session + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// newWorkspace creates a seeded workspace directory inside a per-test temp +// dir and returns its path plus the captured start manifest. +func newWorkspace(t *testing.T) (string, *Manifest) { + t.Helper() + wsDir := filepath.Join(t.TempDir(), "ws") + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("mkdir wsDir: %v", err) + } + if err := os.WriteFile(filepath.Join(wsDir, "notes.md"), []byte("seed"), 0644); err != nil { + t.Fatalf("seed notes.md: %v", err) + } + m, err := CaptureManifest(wsDir) + if err != nil { + t.Fatalf("CaptureManifest: %v", err) + } + return wsDir, m +} + +func TestHandleProvisionalRemovesWhenNewlyCreatedAndEmptyDiff(t *testing.T) { + wsDir, startManifest := newWorkspace(t) + + opts := LaunchOpts{WsDir: wsDir, NewlyCreated: true} + if !handleProvisional(opts, startManifest) { + t.Fatal("expected handleProvisional to return true (workspace removed)") + } + if _, err := os.Stat(wsDir); !errors.Is(err, os.ErrNotExist) { + t.Errorf("expected wsDir to be removed, got err=%v", err) + } +} + +func TestHandleProvisionalKeepsWhenNotNewlyCreated(t *testing.T) { + wsDir, startManifest := newWorkspace(t) + + opts := LaunchOpts{WsDir: wsDir, NewlyCreated: false} + if handleProvisional(opts, startManifest) { + t.Fatal("expected handleProvisional to return false when NewlyCreated is false") + } + if _, err := os.Stat(wsDir); err != nil { + t.Errorf("expected wsDir to still exist, got err=%v", err) + } +} + +func TestHandleProvisionalKeepsWhenDiffNonEmpty(t *testing.T) { + wsDir, startManifest := newWorkspace(t) + + // Simulate real work by adding a new file after the start manifest. + if err := os.WriteFile(filepath.Join(wsDir, "output.md"), []byte("new"), 0644); err != nil { + t.Fatalf("write output.md: %v", err) + } + + opts := LaunchOpts{WsDir: wsDir, NewlyCreated: true} + if handleProvisional(opts, startManifest) { + t.Fatal("expected handleProvisional to return false when diff has changes") + } + if _, err := os.Stat(wsDir); err != nil { + t.Errorf("expected wsDir to still exist, got err=%v", err) + } +} + +func TestHandleProvisionalKeepsWhenStartManifestNil(t *testing.T) { + wsDir := filepath.Join(t.TempDir(), "ws") + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + opts := LaunchOpts{WsDir: wsDir, NewlyCreated: true} + if handleProvisional(opts, nil) { + t.Fatal("expected handleProvisional to return false when startManifest is nil") + } + if _, err := os.Stat(wsDir); err != nil { + t.Errorf("expected wsDir to still exist, got err=%v", err) + } +} + +func TestHandleProvisionalRemovesShellSession(t *testing.T) { + // The fix is not agent-specific: a --shell launch with no file changes + // must also be cleaned up. + wsDir, startManifest := newWorkspace(t) + + opts := LaunchOpts{WsDir: wsDir, NewlyCreated: true, Shell: true} + if !handleProvisional(opts, startManifest) { + t.Fatal("expected handleProvisional to remove workspace for shell session with no changes") + } + if _, err := os.Stat(wsDir); !errors.Is(err, os.ErrNotExist) { + t.Errorf("expected wsDir to be removed, got err=%v", err) + } +} + +func TestHandleProvisionalRemovalWipesV04State(t *testing.T) { + // Removing the workspace directory must also remove all v0.4 state inside + // .ctask/ (session.json, manifest-start.json, last-session-summary.json, + // write.lock) with no separate cleanup required. + wsDir, startManifest := newWorkspace(t) + + ctaskDir := filepath.Join(wsDir, ".ctask") + if err := os.MkdirAll(ctaskDir, 0755); err != nil { + t.Fatalf("mkdir .ctask: %v", err) + } + stateFiles := []string{ + "session.json", + "manifest-start.json", + "last-session-summary.json", + "write.lock", + } + for _, name := range stateFiles { + if err := os.WriteFile(filepath.Join(ctaskDir, name), []byte("x"), 0644); err != nil { + t.Fatalf("seed %s: %v", name, err) + } + } + + opts := LaunchOpts{WsDir: wsDir, NewlyCreated: true} + if !handleProvisional(opts, startManifest) { + t.Fatal("expected handleProvisional to remove workspace") + } + for _, name := range stateFiles { + p := filepath.Join(ctaskDir, name) + if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) { + t.Errorf("expected %s to be gone after workspace removal, got err=%v", p, err) + } + } +}