mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-12 00:19:20 +00:00
fixup! chore: update marketplace for v0.11.37
march-improvements
This commit is contained in:
+11
-1
@@ -2,6 +2,7 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HookResponse is the response sent back to Claude Code.
|
||||
@@ -31,6 +33,14 @@ func ProjectIDWithName(cwd string) string {
|
||||
return fmt.Sprintf("%s_%s", dirName, shortHash)
|
||||
}
|
||||
|
||||
// HookDeadline returns a context with the hook's timeout budget minus a safety margin.
|
||||
// This ensures hooks return gracefully before Claude kills them.
|
||||
func HookDeadline(timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
// Use 80% of the timeout to leave margin for response serialization
|
||||
safeTimeout := time.Duration(float64(timeout) * 0.8)
|
||||
return context.WithTimeout(context.Background(), safeTimeout)
|
||||
}
|
||||
|
||||
// Exit codes for Claude Code hooks
|
||||
const (
|
||||
ExitSuccess = 0
|
||||
@@ -92,7 +102,7 @@ func RunHook[T any](hookName string, handler HookHandler[T]) {
|
||||
|
||||
// Parse input
|
||||
var input T
|
||||
if err := json.Unmarshal(inputData, &input); err != nil {
|
||||
if err = json.Unmarshal(inputData, &input); err != nil {
|
||||
WriteError(hookName, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
+242
-15
@@ -3,6 +3,7 @@ package hooks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,13 +25,53 @@ const (
|
||||
// DefaultWorkerPort is the default worker port.
|
||||
DefaultWorkerPort = 37777
|
||||
|
||||
// HealthCheckTimeout is the timeout for health checks (reduced from 5s for faster startup).
|
||||
HealthCheckTimeout = 1 * time.Second
|
||||
// HealthCheckTimeout is the timeout for health checks.
|
||||
HealthCheckTimeout = 2 * time.Second
|
||||
|
||||
// StartupTimeout is the timeout for worker startup.
|
||||
StartupTimeout = 30 * time.Second
|
||||
|
||||
// workerCacheMaxAge is how long the worker cache is considered fresh.
|
||||
workerCacheMaxAge = 10 * time.Second
|
||||
|
||||
// circuitBreakerCooldown is how long to wait after a startup failure before retrying.
|
||||
circuitBreakerCooldown = 30 * time.Second
|
||||
|
||||
// healthCheckRetries is the number of health check attempts before declaring dead.
|
||||
healthCheckRetries = 3
|
||||
|
||||
// healthCheckRetryDelay is the delay between health check retries.
|
||||
healthCheckRetryDelay = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
// circuitBreakerMu protects lastStartupFailure.
|
||||
circuitBreakerMu sync.Mutex
|
||||
lastStartupFailure time.Time
|
||||
)
|
||||
|
||||
// IsWorkerAvailable performs a fast check without network calls.
|
||||
// Returns true if the worker is likely available, false if definitely down.
|
||||
func IsWorkerAvailable() bool {
|
||||
// Check circuit breaker first
|
||||
circuitBreakerMu.Lock()
|
||||
if !lastStartupFailure.IsZero() && time.Since(lastStartupFailure) < circuitBreakerCooldown {
|
||||
circuitBreakerMu.Unlock()
|
||||
return false
|
||||
}
|
||||
circuitBreakerMu.Unlock()
|
||||
|
||||
// Check PID cache
|
||||
entry := readWorkerCache()
|
||||
if entry == nil {
|
||||
return true // No cache = unknown, don't block
|
||||
}
|
||||
|
||||
// Cache exists and is fresh (readWorkerCache already checks staleness)
|
||||
// Check if cached process is alive
|
||||
return isProcessAlive(entry.PID)
|
||||
}
|
||||
|
||||
// GetWorkerPort returns the worker port from environment or default.
|
||||
func GetWorkerPort() int {
|
||||
if port := os.Getenv("CLAUDE_MNEMONIC_WORKER_PORT"); port != "" {
|
||||
@@ -40,29 +83,149 @@ func GetWorkerPort() int {
|
||||
}
|
||||
|
||||
// IsWorkerRunning checks if the worker is running and healthy.
|
||||
// Parses the JSON health response to check the "ready" field when available.
|
||||
// Falls back to HTTP status code check for backwards compatibility.
|
||||
func IsWorkerRunning(port int) bool {
|
||||
client := &http.Client{Timeout: HealthCheckTimeout}
|
||||
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Try to parse JSON response for structured health check
|
||||
var health struct {
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&health); err == nil {
|
||||
return health.Ready
|
||||
}
|
||||
|
||||
// Fallback: treat HTTP 200 as healthy (backwards compatibility)
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
// workerCachePath returns the path to the worker cache file.
|
||||
func workerCachePath() string {
|
||||
home := os.Getenv("HOME")
|
||||
if home == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".claude-mnemonic", ".worker-cache")
|
||||
}
|
||||
|
||||
// workerCacheEntry holds cached worker state: "port:pid:timestamp".
|
||||
type workerCacheEntry struct {
|
||||
Timestamp time.Time
|
||||
Port int
|
||||
PID int
|
||||
}
|
||||
|
||||
// readWorkerCache reads the worker cache file and returns the entry if fresh.
|
||||
func readWorkerCache() *workerCacheEntry {
|
||||
path := workerCachePath()
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimSpace(string(data)), ":", 3)
|
||||
if len(parts) != 3 {
|
||||
return nil
|
||||
}
|
||||
port, err := strconv.Atoi(parts[0])
|
||||
if err != nil || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
pid, err := strconv.Atoi(parts[1])
|
||||
if err != nil || pid <= 0 {
|
||||
return nil
|
||||
}
|
||||
ts, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
entry := &workerCacheEntry{
|
||||
Port: port,
|
||||
PID: pid,
|
||||
Timestamp: time.Unix(ts, 0),
|
||||
}
|
||||
// Check freshness
|
||||
if time.Since(entry.Timestamp) > workerCacheMaxAge {
|
||||
return nil
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// writeWorkerCache writes the worker cache file.
|
||||
func writeWorkerCache(port, pid int) {
|
||||
path := workerCachePath()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
_ = os.MkdirAll(dir, 0o700)
|
||||
data := fmt.Sprintf("%d:%d:%d", port, pid, time.Now().Unix())
|
||||
_ = os.WriteFile(path, []byte(data), 0o600)
|
||||
}
|
||||
|
||||
// isProcessAlive checks if a process with the given PID exists and is alive.
|
||||
func isProcessAlive(pid int) bool {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// Signal 0 checks if process exists without actually sending a signal.
|
||||
err = proc.Signal(syscall.Signal(0))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// isWorkerRunningWithRetries checks if the worker is running, retrying on timeout.
|
||||
// Returns true only if health check succeeds. Returns false if all retries fail.
|
||||
func isWorkerRunningWithRetries(port int) bool {
|
||||
for i := 0; i < healthCheckRetries; i++ {
|
||||
if IsWorkerRunning(port) {
|
||||
return true
|
||||
}
|
||||
if i < healthCheckRetries-1 {
|
||||
time.Sleep(healthCheckRetryDelay)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnsureWorkerRunning ensures the worker is running, starting it if necessary.
|
||||
// If a worker is already running and healthy with matching version, it reuses it.
|
||||
// If version mismatch or unhealthy, it kills the old worker and starts fresh.
|
||||
func EnsureWorkerRunning() (int, error) {
|
||||
port := GetWorkerPort()
|
||||
|
||||
// Check if already running and healthy
|
||||
if IsWorkerRunning(port) {
|
||||
// Fast path: check PID cache before making any HTTP calls.
|
||||
if entry := readWorkerCache(); entry != nil && entry.Port == port {
|
||||
if isProcessAlive(entry.PID) {
|
||||
return port, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Circuit breaker: if we failed to start recently, don't retry immediately.
|
||||
circuitBreakerMu.Lock()
|
||||
if !lastStartupFailure.IsZero() && time.Since(lastStartupFailure) < circuitBreakerCooldown {
|
||||
circuitBreakerMu.Unlock()
|
||||
return 0, fmt.Errorf("worker startup failed recently (circuit breaker open, retry after %v)", circuitBreakerCooldown-time.Since(lastStartupFailure))
|
||||
}
|
||||
circuitBreakerMu.Unlock()
|
||||
|
||||
// Check if already running and healthy (with retries to avoid false negatives under load)
|
||||
if isWorkerRunningWithRetries(port) {
|
||||
// Check version - if mismatch, restart (unless both are dev builds)
|
||||
if runningVersion := GetWorkerVersion(port); runningVersion != "" {
|
||||
if runningVersion != Version {
|
||||
// For dev/dirty builds, don't restart if base versions match
|
||||
if versionsCompatible(runningVersion, Version) {
|
||||
updateCacheFromPort(port)
|
||||
return port, nil
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Worker version mismatch (running: %s, expected: %s), restarting...\n", runningVersion, Version)
|
||||
@@ -72,23 +235,34 @@ func EnsureWorkerRunning() (int, error) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
} else {
|
||||
// Version matches, reuse existing worker
|
||||
updateCacheFromPort(port)
|
||||
return port, nil
|
||||
}
|
||||
} else {
|
||||
// Couldn't get version, assume it's fine
|
||||
updateCacheFromPort(port)
|
||||
return port, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check if port is in use but worker is unhealthy
|
||||
// Port is in use but health check failed -- worker may be slow, not dead.
|
||||
if IsPortInUse(port) {
|
||||
// Something is using the port but not responding to health checks
|
||||
// Try to kill it
|
||||
// The port is responding to TCP but health check timed out.
|
||||
// Don't kill it -- it's likely just under load. Give it more time.
|
||||
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Worker on port %d is slow to respond, waiting...\n", port)
|
||||
// Try a few more times with longer delays before giving up
|
||||
for i := 0; i < 3; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if IsWorkerRunning(port) {
|
||||
updateCacheFromPort(port)
|
||||
return port, nil
|
||||
}
|
||||
}
|
||||
// Still not healthy after extended wait -- kill and restart
|
||||
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Worker unresponsive after extended wait, restarting...\n")
|
||||
if err := KillProcessOnPort(port); err != nil {
|
||||
// Log but continue - maybe it will die on its own
|
||||
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Warning: failed to kill unhealthy process on port %d: %v\n", port, err)
|
||||
}
|
||||
// Wait a moment for port to be released
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -103,9 +277,14 @@ func EnsureWorkerRunning() (int, error) {
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
circuitBreakerMu.Lock()
|
||||
lastStartupFailure = time.Now()
|
||||
circuitBreakerMu.Unlock()
|
||||
return 0, fmt.Errorf("failed to start worker: %w", err)
|
||||
}
|
||||
|
||||
pid := cmd.Process.Pid
|
||||
|
||||
// Wait for worker to be ready with exponential backoff
|
||||
deadline := time.Now().Add(StartupTimeout)
|
||||
backoff := 50 * time.Millisecond
|
||||
@@ -113,6 +292,7 @@ func EnsureWorkerRunning() (int, error) {
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
if IsWorkerRunning(port) {
|
||||
writeWorkerCache(port, pid)
|
||||
return port, nil
|
||||
}
|
||||
time.Sleep(backoff)
|
||||
@@ -123,9 +303,31 @@ func EnsureWorkerRunning() (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
circuitBreakerMu.Lock()
|
||||
lastStartupFailure = time.Now()
|
||||
circuitBreakerMu.Unlock()
|
||||
return 0, fmt.Errorf("worker failed to start within timeout")
|
||||
}
|
||||
|
||||
// updateCacheFromPort finds the PID of the process on the port and updates the cache.
|
||||
func updateCacheFromPort(port int) {
|
||||
cmd := exec.Command("lsof", "-t", "-i", fmt.Sprintf(":%d", port)) // #nosec G204 -- port is from internal config
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pidStr := strings.TrimSpace(string(output))
|
||||
// Take first PID if multiple
|
||||
if idx := strings.Index(pidStr, "\n"); idx > 0 {
|
||||
pidStr = pidStr[:idx]
|
||||
}
|
||||
pid, err := strconv.Atoi(pidStr)
|
||||
if err != nil || pid <= 0 {
|
||||
return
|
||||
}
|
||||
writeWorkerCache(port, pid)
|
||||
}
|
||||
|
||||
// GetWorkerVersion gets the version of the running worker.
|
||||
func GetWorkerVersion(port int) string {
|
||||
client := &http.Client{Timeout: HealthCheckTimeout}
|
||||
@@ -133,7 +335,7 @@ func GetWorkerVersion(port int) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ""
|
||||
@@ -243,7 +445,7 @@ func POST(port int, path string, body interface{}) (map[string]interface{}, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("request failed: %s", resp.Status)
|
||||
@@ -251,13 +453,38 @@ func POST(port int, path string, body interface{}) (map[string]interface{}, erro
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
// Not all endpoints return JSON body - return empty map for success with no body
|
||||
return map[string]interface{}{}, nil
|
||||
// Not all endpoints return JSON
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// POSTWithContext sends a POST request using the provided context.
|
||||
// Used for fire-and-forget calls where we want to control the timeout externally.
|
||||
func POSTWithContext(ctx context.Context, port int, path string, body interface{}) error {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
fmt.Sprintf("http://127.0.0.1:%d%s", port, path),
|
||||
bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GET sends a GET request to the worker.
|
||||
func GET(port int, path string) (map[string]interface{}, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
@@ -266,7 +493,7 @@ func GET(port int, path string) (map[string]interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("request failed: %s", resp.Status)
|
||||
|
||||
@@ -517,7 +517,7 @@ func TestProjectIDWithName_Uniqueness(t *testing.T) {
|
||||
// TestHookConstants tests hook-related constants.
|
||||
func TestHookConstants(t *testing.T) {
|
||||
assert.Equal(t, 37777, DefaultWorkerPort)
|
||||
assert.Equal(t, 1*time.Second, HealthCheckTimeout)
|
||||
assert.Equal(t, 2*time.Second, HealthCheckTimeout)
|
||||
assert.Equal(t, 30*time.Second, StartupTimeout)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user