From f80ca14fb8cfd32667f22d19f81bf5ddd1f5ebb2 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Thu, 5 Nov 2026 18:10:40 -0800 Subject: [PATCH 1/2] BUG: A plan whose write is cut short leaves nothing behind instead of half a plan --- internal/session/plan.go | 11 +++++-- internal/session/plan_unix_test.go | 46 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 internal/session/plan_unix_test.go 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..11ff247 --- /dev/null +++ b/internal/session/plan_unix_test.go @@ -0,0 +1,46 @@ +//go:build unix + +package session + +import ( + "os" + "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) { + vaultDir := t.TempDir() + p := Plan{Project: "kestrel", Agent: "claude", Text: strings.Repeat("step of a long plan\n", 1<<16), TS: 1755172800} + + signal.Ignore(syscall.SIGXFSZ) // so an over-limit write fails instead of killing the test + defer signal.Reset(syscall.SIGXFSZ) + var was syscall.Rlimit + if err := syscall.Getrlimit(syscall.RLIMIT_FSIZE, &was); err != nil { + t.Skipf("no file-size limit here: %v", err) + } + limit := was + limit.Cur = 64 << 10 + if err := syscall.Setrlimit(syscall.RLIMIT_FSIZE, &limit); err != nil { + t.Skipf("cannot set a file-size limit: %v", err) + } + _, err := SavePlan(vaultDir, p) + syscall.Setrlimit(syscall.RLIMIT_FSIZE, &was) + if err == nil { + t.Fatal("a plan past the file-size limit was saved without an error") + } + + 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)) + } +} From 8dacc57aaa767abd87ade064094b61552c6ae767 Mon Sep 17 00:00:00 2001 From: Coder8124 Date: Sat, 7 Nov 2026 16:55:59 -0800 Subject: [PATCH 2/2] BUG: The torn-plan test lowers the file-size limit in a child process, so it cannot fail the rest of the package --- internal/session/plan_unix_test.go | 56 +++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/internal/session/plan_unix_test.go b/internal/session/plan_unix_test.go index 11ff247..22ebfdf 100644 --- a/internal/session/plan_unix_test.go +++ b/internal/session/plan_unix_test.go @@ -3,7 +3,9 @@ package session import ( + "fmt" "os" + "os/exec" "os/signal" "path/filepath" "strings" @@ -16,24 +18,24 @@ import ( // 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) { - vaultDir := t.TempDir() - p := Plan{Project: "kestrel", Agent: "claude", Text: strings.Repeat("step of a long plan\n", 1<<16), TS: 1755172800} - - signal.Ignore(syscall.SIGXFSZ) // so an over-limit write fails instead of killing the test - defer signal.Reset(syscall.SIGXFSZ) - var was syscall.Rlimit - if err := syscall.Getrlimit(syscall.RLIMIT_FSIZE, &was); err != nil { - t.Skipf("no file-size limit here: %v", err) - } - limit := was - limit.Cur = 64 << 10 - if err := syscall.Setrlimit(syscall.RLIMIT_FSIZE, &limit); err != nil { - t.Skipf("cannot set a file-size limit: %v", err) + // 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 } - _, err := SavePlan(vaultDir, p) - syscall.Setrlimit(syscall.RLIMIT_FSIZE, &was) - if err == nil { + 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)) @@ -44,3 +46,25 @@ func TestAPlanWriteCutShortLeavesNoHalfPlan(t *testing.T) { 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") +}