Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions internal/datalifecycle/datalifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -588,15 +588,27 @@ func dirBytes(path string) (int64, error) {
return total, err
}

// removeEmptyParents removes empty layout directories above attempt dirs. It
// never enters or removes an attempt dir: a live run creates artifact subdirs
// (such as agent-logs/thread-analysis) before writing to them, so an empty
// subdir there is still in use.
func removeEmptyParents(root string) error {
var dirs []string
if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() && path != root {
dirs = append(dirs, path)
if !entry.IsDir() || path == root {
return nil
}
isAttempt, err := isAttemptDir(root, path)
if err != nil {
return err
}
if isAttempt {
return filepath.SkipDir
}
dirs = append(dirs, path)
return nil
}); err != nil {
if errors.Is(err, fs.ErrNotExist) {
Expand Down
27 changes: 27 additions & 0 deletions internal/datalifecycle/datalifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,3 +483,30 @@ func TestPruneKeepsOrphansWithinGraceWindow(t *testing.T) {
t.Fatalf("stale orphan stat error = %v, want not exist", err)
}
}

// Regression for #699: a live run's empty artifact subdirs must survive the
// empty-dir sweep, while empty layout parents left by deleted runs go away.
func TestPruneKeepsEmptyArtifactSubdirsOfLiveRuns(t *testing.T) {
layout := testLayout(t)
live := testRun(layout, "live", ledger.PostModeLive, testNow())
threadLogs := filepath.Join(live.ArtifactPath, "agent-logs", "thread-analysis")
if err := os.MkdirAll(threadLogs, 0o700); err != nil {
t.Fatalf("MkdirAll(thread logs): %v", err)
}
emptyParent := filepath.Join(layout.DataRoot, "runs", "github_owner_repo_2", "cccccccccccccccccccccccccccccccccccccccc")
if err := os.MkdirAll(emptyParent, 0o700); err != nil {
t.Fatalf("MkdirAll(empty parent): %v", err)
}
store := &fakeStore{runs: []ledger.Run{live}}
keep := 10

if _, err := Prune(context.Background(), Options{Layout: layout, Store: store, Now: testNow}, PruneOptions{KeepLast: &keep}); err != nil {
t.Fatalf("Prune: %v", err)
}
if _, err := os.Stat(threadLogs); err != nil {
t.Fatalf("live run's empty thread-analysis dir was removed: %v", err)
}
if _, err := os.Stat(filepath.Dir(emptyParent)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("empty layout parent stat error = %v, want not exist", err)
}
}
7 changes: 7 additions & 0 deletions internal/llm/subprocess.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -290,6 +291,12 @@ func openSubprocessLog(path string) (*os.File, error) {
if strings.TrimSpace(path) == "" {
return nil, nil
}
// Create the parent at open time: callers create artifact dirs early, and an
// empty log dir can be pruned before a resumed task first writes to it.
// #nosec G301 G703 -- the parent is the caller-selected private artifact log directory.
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
// #nosec G304 -- LogPath is an explicit caller-selected artifact path for tailable adapter logs.
return os.Create(path)
}
Expand Down
23 changes: 23 additions & 0 deletions internal/llmadapters/subprocess_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,29 @@ func TestSubprocessCodexSafetyModes(t *testing.T) {
}
})

t.Run("resume creates a missing log directory", func(t *testing.T) {
// Regression for #699: a resumed thread analysis failed before launch
// when its agent-logs/thread-analysis directory no longer existed.
tempDir := t.TempDir()
recordPath := filepath.Join(tempDir, "records.jsonl")
logPath := filepath.Join(tempDir, "agent-logs", "thread-analysis", "thread-1.jsonl")
adapter := newCodexHelperAdapter("success", recordPath, 5*time.Second)
stream, err := adapter.Resume(context.Background(), "prior-session", Request{
Model: "gpt-5.5",
Prompt: "resume prompt",
LogPath: logPath,
})
if err != nil {
t.Fatalf("Resume: %v", err)
}
if _, err := stream.Wait(context.Background()); err != nil {
t.Fatalf("Wait: %v", err)
}
if _, err := os.Stat(logPath); err != nil {
t.Fatalf("Stat(log): %v", err)
}
})

for _, tt := range []struct {
name string
sessionID string
Expand Down
Loading