diff --git a/internal/session/plan.go b/internal/session/plan.go index de31260..408317e 100644 --- a/internal/session/plan.go +++ b/internal/session/plan.go @@ -73,8 +73,15 @@ func SavePlan(vaultDir string, p Plan) (string, error) { return "", err } path := filepath.Join(dir, name) - if err := os.WriteFile(path, []byte(p.Markdown()), vault.FileMode); err != nil { - return "", err + // Filled through a temp file and a rename, as a checkpoint is: a plain write + // over the claimed name left half a plan under a real plan's name when it + // was cut short (#384). The empty claim is removed on failure too, so a + // save that failed leaves nothing a reader could take for a plan. + if err := vault.WriteAtomic(path, []byte(p.Markdown())); err != nil { + if rmErr := os.Remove(path); rmErr != nil && !os.IsNotExist(rmErr) { + return "", fmt.Errorf("writing plan %s: %w (and its empty placeholder could not be removed: %v)", name, err, rmErr) + } + return "", fmt.Errorf("writing plan %s: %w", name, err) } slug := filepath.ToSlash(filepath.Join(CheckpointDir, safeScope(p.Project), PlanDir, strings.TrimSuffix(name, ".md"))) return slug, nil diff --git a/internal/session/plan_unix_test.go b/internal/session/plan_unix_test.go new file mode 100644 index 0000000..22ebfdf --- /dev/null +++ b/internal/session/plan_unix_test.go @@ -0,0 +1,70 @@ +//go:build unix + +package session + +import ( + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "testing" +) + +// A plan was filled with a plain write over its claimed name, so a write cut +// short — a kill, a full disk — left half a plan under a real plan's name, +// listed and indexed as one (#384). A file-size limit stops the write part way, +// as a full disk does, and nothing torn may be left behind. +func TestAPlanWriteCutShortLeavesNoHalfPlan(t *testing.T) { + // The limit is per process, so it is set in a child: lowered here it also + // caught the test binary's own log once the package wrote past 64KB of it, + // and every test running alongside. + if dir := os.Getenv("LOGOS_TEST_FSIZE_CHILD"); dir != "" { + saveUnderFileSizeLimit(dir) + return + } + vaultDir := t.TempDir() + cmd := exec.Command(os.Args[0], "-test.run=^TestAPlanWriteCutShortLeavesNoHalfPlan$") + cmd.Env = append(os.Environ(), "LOGOS_TEST_FSIZE_CHILD="+vaultDir) + out, err := cmd.CombinedOutput() + switch { + case strings.Contains(string(out), "SKIP:"): + t.Skip(strings.TrimSpace(string(out))) + case strings.Contains(string(out), "SAVED"): + t.Fatal("a plan past the file-size limit was saved without an error") + case err != nil || !strings.Contains(string(out), "REFUSED"): + t.Fatalf("the limited save did not run: %v\n%s", err, out) + } + + entries, _ := os.ReadDir(filepath.Join(vaultDir, CheckpointDir, "kestrel", PlanDir)) + for _, e := range entries { + t.Errorf("a failed save left %s behind", e.Name()) + } + if plans, _ := ListPlans(vaultDir, "kestrel"); len(plans) != 0 { + t.Errorf("a failed save is listed as %d plan(s)", len(plans)) + } +} + +// saveUnderFileSizeLimit is the child's half: one plan save under a 64KB +// file-size limit, as a full disk stops a write, reported on stdout. +func saveUnderFileSizeLimit(vaultDir string) { + signal.Ignore(syscall.SIGXFSZ) // so an over-limit write fails instead of killing the process + var limit syscall.Rlimit + if err := syscall.Getrlimit(syscall.RLIMIT_FSIZE, &limit); err != nil { + fmt.Println("SKIP: no file-size limit here:", err) + return + } + limit.Cur = 64 << 10 + if err := syscall.Setrlimit(syscall.RLIMIT_FSIZE, &limit); err != nil { + fmt.Println("SKIP: cannot set a file-size limit:", err) + return + } + p := Plan{Project: "kestrel", Agent: "claude", Text: strings.Repeat("step of a long plan\n", 1<<16), TS: 1755172800} + if _, err := SavePlan(vaultDir, p); err != nil { + fmt.Println("REFUSED") + return + } + fmt.Println("SAVED") +}