fix: remove provisional workspace when launch is canceled with no changes
When `ctask new` launched the agent or shell and the child exited without touching any files (e.g. the user canceled at Claude Code's trust prompt), the workspace was left behind as empty clutter. Now, if the invocation just created the workspace and the manifest diff is empty (zero added, modified, deleted), the workspace directory is removed and finalize is skipped. resume/open/last are unaffected (NewlyCreated defaults to false), and --no-launch is unaffected (session.Run is never called). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+7
-6
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user