← back to Cli Printing Press
feat(pipeline): add ClaimOutputDir for atomic directory claiming
93b8b4cc853b100251fa314038b3eb731ed372c7 · 2026-03-27 19:34:02 -0700 · Trevin Chow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Files touched
A internal/pipeline/claim_test.goM internal/pipeline/pipeline.go
Diff
commit 93b8b4cc853b100251fa314038b3eb731ed372c7
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri Mar 27 19:34:02 2026 -0700
feat(pipeline): add ClaimOutputDir for atomic directory claiming
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
internal/pipeline/claim_test.go | 67 +++++++++++++++++++++++++++++++++++++++++
internal/pipeline/pipeline.go | 35 +++++++++++++++++++++
2 files changed, 102 insertions(+)
diff --git a/internal/pipeline/claim_test.go b/internal/pipeline/claim_test.go
new file mode 100644
index 00000000..c21a9d2f
--- /dev/null
+++ b/internal/pipeline/claim_test.go
@@ -0,0 +1,67 @@
+package pipeline
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestClaimOutputDir_Fresh(t *testing.T) {
+ tmp := t.TempDir()
+ base := filepath.Join(tmp, "notion-pp-cli")
+
+ claimed, err := ClaimOutputDir(base)
+ require.NoError(t, err)
+ assert.Equal(t, base, claimed)
+
+ // Directory should exist
+ info, err := os.Stat(claimed)
+ require.NoError(t, err)
+ assert.True(t, info.IsDir())
+}
+
+func TestClaimOutputDir_Increments(t *testing.T) {
+ tmp := t.TempDir()
+ base := filepath.Join(tmp, "notion-pp-cli")
+
+ // Pre-create base and -2
+ require.NoError(t, os.Mkdir(base, 0o755))
+ require.NoError(t, os.Mkdir(base+"-2", 0o755))
+
+ claimed, err := ClaimOutputDir(base)
+ require.NoError(t, err)
+ assert.Equal(t, base+"-3", claimed)
+}
+
+func TestClaimOutputDir_MaxRetries(t *testing.T) {
+ tmp := t.TempDir()
+ base := filepath.Join(tmp, "notion-pp-cli")
+
+ // Pre-create base + -2 through -99
+ require.NoError(t, os.Mkdir(base, 0o755))
+ for i := 2; i <= 99; i++ {
+ require.NoError(t, os.Mkdir(base+"-"+fmt.Sprintf("%d", i), 0o755))
+ }
+
+ _, err := ClaimOutputDir(base)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "could not claim")
+}
+
+func TestClaimOutputDir_PermissionError(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("test requires non-root")
+ }
+ // Parent dir that doesn't exist and can't be created — on macOS /proc doesn't exist,
+ // so use a path that will fail os.MkdirAll
+ base := "/nonexistent-root-dir/fakedir/notion-pp-cli"
+
+ _, err := ClaimOutputDir(base)
+ assert.Error(t, err)
+ // Should NOT contain "could not claim" — should be the underlying OS error
+ assert.NotContains(t, err.Error(), "could not claim")
+}
diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go
index d8ce68ab..2b87fd8d 100644
--- a/internal/pipeline/pipeline.go
+++ b/internal/pipeline/pipeline.go
@@ -1,9 +1,11 @@
package pipeline
import (
+ "errors"
"fmt"
"os"
"path/filepath"
+ "strconv"
)
// DefaultOutputDir returns the default output directory for a given API name.
@@ -12,6 +14,39 @@ func DefaultOutputDir(apiName string) string {
return filepath.Join("library", apiName+"-cli")
}
+// ClaimOutputDir atomically claims an output directory. If base already exists,
+// it tries base-2, base-3, ... up to base-99. Uses os.Mkdir (not MkdirAll) for
+// the leaf directory so exactly one concurrent caller wins each slot.
+func ClaimOutputDir(base string) (string, error) {
+ parent := filepath.Dir(base)
+ if err := os.MkdirAll(parent, 0o755); err != nil {
+ return "", fmt.Errorf("creating parent directory: %w", err)
+ }
+
+ // Try the base name first
+ err := os.Mkdir(base, 0o755)
+ if err == nil {
+ return base, nil
+ }
+ if !errors.Is(err, os.ErrExist) {
+ return "", fmt.Errorf("creating output directory: %w", err)
+ }
+
+ // Base exists — try -2 through -99
+ for i := 2; i <= 99; i++ {
+ candidate := base + "-" + strconv.Itoa(i)
+ err := os.Mkdir(candidate, 0o755)
+ if err == nil {
+ return candidate, nil
+ }
+ if !errors.Is(err, os.ErrExist) {
+ return "", fmt.Errorf("creating output directory: %w", err)
+ }
+ }
+
+ return "", fmt.Errorf("could not claim output directory: all slots %s through %s-99 are taken", base, base)
+}
+
// Options configures a pipeline run.
type Options struct {
OutputDir string
← 5a6c8095 feat(skill): add Phase 4.9 agent readiness review loop to SK
·
back to Cli Printing Press
·
test(pipeline): add concurrency test for ClaimOutputDir 0fd4cba6 →