diff --git a/internal/config/config.go b/internal/config/config.go index 09e2971..e3199e6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ type Config struct { Agent string `json:"agent"` LogLevel string `json:"log_level"` StrategyOptions StrategyOptions `json:"strategy_options"` + HookOptions HookOptions `json:"hook_options"` } // StrategyOptions holds strategy-specific options. @@ -14,5 +15,12 @@ type StrategyOptions struct { PushSessions bool `json:"push_sessions"` } +// HookOptions holds hook-specific options. +type HookOptions struct { + // SessionRetryTimeoutMs is the maximum time in milliseconds to retry + // reading session data in the post-commit hook. 0 disables retries. + SessionRetryTimeoutMs int `json:"session_retry_timeout_ms"` +} + // PartioDir is the directory name for partio config within a repo. const PartioDir = ".partio" diff --git a/internal/config/defaults.go b/internal/config/defaults.go index f06dde6..f85c408 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -10,5 +10,8 @@ func Defaults() Config { StrategyOptions: StrategyOptions{ PushSessions: true, }, + HookOptions: HookOptions{ + SessionRetryTimeoutMs: 3000, + }, } } diff --git a/internal/config/env.go b/internal/config/env.go index 090b142..040370f 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -2,6 +2,7 @@ package config import ( "os" + "strconv" "strings" ) @@ -15,4 +16,9 @@ func applyEnv(cfg *Config) { if v := os.Getenv("PARTIO_LOG_LEVEL"); v != "" { cfg.LogLevel = v } + if v := os.Getenv("PARTIO_SESSION_RETRY_TIMEOUT_MS"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.HookOptions.SessionRetryTimeoutMs = n + } + } } diff --git a/internal/hooks/postcommit.go b/internal/hooks/postcommit.go index 0cd65b1..b54e6fa 100644 --- a/internal/hooks/postcommit.go +++ b/internal/hooks/postcommit.go @@ -55,9 +55,9 @@ func runPostCommit(repoRoot string, cfg config.Config) error { attr = &attribution.Result{AgentPercent: 100} } - // Parse agent session data - detector := claude.New() - sessionPath, sessionData, err := detector.FindLatestSession(repoRoot) + // Parse agent session data, retrying if data is not yet flushed to disk. + retryTimeout := time.Duration(cfg.HookOptions.SessionRetryTimeoutMs) * time.Millisecond + sessionPath, sessionData, err := findSessionWithRetry(newSessionFinder(), repoRoot, retryTimeout) if err != nil { slog.Warn("could not read agent session", "error", err) } diff --git a/internal/hooks/retry_session.go b/internal/hooks/retry_session.go new file mode 100644 index 0000000..7fec938 --- /dev/null +++ b/internal/hooks/retry_session.go @@ -0,0 +1,55 @@ +package hooks + +import ( + "log/slog" + "time" + + "github.com/partio-io/cli/internal/agent" + "github.com/partio-io/cli/internal/agent/claude" +) + +const initialRetryBackoff = 100 * time.Millisecond + +// sessionFinder is a function that attempts to find a session. +type sessionFinder func(repoRoot string) (string, *agent.SessionData, error) + +// sessionDataReady returns true when the session data is considered available. +func sessionDataReady(data *agent.SessionData, err error) bool { + return err == nil && data != nil && data.SessionID != "" +} + +// findSessionWithRetry calls finder repeatedly with exponential backoff until +// session data is available or the timeout expires. If the timeout is <= 0 no +// retries are performed. +func findSessionWithRetry(finder sessionFinder, repoRoot string, timeout time.Duration) (string, *agent.SessionData, error) { + path, data, err := finder(repoRoot) + if sessionDataReady(data, err) || timeout <= 0 { + return path, data, err + } + + deadline := time.Now().Add(timeout) + backoff := initialRetryBackoff + + for time.Now().Before(deadline) { + sleep := backoff + if remaining := time.Until(deadline); sleep > remaining { + sleep = remaining + } + time.Sleep(sleep) + backoff *= 2 + + path, data, err = finder(repoRoot) + if sessionDataReady(data, err) { + return path, data, err + } + } + + slog.Warn("session data not available after retry window", "timeout_ms", timeout.Milliseconds()) + return path, data, err +} + +// newSessionFinder wraps the claude detector's FindLatestSession. +func newSessionFinder() sessionFinder { + d := claude.New() + return d.FindLatestSession +} diff --git a/internal/hooks/retry_session_test.go b/internal/hooks/retry_session_test.go new file mode 100644 index 0000000..1a401dc --- /dev/null +++ b/internal/hooks/retry_session_test.go @@ -0,0 +1,124 @@ +package hooks + +import ( + "errors" + "testing" + "time" + + "github.com/partio-io/cli/internal/agent" +) + +func TestFindSessionWithRetry_SuccessFirstTry(t *testing.T) { + calls := 0 + finder := func(repoRoot string) (string, *agent.SessionData, error) { + calls++ + return "/path/session.jsonl", &agent.SessionData{SessionID: "abc123"}, nil + } + + path, data, err := findSessionWithRetry(finder, "/repo", 3*time.Second) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "/path/session.jsonl" { + t.Errorf("unexpected path: %s", path) + } + if data.SessionID != "abc123" { + t.Errorf("unexpected session ID: %s", data.SessionID) + } + if calls != 1 { + t.Errorf("expected 1 call, got %d", calls) + } +} + +func TestFindSessionWithRetry_SuccessAfterRetry(t *testing.T) { + calls := 0 + finder := func(repoRoot string) (string, *agent.SessionData, error) { + calls++ + if calls < 3 { + return "", nil, errors.New("not ready") + } + return "/path/session.jsonl", &agent.SessionData{SessionID: "abc123"}, nil + } + + path, data, err := findSessionWithRetry(finder, "/repo", 5*time.Second) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.SessionID != "abc123" { + t.Errorf("unexpected session ID: %s", data.SessionID) + } + if path != "/path/session.jsonl" { + t.Errorf("unexpected path: %s", path) + } + if calls < 3 { + t.Errorf("expected at least 3 calls, got %d", calls) + } +} + +func TestFindSessionWithRetry_TimeoutExhausted(t *testing.T) { + calls := 0 + finder := func(repoRoot string) (string, *agent.SessionData, error) { + calls++ + return "", nil, errors.New("not ready") + } + + start := time.Now() + _, _, err := findSessionWithRetry(finder, "/repo", 300*time.Millisecond) + elapsed := time.Since(start) + + if err == nil { + t.Error("expected error after timeout") + } + if calls < 2 { + t.Errorf("expected retries, got %d calls", calls) + } + if elapsed < 290*time.Millisecond { + t.Errorf("returned too early: %v", elapsed) + } + if elapsed > 2*time.Second { + t.Errorf("took too long: %v", elapsed) + } +} + +func TestFindSessionWithRetry_ZeroTimeout_NoRetry(t *testing.T) { + calls := 0 + finder := func(repoRoot string) (string, *agent.SessionData, error) { + calls++ + return "", nil, errors.New("not ready") + } + + _, _, err := findSessionWithRetry(finder, "/repo", 0) + + if err == nil { + t.Error("expected error") + } + if calls != 1 { + t.Errorf("expected exactly 1 call with zero timeout, got %d", calls) + } +} + +func TestFindSessionWithRetry_EmptySessionID_Retries(t *testing.T) { + calls := 0 + finder := func(repoRoot string) (string, *agent.SessionData, error) { + calls++ + if calls < 2 { + // File found but no session ID yet (empty/not flushed) + return "/path/session.jsonl", &agent.SessionData{SessionID: ""}, nil + } + return "/path/session.jsonl", &agent.SessionData{SessionID: "abc123"}, nil + } + + _, data, err := findSessionWithRetry(finder, "/repo", 5*time.Second) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data.SessionID != "abc123" { + t.Errorf("unexpected session ID: %s", data.SessionID) + } + if calls < 2 { + t.Errorf("expected at least 2 calls, got %d", calls) + } +}