fix: only remove provisional workspace when child exits non-zero

Adds a child-exit-code guard to handleProvisional so a `ctask new`
workspace is reclaimed only when the agent was actually canceled
before real work (trust prompt rejected, Esc during startup, Ctrl+C
mid-launch — all confirmed empirically as exit code 1). A zero exit
means the user entered the agent and exited cleanly, which is a
legitimate workflow that must preserve the workspace even with an
empty manifest diff — for example when the user wants the workspace
directory established so they can populate context/ before resuming.

The exit code reaches handleProvisional via a new childExitCode
helper that unwraps *exec.ExitError from cmd.Run's return. Non-exit
errors (agent not found, OS-level failure) map to -1 so the
workspace is still cleaned up — the child never actually ran.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 19:03:17 -04:00
parent 35d3b24e09
commit ba8b3a19f9
3 changed files with 61 additions and 17 deletions
+21 -5
View File
@@ -3,6 +3,7 @@ package session
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
@@ -153,11 +154,12 @@ func Run(opts LaunchOpts) error {
}
// ---- 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) {
// If `ctask new` launched this session, the child exited non-zero
// (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)) {
return childErr
}
@@ -173,6 +175,20 @@ func Run(opts LaunchOpts) error {
return childErr
}
// 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
// failure) so provisional cleanup still kicks in — the child never ran.
func childExitCode(err error) int {
if err == nil {
return 0
}
if ee, ok := err.(*exec.ExitError); ok {
return ee.ExitCode()
}
return -1
}
// finalize runs all end-of-session metadata writes under one write-lock
// acquisition: session log append (best-effort), last-session-summary.json
// (best-effort), lease removal (best-effort, if we owned it), and