[object Object]

← 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

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 →